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
//! section —— 配置托底契约(BD-CFG-1..6;2026-10-25 定盘,用户裁定「软集中」)。
//!
//! 定位纪律(BD-CFG-2):**纯数据 + trait,零格式解析**。行式键值的解析/序列化
//! 在 ek-cfg(BD-CFG-3);本 crate 只保证「全链一份类型」。两宿主(ek-station /
//! ek-ui)的 boot 载入与 svctl.start 重注路径经 `Plugin::as_configurable`
//! 探查后直调 [`Configurable::apply_config`](Configurable::apply_config)
//! 回灌(BD-CFG-1:宿主两步直调,不解释不转发)。
//!
//! 生效三档(BD-CFG-6):`apply_config` 回 [`ApplyReport`],`deferred` 非空 =
//! 「已采纳+需重启插件才生效」。配置面修改**永不**要求重启整个应用;插件级
//! 重启 = svctl.remove→start(五步闭停,秒级)。

use std::fmt;

/// 字段形态(BD-CFG-12/15)。
///
/// - [`Field::Plain`] = 明面值(进文件);
/// - [`Field::Secret`] = 秘面引用(**只有键名,无值**)——真值在系统凭据库或
///   env(BD-CFG-12),文件里落的是 `secret://ek/<ns>/<key>` 引用串
///   (由 ek-cfg 的 `secret::ref_of` 单源生成,BD-CFG-13)。
///
/// 本枚举**不含秘值本体**(key 只是键名),`derive(Debug)` 合法;
/// 含真值的类型(ek-cfg `SecretValue`)按 BD-CFG-18 走手工 Debug。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Field {
    /// 明面值(单值或逗号并列的列表值;分隔细节归格式层 ek-cfg)。
    Plain(String),
    /// 秘面引用;`key` = 秘库键名(不含值)。
    Secret {
        /// 秘库键名(与 ns 组合出唯一条目 `ek/<ns>/<key>`)。
        key: String,
    },
}

impl Field {
    /// 明面值便捷构造。
    #[must_use]
    pub fn plain(value: impl Into<String>) -> Self {
        Self::Plain(value.into())
    }

    /// 秘面引用便捷构造。
    #[must_use]
    pub fn secret(key: impl Into<String>) -> Self {
        Self::Secret { key: key.into() }
    }

    /// 是明面吗。秘面(false)时取引用键走 [`Self::secret_key`]。
    #[must_use]
    pub fn is_plain(&self) -> bool {
        matches!(self, Self::Plain(_))
    }

    /// 明面值借用(`None` = 秘面)。
    #[must_use]
    pub fn plain_value(&self) -> Option<&str> {
        match self {
            Self::Plain(v) => Some(v.as_str()),
            Self::Secret { .. } => None,
        }
    }

    /// 秘面引用键借用(`None` = 明面)。
    #[must_use]
    pub fn secret_key(&self) -> Option<&str> {
        match self {
            Self::Secret { key } => Some(key.as_str()),
            Self::Plain(_) => None,
        }
    }
}

/// 一段配置(一个 ns 一段;磁盘上 = `${config_dir}/ek/cfg/<ns>.ini`)。
///
/// 纯数据容器(BD-CFG-2):键不重复(`set` 覆盖同键);`rev` 对应文件首行
/// `rev = 1`(语义迁移由插件按 rev 自裁,BD-CFG-3);列表值 = 逗号并列字符串,
/// 分列/归一归格式层。**不含解析**——从文件到本类型只发生在 ek-cfg。
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SectionMap {
    /// 文件首行 `rev`;无文件回灌时 = 0(插件自判首启形态)。
    pub rev: u32,
    entries: Vec<(String, Field)>,
}

impl SectionMap {
    /// 空段(rev 显式给——0 = 「无文件」,插件侧自判首启)。
    #[must_use]
    pub fn new(rev: u32) -> Self {
        Self {
            rev,
            entries: Vec::new(),
        }
    }

    /// 设键(存在即覆盖,保位次序)。链式可用。
    pub fn set(mut self, key: impl Into<String>, field: Field) -> Self {
        let key = key.into();
        match self.entries.iter_mut().find(|(k, _)| *k == key) {
            Some(slot) => slot.1 = field,
            None => self.entries.push((key, field)),
        }
        self
    }

    /// 取字段借查。
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&Field> {
        self.entries
            .iter()
            .find(|(k, _)| k.as_str() == key)
            .map(|(_, f)| f)
    }

    /// 明面值借查(存在且为明面才 `Some`)。
    #[must_use]
    pub fn get_plain(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Field::plain_value)
    }

    /// 秘面引用键借查(存在且为秘面才 `Some`)。
    #[must_use]
    pub fn get_secret_key(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Field::secret_key)
    }

    /// 摘键(不存在 = `None`)。
    pub fn remove(&mut self, key: &str) -> Option<Field> {
        let idx = self.entries.iter().position(|(k, _)| k.as_str() == key)?;
        Some(self.entries.remove(idx).1)
    }

    /// 键序遍历(载体顺序 = set 序,覆盖保位)。
    pub fn iter(&self) -> impl Iterator<Item = (&str, &Field)> {
        self.entries.iter().map(|(k, f)| (k.as_str(), f))
    }

    /// 键数。
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// 空段吗。
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

impl fmt::Debug for SectionMap {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // 明面值可直读;秘面只打键名,不打任何"值"形态(BD-CFG-18 兜底),
        // 与 Field 自身的派生 Debug 语义一致。
        f.debug_struct("SectionMap")
            .field("rev", &self.rev)
            .field(
                "entries",
                &self
                    .entries
                    .iter()
                    .map(|(k, field)| match field {
                        Field::Plain(v) => (k.as_str(), v.as_str()),
                        Field::Secret { key } => (k.as_str(), key.as_str()),
                    })
                    .collect::<Vec<_>>(),
            )
            .finish()
    }
}

/// `apply_config` 的回执(BD-CFG-6):两段键清单,缺省 = 全采纳。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ApplyReport {
    /// 本回已采纳(即 Live/NextUse 两档的字段;具体档位是插件内部细节,不外漏)。
    pub adopted: Vec<String>,
    /// 「已采纳但需重启插件才生效」的键(NextStart 档)。
    pub deferred: Vec<String>,
}

impl ApplyReport {
    /// 全采纳报告。
    #[must_use]
    pub const fn all_adopted() -> Self {
        Self {
            adopted: Vec::new(),
            deferred: Vec::new(),
        }
    }

    /// 无 deferred 吗(boot 载入恒真——插件未启动,一切即 Live)。
    #[must_use]
    pub fn no_deferred(&self) -> bool {
        self.deferred.is_empty()
    }
}

/// 配置托底契约(**可选能力**:有配置的插件实现它,没配置的插件零负担)。
///
/// 实现面(目标:
/// - [`Configurable::config_ns`] 全链唯一(命名实例 = `<工厂>.<注册名>`,
///   存构造期字符串回 `&str`;字符纪律及校验见 [`valid_config_ns`]);
/// - [`Configurable::apply_config`] **只回灌引用**:秘面字段在本方法内**禁止**
///   调 secret 取值(宿主管理线程,ek-cfg 秘取可秒级阻塞,BD-CFG-16);
///   真实取值由插件在自己的时机(典型 = on_start 后自发连接前)做;
/// - [`Configurable::dump_config`] 只输出要持久的字段——不输出 = 不持久
///   (BD-CFG-15);秘面字段只出引用形态。
///
/// 内部状态修改用自锁(与 Plugin 纪律同式:`Mutex/RwLock`)——本 trait 是
/// `&self`,可重入(boot 载入与运行期回灌同一条路径)。
pub trait Configurable {
    /// 命名空间(全链唯一;add_plugin 期撞名校验)。
    fn config_ns(&self) -> &str;

    /// 托底载入(宿主 boot / svctl.start 时直调;失败 = 上抛给单一硬报错点)。
    fn apply_config(&self, cfg: &SectionMap) -> Result<ApplyReport, String>;

    /// 托底导出(插件受理成功后由插件自己 dump → ask `cfg.save`;见 Ek-14)。
    fn dump_config(&self) -> SectionMap;

    /// 参数自描述(**「插件即 App」波加项,缺省空 = 老插件零负担**)。
    ///
    /// 声明了才进通用参数窗(ek-ui 呈现层按此渲染字段与界,不硬编码字段表);
    /// 校验单源 = [`crate::cfg_face::ConfigField::validate`](crate::cfg_face::ConfigField::validate),
    /// 界面/CLI/导入档案包三条路共用。
    fn config_schema(&self) -> Vec<crate::cfg_face::ConfigField> {
        Vec::new()
    }

    /// 当前值视图(**缺省走 [`crate::cfg_face::view_of`](crate::cfg_face::view_of) = dump 形态**)。
    ///
    /// 秘面只出引用串,值永不进本面(呈现侧纪律,同 BD-CFG-12/15)。
    fn config_view(&self) -> crate::cfg_face::ConfigView {
        crate::cfg_face::view_of(self)
    }
}

/// ns 字符纪律(BD-CFG-3/6):首字符字母或数字,其余 alnum/`_`/`-`/`.`;
/// 长度 1..=64;禁 `..`(防路径穿越)、禁首尾点。命名实例形态
/// `<工厂名>.<注册名>` 两段均过 [`crate::plugin::valid_plugin_name`]。
#[must_use]
pub fn valid_config_ns(ns: &str) -> bool {
    let b = ns.as_bytes();
    if b.is_empty() || b.len() > 64 || !b[0].is_ascii_alphanumeric() {
        return false;
    }
    if ns.starts_with('.') || ns.ends_with('.') || ns.contains("..") {
        return false;
    }
    b[1..]
        .iter()
        .all(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'-' || *c == b'.')
}

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

    #[test]
    fn t1_field_helpers() {
        let p = Field::plain("x");
        assert_eq!(p.plain_value(), Some("x"));
        assert!(p.is_plain());
        assert!(p.secret_key().is_none());

        let s = Field::secret("k1");
        assert_eq!(s.secret_key(), Some("k1"));
        assert!(s.plain_value().is_none());
        assert!(!s.is_plain());
    }

    #[test]
    fn t2_set_get_remove_cover() {
        let m = SectionMap::new(1)
            .set("a", Field::plain("1"))
            .set("b", Field::secret("bb"));
        assert_eq!(m.get_plain("a"), Some("1"));
        assert_eq!(m.get_secret_key("b"), Some("bb"));
        assert!(m.get("c").is_none());
        assert_eq!(m.len(), 2);

        // 覆盖 = 同键覆盖,保位次序,不增行。
        let m2 = m.clone().set("a", Field::plain("z"));
        assert_eq!(m2.len(), 2);
        assert_eq!(m2.get_plain("a"), Some("z"));
        assert_eq!(m2.iter().next().map(|(k, _)| k), Some("a"));

        let mut m3 = m2.clone();
        assert_eq!(m3.remove("a"), Some(Field::plain("z")));
        assert!(m3.get("a").is_none());
        assert!(m3.remove("zz").is_none());
    }

    #[test]
    fn t3_apply_report_default_and_helpers() {
        let r = ApplyReport::all_adopted();
        assert!(r.no_deferred());
        let r2 = ApplyReport::default();
        assert!(r2.no_deferred());
        let r3 = ApplyReport {
            adopted: vec!["a".into()],
            deferred: vec!["b".into()],
        };
        assert!(!r3.no_deferred());
    }

    #[test]
    fn t4_valid_config_ns_matrix() {
        // 正样(含命名实例形态)。
        assert!(valid_config_ns("oms"));
        assert!(valid_config_ns("ctp"));
        assert!(valid_config_ns("ctp.ctp_i130_001"));
        assert!(valid_config_ns("a1-b_c.d2"));
        // 拒样:空/超长/非首字母开/首尾点/双点/非法字符。
        assert!(!valid_config_ns(""));
        assert!(!valid_config_ns(&"a".repeat(65)));
        assert!(!valid_config_ns("_oms"));
        assert!(!valid_config_ns("-oms"));
        assert!(!valid_config_ns(".oms"));
        assert!(!valid_config_ns("oms."));
        assert!(!valid_config_ns("a..b"));
        assert!(!valid_config_ns("a/b"));
        assert!(!valid_config_ns("a b"));
        assert!(!valid_config_ns("a\\b"));
    }

    #[test]
    fn t5_debug_never_leaks_secret_field_shape() {
        // 秘面在 Debug 里只出键名(这本身就是键名,非值)——形状钉防将来
        // 有人把 Field::Secret 改回携带值(值一进来就会随这行测试的形状断言爆)。
        let m = SectionMap::new(1).set("pw", Field::secret("k1"));
        let d = format!("{m:?}");
        assert!(d.contains("pw"));
        assert!(d.contains("k1"));
        // 明面照读。
        let m2 = SectionMap::new(1).set("addr", Field::plain("tcp://x"));
        assert!(format!("{m2:?}").contains("tcp://x"));
    }
}