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