ek-core 0.5.2

Business-agnostic plugin host and actor-over-bus runtime on top of ek-event: capability index with runtime service discovery, per-plugin actor workers, dynamic add/remove with lifecycle broadcasts, one-shot ask/ask_service with contract gates, panic quarantine, five-step close.
Documentation
//! cfg_face —— 插件配置面元数据 + 界面宿主契约(「插件即 App」波,2026-10-28)。
//!
//! 定位(照 Python 版 `app/engine.py` + `app/ui/widget.py` 一对一):
//! - **参数事实源永远是 ek-cfg 单源**(`<ns>.ini` + [`Configurable`] 受理面 + 密库引用),
//!   本模块不新增持久面,只补两件事:
//!   1. [`ConfigField`] **自描述元数据** —— 插件声明「我有哪些参数、什么型、什么界、
//!      重启才生效吗」,让呈现层(ek-ui 的通用参数窗)与 CLI 都能**不硬编码字段表**;
//!   2. [`CfgHost`] **界面宿主契约** —— 插件自带的界面面(feature `ui` 门控,缺省关)
//!      由此存/读段与写日志,不必依赖 ek-ui(依赖方向:ek-ui → 插件 → ek-core,无环)。
//! - **零 egui 依赖**:本模块纯数据(section.rs 同款纪律,呈现归呈现层)。
//!
//! 加项纪律:`Configurable` 的两个新方法都带**缺省实现**(空表)——老插件零负担,
//! 全链按 [`section`] 既有受理面走,不实现照旧工作。

use crate::section::{Configurable, SectionMap};

/// 参数型与界(校验单源:界面编辑、CLI 写档、导入档案包共用同一条 [`ConfigField::validate`])。
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigKind {
    /// 整数(含界;`unit` 供界面显单位,如「秒」「手」)。
    Int {
        min: i64,
        max: i64,
        unit: Option<String>,
    },
    /// 浮点(含界;必须有限值)。
    Float {
        min: f64,
        max: f64,
        unit: Option<String>,
    },
    /// 布尔(字面量 `true`/`false`)。
    Bool,
    /// 文本(长度上限,按字符计)。
    Text { max_len: usize },
    /// 逗号并列的整数表(如 K 线周期秒表 `60,300,900`)。
    CsvInt { min: i64, max: i64 },
    /// 单选枚举。
    Enum { options: Vec<String> },
    /// 秘面引用(值在密库,档案只落引用行,界面永不回显值)。
    Secret,
}

/// 一个参数的自描述(BD-CFG 口径:key 与 [`Configurable::apply_config`] 受理的键一致)。
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigField {
    /// 段内键名。
    pub key: String,
    /// 界面标签(中文由插件自给)。
    pub label: String,
    /// 型与界。
    pub kind: ConfigKind,
    /// 缺省值(字符串形态,与段内形态一致)。
    pub default: String,
    /// 一行说明(界面提示用;None = 无)。
    pub help: Option<String>,
    /// 改了要不要重启插件(BD-CFG-6 三档在元数据上的显形;false = 受理即生效)。
    pub needs_restart: bool,
}

impl ConfigField {
    /// 便捷构造(界/单位/说明走 [`ConfigKind`] 与后续 setter)。
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>, kind: ConfigKind) -> Self {
        Self {
            key: key.into(),
            label: label.into(),
            kind,
            default: String::new(),
            help: None,
            needs_restart: false,
        }
    }

    /// 链式补:缺省值。
    #[must_use]
    pub fn with_default(mut self, v: impl Into<String>) -> Self {
        self.default = v.into();
        self
    }

    /// 链式补:一行说明。
    #[must_use]
    pub fn with_help(mut self, h: impl Into<String>) -> Self {
        self.help = Some(h.into());
        self
    }

    /// 链式补:标「重启插件才生效」(BD-CFG-6 的 deferred 口径在此显形)。
    #[must_use]
    pub fn needs_restart(mut self, yes: bool) -> Self {
        self.needs_restart = yes;
        self
    }

    /// 校验一条原始输入(界面/CLI/导入共用)。Ok(()) = 可入段。
    ///
    /// 秘面字段:值不在本面(引用行由 ek-cfg 生成),恒 Ok —— 界面侧只显引用徽标。
    /// (返回 Result 本身已 must_use,不叠 `#[must_use]`——clippy::double_must_use。)
    pub fn validate(&self, raw: &str) -> Result<(), String> {
        match &self.kind {
            ConfigKind::Int { min, max, .. } => {
                let v: i64 = raw
                    .trim()
                    .parse()
                    .map_err(|_| format!("{}:需要整数(收到「{}」)", self.key, raw.trim()))?;
                if v < *min || v > *max {
                    return Err(format!("{}:需落在 {min}..={max}", self.key));
                }
                Ok(())
            }
            ConfigKind::Float { min, max, .. } => {
                let v: f64 = raw
                    .trim()
                    .parse()
                    .map_err(|_| format!("{}:需要数值(收到「{}」)", self.key, raw.trim()))?;
                if !v.is_finite() {
                    return Err(format!("{}:需要有限数值", self.key));
                }
                if v < *min || v > *max {
                    return Err(format!("{}:需落在 {min}..={max}", self.key));
                }
                Ok(())
            }
            ConfigKind::Bool => match raw.trim() {
                "true" | "false" => Ok(()),
                other => Err(format!("{}:需要 true/false(收到「{other}」)", self.key)),
            },
            ConfigKind::Text { max_len } => {
                let v = raw.trim();
                if v.chars().count() > *max_len {
                    return Err(format!("{}:长度需 ≤ {max_len}", self.key));
                }
                Ok(())
            }
            ConfigKind::CsvInt { min, max } => {
                let v = Self::csv_ints(raw).map_err(|e| format!("{}:{e}", self.key))?;
                if v.is_empty() {
                    return Err(format!("{}:至少一项", self.key));
                }
                if v.iter().any(|x| *x < *min || *x > *max) {
                    return Err(format!("{}:每项需落在 {min}..={max}", self.key));
                }
                Ok(())
            }
            ConfigKind::Enum { options } => {
                let v = raw.trim();
                if options.iter().any(|o| o == v) {
                    Ok(())
                } else {
                    Err(format!(
                        "{}:需取 {} 之一",
                        self.key,
                        options.join("/").as_str()
                    ))
                }
            }
            ConfigKind::Secret => Ok(()),
        }
    }

    /// 逗号整数表的解析器(**去重保序**,空项跳过)。dm 周期表等处复用同一条口径。
    /// (返回 Result 本身已 must_use,不叠 `#[must_use]`。)
    pub fn csv_ints(raw: &str) -> Result<Vec<i64>, String> {
        let mut out: Vec<i64> = Vec::new();
        for part in raw.split(',') {
            let p = part.trim();
            if p.is_empty() {
                continue;
            }
            let v: i64 = p.parse().map_err(|_| format!("整数表含非整数项「{p}」"))?;
            if !out.contains(&v) {
                out.push(v);
            }
        }
        Ok(out)
    }
}

/// 当前值视图(键 → **呈现串**)。
///
/// 纪律:秘面字段只出**引用串**(`secret://ek/<ns>/<key>`),值永不进本面 ——
/// 界面照此渲染徽标,不回显(BD-CFG-12/15 的呈现侧)。
pub type ConfigView = Vec<(String, String)>;

/// 界面宿主契约:插件自带界面面(`ui` 特性)由此存/读段与写日志。
///
/// 实现方 = 宿主呈现层(ek-ui:`ek-cfg` 托底根 + 界面日志面)。插件侧**不得**
/// 直连密库取值(秘取可秒级阻塞,BD-CFG-16),也**不得**绕过本契约自开持久面。
pub trait CfgHost {
    /// 存段(整段覆盖 = 单写手口径,同 ek-cfg `store` 语义)。
    fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String>;

    /// 读段(无段 = None;坏段 = Err 显形,不静默)。
    fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String>;

    /// 写一行日志(界面日志面/输出面由宿主定,插件不挑)。
    fn log(&self, line: &str);
}

/// 便捷:把带受理面的插件当前段读成 [`ConfigView`](schema 驱动窗口的预填)。
///
/// 泛型收 `?Sized` —— 具体插件与 `dyn Configurable` 都能喂(station 侧持有的正是后两者)。
/// (返回 Vec 本身已 must_use,本函数不再叠 `#[must_use]`——clippy::double_must_use。)
pub fn view_of<P: Configurable + ?Sized>(plugin: &P) -> ConfigView {
    plugin
        .dump_config()
        .iter()
        .map(|(k, f)| {
            let shown = match f {
                crate::section::Field::Plain(v) => v.clone(),
                crate::section::Field::Secret { key } => {
                    format!("secret://ek/{}/{}", plugin.config_ns(), key)
                }
            };
            (k.to_string(), shown)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn int() -> ConfigField {
        ConfigField::new(
            "max_volume",
            "单笔最大手数",
            ConfigKind::Int {
                min: 1,
                max: 10000,
                unit: Some("手".into()),
            },
        )
    }

    #[test]
    fn c1_validate_int_range() {
        let f = int();
        assert!(f.validate("500").is_ok());
        assert!(f.validate("0").is_err());
        assert!(f.validate("10001").is_err());
        assert!(f.validate("abc").is_err());
        assert!(f.validate(" 12 ").is_ok(), "首尾空白容忍");
    }

    #[test]
    fn c2_validate_float_finite_and_range() {
        let f = ConfigField::new(
            "band",
            "价格带",
            ConfigKind::Float {
                min: 0.0,
                max: 1.0,
                unit: None,
            },
        );
        assert!(f.validate("0.02").is_ok());
        assert!(f.validate("1.5").is_err());
        assert!(f.validate("nan").is_err(), "NaN 必拒");
        assert!(f.validate("inf").is_err(), "inf 必拒");
    }

    #[test]
    fn c3_validate_bool_text_enum() {
        let b = ConfigField::new("on", "开", ConfigKind::Bool);
        assert!(b.validate("true").is_ok());
        assert!(b.validate("True").is_err(), "字面量口径严格");
        let t = ConfigField::new("gw", "网关", ConfigKind::Text { max_len: 3 });
        assert!(t.validate("ctp").is_ok());
        assert!(t.validate("ctp9999").is_err());
        let e = ConfigField::new(
            "mode",
            "模式",
            ConfigKind::Enum {
                options: vec!["net".into(), "lock".into()],
            },
        );
        assert!(e.validate("lock").is_ok());
        assert!(e.validate("both").is_err());
    }

    #[test]
    fn c4_csv_ints_dedupes_preserves_order() {
        assert_eq!(
            ConfigField::csv_ints("60, 300,60, 900,").unwrap(),
            vec![60, 300, 900]
        );
        assert!(ConfigField::csv_ints("60,x").is_err());
        let f = ConfigField::new(
            "intervals",
            "周期",
            ConfigKind::CsvInt { min: 1, max: 86400 },
        );
        assert!(f.validate("60,300").is_ok());
        assert!(f.validate("").is_err(), "空表必拒");
        assert!(f.validate("60,0").is_err(), "越界项必拒");
    }

    #[test]
    fn c5_secret_field_never_blocks_and_carries_no_value() {
        let f = ConfigField::new("td_password", "交易密码", ConfigKind::Secret);
        assert!(f.validate("").is_ok());
        assert!(
            f.validate("anything").is_ok(),
            "值不在本面(引用行归 ek-cfg)"
        );
    }

    #[test]
    fn c6_view_of_shows_secret_as_ref_only() {
        struct P;
        impl Configurable for P {
            fn config_ns(&self) -> &str {
                "ctp_ctp_9_11"
            }
            fn apply_config(
                &self,
                _cfg: &SectionMap,
            ) -> Result<crate::section::ApplyReport, String> {
                Ok(crate::section::ApplyReport::all_adopted())
            }
            fn dump_config(&self) -> SectionMap {
                SectionMap::new(1)
                    .set("td_addr", crate::section::Field::plain("tcp://a:1"))
                    .set(
                        "td_password",
                        crate::section::Field::secret("gw_td_password"),
                    )
            }
        }
        let v = view_of(&P);
        assert_eq!(v.len(), 2);
        let pw = v.iter().find(|(k, _)| k == "td_password").unwrap();
        assert_eq!(pw.1, "secret://ek/ctp_ctp_9_11/gw_td_password");
        assert!(!pw.1.contains("tcp"), "秘面行不含值");
    }

    #[test]
    fn c7_schema_defaults_are_empty_for_old_plugins() {
        struct Q;
        impl Configurable for Q {
            fn config_ns(&self) -> &str {
                "q"
            }
            fn apply_config(
                &self,
                _cfg: &SectionMap,
            ) -> Result<crate::section::ApplyReport, String> {
                Ok(crate::section::ApplyReport::all_adopted())
            }
            fn dump_config(&self) -> SectionMap {
                SectionMap::new(0)
            }
        }
        // 缺省实现:老插件零负担(不实现照旧编译)。
        assert!(Configurable::config_schema(&Q).is_empty());
        assert!(Configurable::config_view(&Q).is_empty());
    }
}