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}
60
61impl ConfigField {
62 #[must_use]
64 pub fn new(key: impl Into<String>, label: impl Into<String>, kind: ConfigKind) -> Self {
65 Self {
66 key: key.into(),
67 label: label.into(),
68 kind,
69 default: String::new(),
70 help: None,
71 needs_restart: false,
72 }
73 }
74
75 #[must_use]
77 pub fn with_default(mut self, v: impl Into<String>) -> Self {
78 self.default = v.into();
79 self
80 }
81
82 #[must_use]
84 pub fn with_help(mut self, h: impl Into<String>) -> Self {
85 self.help = Some(h.into());
86 self
87 }
88
89 #[must_use]
91 pub fn needs_restart(mut self, yes: bool) -> Self {
92 self.needs_restart = yes;
93 self
94 }
95
96 pub fn validate(&self, raw: &str) -> Result<(), String> {
101 match &self.kind {
102 ConfigKind::Int { min, max, .. } => {
103 let v: i64 = raw
104 .trim()
105 .parse()
106 .map_err(|_| format!("{}:需要整数(收到「{}」)", self.key, raw.trim()))?;
107 if v < *min || v > *max {
108 return Err(format!("{}:需落在 {min}..={max}", self.key));
109 }
110 Ok(())
111 }
112 ConfigKind::Float { min, max, .. } => {
113 let v: f64 = raw
114 .trim()
115 .parse()
116 .map_err(|_| format!("{}:需要数值(收到「{}」)", self.key, raw.trim()))?;
117 if !v.is_finite() {
118 return Err(format!("{}:需要有限数值", self.key));
119 }
120 if v < *min || v > *max {
121 return Err(format!("{}:需落在 {min}..={max}", self.key));
122 }
123 Ok(())
124 }
125 ConfigKind::Bool => match raw.trim() {
126 "true" | "false" => Ok(()),
127 other => Err(format!("{}:需要 true/false(收到「{other}」)", self.key)),
128 },
129 ConfigKind::Text { max_len } => {
130 let v = raw.trim();
131 if v.chars().count() > *max_len {
132 return Err(format!("{}:长度需 ≤ {max_len}", self.key));
133 }
134 Ok(())
135 }
136 ConfigKind::CsvInt { min, max } => {
137 let v = Self::csv_ints(raw).map_err(|e| format!("{}:{e}", self.key))?;
138 if v.is_empty() {
139 return Err(format!("{}:至少一项", self.key));
140 }
141 if v.iter().any(|x| *x < *min || *x > *max) {
142 return Err(format!("{}:每项需落在 {min}..={max}", self.key));
143 }
144 Ok(())
145 }
146 ConfigKind::Enum { options } => {
147 let v = raw.trim();
148 if options.iter().any(|o| o == v) {
149 Ok(())
150 } else {
151 Err(format!(
152 "{}:需取 {} 之一",
153 self.key,
154 options.join("/").as_str()
155 ))
156 }
157 }
158 ConfigKind::Secret => Ok(()),
159 }
160 }
161
162 pub fn csv_ints(raw: &str) -> Result<Vec<i64>, String> {
165 let mut out: Vec<i64> = Vec::new();
166 for part in raw.split(',') {
167 let p = part.trim();
168 if p.is_empty() {
169 continue;
170 }
171 let v: i64 = p.parse().map_err(|_| format!("整数表含非整数项「{p}」"))?;
172 if !out.contains(&v) {
173 out.push(v);
174 }
175 }
176 Ok(out)
177 }
178}
179
180pub type ConfigView = Vec<(String, String)>;
185
186pub trait CfgHost {
191 fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String>;
193
194 fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String>;
196
197 fn log(&self, line: &str);
199}
200
201pub fn view_of<P: Configurable + ?Sized>(plugin: &P) -> ConfigView {
206 plugin
207 .dump_config()
208 .iter()
209 .map(|(k, f)| {
210 let shown = match f {
211 crate::section::Field::Plain(v) => v.clone(),
212 crate::section::Field::Secret { key } => {
213 format!("secret://ek/{}/{}", plugin.config_ns(), key)
214 }
215 };
216 (k.to_string(), shown)
217 })
218 .collect()
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn int() -> ConfigField {
226 ConfigField::new(
227 "max_volume",
228 "单笔最大手数",
229 ConfigKind::Int {
230 min: 1,
231 max: 10000,
232 unit: Some("手".into()),
233 },
234 )
235 }
236
237 #[test]
238 fn c1_validate_int_range() {
239 let f = int();
240 assert!(f.validate("500").is_ok());
241 assert!(f.validate("0").is_err());
242 assert!(f.validate("10001").is_err());
243 assert!(f.validate("abc").is_err());
244 assert!(f.validate(" 12 ").is_ok(), "首尾空白容忍");
245 }
246
247 #[test]
248 fn c2_validate_float_finite_and_range() {
249 let f = ConfigField::new(
250 "band",
251 "价格带",
252 ConfigKind::Float {
253 min: 0.0,
254 max: 1.0,
255 unit: None,
256 },
257 );
258 assert!(f.validate("0.02").is_ok());
259 assert!(f.validate("1.5").is_err());
260 assert!(f.validate("nan").is_err(), "NaN 必拒");
261 assert!(f.validate("inf").is_err(), "inf 必拒");
262 }
263
264 #[test]
265 fn c3_validate_bool_text_enum() {
266 let b = ConfigField::new("on", "开", ConfigKind::Bool);
267 assert!(b.validate("true").is_ok());
268 assert!(b.validate("True").is_err(), "字面量口径严格");
269 let t = ConfigField::new("gw", "网关", ConfigKind::Text { max_len: 3 });
270 assert!(t.validate("ctp").is_ok());
271 assert!(t.validate("ctp9999").is_err());
272 let e = ConfigField::new(
273 "mode",
274 "模式",
275 ConfigKind::Enum {
276 options: vec!["net".into(), "lock".into()],
277 },
278 );
279 assert!(e.validate("lock").is_ok());
280 assert!(e.validate("both").is_err());
281 }
282
283 #[test]
284 fn c4_csv_ints_dedupes_preserves_order() {
285 assert_eq!(
286 ConfigField::csv_ints("60, 300,60, 900,").unwrap(),
287 vec![60, 300, 900]
288 );
289 assert!(ConfigField::csv_ints("60,x").is_err());
290 let f = ConfigField::new(
291 "intervals",
292 "周期",
293 ConfigKind::CsvInt { min: 1, max: 86400 },
294 );
295 assert!(f.validate("60,300").is_ok());
296 assert!(f.validate("").is_err(), "空表必拒");
297 assert!(f.validate("60,0").is_err(), "越界项必拒");
298 }
299
300 #[test]
301 fn c5_secret_field_never_blocks_and_carries_no_value() {
302 let f = ConfigField::new("td_password", "交易密码", ConfigKind::Secret);
303 assert!(f.validate("").is_ok());
304 assert!(
305 f.validate("anything").is_ok(),
306 "值不在本面(引用行归 ek-cfg)"
307 );
308 }
309
310 #[test]
311 fn c6_view_of_shows_secret_as_ref_only() {
312 struct P;
313 impl Configurable for P {
314 fn config_ns(&self) -> &str {
315 "ctp_ctp_9_11"
316 }
317 fn apply_config(
318 &self,
319 _cfg: &SectionMap,
320 ) -> Result<crate::section::ApplyReport, String> {
321 Ok(crate::section::ApplyReport::all_adopted())
322 }
323 fn dump_config(&self) -> SectionMap {
324 SectionMap::new(1)
325 .set("td_addr", crate::section::Field::plain("tcp://a:1"))
326 .set(
327 "td_password",
328 crate::section::Field::secret("gw_td_password"),
329 )
330 }
331 }
332 let v = view_of(&P);
333 assert_eq!(v.len(), 2);
334 let pw = v.iter().find(|(k, _)| k == "td_password").unwrap();
335 assert_eq!(pw.1, "secret://ek/ctp_ctp_9_11/gw_td_password");
336 assert!(!pw.1.contains("tcp"), "秘面行不含值");
337 }
338
339 #[test]
340 fn c7_schema_defaults_are_empty_for_old_plugins() {
341 struct Q;
342 impl Configurable for Q {
343 fn config_ns(&self) -> &str {
344 "q"
345 }
346 fn apply_config(
347 &self,
348 _cfg: &SectionMap,
349 ) -> Result<crate::section::ApplyReport, String> {
350 Ok(crate::section::ApplyReport::all_adopted())
351 }
352 fn dump_config(&self) -> SectionMap {
353 SectionMap::new(0)
354 }
355 }
356 assert!(Configurable::config_schema(&Q).is_empty());
358 assert!(Configurable::config_view(&Q).is_empty());
359 }
360}