Skip to main content

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;