1use std::fs::{self, OpenOptions};
24use std::io::{BufRead, BufReader, Write};
25use std::path::{Path, PathBuf};
26
27use crate::incident::{IncidentEvent, IncidentReasonCode};
28
29const DEFAULT_MAX_ENTRIES: usize = 5_000;
31const DEFAULT_MAX_BYTES: u64 = 4 * 1024 * 1024;
33
34#[derive(Debug, Clone)]
36pub struct IncidentLedgerConfig {
37 pub path: PathBuf,
39 pub max_entries: usize,
41 pub max_bytes: u64,
43}
44
45impl IncidentLedgerConfig {
46 #[must_use]
48 pub fn with_path(path: impl Into<PathBuf>) -> Self {
49 Self {
50 path: path.into(),
51 max_entries: DEFAULT_MAX_ENTRIES,
52 max_bytes: DEFAULT_MAX_BYTES,
53 }
54 }
55}
56
57impl Default for IncidentLedgerConfig {
58 fn default() -> Self {
59 Self {
60 path: default_ledger_path(),
61 max_entries: DEFAULT_MAX_ENTRIES,
62 max_bytes: DEFAULT_MAX_BYTES,
63 }
64 }
65}
66
67#[must_use]
71pub fn default_ledger_path() -> PathBuf {
72 let base = std::env::var_os("RCH_STATE_HOME")
73 .map(PathBuf::from)
74 .filter(|p| !p.as_os_str().is_empty())
75 .or_else(|| {
76 std::env::var_os("XDG_STATE_HOME")
77 .map(|d| PathBuf::from(d).join("rch"))
78 .filter(|p| p.as_os_str().len() > "rch".len())
79 })
80 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
81 .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
82 base.join("incidents.jsonl")
83}
84
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct LedgerReadStats {
88 pub parsed: usize,
90 pub skipped: usize,
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct IncidentFilter {
97 pub project_id: Option<String>,
98 pub command_fingerprint: Option<String>,
99 pub worker_id: Option<String>,
100 pub reason_code: Option<IncidentReasonCode>,
101}
102
103impl IncidentFilter {
104 #[must_use]
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 #[must_use]
110 pub fn project(mut self, project_id: impl Into<String>) -> Self {
111 self.project_id = Some(project_id.into());
112 self
113 }
114
115 #[must_use]
116 pub fn command_fingerprint(mut self, fp: impl Into<String>) -> Self {
117 self.command_fingerprint = Some(fp.into());
118 self
119 }
120
121 #[must_use]
122 pub fn worker(mut self, worker_id: impl Into<String>) -> Self {
123 self.worker_id = Some(worker_id.into());
124 self
125 }
126
127 #[must_use]
128 pub fn reason(mut self, reason: IncidentReasonCode) -> Self {
129 self.reason_code = Some(reason);
130 self
131 }
132
133 #[must_use]
135 pub fn matches(&self, event: &IncidentEvent) -> bool {
136 if let Some(p) = &self.project_id
137 && *p != event.project_id
138 {
139 return false;
140 }
141 if let Some(fp) = &self.command_fingerprint
142 && *fp != event.command_fingerprint
143 {
144 return false;
145 }
146 if let Some(w) = &self.worker_id
147 && event.worker_id.as_deref() != Some(w.as_str())
148 {
149 return false;
150 }
151 if let Some(r) = self.reason_code
152 && r != event.reason_code
153 {
154 return false;
155 }
156 true
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct IncidentLedger {
163 config: IncidentLedgerConfig,
164}
165
166impl IncidentLedger {
167 #[must_use]
169 pub fn new(config: IncidentLedgerConfig) -> Self {
170 Self { config }
171 }
172
173 #[must_use]
175 pub fn with_path(path: impl Into<PathBuf>) -> Self {
176 Self::new(IncidentLedgerConfig::with_path(path))
177 }
178
179 #[must_use]
181 pub fn path(&self) -> &Path {
182 &self.config.path
183 }
184
185 pub fn append(&self, event: &IncidentEvent) -> std::io::Result<()> {
192 if let Some(parent) = self.config.path.parent()
193 && !parent.as_os_str().is_empty()
194 {
195 fs::create_dir_all(parent)?;
196 }
197 let mut line = serde_json::to_string(&event.redacted())
202 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
203 line.push('\n');
204
205 let oversized = {
206 let lock = self.open_lock()?;
207 lock.lock_shared()?;
208 let mut file = OpenOptions::new()
209 .create(true)
210 .append(true)
211 .open(&self.config.path)?;
212 file.write_all(line.as_bytes())?;
213 file.flush()?;
214 fs::metadata(&self.config.path).is_ok_and(|meta| meta.len() > self.config.max_bytes)
216 };
217 if oversized {
220 let lock = self.open_lock()?;
221 lock.lock()?;
222 if fs::metadata(&self.config.path).is_ok_and(|meta| meta.len() > self.config.max_bytes)
223 {
224 self.rewrite_retained()?;
225 }
226 }
227 Ok(())
228 }
229
230 fn open_lock(&self) -> std::io::Result<fs::File> {
233 let mut path = self.config.path.clone().into_os_string();
234 path.push(".lock");
235 OpenOptions::new()
236 .create(true)
237 .truncate(false)
238 .read(true)
239 .write(true)
240 .open(PathBuf::from(path))
241 }
242
243 #[must_use]
245 pub fn read_all(&self) -> Vec<IncidentEvent> {
246 self.read_all_with_stats().0
247 }
248
249 #[must_use]
251 pub fn read_all_with_stats(&self) -> (Vec<IncidentEvent>, LedgerReadStats) {
252 let mut events = Vec::new();
253 let mut stats = LedgerReadStats::default();
254 let file = match fs::File::open(&self.config.path) {
255 Ok(f) => f,
256 Err(_) => return (events, stats),
257 };
258 for line in BufReader::new(file).split(b'\n') {
261 let Ok(line) = line else {
262 stats.skipped += 1;
264 break;
265 };
266 let trimmed = line.trim_ascii();
267 if trimmed.is_empty() {
268 continue;
269 }
270 match serde_json::from_slice::<IncidentEvent>(trimmed) {
271 Ok(event) => {
272 events.push(event);
273 stats.parsed += 1;
274 }
275 Err(_) => stats.skipped += 1,
276 }
277 }
278 (events, stats)
279 }
280
281 #[must_use]
283 pub fn query(&self, filter: &IncidentFilter) -> Vec<IncidentEvent> {
284 self.read_all()
285 .into_iter()
286 .filter(|e| filter.matches(e))
287 .collect()
288 }
289
290 pub fn compact(&self) -> std::io::Result<()> {
294 let lock = self.open_lock()?;
295 lock.lock()?;
296 self.rewrite_retained()
297 }
298
299 fn rewrite_retained(&self) -> std::io::Result<()> {
301 let (mut events, _) = self.read_all_with_stats();
302 if events.len() > self.config.max_entries {
303 let drop = events.len() - self.config.max_entries;
304 events.drain(0..drop);
305 }
306
307 let parent = self.config.path.parent().unwrap_or_else(|| Path::new("."));
308 let tmp = parent.join(format!(".incidents.{}.tmp", std::process::id(),));
310 {
311 let mut tmp_file = OpenOptions::new()
312 .create(true)
313 .write(true)
314 .truncate(true)
315 .open(&tmp)?;
316 for event in &events {
317 let mut line = serde_json::to_string(event)
318 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
319 line.push('\n');
320 tmp_file.write_all(line.as_bytes())?;
321 }
322 tmp_file.flush()?;
323 }
324 match fs::rename(&tmp, &self.config.path) {
325 Ok(()) => Ok(()),
326 Err(e) => {
327 let _ = fs::remove_file(&tmp);
328 Err(e)
329 }
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::incident::{IncidentEventType, IncidentSource, SelectedMode};
338
339 fn event(
340 reason: IncidentReasonCode,
341 project: &str,
342 worker: Option<&str>,
343 ts: u64,
344 ) -> IncidentEvent {
345 let mut e = IncidentEvent::new(
346 IncidentEventType::Selection,
347 reason,
348 IncidentSource::Daemon,
349 project,
350 "cargo build",
351 SelectedMode::Remote,
352 true,
353 ts,
354 );
355 if let Some(w) = worker {
356 e = e.with_worker_id(w);
357 }
358 e
359 }
360
361 fn temp_ledger() -> (IncidentLedger, tempfile::TempDir) {
362 let dir = tempfile::tempdir().unwrap();
363 let ledger = IncidentLedger::with_path(dir.path().join("incidents.jsonl"));
364 (ledger, dir)
365 }
366
367 #[test]
368 fn append_then_read_roundtrips() {
369 let (ledger, _dir) = temp_ledger();
370 let e = event(IncidentReasonCode::LocalFallback, "p1", Some("css"), 100);
371 ledger.append(&e).unwrap();
372 let read = ledger.read_all();
373 assert_eq!(read.len(), 1);
374 assert_eq!(read[0], e);
375 }
376
377 #[test]
378 fn missing_file_reads_empty() {
379 let dir = tempfile::tempdir().unwrap();
380 let ledger = IncidentLedger::with_path(dir.path().join("nope.jsonl"));
381 assert!(ledger.read_all().is_empty());
382 }
383
384 #[test]
385 fn survives_reopen_across_instances() {
386 let dir = tempfile::tempdir().unwrap();
387 let path = dir.path().join("incidents.jsonl");
388 {
389 let ledger = IncidentLedger::with_path(&path);
390 ledger
391 .append(&event(IncidentReasonCode::CircuitOpen, "p1", None, 1))
392 .unwrap();
393 ledger
394 .append(&event(IncidentReasonCode::DiskFull, "p2", None, 2))
395 .unwrap();
396 }
397 let reopened = IncidentLedger::with_path(&path);
399 assert_eq!(reopened.read_all().len(), 2);
400 }
401
402 #[test]
403 fn invalid_utf8_line_is_skipped_not_the_end_of_the_ledger() {
404 let (ledger, _dir) = temp_ledger();
405 ledger
406 .append(&event(IncidentReasonCode::LocalFallback, "p1", None, 1))
407 .unwrap();
408 let mut file = OpenOptions::new().append(true).open(ledger.path()).unwrap();
409 file.write_all(b"\xff\xfe torn\n").unwrap();
410 ledger
411 .append(&event(IncidentReasonCode::LocalFallback, "p2", None, 2))
412 .unwrap();
413 let (events, stats) = ledger.read_all_with_stats();
414 assert_eq!(events.len(), 2);
415 assert_eq!(stats.skipped, 1);
416 ledger.compact().unwrap();
417 assert_eq!(
418 ledger.read_all().len(),
419 2,
420 "compaction dropped later events"
421 );
422 }
423
424 #[test]
425 fn concurrent_appends_survive_repeated_compaction() {
426 let dir = tempfile::tempdir().unwrap();
427 let ledger = IncidentLedger::new(IncidentLedgerConfig {
428 path: dir.path().join("incidents.jsonl"),
429 max_entries: 100_000,
430 max_bytes: 2_048,
432 });
433 std::thread::scope(|scope| {
434 for thread in 0..8_u64 {
435 let ledger = &ledger;
436 scope.spawn(move || {
437 for i in 0..50_u64 {
438 let e = event(
439 IncidentReasonCode::LocalFallback,
440 "p",
441 None,
442 thread * 1_000 + i,
443 );
444 ledger.append(&e).unwrap();
445 }
446 });
447 }
448 });
449 assert_eq!(ledger.read_all().len(), 400, "appends lost to compaction");
450 }
451
452 #[test]
453 fn corrupt_lines_are_tolerated_and_counted() {
454 let dir = tempfile::tempdir().unwrap();
455 let path = dir.path().join("incidents.jsonl");
456 let ledger = IncidentLedger::with_path(&path);
457 ledger
458 .append(&event(IncidentReasonCode::ArtifactMiss, "p1", None, 1))
459 .unwrap();
460 {
462 use std::io::Write;
463 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
464 writeln!(f, "{{ this is not valid json").unwrap();
465 writeln!(f).unwrap();
466 writeln!(f, "garbage").unwrap();
467 }
468 ledger
469 .append(&event(IncidentReasonCode::QueueAmbiguity, "p2", None, 2))
470 .unwrap();
471
472 let (events, stats) = ledger.read_all_with_stats();
473 assert_eq!(events.len(), 2, "two valid events survive");
474 assert_eq!(stats.parsed, 2);
475 assert_eq!(
476 stats.skipped, 2,
477 "two garbage lines skipped (blank ignored)"
478 );
479 }
480
481 #[test]
482 fn query_filters_by_project_worker_and_reason() {
483 let (ledger, _dir) = temp_ledger();
484 ledger
485 .append(&event(
486 IncidentReasonCode::LocalFallback,
487 "p1",
488 Some("css"),
489 1,
490 ))
491 .unwrap();
492 ledger
493 .append(&event(
494 IncidentReasonCode::CircuitOpen,
495 "p1",
496 Some("bil"),
497 2,
498 ))
499 .unwrap();
500 ledger
501 .append(&event(
502 IncidentReasonCode::CircuitOpen,
503 "p2",
504 Some("css"),
505 3,
506 ))
507 .unwrap();
508
509 assert_eq!(ledger.query(&IncidentFilter::new().project("p1")).len(), 2);
510 assert_eq!(ledger.query(&IncidentFilter::new().worker("css")).len(), 2);
511 assert_eq!(
512 ledger
513 .query(&IncidentFilter::new().reason(IncidentReasonCode::CircuitOpen))
514 .len(),
515 2
516 );
517 let combined = IncidentFilter::new()
519 .project("p1")
520 .reason(IncidentReasonCode::CircuitOpen);
521 let hits = ledger.query(&combined);
522 assert_eq!(hits.len(), 1);
523 assert_eq!(hits[0].worker_id.as_deref(), Some("bil"));
524 }
525
526 #[test]
527 fn retention_compacts_to_max_entries_keeping_newest() {
528 let dir = tempfile::tempdir().unwrap();
529 let path = dir.path().join("incidents.jsonl");
530 let ledger = IncidentLedger::new(IncidentLedgerConfig {
532 path,
533 max_entries: 3,
534 max_bytes: 1, });
536 for i in 0..10u64 {
537 ledger
538 .append(&event(IncidentReasonCode::LocalFallback, "p", None, i))
539 .unwrap();
540 }
541 let events = ledger.read_all();
542 assert_eq!(events.len(), 3, "retained at most max_entries");
543 let timestamps: Vec<u64> = events.iter().map(|e| e.occurred_at_unix_ms).collect();
545 assert_eq!(timestamps, vec![7, 8, 9]);
546 }
547
548 #[test]
549 fn explicit_compact_drops_corrupt_lines() {
550 let dir = tempfile::tempdir().unwrap();
551 let path = dir.path().join("incidents.jsonl");
552 let ledger = IncidentLedger::with_path(&path);
553 ledger
554 .append(&event(IncidentReasonCode::DiskFull, "p1", None, 1))
555 .unwrap();
556 {
557 use std::io::Write;
558 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
559 writeln!(f, "not json").unwrap();
560 }
561 ledger.compact().unwrap();
562 let (events, stats) = ledger.read_all_with_stats();
564 assert_eq!(events.len(), 1);
565 assert_eq!(stats.skipped, 0);
566 }
567
568 #[test]
569 fn default_ledger_path_honors_state_home() {
570 let p = default_ledger_path();
573 assert!(p.ends_with("incidents.jsonl"));
574 }
575}