use crate::section::{Configurable, SectionMap};
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigKind {
Int {
min: i64,
max: i64,
unit: Option<String>,
},
Float {
min: f64,
max: f64,
unit: Option<String>,
},
Bool,
Text { max_len: usize },
CsvInt { min: i64, max: i64 },
Enum { options: Vec<String> },
Secret,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigField {
pub key: String,
pub label: String,
pub kind: ConfigKind,
pub default: String,
pub help: Option<String>,
pub needs_restart: bool,
}
impl ConfigField {
#[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
}
#[must_use]
pub fn needs_restart(mut self, yes: bool) -> Self {
self.needs_restart = yes;
self
}
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(()),
}
}
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)
}
}
pub type ConfigView = Vec<(String, String)>;
pub trait CfgHost {
fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String>;
fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String>;
fn log(&self, line: &str);
}
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());
}
}