dm_database_sqllog2db/config/
validate.rs1use 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 #[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 #[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 #[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 #[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 assert!(cfg.validate().is_ok());
355 }
356}