Skip to main content

dm_database_sqllog2db/config/
validate.rs

1use super::Config;
2use crate::error::{ConfigError, Error, Result};
3
4impl Config {
5    pub fn validate(&self) -> Result<()> {
6        self.logging.validate()?;
7        self.exporter.validate()?;
8        self.sqllog.validate()?;
9        self.validate_output_fields()?;
10        self.validate_stats_time_fields()?;
11        self.validate_error_log()?;
12        Ok(())
13    }
14
15    fn validate_error_log(&self) -> Result<()> {
16        if let Some(err_cfg) = &self.error {
17            if err_cfg.file.trim().is_empty() {
18                return Err(Error::Config(ConfigError::InvalidValue {
19                    field: "error.file".to_string(),
20                    value: err_cfg.file.clone(),
21                    reason: "error log file path must not be empty or whitespace".to_string(),
22                }));
23            }
24        }
25        Ok(())
26    }
27
28    fn validate_stats_time_fields(&self) -> Result<()> {
29        crate::stats::config::validate_stats_time_range(&self.stats)
30    }
31
32    fn validate_output_fields(&self) -> Result<()> {
33        if let Some(names) = self.output.as_ref().and_then(|o| o.fields.as_ref()) {
34            for name in names {
35                if !crate::pipeline::FIELD_NAMES.contains(&name.as_str()) {
36                    return Err(Error::Config(ConfigError::InvalidValue {
37                        field: "output.fields".to_string(),
38                        value: name.clone(),
39                        reason: format!(
40                            "unknown field '{name}'; valid fields: {}",
41                            crate::pipeline::FIELD_NAMES.join(", ")
42                        ),
43                    }));
44                }
45            }
46        }
47        Ok(())
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::config::{CsvExporterConfig, SqliteExporterConfig};
55    use crate::pipeline::OutputConfig;
56
57    fn default_config() -> Config {
58        Config::default()
59    }
60
61    // ── validate ───────────────────────────────────────────────
62    #[test]
63    fn test_validate_default_config_passes() {
64        assert!(default_config().validate().is_ok());
65    }
66
67    #[test]
68    fn test_validate_empty_logging_file() {
69        let mut cfg = default_config();
70        cfg.logging.file = "  ".into();
71        assert!(cfg.validate().is_err());
72    }
73
74    #[test]
75    fn test_validate_empty_csv_file() {
76        let mut cfg = default_config();
77        cfg.exporter.csv = Some(CsvExporterConfig {
78            file: "  ".into(),
79            ..CsvExporterConfig::default()
80        });
81        assert!(cfg.validate().is_err());
82    }
83
84    #[test]
85    fn test_validate_empty_sqlite_database_url() {
86        let mut cfg = default_config();
87        cfg.exporter.csv = None;
88        cfg.exporter.sqlite = Some(SqliteExporterConfig {
89            database_url: "  ".into(),
90            ..SqliteExporterConfig::default()
91        });
92        assert!(cfg.validate().is_err());
93    }
94
95    #[test]
96    fn test_validate_empty_sqlite_table_name() {
97        let mut cfg = default_config();
98        cfg.exporter.csv = None;
99        cfg.exporter.sqlite = Some(SqliteExporterConfig {
100            table_name: "  ".into(),
101            ..SqliteExporterConfig::default()
102        });
103        assert!(cfg.validate().is_err());
104    }
105
106    #[test]
107    fn test_validate_invalid_log_level() {
108        let mut cfg = default_config();
109        cfg.logging.level = "invalid".into();
110        assert!(cfg.validate().is_err());
111    }
112
113    #[test]
114    fn test_validate_retention_days_zero() {
115        let mut cfg = default_config();
116        cfg.logging.retention_days = 0;
117        assert!(cfg.validate().is_err());
118    }
119
120    #[test]
121    fn test_validate_retention_days_over_365() {
122        let mut cfg = default_config();
123        cfg.logging.retention_days = 366;
124        assert!(cfg.validate().is_err());
125    }
126
127    #[test]
128    fn test_validate_rejects_whitespace_input_entry() {
129        let mut cfg = default_config();
130        cfg.sqllog.inputs = vec!["  ".to_string()];
131        assert!(cfg.validate().is_err());
132    }
133
134    #[test]
135    fn test_validate_no_exporters() {
136        let mut cfg = default_config();
137        cfg.exporter.csv = None;
138        assert!(cfg.validate().is_err());
139    }
140
141    // ── table_name ASCII 标识符校验 ────────────────────────────
142    #[test]
143    fn test_validate_sqlite_table_name_valid_simple() {
144        let mut cfg = default_config();
145        cfg.exporter.sqlite = Some(SqliteExporterConfig {
146            database_url: "/tmp/x.db".into(),
147            table_name: "tbl".into(),
148            ..SqliteExporterConfig::default()
149        });
150        cfg.exporter.csv = None;
151        assert!(cfg.validate().is_ok());
152    }
153
154    #[test]
155    fn test_validate_sqlite_table_name_valid_underscore_prefix() {
156        let mut cfg = default_config();
157        cfg.exporter.sqlite = Some(SqliteExporterConfig {
158            database_url: "/tmp/x.db".into(),
159            table_name: "_records".into(),
160            ..SqliteExporterConfig::default()
161        });
162        cfg.exporter.csv = None;
163        assert!(cfg.validate().is_ok());
164    }
165
166    #[test]
167    fn test_validate_sqlite_table_name_valid_with_digits() {
168        let mut cfg = default_config();
169        cfg.exporter.sqlite = Some(SqliteExporterConfig {
170            database_url: "/tmp/x.db".into(),
171            table_name: "t1_log_2024".into(),
172            ..SqliteExporterConfig::default()
173        });
174        cfg.exporter.csv = None;
175        assert!(cfg.validate().is_ok());
176    }
177
178    #[test]
179    fn test_validate_sqlite_table_name_rejects_leading_digit() {
180        let mut cfg = default_config();
181        cfg.exporter.sqlite = Some(SqliteExporterConfig {
182            database_url: "/tmp/x.db".into(),
183            table_name: "1tbl".into(),
184            ..SqliteExporterConfig::default()
185        });
186        cfg.exporter.csv = None;
187        let err = cfg.validate().unwrap_err();
188        let msg = format!("{err}");
189        assert!(msg.contains("ASCII identifiers only"), "actual: {msg}");
190        assert!(msg.contains("exporter.sqlite.table_name"), "actual: {msg}");
191    }
192
193    #[test]
194    fn test_validate_sqlite_table_name_rejects_special_char() {
195        let mut cfg = default_config();
196        cfg.exporter.sqlite = Some(SqliteExporterConfig {
197            database_url: "/tmp/x.db".into(),
198            table_name: "tbl;DROP".into(),
199            ..SqliteExporterConfig::default()
200        });
201        cfg.exporter.csv = None;
202        let err = cfg.validate().unwrap_err();
203        let msg = format!("{err}");
204        assert!(msg.contains("ASCII identifiers only"), "actual: {msg}");
205    }
206
207    #[test]
208    fn test_validate_sqlite_table_name_rejects_quote() {
209        let mut cfg = default_config();
210        cfg.exporter.sqlite = Some(SqliteExporterConfig {
211            database_url: "/tmp/x.db".into(),
212            table_name: "tbl\"x".into(),
213            ..SqliteExporterConfig::default()
214        });
215        cfg.exporter.csv = None;
216        let err = cfg.validate().unwrap_err();
217        let msg = format!("{err}");
218        assert!(msg.contains("ASCII identifiers only"), "actual: {msg}");
219    }
220
221    #[test]
222    fn test_validate_sqlite_table_name_rejects_non_ascii() {
223        let mut cfg = default_config();
224        cfg.exporter.sqlite = Some(SqliteExporterConfig {
225            database_url: "/tmp/x.db".into(),
226            table_name: "日志表".into(),
227            ..SqliteExporterConfig::default()
228        });
229        cfg.exporter.csv = None;
230        let err = cfg.validate().unwrap_err();
231        let msg = format!("{err}");
232        assert!(msg.contains("ASCII identifiers only"), "actual: {msg}");
233    }
234
235    #[test]
236    fn test_validate_sqlite_table_name_rejects_space() {
237        let mut cfg = default_config();
238        cfg.exporter.sqlite = Some(SqliteExporterConfig {
239            database_url: "/tmp/x.db".into(),
240            table_name: "my tbl".into(),
241            ..SqliteExporterConfig::default()
242        });
243        cfg.exporter.csv = None;
244        let err = cfg.validate().unwrap_err();
245        let msg = format!("{err}");
246        assert!(msg.contains("ASCII identifiers only"), "actual: {msg}");
247    }
248
249    #[test]
250    fn test_validate_new_nested_format_passes() {
251        let toml = r#"
252[sqllog]
253inputs = ["sqllogs"]
254[filter]
255enable = true
256[filter.include]
257users = ["admin"]
258[filter.exclude]
259users = ["guest"]
260[exporter.csv]
261file = "out.csv"
262"#;
263        let cfg: Config = toml::from_str(toml).unwrap();
264        assert!(cfg.validate().is_ok());
265    }
266
267    #[test]
268    fn test_validate_rejects_legacy_sqllog_path_key() {
269        let toml = r#"
270[sqllog]
271path = "sqllogs"
272[exporter.csv]
273file = "out.csv"
274"#;
275        let cfg: Config = toml::from_str(toml).unwrap();
276        let result = cfg.validate();
277        assert!(result.is_err());
278        let err_msg = result.unwrap_err().to_string();
279        assert!(
280            err_msg.contains("sqllog.path"),
281            "expect sqllog.path field name; got: {err_msg}"
282        );
283        assert!(
284            err_msg.contains("inputs"),
285            "expect migration hint to mention inputs; got: {err_msg}"
286        );
287    }
288
289    #[test]
290    fn test_validate_new_top_level_format_passes() {
291        let toml = r#"
292[sqllog]
293inputs = ["sqllogs"]
294[filter]
295enable = false
296[output]
297fields = ["ts", "sql", "username"]
298[exporter.csv]
299file = "out.csv"
300"#;
301        let cfg: Config = toml::from_str(toml).unwrap();
302        assert!(cfg.validate().is_ok());
303    }
304
305    // ── output.fields 校验 ───────────────────────────────────
306    #[test]
307    fn test_validate_output_fields_unknown_field_rejected() {
308        let mut cfg = default_config();
309        cfg.output = Some(OutputConfig {
310            fields: Some(vec!["unknown_field".into()]),
311        });
312        let result = cfg.validate();
313        assert!(result.is_err());
314        let msg = result.unwrap_err().to_string();
315        assert!(msg.contains("output.fields"), "actual: {msg}");
316        assert!(msg.contains("unknown_field"), "actual: {msg}");
317    }
318
319    // ── stats.from / stats.to 时间格式校验 ───────────────────
320    #[test]
321    fn test_validate_rejects_invalid_stats_from() {
322        let mut cfg = default_config();
323        cfg.stats.from = Some("not-a-date".into());
324        let result = cfg.validate();
325        assert!(result.is_err());
326        let msg = result.unwrap_err().to_string();
327        assert!(msg.contains("stats.from"), "actual: {msg}");
328        assert!(msg.contains("YYYY-MM-DD"), "actual: {msg}");
329    }
330
331    #[test]
332    fn test_validate_rejects_invalid_stats_to() {
333        let mut cfg = default_config();
334        cfg.stats.to = Some("20240101".into());
335        let result = cfg.validate();
336        assert!(result.is_err());
337        let msg = result.unwrap_err().to_string();
338        assert!(msg.contains("stats.to"), "actual: {msg}");
339        assert!(msg.contains("YYYY-MM-DD"), "actual: {msg}");
340    }
341
342    #[test]
343    fn test_validate_accepts_valid_stats_time_strings() {
344        let mut cfg = default_config();
345        cfg.stats.from = Some("2024-01-01".into());
346        cfg.stats.to = Some("2024-01-31 23:59:59".into());
347        assert!(cfg.validate().is_ok());
348    }
349
350    #[test]
351    fn test_validate_accepts_none_stats_time() {
352        let cfg = default_config();
353        // stats 默认全 None,不应触发验证错误
354        assert!(cfg.validate().is_ok());
355    }
356}