1use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21
22use crate::history::{RegistryEntry, ScanRegistry};
23
24#[must_use]
30pub fn workspace_root() -> PathBuf {
31 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
32 let p = PathBuf::from(root);
33 if p.is_dir() {
34 return p;
35 }
36 }
37 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
38}
39
40#[must_use]
44pub fn resolve_output_root(raw: Option<&str>) -> PathBuf {
45 let value = raw.unwrap_or("out/web").trim();
46 let path = if value.is_empty() {
47 PathBuf::from("out/web")
48 } else {
49 PathBuf::from(value)
50 };
51 if path.is_absolute() {
52 path
53 } else {
54 workspace_root().join(path)
55 }
56}
57
58#[must_use]
61pub fn resolve_registry_path(output_root: &Path) -> PathBuf {
62 std::env::var("SLOC_REGISTRY_PATH")
63 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from)
64}
65
66#[must_use]
72pub fn dir_size_bytes(path: &Path) -> u64 {
73 fn walk(path: &Path, acc: &mut u64) {
74 let Ok(entries) = std::fs::read_dir(path) else {
75 return;
76 };
77 for entry in entries.flatten() {
78 let Ok(ft) = entry.file_type() else { continue };
79 if ft.is_dir() {
80 walk(&entry.path(), acc);
81 } else if let Ok(meta) = entry.metadata() {
82 *acc += meta.len();
83 }
84 }
85 }
86 let mut total = 0;
87 if path.is_file() {
88 return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
89 }
90 walk(path, &mut total);
91 total
92}
93
94#[must_use]
99pub fn run_output_dir(entry: &RegistryEntry) -> Option<PathBuf> {
100 let p = entry
101 .html_path
102 .as_ref()
103 .or(entry.json_path.as_ref())
104 .or(entry.pdf_path.as_ref())
105 .or(entry.csv_path.as_ref())
106 .or(entry.xlsx_path.as_ref())?;
107 let parent = p.parent()?;
108 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
109 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
110 parent.parent().map(PathBuf::from)
111 } else {
112 Some(parent.to_path_buf())
113 }
114}
115
116#[derive(Debug, Clone)]
120pub struct PrunedRun {
121 pub run_id: String,
122 pub project_label: String,
123 pub timestamp_utc: DateTime<Utc>,
124 pub output_dir: Option<PathBuf>,
125 pub bytes: u64,
126}
127
128#[derive(Debug, Clone, Default)]
130pub struct PrunePlan {
131 pub runs: Vec<PrunedRun>,
132 pub total_bytes: u64,
133}
134
135impl PrunePlan {
136 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 self.runs.is_empty()
139 }
140}
141
142#[must_use]
151pub fn plan_run_prune(
152 reg: &ScanRegistry,
153 older_than_days: Option<u32>,
154 keep_last: Option<u32>,
155) -> PrunePlan {
156 use std::collections::HashSet;
157
158 let mut ordered: Vec<&RegistryEntry> = reg.entries.iter().collect();
161 ordered.sort_by_key(|e| std::cmp::Reverse(e.timestamp_utc));
162
163 let mut selected: HashSet<&str> = HashSet::new();
164 if let Some(days) = older_than_days {
165 let cutoff = Utc::now() - chrono::Duration::days(i64::from(days));
166 for e in &ordered {
167 if e.timestamp_utc < cutoff {
168 selected.insert(e.run_id.as_str());
169 }
170 }
171 }
172 if let Some(keep) = keep_last {
173 for e in ordered.iter().skip(keep as usize) {
174 selected.insert(e.run_id.as_str());
175 }
176 }
177
178 let mut runs = Vec::new();
179 let mut total_bytes = 0u64;
180 for e in &ordered {
181 if !selected.contains(e.run_id.as_str()) {
182 continue;
183 }
184 let output_dir = run_output_dir(e);
185 let bytes = output_dir.as_deref().map(dir_size_bytes).unwrap_or(0);
186 total_bytes += bytes;
187 runs.push(PrunedRun {
188 run_id: e.run_id.clone(),
189 project_label: e.project_label.clone(),
190 timestamp_utc: e.timestamp_utc,
191 output_dir,
192 bytes,
193 });
194 }
195 PrunePlan { runs, total_bytes }
196}
197
198#[derive(Debug, Clone, Default)]
200pub struct PruneReport {
201 pub deleted_runs: usize,
202 pub bytes_freed: u64,
203 pub failures: Vec<(String, String)>,
205}
206
207#[must_use]
213pub fn execute_run_prune(reg: &mut ScanRegistry, plan: &PrunePlan) -> PruneReport {
214 use std::collections::HashSet;
215
216 let mut report = PruneReport::default();
217 let mut removed_ids: HashSet<String> = HashSet::new();
218
219 for run in &plan.runs {
220 if let Some(dir) = &run.output_dir {
221 if dir.exists() {
222 if let Err(e) = std::fs::remove_dir_all(dir) {
223 if e.kind() != std::io::ErrorKind::NotFound {
224 report
225 .failures
226 .push((dir.display().to_string(), e.to_string()));
227 continue;
228 }
229 }
230 }
231 }
232 report.bytes_freed += run.bytes;
233 report.deleted_runs += 1;
234 removed_ids.insert(run.run_id.clone());
235 }
236
237 reg.entries.retain(|e| !removed_ids.contains(&e.run_id));
238 report
239}
240
241pub fn rotate_log(path: &Path, max_bytes: u64, keep: u32) -> anyhow::Result<bool> {
256 let Ok(meta) = std::fs::metadata(path) else {
257 return Ok(false); };
259 if meta.len() <= max_bytes {
260 return Ok(false);
261 }
262
263 if keep == 0 {
264 std::fs::write(path, b"")?;
266 return Ok(true);
267 }
268
269 let gen_path = |n: u32| -> PathBuf {
271 let mut s = path.as_os_str().to_owned();
272 s.push(format!(".{n}"));
273 PathBuf::from(s)
274 };
275 let oldest = gen_path(keep);
276 if oldest.exists() {
277 std::fs::remove_file(&oldest)?;
278 }
279 for n in (1..keep).rev() {
280 let from = gen_path(n);
281 if from.exists() {
282 std::fs::rename(&from, gen_path(n + 1))?;
283 }
284 }
285 std::fs::rename(path, gen_path(1))?;
286 Ok(true)
287}
288
289#[must_use]
292pub fn rotated_log_paths(path: &Path) -> Vec<PathBuf> {
293 let mut out = Vec::new();
294 let mut n = 1u32;
295 loop {
296 let mut s = path.as_os_str().to_owned();
297 s.push(format!(".{n}"));
298 let p = PathBuf::from(s);
299 if p.exists() {
300 out.push(p);
301 n += 1;
302 } else {
303 break;
304 }
305 }
306 out
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::history::{RegistryEntry, ScanRegistry, ScanSummarySnapshot};
313 use std::sync::{Mutex, MutexGuard, OnceLock};
314
315 fn env_lock() -> MutexGuard<'static, ()> {
319 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
320 ENV_MUTEX
321 .get_or_init(|| Mutex::new(()))
322 .lock()
323 .unwrap_or_else(std::sync::PoisonError::into_inner)
324 }
325
326 fn entry(run_id: &str, age_days: i64, root: &Path) -> RegistryEntry {
327 let dir = root.join(run_id);
328 std::fs::create_dir_all(dir.join("json")).unwrap();
329 std::fs::write(dir.join("json").join("result.json"), b"{}").unwrap();
330 RegistryEntry {
331 run_id: run_id.to_owned(),
332 timestamp_utc: Utc::now() - chrono::Duration::days(age_days),
333 project_label: format!("proj-{run_id}"),
334 input_roots: vec![],
335 json_path: Some(dir.join("json").join("result.json")),
336 html_path: None,
337 pdf_path: None,
338 csv_path: None,
339 xlsx_path: None,
340 summary: ScanSummarySnapshot::default(),
341 git_branch: None,
342 git_commit: None,
343 git_commit_long: None,
344 git_author: None,
345 git_tags: None,
346 git_nearest_tag: None,
347 git_commit_date: None,
348 }
349 }
350
351 fn tmp() -> PathBuf {
352 let d = std::env::temp_dir().join(format!("sloc_maint_{}", uuid::Uuid::new_v4()));
353 std::fs::create_dir_all(&d).unwrap();
354 d
355 }
356
357 #[test]
358 fn run_output_dir_handles_nested_layout() {
359 let root = tmp();
360 let e = entry("abc", 0, &root);
361 assert_eq!(run_output_dir(&e), Some(root.join("abc")));
363 std::fs::remove_dir_all(&root).ok();
364 }
365
366 #[test]
367 fn plan_selects_by_age() {
368 let root = tmp();
369 let mut reg = ScanRegistry::default();
370 reg.entries.push(entry("old", 40, &root));
371 reg.entries.push(entry("new", 1, &root));
372
373 let plan = plan_run_prune(®, Some(30), None);
374 assert_eq!(plan.runs.len(), 1);
375 assert_eq!(plan.runs[0].run_id, "old");
376 std::fs::remove_dir_all(&root).ok();
377 }
378
379 #[test]
380 fn plan_selects_by_keep_last() {
381 let root = tmp();
382 let mut reg = ScanRegistry::default();
383 for (i, id) in ["a", "b", "c", "d"].iter().enumerate() {
384 reg.entries.push(entry(id, i as i64, &root)); }
386 let plan = plan_run_prune(®, None, Some(2));
387 let ids: Vec<_> = plan.runs.iter().map(|r| r.run_id.clone()).collect();
388 assert_eq!(plan.runs.len(), 2, "keep 2, delete the 2 oldest");
389 assert!(ids.contains(&"c".to_owned()) && ids.contains(&"d".to_owned()));
390 std::fs::remove_dir_all(&root).ok();
391 }
392
393 #[test]
394 fn execute_removes_dirs_and_entries() {
395 let root = tmp();
396 let mut reg = ScanRegistry::default();
397 reg.entries.push(entry("gone", 40, &root));
398 reg.entries.push(entry("kept", 1, &root));
399
400 let plan = plan_run_prune(®, Some(30), None);
401 let report = execute_run_prune(&mut reg, &plan);
402
403 assert_eq!(report.deleted_runs, 1);
404 assert!(report.failures.is_empty());
405 assert!(!root.join("gone").exists(), "artifacts removed");
406 assert!(root.join("kept").exists(), "recent run untouched");
407 assert_eq!(reg.entries.len(), 1);
408 assert_eq!(reg.entries[0].run_id, "kept");
409 std::fs::remove_dir_all(&root).ok();
410 }
411
412 #[test]
413 fn empty_plan_when_no_rules() {
414 let root = tmp();
415 let mut reg = ScanRegistry::default();
416 reg.entries.push(entry("x", 100, &root));
417 assert!(plan_run_prune(®, None, None).is_empty());
418 std::fs::remove_dir_all(&root).ok();
419 }
420
421 #[test]
422 fn rotate_log_shifts_generations() {
423 let root = tmp();
424 let log = root.join("audit.log");
425 std::fs::write(&log, vec![b'x'; 100]).unwrap();
426
427 assert!(!rotate_log(&log, 1000, 3).unwrap());
429 assert!(rotate_log(&log, 50, 3).unwrap());
431 assert!(log.with_extension("log.1").exists());
432 assert!(!log.exists());
433
434 std::fs::write(&log, vec![b'y'; 100]).unwrap();
436 assert!(rotate_log(&log, 50, 3).unwrap());
437 assert!(log.with_extension("log.1").exists());
438 assert!(log.with_extension("log.2").exists());
439 assert_eq!(rotated_log_paths(&log).len(), 2);
440 std::fs::remove_dir_all(&root).ok();
441 }
442
443 #[test]
444 fn rotate_log_keep_zero_truncates() {
445 let root = tmp();
446 let log = root.join("audit.log");
447 std::fs::write(&log, vec![b'x'; 100]).unwrap();
448 assert!(rotate_log(&log, 10, 0).unwrap());
449 assert!(log.exists());
450 assert_eq!(std::fs::metadata(&log).unwrap().len(), 0);
451 std::fs::remove_dir_all(&root).ok();
452 }
453
454 #[test]
455 fn workspace_root_prefers_env_dir_then_falls_back() {
456 let _guard = env_lock();
457 let dir = tmp();
458 std::env::set_var("OXIDE_SLOC_ROOT", &dir);
459 assert_eq!(workspace_root(), dir);
460 std::env::set_var("OXIDE_SLOC_ROOT", dir.join("does-not-exist"));
462 assert!(workspace_root().is_dir());
463 std::env::remove_var("OXIDE_SLOC_ROOT");
464 assert!(workspace_root().is_dir());
465 std::fs::remove_dir_all(&dir).ok();
466 }
467
468 #[test]
469 fn resolve_output_root_handles_absolute_relative_and_default() {
470 let _guard = env_lock();
471 let dir = tmp();
472 let abs = dir.join("art");
474 assert_eq!(resolve_output_root(Some(abs.to_str().unwrap())), abs);
475 std::env::set_var("OXIDE_SLOC_ROOT", &dir);
477 assert_eq!(resolve_output_root(Some(" ")), dir.join("out/web"));
478 assert_eq!(resolve_output_root(None), dir.join("out/web"));
479 assert_eq!(
481 resolve_output_root(Some("custom/out")),
482 dir.join("custom/out")
483 );
484 std::env::remove_var("OXIDE_SLOC_ROOT");
485 std::fs::remove_dir_all(&dir).ok();
486 }
487
488 #[test]
489 fn resolve_registry_path_honours_env_override() {
490 let _guard = env_lock();
491 let dir = tmp();
492 std::env::remove_var("SLOC_REGISTRY_PATH");
493 assert_eq!(resolve_registry_path(&dir), dir.join("registry.json"));
494 std::env::set_var("SLOC_REGISTRY_PATH", dir.join("shared.json"));
495 assert_eq!(resolve_registry_path(&dir), dir.join("shared.json"));
496 std::env::remove_var("SLOC_REGISTRY_PATH");
497 std::fs::remove_dir_all(&dir).ok();
498 }
499
500 #[test]
501 fn dir_size_bytes_counts_files_and_handles_single_file() {
502 let root = tmp();
503 std::fs::write(root.join("a.txt"), vec![b'x'; 10]).unwrap();
504 std::fs::create_dir_all(root.join("sub")).unwrap();
505 std::fs::write(root.join("sub").join("b.txt"), vec![b'y'; 5]).unwrap();
506 assert_eq!(dir_size_bytes(&root), 15);
507 assert_eq!(dir_size_bytes(&root.join("a.txt")), 10);
509 assert_eq!(dir_size_bytes(&root.join("nope")), 0);
511 std::fs::remove_dir_all(&root).ok();
512 }
513
514 #[test]
515 fn run_output_dir_handles_flat_layout_and_missing_paths() {
516 let root = tmp();
517 let mut e = entry("flat", 0, &root);
519 e.json_path = Some(root.join("flat").join("result.json"));
520 assert_eq!(run_output_dir(&e), Some(root.join("flat")));
521 e.json_path = None;
523 assert_eq!(run_output_dir(&e), None);
524 std::fs::remove_dir_all(&root).ok();
525 }
526
527 #[test]
528 fn execute_run_prune_records_failure_for_locked_dir() {
529 let root = tmp();
530 let mut reg = ScanRegistry::default();
531 reg.entries.push(entry("target", 40, &root));
532 let mut plan = plan_run_prune(®, Some(30), None);
533 let bogus = root.join("target").join("json").join("result.json");
537 plan.runs[0].output_dir = Some(bogus.clone());
538 let report = execute_run_prune(&mut reg, &plan);
539 assert_eq!(report.deleted_runs, 0);
540 assert_eq!(report.failures.len(), 1);
541 assert!(reg.entries.iter().any(|e| e.run_id == "target"));
542 std::fs::remove_dir_all(&root).ok();
543 }
544
545 #[test]
546 fn rotate_log_drops_oldest_generation_at_keep_cap() {
547 let root = tmp();
548 let log = root.join("audit.log");
549 std::fs::write(&log, vec![b'x'; 100]).unwrap();
551 std::fs::write(log.with_extension("log.1"), b"g1").unwrap();
552 std::fs::write(log.with_extension("log.2"), b"g2").unwrap();
553 assert!(rotate_log(&log, 50, 2).unwrap());
555 assert!(log.with_extension("log.1").exists());
556 assert!(log.with_extension("log.2").exists());
557 assert!(!log.with_extension("log.3").exists());
558 std::fs::remove_dir_all(&root).ok();
559 }
560}