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
10pub trait TimeFilterExt {
12 fn matches(&self, time: DateTime<Utc>) -> bool;
14
15 fn is_lower_bound(&self) -> bool;
17
18 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#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum DiskFreeThreshold {
47 Percent(f64),
48 Bytes(u64),
49}
50
51pub 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
75pub fn get_process_info_by_pid(
80 pid: u32,
81 file_path: &Path,
82 proc_cache: Option<&ProcCache>,
83) -> ProcInfo {
84 if let Some(cache) = proc_cache
86 && let Some(info) = cache.get_info(pid)
87 {
88 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 }
96
97 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
117fn 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
134fn 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
141pub 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 #[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 assert!(!formatted.is_empty());
306 assert!(formatted.contains("2024"));
307 }
308
309 #[test]
312 fn reexported_parse_size_still_works() {
313 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 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 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}