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
97#[must_use]
103pub fn run_output_dir(entry: &RegistryEntry) -> Option<PathBuf> {
104 let p = entry
105 .html_path
106 .as_ref()
107 .or(entry.json_path.as_ref())
108 .or(entry.pdf_path.as_ref())
109 .or(entry.csv_path.as_ref())
110 .or(entry.xlsx_path.as_ref())?;
111 let parent = p.parent()?;
112 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
113 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
114 parent.parent().map(PathBuf::from)
115 } else {
116 Some(parent.to_path_buf())
117 }
118}
119
120#[derive(Debug, Clone)]
124pub struct PrunedRun {
125 pub run_id: String,
126 pub project_label: String,
127 pub timestamp_utc: DateTime<Utc>,
128 pub output_dir: Option<PathBuf>,
129 pub bytes: u64,
130}
131
132#[derive(Debug, Clone, Default)]
134pub struct PrunePlan {
135 pub runs: Vec<PrunedRun>,
136 pub total_bytes: u64,
137}
138
139impl PrunePlan {
140 #[must_use]
141 pub const fn is_empty(&self) -> bool {
142 self.runs.is_empty()
143 }
144}
145
146#[must_use]
155pub fn plan_run_prune(
156 reg: &ScanRegistry,
157 older_than_days: Option<u32>,
158 keep_last: Option<u32>,
159) -> PrunePlan {
160 use std::collections::HashSet;
161
162 let mut ordered: Vec<&RegistryEntry> = reg.entries.iter().collect();
165 ordered.sort_by_key(|e| std::cmp::Reverse(e.timestamp_utc));
166
167 let mut selected: HashSet<&str> = HashSet::new();
168 if let Some(days) = older_than_days {
169 let cutoff = Utc::now() - chrono::Duration::days(i64::from(days));
170 for e in &ordered {
171 if e.timestamp_utc < cutoff {
172 selected.insert(e.run_id.as_str());
173 }
174 }
175 }
176 if let Some(keep) = keep_last {
177 for e in ordered.iter().skip(keep as usize) {
178 selected.insert(e.run_id.as_str());
179 }
180 }
181
182 let mut runs = Vec::new();
183 let mut total_bytes = 0u64;
184 for e in &ordered {
185 if !selected.contains(e.run_id.as_str()) {
186 continue;
187 }
188 let output_dir = run_output_dir(e);
189 let bytes = output_dir.as_deref().map_or(0, dir_size_bytes);
190 total_bytes += bytes;
191 runs.push(PrunedRun {
192 run_id: e.run_id.clone(),
193 project_label: e.project_label.clone(),
194 timestamp_utc: e.timestamp_utc,
195 output_dir,
196 bytes,
197 });
198 }
199 PrunePlan { runs, total_bytes }
200}
201
202#[derive(Debug, Clone, Default)]
204pub struct PruneReport {
205 pub deleted_runs: usize,
206 pub bytes_freed: u64,
207 pub failures: Vec<(String, String)>,
209}
210
211#[must_use]
217pub fn execute_run_prune(reg: &mut ScanRegistry, plan: &PrunePlan) -> PruneReport {
218 use std::collections::HashSet;
219
220 let mut report = PruneReport::default();
221 let mut removed_ids: HashSet<String> = HashSet::new();
222
223 for run in &plan.runs {
224 if let Some(dir) = &run.output_dir
225 && dir.exists()
226 && let Err(e) = std::fs::remove_dir_all(dir)
227 && e.kind() != std::io::ErrorKind::NotFound
228 {
229 report
230 .failures
231 .push((dir.display().to_string(), e.to_string()));
232 continue;
233 }
234 report.bytes_freed += run.bytes;
235 report.deleted_runs += 1;
236 removed_ids.insert(run.run_id.clone());
237 }
238
239 reg.entries.retain(|e| !removed_ids.contains(&e.run_id));
240 report
241}
242
243pub fn rotate_log(path: &Path, max_bytes: u64, keep: u32) -> anyhow::Result<bool> {
258 let Ok(meta) = std::fs::metadata(path) else {
259 return Ok(false); };
261 if meta.len() <= max_bytes {
262 return Ok(false);
263 }
264
265 if keep == 0 {
266 std::fs::write(path, b"")?;
268 return Ok(true);
269 }
270
271 let gen_path = |n: u32| -> PathBuf {
273 let mut s = path.as_os_str().to_owned();
274 s.push(format!(".{n}"));
275 PathBuf::from(s)
276 };
277 let oldest = gen_path(keep);
278 if oldest.exists() {
279 std::fs::remove_file(&oldest)?;
280 }
281 for n in (1..keep).rev() {
282 let from = gen_path(n);
283 if from.exists() {
284 std::fs::rename(&from, gen_path(n + 1))?;
285 }
286 }
287 std::fs::rename(path, gen_path(1))?;
288 Ok(true)
289}
290
291#[must_use]
294pub fn rotated_log_paths(path: &Path) -> Vec<PathBuf> {
295 let mut out = Vec::new();
296 let mut n = 1u32;
297 loop {
298 let mut s = path.as_os_str().to_owned();
299 s.push(format!(".{n}"));
300 let p = PathBuf::from(s);
301 if p.exists() {
302 out.push(p);
303 n += 1;
304 } else {
305 break;
306 }
307 }
308 out
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::history::{RegistryEntry, ScanRegistry, ScanSummarySnapshot};
315 use std::sync::{Mutex, MutexGuard, OnceLock};
316
317 fn env_lock() -> MutexGuard<'static, ()> {
321 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
322 ENV_MUTEX
323 .get_or_init(|| Mutex::new(()))
324 .lock()
325 .unwrap_or_else(std::sync::PoisonError::into_inner)
326 }
327
328 fn entry(run_id: &str, age_days: i64, root: &Path) -> RegistryEntry {
329 let dir = root.join(run_id);
330 std::fs::create_dir_all(dir.join("json")).unwrap();
331 std::fs::write(dir.join("json").join("result.json"), b"{}").unwrap();
332 RegistryEntry {
333 run_id: run_id.to_owned(),
334 timestamp_utc: Utc::now() - chrono::Duration::days(age_days),
335 project_label: format!("proj-{run_id}"),
336 input_roots: vec![],
337 json_path: Some(dir.join("json").join("result.json")),
338 html_path: None,
339 pdf_path: None,
340 csv_path: None,
341 xlsx_path: None,
342 summary: ScanSummarySnapshot::default(),
343 git_branch: None,
344 git_commit: None,
345 git_commit_long: None,
346 git_author: None,
347 git_tags: None,
348 git_nearest_tag: None,
349 git_commit_date: None,
350 }
351 }
352
353 fn tmp() -> PathBuf {
354 let d = std::env::temp_dir().join(format!("sloc_maint_{}", uuid::Uuid::new_v4()));
355 std::fs::create_dir_all(&d).unwrap();
356 d
357 }
358
359 #[test]
360 fn run_output_dir_handles_nested_layout() {
361 let root = tmp();
362 let e = entry("abc", 0, &root);
363 assert_eq!(run_output_dir(&e), Some(root.join("abc")));
365 std::fs::remove_dir_all(&root).ok();
366 }
367
368 #[test]
369 fn plan_selects_by_age() {
370 let root = tmp();
371 let mut reg = ScanRegistry::default();
372 reg.entries.push(entry("old", 40, &root));
373 reg.entries.push(entry("new", 1, &root));
374
375 let plan = plan_run_prune(®, Some(30), None);
376 assert_eq!(plan.runs.len(), 1);
377 assert_eq!(plan.runs[0].run_id, "old");
378 std::fs::remove_dir_all(&root).ok();
379 }
380
381 #[test]
382 fn plan_selects_by_keep_last() {
383 let root = tmp();
384 let mut reg = ScanRegistry::default();
385 for (i, id) in ["a", "b", "c", "d"].iter().enumerate() {
386 reg.entries
387 .push(entry(id, i64::try_from(i).unwrap(), &root)); }
389 let plan = plan_run_prune(®, None, Some(2));
390 let ids: Vec<_> = plan.runs.iter().map(|r| r.run_id.clone()).collect();
391 assert_eq!(plan.runs.len(), 2, "keep 2, delete the 2 oldest");
392 assert!(ids.contains(&"c".to_owned()) && ids.contains(&"d".to_owned()));
393 std::fs::remove_dir_all(&root).ok();
394 }
395
396 #[test]
397 fn execute_removes_dirs_and_entries() {
398 let root = tmp();
399 let mut reg = ScanRegistry::default();
400 reg.entries.push(entry("gone", 40, &root));
401 reg.entries.push(entry("kept", 1, &root));
402
403 let plan = plan_run_prune(®, Some(30), None);
404 let report = execute_run_prune(&mut reg, &plan);
405
406 assert_eq!(report.deleted_runs, 1);
407 assert!(report.failures.is_empty());
408 assert!(!root.join("gone").exists(), "artifacts removed");
409 assert!(root.join("kept").exists(), "recent run untouched");
410 assert_eq!(reg.entries.len(), 1);
411 assert_eq!(reg.entries[0].run_id, "kept");
412 std::fs::remove_dir_all(&root).ok();
413 }
414
415 #[test]
416 fn empty_plan_when_no_rules() {
417 let root = tmp();
418 let mut reg = ScanRegistry::default();
419 reg.entries.push(entry("x", 100, &root));
420 assert!(plan_run_prune(®, None, None).is_empty());
421 std::fs::remove_dir_all(&root).ok();
422 }
423
424 #[test]
425 fn rotate_log_shifts_generations() {
426 let root = tmp();
427 let log = root.join("audit.log");
428 std::fs::write(&log, vec![b'x'; 100]).unwrap();
429
430 assert!(!rotate_log(&log, 1000, 3).unwrap());
432 assert!(rotate_log(&log, 50, 3).unwrap());
434 assert!(log.with_extension("log.1").exists());
435 assert!(!log.exists());
436
437 std::fs::write(&log, vec![b'y'; 100]).unwrap();
439 assert!(rotate_log(&log, 50, 3).unwrap());
440 assert!(log.with_extension("log.1").exists());
441 assert!(log.with_extension("log.2").exists());
442 assert_eq!(rotated_log_paths(&log).len(), 2);
443 std::fs::remove_dir_all(&root).ok();
444 }
445
446 #[test]
447 fn rotate_log_keep_zero_truncates() {
448 let root = tmp();
449 let log = root.join("audit.log");
450 std::fs::write(&log, vec![b'x'; 100]).unwrap();
451 assert!(rotate_log(&log, 10, 0).unwrap());
452 assert!(log.exists());
453 assert_eq!(std::fs::metadata(&log).unwrap().len(), 0);
454 std::fs::remove_dir_all(&root).ok();
455 }
456
457 #[test]
458 fn workspace_root_prefers_env_dir_then_falls_back() {
459 let _guard = env_lock();
460 let dir = tmp();
461 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
463 assert_eq!(workspace_root(), dir);
464 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", dir.join("does-not-exist")) };
467 assert!(workspace_root().is_dir());
468 unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
470 assert!(workspace_root().is_dir());
471 std::fs::remove_dir_all(&dir).ok();
472 }
473
474 #[test]
475 fn resolve_output_root_handles_absolute_relative_and_default() {
476 let _guard = env_lock();
477 let dir = tmp();
478 let abs = dir.join("art");
480 assert_eq!(resolve_output_root(Some(abs.to_str().unwrap())), abs);
481 unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
484 assert_eq!(resolve_output_root(Some(" ")), dir.join("out/web"));
485 assert_eq!(resolve_output_root(None), dir.join("out/web"));
486 assert_eq!(
488 resolve_output_root(Some("custom/out")),
489 dir.join("custom/out")
490 );
491 unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
493 std::fs::remove_dir_all(&dir).ok();
494 }
495
496 #[test]
497 fn resolve_registry_path_honours_env_override() {
498 let _guard = env_lock();
499 let dir = tmp();
500 unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
502 assert_eq!(resolve_registry_path(&dir), dir.join("registry.json"));
503 unsafe { std::env::set_var("SLOC_REGISTRY_PATH", dir.join("shared.json")) };
505 assert_eq!(resolve_registry_path(&dir), dir.join("shared.json"));
506 unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
508 std::fs::remove_dir_all(&dir).ok();
509 }
510
511 #[test]
512 fn dir_size_bytes_counts_files_and_handles_single_file() {
513 let root = tmp();
514 std::fs::write(root.join("a.txt"), vec![b'x'; 10]).unwrap();
515 std::fs::create_dir_all(root.join("sub")).unwrap();
516 std::fs::write(root.join("sub").join("b.txt"), vec![b'y'; 5]).unwrap();
517 assert_eq!(dir_size_bytes(&root), 15);
518 assert_eq!(dir_size_bytes(&root.join("a.txt")), 10);
520 assert_eq!(dir_size_bytes(&root.join("nope")), 0);
522 std::fs::remove_dir_all(&root).ok();
523 }
524
525 #[test]
526 fn run_output_dir_handles_flat_layout_and_missing_paths() {
527 let root = tmp();
528 let mut e = entry("flat", 0, &root);
530 e.json_path = Some(root.join("flat").join("result.json"));
531 assert_eq!(run_output_dir(&e), Some(root.join("flat")));
532 e.json_path = None;
534 assert_eq!(run_output_dir(&e), None);
535 std::fs::remove_dir_all(&root).ok();
536 }
537
538 #[test]
539 fn execute_run_prune_records_failure_for_locked_dir() {
540 let root = tmp();
541 let mut reg = ScanRegistry::default();
542 reg.entries.push(entry("target", 40, &root));
543 let mut plan = plan_run_prune(®, Some(30), None);
544 let bogus = root.join("target").join("json").join("result.json");
548 plan.runs[0].output_dir = Some(bogus);
549 let report = execute_run_prune(&mut reg, &plan);
550 assert_eq!(report.deleted_runs, 0);
551 assert_eq!(report.failures.len(), 1);
552 assert!(reg.entries.iter().any(|e| e.run_id == "target"));
553 std::fs::remove_dir_all(&root).ok();
554 }
555
556 #[test]
557 fn rotate_log_drops_oldest_generation_at_keep_cap() {
558 let root = tmp();
559 let log = root.join("audit.log");
560 std::fs::write(&log, vec![b'x'; 100]).unwrap();
562 std::fs::write(log.with_extension("log.1"), b"g1").unwrap();
563 std::fs::write(log.with_extension("log.2"), b"g2").unwrap();
564 assert!(rotate_log(&log, 50, 2).unwrap());
566 assert!(log.with_extension("log.1").exists());
567 assert!(log.with_extension("log.2").exists());
568 assert!(!log.with_extension("log.3").exists());
569 std::fs::remove_dir_all(&root).ok();
570 }
571}