Skip to main content

dm_database_sqllog2db/stats/
config.rs

1//! Stats 子命令配置:时间段过滤字段与时间格式验证工具函数。
2
3use crate::error::{ConfigError, Error};
4
5/// Stats 子命令的配置字段:起止时间(可选)与 top-N 数量(可选)。
6#[derive(Debug, Clone, Default, serde::Deserialize)]
7pub struct StatsConfig {
8    #[serde(default)]
9    pub from: Option<String>,
10    #[serde(default)]
11    pub to: Option<String>,
12    #[serde(default)]
13    pub top: Option<u32>,
14}
15
16/// 验证 `StatsConfig` 的 from/to 字段格式;供 `Config::validate` 和 `run_stats` 共用(IN-02)。
17pub fn validate_stats_time_range(stats: &StatsConfig) -> crate::error::Result<()> {
18    if let Some(from) = &stats.from {
19        validate_time_str(from).map_err(|reason| {
20            Error::Config(ConfigError::InvalidValue {
21                field: "stats.from".to_string(),
22                value: from.clone(),
23                reason,
24            })
25        })?;
26    }
27    if let Some(to) = &stats.to {
28        validate_time_str(to).map_err(|reason| {
29            Error::Config(ConfigError::InvalidValue {
30                field: "stats.to".to_string(),
31                value: to.clone(),
32                reason,
33            })
34        })?;
35    }
36    if let (Some(from), Some(to)) = (&stats.from, &stats.to) {
37        // Compare only the common prefix so "2024-01-15 00:00:00" and "2024-01-15"
38        // are treated as equal at the day boundary (matches aggregate.rs prefix logic).
39        let cmp_len = from.len().min(to.len());
40        if from.as_bytes()[..cmp_len] > to.as_bytes()[..cmp_len] {
41            return Err(Error::Config(ConfigError::InvalidValue {
42                field: "stats.from".to_string(),
43                value: from.clone(),
44                reason: format!("stats.from ({from}) must be <= stats.to ({to})"),
45            }));
46        }
47    }
48    Ok(())
49}
50
51/// 验证时间字符串格式。
52///
53/// 支持两种格式:
54/// - `"YYYY-MM-DD"`(10 个字符)
55/// - `"YYYY-MM-DD HH:MM:SS"`(19 个字符)
56///
57/// # Errors
58///
59/// 如果格式不符合要求,返回包含格式说明的错误字符串。
60pub fn validate_time_str(s: &str) -> Result<(), String> {
61    let err = || r#"格式不合法,支持 "YYYY-MM-DD" 或 "YYYY-MM-DD HH:MM:SS""#.to_string();
62
63    if !s.is_ascii() {
64        return Err(err());
65    }
66
67    let bytes = s.as_bytes();
68    match bytes.len() {
69        10 => {
70            if check_date_part(bytes) {
71                Ok(())
72            } else {
73                Err(err())
74            }
75        }
76        19 => {
77            if check_date_part(bytes) && check_time_part(bytes) {
78                Ok(())
79            } else {
80                Err(err())
81            }
82        }
83        _ => Err(err()),
84    }
85}
86
87/// 检查 bytes[0..10] 是否符合 `YYYY-MM-DD` 格式(位置 + 数字 + 月/日范围校验)。
88fn check_date_part(bytes: &[u8]) -> bool {
89    debug_assert!(bytes.len() >= 10, "check_date_part: need at least 10 bytes");
90    if !(bytes[4] == b'-' && bytes[7] == b'-') {
91        return false;
92    }
93    if !bytes[..4].iter().all(u8::is_ascii_digit) {
94        return false;
95    }
96    if !bytes[5..7].iter().all(u8::is_ascii_digit) {
97        return false;
98    }
99    if !bytes[8..10].iter().all(u8::is_ascii_digit) {
100        return false;
101    }
102    let month = (bytes[5] - b'0') * 10 + (bytes[6] - b'0');
103    let day = (bytes[8] - b'0') * 10 + (bytes[9] - b'0');
104    if !(1..=12).contains(&month) {
105        return false;
106    }
107    let max_day: u8 = match month {
108        2 => 29,
109        4 | 6 | 9 | 11 => 30,
110        _ => 31,
111    };
112    (1..=max_day).contains(&day)
113}
114
115/// 检查 bytes[10..19] 是否符合 ` HH:MM:SS` 格式(位置 + 数字 + 时/分/秒范围校验)。
116fn check_time_part(bytes: &[u8]) -> bool {
117    debug_assert!(bytes.len() >= 19, "check_time_part: need at least 19 bytes");
118    if !(bytes[10] == b' ' && bytes[13] == b':' && bytes[16] == b':') {
119        return false;
120    }
121    if !bytes[11..13]
122        .iter()
123        .chain(bytes[14..16].iter())
124        .chain(bytes[17..19].iter())
125        .all(u8::is_ascii_digit)
126    {
127        return false;
128    }
129    let hour = (bytes[11] - b'0') * 10 + (bytes[12] - b'0');
130    let min = (bytes[14] - b'0') * 10 + (bytes[15] - b'0');
131    let sec = (bytes[17] - b'0') * 10 + (bytes[18] - b'0');
132    hour <= 23 && min <= 59 && sec <= 59
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_validate_time_str_accepts_date_only() {
141        assert!(validate_time_str("2024-01-01").is_ok());
142    }
143
144    #[test]
145    fn test_validate_time_str_accepts_datetime() {
146        assert!(validate_time_str("2024-12-31 23:59:59").is_ok());
147    }
148
149    #[test]
150    fn test_validate_time_str_rejects_no_separator() {
151        let result = validate_time_str("20240101");
152        assert!(result.is_err());
153        let msg = result.unwrap_err();
154        assert!(
155            msg.contains("YYYY-MM-DD"),
156            "error should contain YYYY-MM-DD: {msg}"
157        );
158        assert!(
159            msg.contains("YYYY-MM-DD HH:MM:SS"),
160            "error should contain YYYY-MM-DD HH:MM:SS: {msg}"
161        );
162    }
163
164    #[test]
165    fn test_validate_time_str_rejects_not_a_date() {
166        let result = validate_time_str("not-a-date");
167        assert!(result.is_err());
168    }
169
170    #[test]
171    fn test_validate_time_str_rejects_short_date() {
172        let result = validate_time_str("2024-1-1");
173        assert!(result.is_err());
174    }
175
176    #[test]
177    fn test_validate_time_str_rejects_t_separator() {
178        let result = validate_time_str("2024-01-01T12:00:00");
179        assert!(result.is_err());
180    }
181
182    #[test]
183    fn test_validate_time_str_rejects_slash_separator() {
184        let result = validate_time_str("2024/01/01");
185        assert!(result.is_err());
186    }
187
188    #[test]
189    fn test_validate_time_str_rejects_empty() {
190        let result = validate_time_str("");
191        assert!(result.is_err());
192    }
193
194    #[test]
195    fn test_validate_time_str_rejects_month_zero() {
196        assert!(validate_time_str("2024-00-01").is_err());
197    }
198
199    #[test]
200    fn test_validate_time_str_rejects_month_13() {
201        assert!(validate_time_str("2024-13-01").is_err());
202    }
203
204    #[test]
205    fn test_validate_time_str_rejects_day_zero() {
206        assert!(validate_time_str("2024-01-00").is_err());
207    }
208
209    #[test]
210    fn test_validate_time_str_rejects_day_32() {
211        assert!(validate_time_str("2024-01-32").is_err());
212    }
213
214    #[test]
215    fn test_validate_time_str_rejects_hour_24() {
216        assert!(validate_time_str("2024-01-01 24:00:00").is_err());
217    }
218
219    #[test]
220    fn test_validate_time_str_rejects_minute_60() {
221        assert!(validate_time_str("2024-01-01 00:60:00").is_err());
222    }
223
224    #[test]
225    fn test_validate_time_str_rejects_second_60() {
226        assert!(validate_time_str("2024-01-01 00:00:60").is_err());
227    }
228
229    #[test]
230    fn test_stats_config_default_all_none() {
231        let cfg = StatsConfig::default();
232        assert!(cfg.from.is_none());
233        assert!(cfg.to.is_none());
234        assert!(cfg.top.is_none());
235    }
236
237    #[test]
238    fn test_stats_config_deserialize_empty_toml() {
239        #[derive(serde::Deserialize)]
240        struct W {
241            stats: StatsConfig,
242        }
243        let w: W = toml::from_str("[stats]").unwrap();
244        assert!(w.stats.from.is_none());
245        assert!(w.stats.to.is_none());
246        assert!(w.stats.top.is_none());
247    }
248
249    #[test]
250    fn test_stats_config_deserialize_partial_toml() {
251        #[derive(serde::Deserialize)]
252        struct W {
253            stats: StatsConfig,
254        }
255        let w: W = toml::from_str("[stats]\nfrom = \"2024-01-01\"\ntop = 10").unwrap();
256        assert_eq!(w.stats.from, Some("2024-01-01".to_string()));
257        assert!(w.stats.to.is_none());
258        assert_eq!(w.stats.top, Some(10));
259    }
260
261    #[test]
262    fn test_validate_stats_time_range_rejects_from_after_to() {
263        let cfg = StatsConfig {
264            from: Some("2024-01-31".to_string()),
265            to: Some("2024-01-01".to_string()),
266            top: None,
267        };
268        let result = validate_stats_time_range(&cfg);
269        assert!(result.is_err(), "from > to should return Err");
270        match result.unwrap_err() {
271            crate::error::Error::Config(crate::error::ConfigError::InvalidValue {
272                field,
273                value,
274                reason,
275            }) => {
276                assert_eq!(field, "stats.from");
277                assert_eq!(value, "2024-01-31");
278                assert!(
279                    reason.contains("must be <="),
280                    "reason should contain 'must be <=': {reason}"
281                );
282                assert!(
283                    reason.contains("2024-01-31"),
284                    "reason should contain from value: {reason}"
285                );
286                assert!(
287                    reason.contains("2024-01-01"),
288                    "reason should contain to value: {reason}"
289                );
290            }
291            err => panic!("expected ConfigError::InvalidValue, got: {err:?}"),
292        }
293    }
294
295    #[test]
296    fn test_validate_stats_time_range_accepts_equal_from_to() {
297        let cfg = StatsConfig {
298            from: Some("2024-01-15".to_string()),
299            to: Some("2024-01-15".to_string()),
300            top: None,
301        };
302        assert!(
303            validate_stats_time_range(&cfg).is_ok(),
304            "from == to should be accepted"
305        );
306    }
307
308    #[test]
309    fn test_validate_stats_time_range_accepts_from_only() {
310        let cfg_from_only = StatsConfig {
311            from: Some("2024-01-15".to_string()),
312            to: None,
313            top: None,
314        };
315        assert!(
316            validate_stats_time_range(&cfg_from_only).is_ok(),
317            "only from should be accepted"
318        );
319        let cfg_to_only = StatsConfig {
320            from: None,
321            to: Some("2024-01-15".to_string()),
322            top: None,
323        };
324        assert!(
325            validate_stats_time_range(&cfg_to_only).is_ok(),
326            "only to should be accepted"
327        );
328    }
329
330    #[test]
331    fn test_validate_stats_time_range_accepts_ordered() {
332        let cfg = StatsConfig {
333            from: Some("2024-01-01".to_string()),
334            to: Some("2024-01-31".to_string()),
335            top: None,
336        };
337        assert!(
338            validate_stats_time_range(&cfg).is_ok(),
339            "from < to should be accepted"
340        );
341    }
342
343    #[test]
344    fn test_validate_stats_time_range_accepts_datetime_from_with_date_to() {
345        let cfg = StatsConfig {
346            from: Some("2024-01-15 00:00:00".to_string()),
347            to: Some("2024-01-15".to_string()),
348            top: None,
349        };
350        assert!(
351            validate_stats_time_range(&cfg).is_ok(),
352            "datetime from at start of to-date should be accepted"
353        );
354    }
355
356    #[test]
357    fn test_validate_time_str_rejects_feb_31() {
358        assert!(validate_time_str("2024-02-31").is_err());
359    }
360
361    #[test]
362    fn test_validate_time_str_rejects_apr_31() {
363        assert!(validate_time_str("2024-04-31").is_err());
364    }
365
366    #[test]
367    fn test_validate_time_str_accepts_feb_29() {
368        assert!(validate_time_str("2024-02-29").is_ok());
369    }
370}