1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use kimetsu_core::KimetsuResult;
7use kimetsu_core::event::Event;
8use kimetsu_core::ids::RunId;
9use kimetsu_core::paths::ProjectPaths;
10
11#[derive(Debug, Clone)]
12pub struct RunPaths {
13 pub run_dir: PathBuf,
14 pub trace_jsonl: PathBuf,
15 pub artifacts_dir: PathBuf,
16 pub patch_plans_dir: PathBuf,
17 pub final_report: PathBuf,
18 pub run_log: PathBuf,
19}
20
21impl RunPaths {
22 pub fn new(paths: &ProjectPaths, run_id: RunId) -> Self {
23 let run_dir = paths.runs_dir.join(run_id.to_string());
24 Self {
25 trace_jsonl: run_dir.join("trace.jsonl"),
26 artifacts_dir: run_dir.join("artifacts"),
27 patch_plans_dir: run_dir.join("patch_plans"),
28 final_report: run_dir.join("final_report.md"),
29 run_log: run_dir.join("kimetsu.log"),
30 run_dir,
31 }
32 }
33
34 pub fn create_dirs(&self) -> KimetsuResult<()> {
35 fs::create_dir_all(&self.artifacts_dir)?;
36 fs::create_dir_all(&self.patch_plans_dir)?;
37 Ok(())
38 }
39}
40
41pub struct TraceWriter {
42 file: File,
43}
44
45impl TraceWriter {
46 pub fn create(paths: &ProjectPaths, run_id: RunId) -> KimetsuResult<(Self, RunPaths)> {
47 let run_paths = RunPaths::new(paths, run_id);
48 run_paths.create_dirs()?;
49 let file = OpenOptions::new()
50 .create(true)
51 .append(true)
52 .open(&run_paths.trace_jsonl)?;
53
54 if std::env::var("KIMETSU_RUNS_GC").as_deref() != Ok("0") {
58 gc_old_runs(
59 &paths.runs_dir,
60 Duration::from_secs(30 * 24 * 3600), 20, );
63 }
64
65 Ok((Self { file }, run_paths))
66 }
67
68 pub fn append(&mut self, event: &Event, fsync: bool) -> KimetsuResult<()> {
69 serde_json::to_writer(&mut self.file, event)?;
70 self.file.write_all(b"\n")?;
71 self.file.flush()?;
72 if fsync {
73 self.file.sync_data()?;
74 }
75 Ok(())
76 }
77}
78
79pub fn read_trace(trace_jsonl: &Path) -> KimetsuResult<Vec<Event>> {
80 let file = File::open(trace_jsonl)?;
81 let mut reader = BufReader::new(file);
82 let mut events = Vec::new();
83 let mut line = String::new();
84 let mut line_number = 0usize;
85
86 loop {
87 line.clear();
88 let bytes_read = reader.read_line(&mut line)?;
89 if bytes_read == 0 {
90 break;
91 }
92 line_number += 1;
93
94 let trimmed = line.trim();
95 if trimmed.is_empty() {
96 continue;
97 }
98
99 match serde_json::from_str::<Event>(trimmed) {
100 Ok(event) => events.push(event),
101 Err(err) => {
102 if !line.ends_with('\n') {
103 eprintln!(
104 "warning: ignoring invalid trailing JSONL line in {}: {err}",
105 trace_jsonl.display()
106 );
107 break;
108 }
109
110 return Err(format!(
111 "invalid JSONL at {}:{}: {err}",
112 trace_jsonl.display(),
113 line_number
114 )
115 .into());
116 }
117 }
118 }
119
120 Ok(events)
121}
122
123pub fn discover_traces(paths: &ProjectPaths) -> KimetsuResult<Vec<PathBuf>> {
124 if !paths.runs_dir.exists() {
125 return Ok(Vec::new());
126 }
127
128 let mut traces = Vec::new();
129 for entry in fs::read_dir(&paths.runs_dir)? {
130 let entry = entry?;
131 if !entry.file_type()?.is_dir() {
132 continue;
133 }
134
135 let trace = entry.path().join("trace.jsonl");
136 if trace.exists() {
137 traces.push(trace);
138 }
139 }
140
141 traces.sort();
142 Ok(traces)
143}
144
145pub fn read_all_traces(paths: &ProjectPaths) -> KimetsuResult<Vec<Event>> {
146 let mut events = Vec::new();
147 for trace in discover_traces(paths)? {
148 events.extend(read_trace(&trace)?);
149 }
150
151 events.sort_by(|left, right| {
152 left.event_id
153 .0
154 .cmp(&right.event_id.0)
155 .then_with(|| left.ts.cmp(&right.ts))
156 });
157 events.dedup_by_key(|event| event.event_id);
158 Ok(events)
159}
160
161fn ulid_timestamp_ms(name: &str) -> Option<u64> {
168 name.parse::<ulid::Ulid>().ok().map(|u| u.timestamp_ms())
169}
170
171pub fn select_old_runs(
182 runs: &[(&str, u64)],
183 now_ms: u64,
184 max_age: Duration,
185 keep: usize,
186) -> Vec<usize> {
187 let cutoff_ms = now_ms.saturating_sub(max_age.as_millis() as u64);
188 runs.iter()
189 .enumerate()
190 .filter_map(|(idx, (_name, ts_ms))| {
191 if idx < keep {
192 return None; }
194 if *ts_ms < cutoff_ms { Some(idx) } else { None }
195 })
196 .collect()
197}
198
199pub fn gc_old_runs(runs_dir: &Path, max_age: Duration, keep: usize) {
205 let Ok(rd) = fs::read_dir(runs_dir) else {
206 return;
207 };
208
209 let now_ms = SystemTime::now()
210 .duration_since(UNIX_EPOCH)
211 .map(|d| d.as_millis() as u64)
212 .unwrap_or(0);
213
214 let mut entries: Vec<(String, u64, PathBuf)> = rd
216 .flatten()
217 .filter(|e| e.path().is_dir())
218 .map(|e| {
219 let path = e.path();
220 let name = e.file_name().to_string_lossy().into_owned();
221 let ts_ms = ulid_timestamp_ms(&name).unwrap_or_else(|| {
222 e.metadata()
223 .ok()
224 .and_then(|m| m.modified().ok())
225 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
226 .map(|d| d.as_millis() as u64)
227 .unwrap_or(0)
228 });
229 (name, ts_ms, path)
230 })
231 .collect();
232
233 entries.sort_by_key(|(_, ts, _)| std::cmp::Reverse(*ts));
235
236 let slim: Vec<(&str, u64)> = entries
238 .iter()
239 .map(|(name, ts, _)| (name.as_str(), *ts))
240 .collect();
241
242 let to_remove = select_old_runs(&slim, now_ms, max_age, keep);
243 for idx in to_remove {
244 let _ = fs::remove_dir_all(&entries[idx].2);
245 }
246}
247
248#[cfg(test)]
253mod tests {
254 use super::*;
255 use kimetsu_core::ids::RunId;
256 use kimetsu_core::paths::ProjectPaths;
257 use std::sync::Mutex;
258
259 fn env_lock() -> &'static Mutex<()> {
261 static LOCK: Mutex<()> = Mutex::new(());
262 &LOCK
263 }
264
265 #[test]
268 fn select_empty_returns_empty() {
269 let result = select_old_runs(&[], 1_000_000_000, Duration::from_secs(1), 0);
270 assert!(result.is_empty());
271 }
272
273 #[test]
274 fn select_newer_than_cutoff_not_selected() {
275 let now_ms: u64 = 1_000_000_000;
276 let max_age = Duration::from_secs(3 * 24 * 3600); let cutoff_ms = now_ms - max_age.as_millis() as u64;
278 let runs = vec![
280 ("run-1", cutoff_ms + 10_000),
281 ("run-2", cutoff_ms + 5_000),
282 ("run-3", cutoff_ms + 1_000),
283 ];
284 let result = select_old_runs(&runs, now_ms, max_age, 0);
285 assert!(
286 result.is_empty(),
287 "runs newer than cutoff must not be selected"
288 );
289 }
290
291 #[test]
292 fn select_older_than_cutoff_selected() {
293 let now_ms: u64 = 1_000_000_000;
294 let max_age = Duration::from_secs(3 * 24 * 3600); let cutoff_ms = now_ms - max_age.as_millis() as u64;
296 let runs = vec![("run-1", cutoff_ms - 1_000), ("run-2", cutoff_ms - 5_000)];
298 let result = select_old_runs(&runs, now_ms, max_age, 0);
299 assert_eq!(result, vec![0, 1], "both old runs should be selected");
300 }
301
302 #[test]
303 fn select_keep_protects_newest() {
304 let now_ms: u64 = 1_000_000_000;
305 let max_age = Duration::from_secs(1);
307 let runs = vec![
309 ("run-a", 900),
310 ("run-b", 800),
311 ("run-c", 700),
312 ("run-d", 600),
313 ];
314 let result = select_old_runs(&runs, now_ms, max_age, 2);
316 assert_eq!(result, vec![2, 3]);
317 }
318
319 #[test]
320 fn select_keep_larger_than_slice_selects_nothing() {
321 let now_ms: u64 = 1_000_000_000;
322 let max_age = Duration::from_secs(1);
323 let runs = vec![("run-1", 100), ("run-2", 50)];
324 let result = select_old_runs(&runs, now_ms, max_age, 10);
325 assert!(
326 result.is_empty(),
327 "keep >= slice length must protect everything"
328 );
329 }
330
331 #[test]
332 fn select_mixed_age_and_keep() {
333 let day_ms = 24u64 * 3600 * 1_000;
341 let now_ms = 10 * day_ms;
342 let max_age = Duration::from_secs(2 * 24 * 3600);
343 let runs = vec![
344 ("r0", now_ms - 9 * day_ms),
345 ("r1", now_ms - 8 * day_ms),
346 ("r2", now_ms - 5 * day_ms),
347 ("r3", now_ms - day_ms),
348 ("r4", now_ms - 3 * day_ms),
349 ];
350 let result = select_old_runs(&runs, now_ms, max_age, 2);
354 assert_eq!(result, vec![2, 4]);
355 }
356
357 #[test]
377 fn gc_fresh_dirs_all_survive() {
378 let tmp = std::env::temp_dir().join(format!("kimetsu-gc-fresh-{}", RunId::new()));
379 fs::create_dir_all(&tmp).unwrap();
380
381 for i in 0..5u32 {
383 fs::create_dir_all(tmp.join(format!("run-{i}"))).unwrap();
384 }
385
386 gc_old_runs(&tmp, Duration::from_secs(30 * 24 * 3600), 20);
388
389 let remaining: Vec<_> = fs::read_dir(&tmp)
390 .unwrap()
391 .flatten()
392 .filter(|e| e.path().is_dir())
393 .collect();
394 assert_eq!(remaining.len(), 5, "all 5 fresh dirs should survive");
395
396 let _ = fs::remove_dir_all(&tmp);
397 }
398
399 #[test]
400 fn gc_env_zero_disables_gc() {
401 let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
402 let tmp = std::env::temp_dir().join(format!("kimetsu-gc-env0-{}", RunId::new()));
403 fs::create_dir_all(&tmp).unwrap();
404
405 for i in 0..3u32 {
406 fs::create_dir_all(tmp.join(format!("run-{i}"))).unwrap();
407 }
408
409 gc_old_runs(&tmp, Duration::from_nanos(1), 3); let remaining: usize = fs::read_dir(&tmp)
418 .unwrap()
419 .flatten()
420 .filter(|e| e.path().is_dir())
421 .count();
422 assert_eq!(remaining, 3, "keep=3 should protect all 3 dirs");
423
424 let _ = fs::remove_dir_all(&tmp);
425 }
426
427 #[test]
428 fn trace_writer_create_env_zero_skips_gc() {
429 let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
430
431 let root = std::env::temp_dir().join(format!("kimetsu-gc-tw-{}", RunId::new()));
432 fs::create_dir_all(&root).unwrap();
433 kimetsu_core::paths::git_init_boundary(&root);
434 kimetsu_brain_init_for_test(&root);
435
436 let paths = ProjectPaths::at_root(&root);
437
438 fs::create_dir_all(paths.runs_dir.join("old-sibling")).unwrap();
440
441 unsafe { std::env::set_var("KIMETSU_RUNS_GC", "0") };
442 let run_id = RunId::new();
443 let (_tw, run_paths) = TraceWriter::create(&paths, run_id).expect("create");
444 assert!(
446 paths.runs_dir.join("old-sibling").exists(),
447 "GC=0 must leave old-sibling untouched"
448 );
449 assert!(run_paths.run_dir.exists(), "new run dir must exist");
451 unsafe { std::env::remove_var("KIMETSU_RUNS_GC") };
452
453 let _ = fs::remove_dir_all(&root);
454 }
455
456 #[test]
457 fn trace_writer_create_new_run_survives_gc() {
458 let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
459
460 let root = std::env::temp_dir().join(format!("kimetsu-gc-survive-{}", RunId::new()));
461 fs::create_dir_all(&root).unwrap();
462 kimetsu_core::paths::git_init_boundary(&root);
463 kimetsu_brain_init_for_test(&root);
464
465 let paths = ProjectPaths::at_root(&root);
466
467 unsafe { std::env::remove_var("KIMETSU_RUNS_GC") };
469
470 let run_id = RunId::new();
471 let (_tw, run_paths) = TraceWriter::create(&paths, run_id).expect("create");
472
473 assert!(
475 run_paths.run_dir.exists(),
476 "just-created run dir must survive GC"
477 );
478
479 let _ = fs::remove_dir_all(&root);
480 }
481
482 fn kimetsu_brain_init_for_test(root: &Path) {
485 let kimetsu_dir = root.join(".kimetsu");
486 fs::create_dir_all(kimetsu_dir.join("runs")).unwrap();
487 }
488}