1use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21
22use crate::history::{RegistryEntry, ScanRegistry};
23
24#[must_use]
31pub fn workspace_root() -> PathBuf {
32 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
33 let p = PathBuf::from(root);
34 if p.is_dir() {
35 return p;
36 }
37 }
38 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
39}
40
41#[must_use]
47pub fn resolve_output_root(raw: Option<&str>) -> PathBuf {
48 let value = raw.unwrap_or("out/web").trim();
49 let path = if value.is_empty() {
50 PathBuf::from("out/web")
51 } else {
52 PathBuf::from(value)
53 };
54 if path.is_absolute() {
55 path
56 } else {
57 workspace_root().join(path)
58 }
59}
60
61#[must_use]
64pub fn resolve_registry_path(output_root: &Path) -> PathBuf {
65 std::env::var("SLOC_REGISTRY_PATH")
66 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from)
67}
68
69#[must_use]
75pub fn dir_size_bytes(path: &Path) -> u64 {
76 fn walk(path: &Path, acc: &mut u64) {
77 let Ok(entries) = std::fs::read_dir(path) else {
78 return;
79 };
80 for entry in entries.flatten() {
81 let Ok(ft) = entry.file_type() else { continue };
82 if ft.is_dir() {
83 walk(&entry.path(), acc);
84 } else if let Ok(meta) = entry.metadata() {
85 *acc += meta.len();
86 }
87 }
88 }
89 let mut total = 0;
90 if path.is_file() {
91 return std::fs::metadata(path).map_or(0, |m| m.len());
92 }
93 walk(path, &mut total);
94 total
95}
96
97pub fn copy_tree(src: &Path, dest: &Path) -> std::io::Result<(usize, u64)> {
108 std::fs::create_dir_all(dest)?;
109 let mut files = 0usize;
110 let mut bytes = 0u64;
111 let mut stack = vec![(src.to_path_buf(), dest.to_path_buf())];
113 while let Some((from, to)) = stack.pop() {
114 for entry in std::fs::read_dir(&from)?.flatten() {
115 let ft = entry.file_type()?;
116 if ft.is_symlink() {
117 continue;
118 }
119 let target = to.join(entry.file_name());
120 if ft.is_dir() {
121 std::fs::create_dir_all(&target)?;
122 stack.push((entry.path(), target));
123 } else if ft.is_file() {
124 let n = std::fs::copy(entry.path(), &target)?;
125 files += 1;
126 bytes = bytes.saturating_add(n);
127 }
128 }
129 }
130 Ok((files, bytes))
131}
132
133#[must_use]
139pub fn run_output_dir(entry: &RegistryEntry) -> Option<PathBuf> {
140 let p = entry
141 .html_path
142 .as_ref()
143 .or(entry.json_path.as_ref())
144 .or(entry.pdf_path.as_ref())
145 .or(entry.csv_path.as_ref())
146 .or(entry.xlsx_path.as_ref())?;
147 let parent = p.parent()?;
148 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
149 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
150 parent.parent().map(PathBuf::from)
151 } else {
152 Some(parent.to_path_buf())
153 }
154}
155
156#[derive(Debug, Clone)]
160pub struct PrunedRun {
161 pub run_id: String,
162 pub project_label: String,
163 pub timestamp_utc: DateTime<Utc>,
164 pub output_dir: Option<PathBuf>,
165 pub bytes: u64,
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct PrunePlan {
171 pub runs: Vec<PrunedRun>,
172 pub total_bytes: u64,
173}
174
175impl PrunePlan {
176 #[must_use]
177 pub const fn is_empty(&self) -> bool {
178 self.runs.is_empty()
179 }
180}
181
182#[must_use]
191pub fn plan_run_prune(
192 reg: &ScanRegistry,
193 older_than_days: Option<u32>,
194 keep_last: Option<u32>,
195) -> PrunePlan {
196 use std::collections::HashSet;
197
198 let mut ordered: Vec<&RegistryEntry> = reg.entries.iter().collect();
201 ordered.sort_by_key(|e| std::cmp::Reverse(e.timestamp_utc));
202
203 let mut selected: HashSet<&str> = HashSet::new();
204 if let Some(days) = older_than_days {
205 let cutoff = Utc::now() - chrono::Duration::days(i64::from(days));
206 for e in &ordered {
207 if e.timestamp_utc < cutoff {
208 selected.insert(e.run_id.as_str());
209 }
210 }
211 }
212 if let Some(keep) = keep_last {
213 for e in ordered.iter().skip(keep as usize) {
214 selected.insert(e.run_id.as_str());
215 }
216 }
217
218 let mut runs = Vec::new();
219 let mut total_bytes = 0u64;
220 for e in &ordered {
221 if !selected.contains(e.run_id.as_str()) {
222 continue;
223 }
224 let output_dir = run_output_dir(e);
225 let bytes = output_dir.as_deref().map_or(0, dir_size_bytes);
226 total_bytes += bytes;
227 runs.push(PrunedRun {
228 run_id: e.run_id.clone(),
229 project_label: e.project_label.clone(),
230 timestamp_utc: e.timestamp_utc,
231 output_dir,
232 bytes,
233 });
234 }
235 PrunePlan { runs, total_bytes }
236}
237
238#[derive(Debug, Clone, Default)]
240pub struct PruneReport {
241 pub deleted_runs: usize,
242 pub bytes_freed: u64,
243 pub failures: Vec<(String, String)>,
245}
246
247#[must_use]
253pub fn execute_run_prune(reg: &mut ScanRegistry, plan: &PrunePlan) -> PruneReport {
254 use std::collections::HashSet;
255
256 let mut report = PruneReport::default();
257 let mut removed_ids: HashSet<String> = HashSet::new();
258
259 for run in &plan.runs {
260 if let Some(dir) = &run.output_dir
261 && dir.exists()
262 && let Err(e) = std::fs::remove_dir_all(dir)
263 && e.kind() != std::io::ErrorKind::NotFound
264 {
265 report
266 .failures
267 .push((dir.display().to_string(), e.to_string()));
268 continue;
269 }
270 report.bytes_freed += run.bytes;
271 report.deleted_runs += 1;
272 removed_ids.insert(run.run_id.clone());
273 }
274
275 reg.entries.retain(|e| !removed_ids.contains(&e.run_id));
276 report
277}
278
279pub fn rotate_log(path: &Path, max_bytes: u64, keep: u32) -> anyhow::Result<bool> {
294 let Ok(meta) = std::fs::metadata(path) else {
295 return Ok(false); };
297 if meta.len() <= max_bytes {
298 return Ok(false);
299 }
300
301 if keep == 0 {
302 std::fs::write(path, b"")?;
304 return Ok(true);
305 }
306
307 let gen_path = |n: u32| -> PathBuf {
309 let mut s = path.as_os_str().to_owned();
310 s.push(format!(".{n}"));
311 PathBuf::from(s)
312 };
313 let oldest = gen_path(keep);
314 if oldest.exists() {
315 std::fs::remove_file(&oldest)?;
316 }
317 for n in (1..keep).rev() {
318 let from = gen_path(n);
319 if from.exists() {
320 std::fs::rename(&from, gen_path(n + 1))?;
321 }
322 }
323 std::fs::rename(path, gen_path(1))?;
324 Ok(true)
325}
326
327#[must_use]
330pub fn rotated_log_paths(path: &Path) -> Vec<PathBuf> {
331 let mut out = Vec::new();
332 let mut n = 1u32;
333 loop {
334 let mut s = path.as_os_str().to_owned();
335 s.push(format!(".{n}"));
336 let p = PathBuf::from(s);
337 if p.exists() {
338 out.push(p);
339 n += 1;
340 } else {
341 break;
342 }
343 }
344 out
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::history::{RegistryEntry, ScanRegistry, ScanSummarySnapshot};
351 use std::sync::{Mutex, MutexGuard, OnceLock};
352
353 fn env_lock() -> MutexGuard<'static, ()> {
357 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
358 ENV_MUTEX
359 .get_or_init(|| Mutex::new(()))
360 .lock()
361 .unwrap_or_else(std::sync::PoisonError::into_inner)
362 }
363
364 #[test]
365 fn copy_tree_copies_nested_files_and_counts() {
366 let src = tempfile::tempdir().unwrap();
367 let dst = tempfile::tempdir().unwrap();
368 std::fs::create_dir_all(src.path().join("json")).unwrap();
369 std::fs::write(src.path().join("json").join("result.json"), b"{\"a\":1}").unwrap();
370 std::fs::write(src.path().join("top.txt"), b"hello").unwrap();
371
372 let dest_root = dst.path().join("run-123");
373 let (files, bytes) = copy_tree(src.path(), &dest_root).unwrap();
374
375 assert_eq!(files, 2);
376 assert_eq!(bytes, 7 + 5);
377 assert!(dest_root.join("json").join("result.json").is_file());
378 assert!(dest_root.join("top.txt").is_file());
379 assert_eq!(
380 std::fs::read(dest_root.join("json").join("result.json")).unwrap(),
381 b"{\"a\":1}"
382 );
383 }
384
385 #[test]
386 fn copy_tree_empty_source_creates_dest() {
387 let src = tempfile::tempdir().unwrap();
388 let dst = tempfile::tempdir().unwrap();
389 let dest_root = dst.path().join("out");
390 let (files, bytes) = copy_tree(src.path(), &dest_root).unwrap();
391 assert_eq!((files, bytes), (0, 0));
392 assert!(dest_root.is_dir());
393 }
394
395 fn entry(run_id: &str, age_days: i64, root: &Path) -> RegistryEntry {
396 let dir = root.join(run_id);
397 std::fs::create_dir_all(dir.join("json")).unwrap();
398 std::fs::write(dir.join("json").join("result.json"), b"{}").unwrap();
399 RegistryEntry {
400 run_id: run_id.to_owned(),
401 timestamp_utc: Utc::now() - chrono::Duration::days(age_days),
402 project_label: format!("proj-{run_id}"),
403 input_roots: vec![],
404 json_path: Some(dir.join("json").join("result.json")),
405 html_path: None,
406 pdf_path: None,
407 csv_path: None,
408 xlsx_path: None,
409 summary: ScanSummarySnapshot::default(),
410 git_branch: None,
411 git_commit: None,
412 git_commit_long: None,
413 git_author: None,
414 git_tags: None,
415 git_nearest_tag: None,
416 git_commit_date: None,
417 scan_os: None,
418 scan_host: None,
419 scan_user: None,
420 scan_ci: None,
421 }
422 }
423
424 fn tmp() -> PathBuf {
425 let d = std::env::temp_dir().join(format!("sloc_maint_{}", uuid::Uuid::new_v4()));
426 std::fs::create_dir_all(&d).unwrap();
427 d
428 }
429
430 #[test]
431 fn run_output_dir_handles_nested_layout() {
432 let root = tmp();
433 let e = entry("abc", 0, &root);
434 assert_eq!(run_output_dir(&e), Some(root.join("abc")));
436 std::fs::remove_dir_all(&root).ok();
437 }
438
439 #[test]
440 fn plan_selects_by_age() {
441 let root = tmp();
442 let mut reg = ScanRegistry::default();
443 reg.entries.push(entry("old", 40, &root));
444 reg.entries.push(entry("new", 1, &root));
445
446 let plan = plan_run_prune(®, Some(30), None);
447 assert_eq!(plan.runs.len(), 1);
448 assert_eq!(plan.runs[0].run_id, "old");
449 std::fs::remove_dir_all(&root).ok();
450 }
451
452 #[test]
453 fn plan_selects_by_keep_last() {
454 let root = tmp();
455 let mut reg = ScanRegistry::default();
456 for (i, id) in ["a", "b", "c", "d"].iter().enumerate() {
457 reg.entries
458 .push(entry(id, i64::try_from(i).unwrap(), &root)); }
460 let plan = plan_run_prune(®, None, Some(2));
461 let ids: Vec<_> = plan.runs.iter().map(|r| r.run_id.clone()).collect();
462 assert_eq!(plan.runs.len(), 2, "keep 2, delete the 2 oldest");
463 assert!(ids.contains(&"c".to_owned()) && ids.contains(&"d".to_owned()));
464 std::fs::remove_dir_all(&root).ok();
465 }
466
467 #[test]
468 fn execute_removes_dirs_and_entries() {
469 let root = tmp();
470 let mut reg = ScanRegistry::default();
471 reg.entries.push(entry("gone", 40, &root));
472 reg.entries.push(entry("kept", 1, &root));
473
474 let plan = plan_run_prune(®, Some(30), None);
475 let report = execute_run_prune(&mut reg, &plan);
476
477 assert_eq!(report.deleted_runs, 1);
478 assert!(report.failures.is_empty());
479 assert!(!root.join("gone").exists(), "artifacts removed");
480 assert!(root.join("kept").exists(), "recent run untouched");
481 assert_eq!(reg.entries.len(), 1);
482 assert_eq!(reg.entries[0].run_id, "kept");
483 std::fs::remove_dir_all(&root).ok();
484 }
485
486 #[test]
487 fn empty_plan_when_no_rules() {
488 let root = tmp();
489 let mut reg = ScanRegistry::default();
490 reg.entries.push(entry("x", 100, &root));
491 assert!(plan_run_prune(®, None, None).is_empty());
492 std::fs::remove_dir_all(&root).ok();
493 }
494
495 #[test]
496 fn rotate_log_shifts_generations() {
497 let root = tmp();
498 let log = root.join("audit.log");
499 std::fs::write(&log, vec![b'x'; 100]).unwrap();
500
501 assert!(!rotate_log(&log, 1000, 3).unwrap());
503 assert!(rotate_log(&log, 50, 3).unwrap());
505 assert!(log.with_extension("log.1").exists());
506 assert!(!log.exists());
507
508 std::fs::write(&log, vec![b'y'; 100]).unwrap();
510 assert!(rotate_log(&log, 50, 3).unwrap());
511 assert!(log.with_extension("log.1").exists());
512 assert!(log.with_extension("log.2").exists());
513 assert_eq!(rotated_log_paths(&log).len(), 2);
514 std::fs::remove_dir_all(&root).ok();
515 }
516
517 #[test]
518 fn rotate_log_keep_zero_truncates() {
519 let root = tmp();
520 let log = root.join("audit.log");
521 std::fs::write(&log, vec![b'x'; 100]).unwrap();
522 assert!(rotate_log(&log, 10, 0).unwrap());
523 assert!(log.exists());
524 assert_eq!(std::fs::metadata(&log).unwrap().len(), 0);
525 std::fs::remove_dir_all(&root).ok();
526 }
527
528 #[test]
529 fn workspace_root_prefers_env_dir_then_falls_back() {
530 let _guard = env_lock();
531 let dir = tmp();
532 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
534 assert_eq!(workspace_root(), dir);
535 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", dir.join("does-not-exist")) };
538 assert!(workspace_root().is_dir());
539 unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
541 assert!(workspace_root().is_dir());
542 std::fs::remove_dir_all(&dir).ok();
543 }
544
545 #[test]
546 fn resolve_output_root_handles_absolute_relative_and_default() {
547 let _guard = env_lock();
548 let dir = tmp();
549 let abs = dir.join("art");
551 assert_eq!(resolve_output_root(Some(abs.to_str().unwrap())), abs);
552 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
555 assert_eq!(resolve_output_root(Some(" ")), dir.join("out/web"));
556 assert_eq!(resolve_output_root(None), dir.join("out/web"));
557 assert_eq!(
559 resolve_output_root(Some("custom/out")),
560 dir.join("custom/out")
561 );
562 unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
564 std::fs::remove_dir_all(&dir).ok();
565 }
566
567 #[test]
568 fn resolve_registry_path_honours_env_override() {
569 let _guard = env_lock();
570 let dir = tmp();
571 unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
573 assert_eq!(resolve_registry_path(&dir), dir.join("registry.json"));
574 unsafe { std::env::set_var("SLOC_REGISTRY_PATH", dir.join("shared.json")) };
576 assert_eq!(resolve_registry_path(&dir), dir.join("shared.json"));
577 unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
579 std::fs::remove_dir_all(&dir).ok();
580 }
581
582 #[test]
583 fn dir_size_bytes_counts_files_and_handles_single_file() {
584 let root = tmp();
585 std::fs::write(root.join("a.txt"), vec![b'x'; 10]).unwrap();
586 std::fs::create_dir_all(root.join("sub")).unwrap();
587 std::fs::write(root.join("sub").join("b.txt"), vec![b'y'; 5]).unwrap();
588 assert_eq!(dir_size_bytes(&root), 15);
589 assert_eq!(dir_size_bytes(&root.join("a.txt")), 10);
591 assert_eq!(dir_size_bytes(&root.join("nope")), 0);
593 std::fs::remove_dir_all(&root).ok();
594 }
595
596 #[test]
597 fn run_output_dir_handles_flat_layout_and_missing_paths() {
598 let root = tmp();
599 let mut e = entry("flat", 0, &root);
601 e.json_path = Some(root.join("flat").join("result.json"));
602 assert_eq!(run_output_dir(&e), Some(root.join("flat")));
603 e.json_path = None;
605 assert_eq!(run_output_dir(&e), None);
606 std::fs::remove_dir_all(&root).ok();
607 }
608
609 #[test]
610 fn execute_run_prune_records_failure_for_locked_dir() {
611 let root = tmp();
612 let mut reg = ScanRegistry::default();
613 reg.entries.push(entry("target", 40, &root));
614 let mut plan = plan_run_prune(®, Some(30), None);
615 let bogus = root.join("target").join("json").join("result.json");
619 plan.runs[0].output_dir = Some(bogus);
620 let report = execute_run_prune(&mut reg, &plan);
621 assert_eq!(report.deleted_runs, 0);
622 assert_eq!(report.failures.len(), 1);
623 assert!(reg.entries.iter().any(|e| e.run_id == "target"));
624 std::fs::remove_dir_all(&root).ok();
625 }
626
627 #[test]
628 fn rotate_log_drops_oldest_generation_at_keep_cap() {
629 let root = tmp();
630 let log = root.join("audit.log");
631 std::fs::write(&log, vec![b'x'; 100]).unwrap();
633 std::fs::write(log.with_extension("log.1"), b"g1").unwrap();
634 std::fs::write(log.with_extension("log.2"), b"g2").unwrap();
635 assert!(rotate_log(&log, 50, 2).unwrap());
637 assert!(log.with_extension("log.1").exists());
638 assert!(log.with_extension("log.2").exists());
639 assert!(!log.with_extension("log.3").exists());
640 std::fs::remove_dir_all(&root).ok();
641 }
642}