Skip to main content

fsmon/
utils.rs

1use std::path::Path;
2
3pub use sizefilter::{SizeFilter, SizeOp, format_size, parse_size, parse_size_filter};
4pub use timefilter::{TimeFilter, TimeOp, format_datetime, parse_time, parse_time_filter};
5
6use crate::proc_cache::{DefaultCache as ProcCache, ProcInfo};
7use chrono::{DateTime, Utc};
8use proc_tree::{CacheStore, read_proc_start_time_ns};
9
10/// Extension trait for TimeFilter to provide matching and classification methods.
11pub trait TimeFilterExt {
12    /// Check if a timestamp matches this filter.
13    fn matches(&self, time: DateTime<Utc>) -> bool;
14
15    /// Check if this filter is a lower bound (Gt or Ge).
16    fn is_lower_bound(&self) -> bool;
17
18    /// Check if this filter is an upper bound (Lt or Le).
19    fn is_upper_bound(&self) -> bool;
20}
21
22impl TimeFilterExt for TimeFilter {
23    fn matches(&self, time: DateTime<Utc>) -> bool {
24        match self.op {
25            TimeOp::Gt => time > self.time,
26            TimeOp::Ge => time >= self.time,
27            TimeOp::Lt => time < self.time,
28            TimeOp::Le => time <= self.time,
29            TimeOp::Eq => time == self.time,
30        }
31    }
32
33    fn is_lower_bound(&self) -> bool {
34        matches!(self.op, TimeOp::Gt | TimeOp::Ge)
35    }
36
37    fn is_upper_bound(&self) -> bool {
38        matches!(self.op, TimeOp::Lt | TimeOp::Le)
39    }
40}
41
42/// Threshold for disk space pre-check.
43/// `Percent(pct)` — warn when free space drops below `pct`% of total.
44/// `Bytes(n)`    — warn when free space drops below `n` bytes.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum DiskFreeThreshold {
47    Percent(f64),
48    Bytes(u64),
49}
50
51/// Parse a `--disk-min-free` style value.
52/// "10%" → DiskFreeThreshold::Percent(10.0)
53/// "5GB" → DiskFreeThreshold::Bytes(5_368_709_120)
54/// "500MB" → DiskFreeThreshold::Bytes(524_288_000)
55pub fn parse_disk_min_free(s: &str) -> Result<DiskFreeThreshold, String> {
56    let s = s.trim();
57    if let Some(pct) = s.strip_suffix('%') {
58        let val: f64 = pct
59            .trim()
60            .parse()
61            .map_err(|e| format!("invalid percentage '{}': {}", pct, e))?;
62        if val <= 0.0 || val > 100.0 {
63            return Err(format!("percentage must be between 0 and 100, got {}", val));
64        }
65        Ok(DiskFreeThreshold::Percent(val))
66    } else {
67        let bytes = parse_size(s).map_err(|e| format!("invalid size '{}': {}", s, e))?;
68        if bytes <= 0 {
69            return Err("disk-min-free must be positive".to_string());
70        }
71        Ok(DiskFreeThreshold::Bytes(bytes as u64))
72    }
73}
74
75/// Get process info by PID (from fanotify event)
76/// Checks proc connector cache first (for short-lived processes),
77/// then falls back to /proc (for long-lived processes),
78/// then falls back to file owner for USER.
79pub fn get_process_info_by_pid(
80    pid: u32,
81    file_path: &Path,
82    proc_cache: Option<&ProcCache>,
83) -> ProcInfo {
84    // Check proc connector cache first (only source for short-lived processes)
85    if let Some(cache) = proc_cache
86        && let Some(info) = cache.get_info(pid)
87    {
88        // Verify the process hasn't been reincarnated with a reused PID.
89        let cached_start = info.start_time_ns;
90        let current_start = read_proc_start_time_ns(pid);
91        if cached_start == current_start || current_start == 0 {
92            return info.clone();
93        }
94        // PID was reused! Fall through to /proc fallback.
95    }
96
97    // Fallback to reading /proc directly (for long-lived processes)
98    // If the process just exited, /proc/{pid} might still exist briefly
99    // as a zombie before the parent reaps it. Retry with short sleep.
100    let cmd =
101        retry(|| proc_tree::proc::read_proc_comm(pid)).unwrap_or_else(|| "unknown".to_string());
102    let (user, ppid, tgid) = retry(|| proc_tree::proc::read_proc_status_fields(pid))
103        .unwrap_or_else(|| {
104            let fallback_user = read_file_owner(file_path).unwrap_or_else(|| "unknown".to_string());
105            (fallback_user, 0u32, 0u32)
106        });
107    let start_time_ns = read_proc_start_time_ns(pid);
108    ProcInfo {
109        cmd,
110        user,
111        ppid,
112        tgid,
113        start_time_ns,
114    }
115}
116
117/// Retry a fallible operation up to 3 times with 500µs sleep between attempts.
118fn retry<T, F>(mut f: F) -> Option<T>
119where
120    F: FnMut() -> Option<T>,
121{
122    if let Some(val) = f() {
123        return Some(val);
124    }
125    for _ in 0..2 {
126        std::thread::sleep(std::time::Duration::from_micros(500));
127        if let Some(val) = f() {
128            return Some(val);
129        }
130    }
131    None
132}
133
134/// Fallback: read file owner UID from filesystem metadata
135fn read_file_owner(path: &Path) -> Option<String> {
136    use std::os::unix::fs::MetadataExt;
137    let metadata = std::fs::metadata(path).ok()?;
138    proc_tree::proc::uid_to_username(metadata.uid())
139}
140
141/// Convert a monitored path to a deterministic, fixed-length log filename.
142/// Resolve log filename from cmd name.
143/// `"_global"` → `"_global_log.jsonl"`, `"openclaw"` → `"openclaw_log.jsonl"`.
144pub fn cmd_to_log_name(cmd: &str) -> String {
145    format!("{}_log.jsonl", cmd)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use chrono::{Datelike, TimeZone, Timelike};
152    use chrono::{Duration, Utc};
153
154    #[test]
155    fn test_parse_time_relative_hours() {
156        let now = Utc::now();
157        let parsed = parse_time("1h").unwrap();
158        let diff = now - parsed;
159        assert!(diff >= Duration::minutes(59));
160        assert!(diff <= Duration::minutes(61));
161    }
162
163    #[test]
164    fn test_parse_time_relative_minutes() {
165        let now = Utc::now();
166        let parsed = parse_time("30m").unwrap();
167        let diff = now - parsed;
168        assert!(diff >= Duration::minutes(29));
169        assert!(diff <= Duration::minutes(31));
170    }
171
172    #[test]
173    fn test_parse_time_relative_days() {
174        let now = Utc::now();
175        let parsed = parse_time("7d").unwrap();
176        let diff = now - parsed;
177        assert!(diff >= Duration::hours(167));
178        assert!(diff <= Duration::hours(169));
179    }
180
181    #[test]
182    fn test_parse_time_relative_seconds() {
183        let now = Utc::now();
184        let parsed = parse_time("30s").unwrap();
185        let diff = now - parsed;
186        assert!(diff >= Duration::seconds(29));
187        assert!(diff <= Duration::seconds(31));
188    }
189
190    #[test]
191    fn test_parse_time_relative_hr_min_suffix() {
192        let now = Utc::now();
193        let parsed = parse_time("2hr").unwrap();
194        let diff = now - parsed;
195        assert!(diff >= Duration::minutes(119));
196        assert!(diff <= Duration::minutes(121));
197
198        let parsed = parse_time("15min").unwrap();
199        let diff = now - parsed;
200        assert!(diff >= Duration::minutes(14));
201        assert!(diff <= Duration::minutes(16));
202    }
203
204    #[test]
205    fn test_parse_time_absolute_datetime() {
206        let parsed = parse_time("2024-05-01 10:00").unwrap();
207        assert_eq!(parsed.year(), 2024);
208        assert_eq!(parsed.month(), 5);
209        assert_eq!(parsed.day(), 1);
210        assert_eq!(parsed.hour(), 10);
211        assert_eq!(parsed.minute(), 0);
212    }
213
214    #[test]
215    fn test_parse_time_absolute_with_seconds() {
216        let parsed = parse_time("2024-12-25 15:30:45").unwrap();
217        assert_eq!(parsed.year(), 2024);
218        assert_eq!(parsed.month(), 12);
219        assert_eq!(parsed.day(), 25);
220        assert_eq!(parsed.hour(), 15);
221        assert_eq!(parsed.minute(), 30);
222        assert_eq!(parsed.second(), 45);
223    }
224
225    #[test]
226    fn test_parse_time_absolute_date_only() {
227        let parsed = parse_time("2024-01-15").unwrap();
228        assert_eq!(parsed.year(), 2024);
229        assert_eq!(parsed.month(), 1);
230        assert_eq!(parsed.day(), 15);
231        assert_eq!(parsed.hour(), 0);
232        assert_eq!(parsed.minute(), 0);
233    }
234
235    #[test]
236    fn test_parse_time_invalid() {
237        assert!(parse_time("invalid").is_err());
238        assert!(parse_time("2024-13-01 10:00").is_err());
239        assert!(parse_time("abc").is_err());
240    }
241
242    // ---- parse_time_filter tests ----
243
244    #[test]
245    fn test_parse_time_filter_gt() {
246        let f = parse_time_filter(">1h").unwrap();
247        assert_eq!(f.op, TimeOp::Gt);
248        let diff = Utc::now() - f.time;
249        assert!(diff >= chrono::Duration::minutes(59) && diff <= chrono::Duration::minutes(61));
250    }
251
252    #[test]
253    fn test_parse_time_filter_ge() {
254        let f = parse_time_filter(">=7d").unwrap();
255        assert_eq!(f.op, TimeOp::Ge);
256        let diff = Utc::now() - f.time;
257        assert!(diff >= chrono::Duration::days(6) && diff <= chrono::Duration::days(8));
258    }
259
260    #[test]
261    fn test_parse_time_filter_lt() {
262        let f = parse_time_filter("<2026-05-01").unwrap();
263        assert_eq!(f.op, TimeOp::Lt);
264        assert_eq!(f.time.year(), 2026);
265        assert_eq!(f.time.month(), 5);
266        assert_eq!(f.time.day(), 1);
267    }
268
269    #[test]
270    fn test_parse_time_filter_le() {
271        let f = parse_time_filter("<=30m").unwrap();
272        assert_eq!(f.op, TimeOp::Le);
273        let diff = Utc::now() - f.time;
274        assert!(diff >= chrono::Duration::minutes(29) && diff <= chrono::Duration::minutes(31));
275    }
276
277    #[test]
278    fn test_parse_time_filter_eq() {
279        let f = parse_time_filter("=2026-05-01 10:00").unwrap();
280        assert_eq!(f.op, TimeOp::Eq);
281        assert_eq!(f.time.year(), 2026);
282        assert_eq!(f.time.month(), 5);
283        assert_eq!(f.time.day(), 1);
284        assert_eq!(f.time.hour(), 10);
285    }
286
287    #[test]
288    fn test_parse_time_filter_no_operator_errors() {
289        assert!(parse_time_filter("1h").is_err());
290        assert!(parse_time_filter("30d").is_err());
291        assert!(parse_time_filter("2026-05-01").is_err());
292    }
293
294    #[test]
295    fn test_parse_time_filter_invalid() {
296        assert!(parse_time_filter(">abc").is_err());
297        assert!(parse_time_filter(">=").is_err());
298    }
299
300    #[test]
301    fn test_format_datetime() {
302        let dt = Utc.with_ymd_and_hms(2024, 5, 1, 10, 30, 45).unwrap();
303        let formatted = format_datetime(&dt);
304        // Output depends on local timezone, just check it's non-empty and contains date parts
305        assert!(!formatted.is_empty());
306        assert!(formatted.contains("2024"));
307    }
308
309    // -- cross-crate integration tests --
310
311    #[test]
312    fn reexported_parse_size_still_works() {
313        // These functions now come from sizefilter crate via re-export
314        assert_eq!(parse_size("1GB").unwrap(), 1073741824);
315        assert_eq!(format_size(1024), "1.0KB");
316        let f = parse_size_filter(">=500MB").unwrap();
317        assert_eq!(f.op, SizeOp::Ge);
318        assert_eq!(f.bytes, 524288000);
319    }
320
321    #[test]
322    fn size_error_converts_to_anyhow() {
323        // SizeError implements std::error::Error → auto-converts to anyhow::Error via ?
324        fn returns_anyhow() -> anyhow::Result<i64> {
325            Ok(parse_size("invalid")?)
326        }
327        let err = returns_anyhow().unwrap_err();
328        assert!(err.to_string().contains("failed to parse number"));
329    }
330
331    #[test]
332    fn time_filter_now_uses_timeop() {
333        // After timefilter integration, TimeFilter uses TimeOp not SizeOp
334        fn check_op(op: TimeOp) -> bool {
335            matches!(
336                op,
337                TimeOp::Gt | TimeOp::Ge | TimeOp::Lt | TimeOp::Le | TimeOp::Eq
338            )
339        }
340        assert!(check_op(TimeOp::Gt));
341        assert!(check_op(TimeOp::Eq));
342
343        let tf = parse_time_filter(">=1h").unwrap();
344        assert!(matches!(tf.op, TimeOp::Ge));
345    }
346
347    #[test]
348    fn test_parse_disk_min_free_percent() {
349        let t = parse_disk_min_free("10%").unwrap();
350        assert_eq!(t, DiskFreeThreshold::Percent(10.0));
351
352        let t = parse_disk_min_free("0.5%").unwrap();
353        assert_eq!(t, DiskFreeThreshold::Percent(0.5));
354    }
355
356    #[test]
357    fn test_parse_disk_min_free_bytes() {
358        let t = parse_disk_min_free("1GB").unwrap();
359        assert_eq!(t, DiskFreeThreshold::Bytes(1_073_741_824));
360
361        let t = parse_disk_min_free("500MB").unwrap();
362        assert_eq!(t, DiskFreeThreshold::Bytes(524_288_000));
363    }
364
365    #[test]
366    fn test_parse_disk_min_free_errors() {
367        assert!(parse_disk_min_free("101%").is_err());
368        assert!(parse_disk_min_free("0%").is_err());
369        assert!(parse_disk_min_free("-1GB").is_err());
370        assert!(parse_disk_min_free("invalid").is_err());
371    }
372
373    #[test]
374    fn test_parse_disk_min_free_invalid_percent_range() {
375        assert!(parse_disk_min_free("150%").is_err(), ">100 should fail");
376        assert!(parse_disk_min_free("-5%").is_err(), "negative should fail");
377        assert!(parse_disk_min_free("0%").is_err(), "0 should fail");
378    }
379}