1use crate::section::{Configurable, SectionMap};
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum ConfigKind {
20 Int {
22 min: i64,
23 max: i64,
24 unit: Option<String>,
25 },
26 Float {
28 min: f64,
29 max: f64,
30 unit: Option<String>,
31 },
32 Bool,
34 Text { max_len: usize },
36 CsvInt { min: i64, max: i64 },
38 Enum { options: Vec<String> },
40 Secret,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct ConfigField {
47 pub key: String,
49 pub label: String,
51 pub kind: ConfigKind,
53 pub default: String,
55 pub help: Option<String>,
57 pub needs_restart: bool,
59 pub group: String,
65}
66
67impl ConfigField {
68 #[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 #[must_use]
84 pub fn with_group(mut self, g: impl Into<String>) -> Self {
85 self.group = g.into();
86 self
87 }
88
89 #[must_use]
91 pub fn with_default(mut self, v: impl Into<String>) -> Self {
92 self.default = v.into();
93 self
94 }
95
96 #[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 #[must_use]
105 pub fn needs_restart(mut self, yes: bool) -> Self {
106 self.needs_restart = yes;
107 self
108 }
109
110 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 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
194pub type ConfigView = Vec<(String, String)>;
199
200pub trait CfgHost {
205 fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String>;
207
208 fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String>;
210
211 fn log(&self, line: &str);
213}
214
215pub 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 assert!(Configurable::config_schema(&Q).is_empty());
372 assert!(Configurable::config_view(&Q).is_empty());
373 }
374}