Skip to main content

ek_core/
cfg_face.rs

1//! cfg_face —— 插件配置面元数据 + 界面宿主契约(「插件即 App」波,2026-10-28)。
2//!
3//! 定位(照 Python 版 `app/engine.py` + `app/ui/widget.py` 一对一):
4//! - **参数事实源永远是 ek-cfg 单源**(`<ns>.ini` + [`Configurable`] 受理面 + 密库引用),
5//!   本模块不新增持久面,只补两件事:
6//!   1. [`ConfigField`] **自描述元数据** —— 插件声明「我有哪些参数、什么型、什么界、
7//!      重启才生效吗」,让呈现层(ek-ui 的通用参数窗)与 CLI 都能**不硬编码字段表**;
8//!   2. [`CfgHost`] **界面宿主契约** —— 插件自带的界面面(feature `ui` 门控,缺省关)
9//!      由此存/读段与写日志,不必依赖 ek-ui(依赖方向:ek-ui → 插件 → ek-core,无环)。
10//! - **零 egui 依赖**:本模块纯数据(section.rs 同款纪律,呈现归呈现层)。
11//!
12//! 加项纪律:`Configurable` 的两个新方法都带**缺省实现**(空表)——老插件零负担,
13//! 全链按 [`section`] 既有受理面走,不实现照旧工作。
14
15use crate::section::{Configurable, SectionMap};
16
17/// 参数型与界(校验单源:界面编辑、CLI 写档、导入档案包共用同一条 [`ConfigField::validate`])。
18#[derive(Debug, Clone, PartialEq)]
19pub enum ConfigKind {
20    /// 整数(含界;`unit` 供界面显单位,如「秒」「手」)。
21    Int {
22        min: i64,
23        max: i64,
24        unit: Option<String>,
25    },
26    /// 浮点(含界;必须有限值)。
27    Float {
28        min: f64,
29        max: f64,
30        unit: Option<String>,
31    },
32    /// 布尔(字面量 `true`/`false`)。
33    Bool,
34    /// 文本(长度上限,按字符计)。
35    Text { max_len: usize },
36    /// 逗号并列的整数表(如 K 线周期秒表 `60,300,900`)。
37    CsvInt { min: i64, max: i64 },
38    /// 单选枚举。
39    Enum { options: Vec<String> },
40    /// 秘面引用(值在密库,档案只落引用行,界面永不回显值)。
41    Secret,
42}
43
44/// 一个参数的自描述(BD-CFG 口径:key 与 [`Configurable::apply_config`] 受理的键一致)。
45#[derive(Debug, Clone, PartialEq)]
46pub struct ConfigField {
47    /// 段内键名。
48    pub key: String,
49    /// 界面标签(中文由插件自给)。
50    pub label: String,
51    /// 型与界。
52    pub kind: ConfigKind,
53    /// 缺省值(字符串形态,与段内形态一致)。
54    pub default: String,
55    /// 一行说明(界面提示用;None = 无)。
56    pub help: Option<String>,
57    /// 改了要不要重启插件(BD-CFG-6 三档在元数据上的显形;false = 受理即生效)。
58    pub needs_restart: bool,
59}
60
61impl ConfigField {
62    /// 便捷构造(界/单位/说明走 [`ConfigKind`] 与后续 setter)。
63    #[must_use]
64    pub fn new(key: impl Into<String>, label: impl Into<String>, kind: ConfigKind) -> Self {
65        Self {
66            key: key.into(),
67            label: label.into(),
68            kind,
69            default: String::new(),
70            help: None,
71            needs_restart: false,
72        }
73    }
74
75    /// 链式补:缺省值。
76    #[must_use]
77    pub fn with_default(mut self, v: impl Into<String>) -> Self {
78        self.default = v.into();
79        self
80    }
81
82    /// 链式补:一行说明。
83    #[must_use]
84    pub fn with_help(mut self, h: impl Into<String>) -> Self {
85        self.help = Some(h.into());
86        self
87    }
88
89    /// 链式补:标「重启插件才生效」(BD-CFG-6 的 deferred 口径在此显形)。
90    #[must_use]
91    pub fn needs_restart(mut self, yes: bool) -> Self {
92        self.needs_restart = yes;
93        self
94    }
95
96    /// 校验一条原始输入(界面/CLI/导入共用)。Ok(()) = 可入段。
97    ///
98    /// 秘面字段:值不在本面(引用行由 ek-cfg 生成),恒 Ok —— 界面侧只显引用徽标。
99    /// (返回 Result 本身已 must_use,不叠 `#[must_use]`——clippy::double_must_use。)
100    pub fn validate(&self, raw: &str) -> Result<(), String> {
101        match &self.kind {
102            ConfigKind::Int { min, max, .. } => {
103                let v: i64 = raw
104                    .trim()
105                    .parse()
106                    .map_err(|_| format!("{}:需要整数(收到「{}」)", self.key, raw.trim()))?;
107                if v < *min || v > *max {
108                    return Err(format!("{}:需落在 {min}..={max}", self.key));
109                }
110                Ok(())
111            }
112            ConfigKind::Float { min, max, .. } => {
113                let v: f64 = raw
114                    .trim()
115                    .parse()
116                    .map_err(|_| format!("{}:需要数值(收到「{}」)", self.key, raw.trim()))?;
117                if !v.is_finite() {
118                    return Err(format!("{}:需要有限数值", self.key));
119                }
120                if v < *min || v > *max {
121                    return Err(format!("{}:需落在 {min}..={max}", self.key));
122                }
123                Ok(())
124            }
125            ConfigKind::Bool => match raw.trim() {
126                "true" | "false" => Ok(()),
127                other => Err(format!("{}:需要 true/false(收到「{other}」)", self.key)),
128            },
129            ConfigKind::Text { max_len } => {
130                let v = raw.trim();
131                if v.chars().count() > *max_len {
132                    return Err(format!("{}:长度需 ≤ {max_len}", self.key));
133                }
134                Ok(())
135            }
136            ConfigKind::CsvInt { min, max } => {
137                let v = Self::csv_ints(raw).map_err(|e| format!("{}:{e}", self.key))?;
138                if v.is_empty() {
139                    return Err(format!("{}:至少一项", self.key));
140                }
141                if v.iter().any(|x| *x < *min || *x > *max) {
142                    return Err(format!("{}:每项需落在 {min}..={max}", self.key));
143                }
144                Ok(())
145            }
146            ConfigKind::Enum { options } => {
147                let v = raw.trim();
148                if options.iter().any(|o| o == v) {
149                    Ok(())
150                } else {
151                    Err(format!(
152                        "{}:需取 {} 之一",
153                        self.key,
154                        options.join("/").as_str()
155                    ))
156                }
157            }
158            ConfigKind::Secret => Ok(()),
159        }
160    }
161
162    /// 逗号整数表的解析器(**去重保序**,空项跳过)。dm 周期表等处复用同一条口径。
163    /// (返回 Result 本身已 must_use,不叠 `#[must_use]`。)
164    pub fn csv_ints(raw: &str) -> Result<Vec<i64>, String> {
165        let mut out: Vec<i64> = Vec::new();
166        for part in raw.split(',') {
167            let p = part.trim();
168            if p.is_empty() {
169                continue;
170            }
171            let v: i64 = p.parse().map_err(|_| format!("整数表含非整数项「{p}」"))?;
172            if !out.contains(&v) {
173                out.push(v);
174            }
175        }
176        Ok(out)
177    }
178}
179
180/// 当前值视图(键 → **呈现串**)。
181///
182/// 纪律:秘面字段只出**引用串**(`secret://ek/<ns>/<key>`),值永不进本面 ——
183/// 界面照此渲染徽标,不回显(BD-CFG-12/15 的呈现侧)。
184pub type ConfigView = Vec<(String, String)>;
185
186/// 界面宿主契约:插件自带界面面(`ui` 特性)由此存/读段与写日志。
187///
188/// 实现方 = 宿主呈现层(ek-ui:`ek-cfg` 托底根 + 界面日志面)。插件侧**不得**
189/// 直连密库取值(秘取可秒级阻塞,BD-CFG-16),也**不得**绕过本契约自开持久面。
190pub trait CfgHost {
191    /// 存段(整段覆盖 = 单写手口径,同 ek-cfg `store` 语义)。
192    fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String>;
193
194    /// 读段(无段 = None;坏段 = Err 显形,不静默)。
195    fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String>;
196
197    /// 写一行日志(界面日志面/输出面由宿主定,插件不挑)。
198    fn log(&self, line: &str);
199}
200
201/// 便捷:把带受理面的插件当前段读成 [`ConfigView`](schema 驱动窗口的预填)。
202///
203/// 泛型收 `?Sized` —— 具体插件与 `dyn Configurable` 都能喂(station 侧持有的正是后两者)。
204/// (返回 Vec 本身已 must_use,本函数不再叠 `#[must_use]`——clippy::double_must_use。)
205pub fn view_of<P: Configurable + ?Sized>(plugin: &P) -> ConfigView {
206    plugin
207        .dump_config()
208        .iter()
209        .map(|(k, f)| {
210            let shown = match f {
211                crate::section::Field::Plain(v) => v.clone(),
212                crate::section::Field::Secret { key } => {
213                    format!("secret://ek/{}/{}", plugin.config_ns(), key)
214                }
215            };
216            (k.to_string(), shown)
217        })
218        .collect()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn int() -> ConfigField {
226        ConfigField::new(
227            "max_volume",
228            "单笔最大手数",
229            ConfigKind::Int {
230                min: 1,
231                max: 10000,
232                unit: Some("手".into()),
233            },
234        )
235    }
236
237    #[test]
238    fn c1_validate_int_range() {
239        let f = int();
240        assert!(f.validate("500").is_ok());
241        assert!(f.validate("0").is_err());
242        assert!(f.validate("10001").is_err());
243        assert!(f.validate("abc").is_err());
244        assert!(f.validate(" 12 ").is_ok(), "首尾空白容忍");
245    }
246
247    #[test]
248    fn c2_validate_float_finite_and_range() {
249        let f = ConfigField::new(
250            "band",
251            "价格带",
252            ConfigKind::Float {
253                min: 0.0,
254                max: 1.0,
255                unit: None,
256            },
257        );
258        assert!(f.validate("0.02").is_ok());
259        assert!(f.validate("1.5").is_err());
260        assert!(f.validate("nan").is_err(), "NaN 必拒");
261        assert!(f.validate("inf").is_err(), "inf 必拒");
262    }
263
264    #[test]
265    fn c3_validate_bool_text_enum() {
266        let b = ConfigField::new("on", "开", ConfigKind::Bool);
267        assert!(b.validate("true").is_ok());
268        assert!(b.validate("True").is_err(), "字面量口径严格");
269        let t = ConfigField::new("gw", "网关", ConfigKind::Text { max_len: 3 });
270        assert!(t.validate("ctp").is_ok());
271        assert!(t.validate("ctp9999").is_err());
272        let e = ConfigField::new(
273            "mode",
274            "模式",
275            ConfigKind::Enum {
276                options: vec!["net".into(), "lock".into()],
277            },
278        );
279        assert!(e.validate("lock").is_ok());
280        assert!(e.validate("both").is_err());
281    }
282
283    #[test]
284    fn c4_csv_ints_dedupes_preserves_order() {
285        assert_eq!(
286            ConfigField::csv_ints("60, 300,60, 900,").unwrap(),
287            vec![60, 300, 900]
288        );
289        assert!(ConfigField::csv_ints("60,x").is_err());
290        let f = ConfigField::new(
291            "intervals",
292            "周期",
293            ConfigKind::CsvInt { min: 1, max: 86400 },
294        );
295        assert!(f.validate("60,300").is_ok());
296        assert!(f.validate("").is_err(), "空表必拒");
297        assert!(f.validate("60,0").is_err(), "越界项必拒");
298    }
299
300    #[test]
301    fn c5_secret_field_never_blocks_and_carries_no_value() {
302        let f = ConfigField::new("td_password", "交易密码", ConfigKind::Secret);
303        assert!(f.validate("").is_ok());
304        assert!(
305            f.validate("anything").is_ok(),
306            "值不在本面(引用行归 ek-cfg)"
307        );
308    }
309
310    #[test]
311    fn c6_view_of_shows_secret_as_ref_only() {
312        struct P;
313        impl Configurable for P {
314            fn config_ns(&self) -> &str {
315                "ctp_ctp_9_11"
316            }
317            fn apply_config(
318                &self,
319                _cfg: &SectionMap,
320            ) -> Result<crate::section::ApplyReport, String> {
321                Ok(crate::section::ApplyReport::all_adopted())
322            }
323            fn dump_config(&self) -> SectionMap {
324                SectionMap::new(1)
325                    .set("td_addr", crate::section::Field::plain("tcp://a:1"))
326                    .set(
327                        "td_password",
328                        crate::section::Field::secret("gw_td_password"),
329                    )
330            }
331        }
332        let v = view_of(&P);
333        assert_eq!(v.len(), 2);
334        let pw = v.iter().find(|(k, _)| k == "td_password").unwrap();
335        assert_eq!(pw.1, "secret://ek/ctp_ctp_9_11/gw_td_password");
336        assert!(!pw.1.contains("tcp"), "秘面行不含值");
337    }
338
339    #[test]
340    fn c7_schema_defaults_are_empty_for_old_plugins() {
341        struct Q;
342        impl Configurable for Q {
343            fn config_ns(&self) -> &str {
344                "q"
345            }
346            fn apply_config(
347                &self,
348                _cfg: &SectionMap,
349            ) -> Result<crate::section::ApplyReport, String> {
350                Ok(crate::section::ApplyReport::all_adopted())
351            }
352            fn dump_config(&self) -> SectionMap {
353                SectionMap::new(0)
354            }
355        }
356        // 缺省实现:老插件零负担(不实现照旧编译)。
357        assert!(Configurable::config_schema(&Q).is_empty());
358        assert!(Configurable::config_view(&Q).is_empty());
359    }
360}