futucli/cmd/key_enums.rs
1//! `gen-key` / `bind-key` 共用 enum / CSV 解析器
2//!
3//! v1.4.106 codex 0608 F5 (P3) sweep — 从 `gen_key.rs` / `bind_key.rs`
4//! 抽出 enum / 字面量解析, 替代松散的 `parse_csv` (any-string-OK) 与每个
5//! 字段独立大小写归一化, 让早期 reject 取代 silent stash:
6//!
7//! - **trd_sides**: BUY / SELL / SELL_SHORT / BUY_BACK 等 backend 枚举严格匹配
8//! - **markets**: HK / US / CN / HKCC / FUTURES / SG / AU / JP / MY / CA /
9//! HKFUND / USFUND / CRYPTO (大小写归一化为 upper, 别名 `HK_FUND` /
10//! `US_FUND` / `CC` 仍接受) — 与 `cmd::account::parse_trd_market`
11//! 对齐已支持的 trade/QOT surface 语义
12//! - **symbols**: `MARKET.CODE` 形式校验 (e.g. "HK.00700"); `MARKET` 必须
13//! 是上述合法 market 列表的一个, `CODE` 不能为空 — 拒绝 "07000" /
14//! "AAPL" / 单纯 "HK" 等显然漏写市场前缀的 input
15//! - **machine_csv**: 64-hex 指纹严格校验 (失败早 fail) — 复用 gen-key /
16//! bind-key 的格式约束
17//!
18//! 与 `parse_csv` 的差别: 这里**对每个 token loud reject 不合法 input**,
19//! 不再 silent insert "随便什么字符串" 进 `HashSet<String>` 然后留给 daemon /
20//! backend 拒绝. 与 v1.4.93 BUG-001 修法 (schema-runtime layer 双 layer 对齐,
21//! pitfall #54) 同精神 — CLI 是离 daemon 的第一道闸门, 早期严格 = 后续少 silent.
22//!
23//! **空 CSV 语义** (与 v1.4.104 eli P2-008 fix 一致): 全空 token (e.g. " ,," /
24//! " ") 解析后是空集 → 调用方应**显式拒绝**, 不 silently stash 空集 (空集
25//! 在 daemon 端可能被当 "无限制" sentinel, silent inverse). 见
26//! `parse_strict_csv_non_empty` helper 文档.
27
28use std::collections::HashSet;
29
30use anyhow::{Result, anyhow, bail};
31
32/// Trd side 合法集 — 与 backend `Trd_Common.proto::TrdSide` enum 对齐.
33///
34/// 仅接 backend 字符串名 (case-insensitive 输入, 大写归一化输出). 与 daemon
35/// `gateway/handlers/trd.rs::map_trd_side_string` 同语义.
36const ALLOWED_TRD_SIDES: &[&str] = &["BUY", "SELL", "SELL_SHORT", "BUY_BACK"];
37
38/// 解析 `--allowed-trd-sides` CSV → `HashSet<String>` (uppercase).
39///
40/// 行为:
41/// - case-insensitive 接入, uppercase 归一化输出 (与 daemon 校验大小写一致)
42/// - 空 token / trailing comma 跳过
43/// - **不在 [`ALLOWED_TRD_SIDES`] 集合的 token → loud Err** (避免 v1.4.93
44/// BUG-001 schema-runtime drift: CLI 接受任意字符串然后 daemon 接 wire
45/// 时拒掉, 用户体验差)
46/// - 全空 / 空 CSV → 返空集; 调用方决定如何对待空集 (gen-key 走 None 表示
47/// 不限制, 见调用点逻辑)
48pub fn parse_trd_sides_csv(s: &str) -> Result<HashSet<String>> {
49 let mut out = HashSet::new();
50 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
51 let upper = token.to_ascii_uppercase();
52 if !ALLOWED_TRD_SIDES.contains(&upper.as_str()) {
53 bail!(
54 "invalid trd_side {token:?}: expected one of {:?} \
55 (case-insensitive). v1.4.106 F5 strict parser 拒绝未知 \
56 trd_side, 不再 silently stash 任意字符串.",
57 ALLOWED_TRD_SIDES
58 );
59 }
60 out.insert(upper);
61 }
62 Ok(out)
63}
64
65/// Trd market 合法集 — 与 backend `Trd_Common.proto::TrdMarket` enum 对齐
66/// 已支持 variant. 与 `cmd::account::parse_trd_market` 同源 (单一 source of truth).
67const ALLOWED_MARKETS: &[&str] = &[
68 "HK", "US", "CN", "HKCC", "FUTURES", "SG", "AU", "JP", "MY", "CA", "HKFUND", "USFUND", "CRYPTO",
69];
70
71/// 别名 → 规范名映射 (额外接受 `HK_FUND` / `US_FUND` / `CC`).
72fn canonicalize_market(s: &str) -> Option<&'static str> {
73 let upper = s.to_ascii_uppercase();
74 match upper.as_str() {
75 "HK" => Some("HK"),
76 "US" => Some("US"),
77 "CN" => Some("CN"),
78 "HKCC" => Some("HKCC"),
79 "FUTURES" => Some("FUTURES"),
80 "SG" => Some("SG"),
81 "AU" => Some("AU"),
82 "JP" => Some("JP"),
83 "MY" => Some("MY"),
84 "CA" => Some("CA"),
85 "HKFUND" | "HK_FUND" => Some("HKFUND"),
86 "USFUND" | "US_FUND" => Some("USFUND"),
87 // Ref: proto/Qot_Common.proto `QotMarket_CC_Security = 91`.
88 // `CC` is the public proto spelling; keep canonical user-facing key
89 // scope as CRYPTO so agent/key policies do not split one market into
90 // two names.
91 "CRYPTO" | "CC" => Some("CRYPTO"),
92 _ => None,
93 }
94}
95
96/// 解析 `--allowed-markets` CSV → `HashSet<String>` (canonical names).
97///
98/// 行为:
99/// - case-insensitive 接入, canonical name 输出 ("HK_FUND" → "HKFUND")
100/// - 空 token / trailing comma 跳过
101/// - **不在 [`ALLOWED_MARKETS`] 集合 (含别名) 的 token → loud Err**
102pub fn parse_markets_csv(s: &str) -> Result<HashSet<String>> {
103 let mut out = HashSet::new();
104 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
105 match canonicalize_market(token) {
106 Some(canon) => {
107 out.insert(canon.to_string());
108 }
109 None => bail!(
110 "invalid market {token:?}: expected one of {:?} \
111 (case-insensitive; HK_FUND / US_FUND 等同 HKFUND / USFUND; \
112 CC 等同 CRYPTO). \
113 v1.4.106 F5 strict parser 拒绝未知 market, 不再 silently \
114 stash 任意字符串.",
115 ALLOWED_MARKETS
116 ),
117 }
118 }
119 Ok(out)
120}
121
122/// 解析 `--allowed-symbols` CSV → `HashSet<String>` (uppercase).
123///
124/// 接受格式: `MARKET.CODE` (e.g. `HK.00700`, `US.AAPL`). 校验:
125/// - 必须含 `.` 分隔符
126/// - `MARKET` 部分必须在 [`ALLOWED_MARKETS`] (含别名)
127/// - `CODE` 部分非空
128/// - case-insensitive 接入, **uppercase 归一化** 输出 (`hk.00700` → `HK.00700`)
129///
130/// 拒绝:
131/// - "AAPL" (缺市场前缀)
132/// - "HK." (code 空)
133/// - ".00700" (market 空)
134/// - "FOO.BAR" (market 不在白名单)
135pub fn parse_symbols_csv(s: &str) -> Result<HashSet<String>> {
136 let mut out = HashSet::new();
137 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
138 let (market, code) = token.split_once('.').ok_or_else(|| {
139 anyhow!(
140 "invalid symbol {token:?}: expected MARKET.CODE (e.g. HK.00700). \
141 v1.4.106 F5 strict parser 拒绝缺市场前缀的 symbol."
142 )
143 })?;
144 if code.is_empty() {
145 bail!(
146 "invalid symbol {token:?}: code part empty after '.'. \
147 expected MARKET.CODE (e.g. HK.00700)."
148 );
149 }
150 let canon_market = canonicalize_market(market).ok_or_else(|| {
151 anyhow!(
152 "invalid symbol {token:?}: market {market:?} not in {:?} \
153 (case-insensitive; HK_FUND / US_FUND 等同 HKFUND / USFUND; \
154 CC 等同 CRYPTO). \
155 v1.4.106 F5 strict parser 拒绝未知 market 前缀.",
156 ALLOWED_MARKETS
157 )
158 })?;
159 // 输出: 大写归一化 (CODE 部分保持原样大小写? 用户指定 case-sensitive,
160 // 但 daemon symbol cache 是 uppercase 归一化, 所以 CLI 也归一)
161 out.insert(format!("{canon_market}.{}", code.to_ascii_uppercase()));
162 }
163 Ok(out)
164}
165
166/// 解析 64-hex 指纹 CSV → `Vec<String>` (保序去重由调用方做).
167///
168/// 与 `bind_key::parse_fingerprints` 同语义, 抽出来给 gen-key /
169/// bind-key 共用. 单个 token:
170/// - 必须 64 字符
171/// - 必须全 ASCII hex digit
172pub fn parse_fingerprints_csv(s: &str) -> Result<Vec<String>> {
173 let mut out = Vec::new();
174 for part in s.split(',') {
175 let fp = part.trim();
176 if fp.is_empty() {
177 continue;
178 }
179 if fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit()) {
180 bail!(
181 "invalid fingerprint {fp:?}: expect 64 hex chars \
182 (run `futucli machine-id --for-key <id>`)"
183 );
184 }
185 out.push(fp.to_string());
186 }
187 Ok(out)
188}
189
190#[cfg(test)]
191mod tests;