1use std::collections::{HashMap, HashSet};
19use std::fs::File;
20use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
21use std::path::{Path, PathBuf};
22
23use chrono::Utc;
24
25use crate::adapters::{self, usage_key, Adapter, Parsed};
26use crate::cli::TimeWindow;
27use crate::config::{Config, TokenCounts};
28use crate::store::{
29 expand_tilde, text_hash, Event, IngestCursor, PromptRecord, ScanQuery, Scanner, StorePaths,
30 StoreWriter,
31};
32
33#[derive(Debug, Clone)]
37pub struct IngestOptions {
38 pub window: TimeWindow,
40 pub project: Option<String>,
44}
45
46impl Default for IngestOptions {
47 fn default() -> Self {
48 Self {
49 window: TimeWindow::all(),
50 project: None,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct AdapterIngest {
58 pub adapter: String,
59 pub root: Option<PathBuf>,
61 pub files_read: usize,
63 pub files_seen: usize,
65 pub new_events: u64,
66 pub new_prompts: u64,
67 pub skipped_unparseable: u64,
69 pub unreadable_files: u64,
73 pub duplicates: u64,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct IngestReport {
80 pub adapters: Vec<AdapterIngest>,
81 pub cursors_withheld: bool,
83}
84
85impl IngestReport {
86 pub fn new_events(&self) -> u64 {
87 self.adapters.iter().map(|a| a.new_events).sum()
88 }
89}
90
91pub fn run(
93 config: &Config,
94 paths: &StorePaths,
95 options: &IngestOptions,
96) -> io::Result<IngestReport> {
97 let mut writer = StoreWriter::open(paths.clone())?;
98 let mut state = StoreState::load(paths)?;
99 let cursors = load_cursors(paths)?;
100
101 let mut report = IngestReport {
102 cursors_withheld: options.project.is_some(),
103 ..IngestReport::default()
104 };
105 for adapter in adapters::enabled(config) {
106 report.adapters.push(ingest_adapter(
107 adapter.as_ref(),
108 config,
109 options,
110 &cursors,
111 &mut state,
112 &mut writer,
113 )?);
114 }
115 Ok(report)
116}
117
118fn ingest_adapter(
119 adapter: &dyn Adapter,
120 config: &Config,
121 options: &IngestOptions,
122 cursors: &[IngestCursor],
123 state: &mut StoreState,
124 writer: &mut StoreWriter,
125) -> io::Result<AdapterIngest> {
126 let mut summary = AdapterIngest {
127 adapter: adapter.name().to_string(),
128 ..AdapterIngest::default()
129 };
130
131 let Some(root) = adapter
132 .root(config)
133 .map(|root| expand_tilde(&root))
134 .transpose()?
135 else {
136 return Ok(summary);
137 };
138 summary.root = Some(root.clone());
139 if !root.is_dir() {
140 return Ok(summary);
141 }
142
143 let mut latest: HashMap<&Path, &IngestCursor> = HashMap::new();
148 for cursor in cursors {
149 if cursor.adapter == adapter.name() {
150 latest.insert(Path::new(&cursor.path), cursor);
151 }
152 }
153
154 for path in adapter.discover(&root)? {
155 summary.files_seen += 1;
156 let Ok(meta) = std::fs::metadata(&path) else {
157 summary.unreadable_files += 1;
158 continue;
159 };
160 let Ok(mtime) = mtime_ms(&meta) else {
161 summary.unreadable_files += 1;
162 continue;
163 };
164 if !options.window.contains(mtime) {
165 continue;
166 }
167
168 let cursor = latest.get(path.as_path()).copied();
169 let start = match cursor {
172 Some(cursor) if cursor.offset <= meta.len() => cursor.offset,
173 _ => 0,
174 };
175 if start == meta.len() {
176 continue;
177 }
178
179 summary.files_read += 1;
180 let consumed = ingest_file(
181 adapter,
182 config,
183 options,
184 &path,
185 start,
186 state,
187 writer,
188 &mut summary,
189 )?;
190 if options.project.is_none() {
191 writer.append_cursor(&IngestCursor {
192 path: path.to_string_lossy().into_owned(),
193 mtime,
194 offset: consumed,
195 adapter: adapter.name().to_string(),
196 ts: Utc::now().timestamp_millis(),
197 })?;
198 }
199 }
200
201 Ok(summary)
202}
203
204#[allow(clippy::too_many_arguments)]
207fn ingest_file(
208 adapter: &dyn Adapter,
209 config: &Config,
210 options: &IngestOptions,
211 path: &Path,
212 start: u64,
213 state: &mut StoreState,
214 writer: &mut StoreWriter,
215 summary: &mut AdapterIngest,
216) -> io::Result<u64> {
217 let mut file = File::open(path)?;
219 file.seek(SeekFrom::Start(start))?;
220 let mut reader = BufReader::new(file);
221
222 let mut offset = start;
223 let mut line = String::new();
224 loop {
225 line.clear();
226 let read = match reader.read_line(&mut line) {
227 Ok(0) => break,
228 Ok(read) => read,
229 Err(err) if err.kind() == io::ErrorKind::InvalidData => {
232 summary.skipped_unparseable += 1;
233 break;
234 }
235 Err(err) => return Err(err),
236 };
237 if !line.ends_with('\n') {
238 summary.skipped_unparseable += 1;
241 break;
242 }
243
244 match adapter.parse_line(path, &line) {
245 Parsed::Skipped => {}
246 Parsed::Unparseable => summary.skipped_unparseable += 1,
247 Parsed::Record(record) => {
248 let mut event = record.event;
249 if !state.claim_event(&event.id) {
250 summary.duplicates += 1;
251 } else if matches(options, &event) {
252 apply_usage(&mut event, record.usage_key.as_deref(), state, config);
253 writer.append_event(&event)?;
254 summary.new_events += 1;
255 if let Some(prompt) =
256 prompt_record(&event, record.prompt_text.as_deref(), config)
257 {
258 writer.append_prompt(event.ts, &prompt)?;
259 summary.new_prompts += 1;
260 }
261 }
262 }
263 }
264 offset += read as u64;
265 }
266 Ok(offset)
267}
268
269fn matches(options: &IngestOptions, event: &Event) -> bool {
270 match &options.project {
271 Some(project) => event.project.as_deref() == Some(project.as_str()),
272 None => true,
273 }
274}
275
276fn apply_usage(event: &mut Event, key: Option<&str>, state: &mut StoreState, config: &Config) {
278 if let Some(key) = key {
279 if !state.claim_usage(key) {
280 event.input_tok = None;
281 event.output_tok = None;
282 event.cache_read_tok = None;
283 event.cache_write_tok = None;
284 return;
285 }
286 }
287 if let (true, Some(model)) = (event.has_usage(), event.model.as_deref()) {
288 event.cost_est = config.estimate_cost(
290 &event.provider,
291 model,
292 TokenCounts {
293 input: event.input_tok,
294 output: event.output_tok,
295 cache_read: event.cache_read_tok,
296 cache_write: event.cache_write_tok,
297 },
298 );
299 }
300}
301
302fn prompt_record(event: &Event, text: Option<&str>, config: &Config) -> Option<PromptRecord> {
304 let text = text?;
305 Some(PromptRecord {
306 event_id: event.id.clone(),
307 text: config.general.index_prompt_text.then(|| text.to_string()),
308 text_hash: text_hash(text),
309 })
310}
311
312struct StoreState {
314 event_ids: HashSet<String>,
315 usage_keys: HashSet<String>,
316}
317
318impl StoreState {
319 fn load(paths: &StorePaths) -> io::Result<Self> {
323 let mut state = Self {
324 event_ids: HashSet::new(),
325 usage_keys: HashSet::new(),
326 };
327 let scanner = Scanner::new(paths.clone());
328 scanner.scan_with(&ScanQuery::new(TimeWindow::all()), |event| {
329 if let (Some(turn), true) = (event.turn_id.as_deref(), event.has_usage()) {
330 state
331 .usage_keys
332 .insert(usage_key(&event.agent, event.session_id.as_deref(), turn));
333 }
334 state.event_ids.insert(event.id);
335 })?;
336 Ok(state)
337 }
338
339 fn claim_event(&mut self, id: &str) -> bool {
341 self.event_ids.insert(id.to_string())
342 }
343
344 fn claim_usage(&mut self, key: &str) -> bool {
346 self.usage_keys.insert(key.to_string())
347 }
348}
349
350pub fn load_cursors(paths: &StorePaths) -> io::Result<Vec<IngestCursor>> {
359 let path = paths.ingest_state_file();
360 let file = match File::open(&path) {
361 Ok(file) => file,
362 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
363 Err(err) => return Err(err),
364 };
365 let mut cursors = Vec::new();
366 for line in BufReader::new(file).lines() {
367 let line = line.map_err(|err| {
368 io::Error::new(
369 err.kind(),
370 format!(
371 "reading ingest cursors from {}: {err}; refusing to continue from a truncated \
372 cursor list, which would silently re-read sources from an older offset",
373 path.display()
374 ),
375 )
376 })?;
377 if let Ok(cursor) = serde_json::from_str::<IngestCursor>(&line) {
378 cursors.push(cursor);
379 }
380 }
381 Ok(cursors)
382}
383
384fn mtime_ms(meta: &std::fs::Metadata) -> io::Result<i64> {
390 let modified = meta.modified()?;
391 let since = modified
392 .duration_since(std::time::UNIX_EPOCH)
393 .map_err(|err| {
394 io::Error::new(
395 io::ErrorKind::InvalidData,
396 format!("source file mtime precedes the unix epoch: {err}"),
397 )
398 })?;
399 i64::try_from(since.as_millis()).map_err(|_| {
400 io::Error::new(
401 io::ErrorKind::InvalidData,
402 "source file mtime does not fit in epoch milliseconds",
403 )
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use std::io::Write;
411
412 fn fixture() -> String {
415 [
416 r#"{"type":"mode","mode":"normal"}"#.to_string(),
417 record("user", "u1", "10:00:00", None),
418 record("assistant", "u2", "10:00:01", Some("req_1")),
419 record("assistant", "u3", "10:00:02", Some("req_1")),
420 "{not json at all".to_string(),
421 record("assistant", "u4", "10:00:03", Some("req_2"))
422 .replace("\"isSidechain\":false", "\"isSidechain\":true"),
423 ]
424 .join("\n")
425 + "\n"
426 }
427
428 fn record(kind: &str, uuid: &str, time: &str, request: Option<&str>) -> String {
429 let request = request
430 .map(|id| format!(r#""requestId":"{id}","#))
431 .unwrap_or_default();
432 let message = if kind == "assistant" {
433 r#""message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use",
434 "usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":30,
435 "cache_creation_input_tokens":40},
436 "content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}"#
437 } else {
438 r#""message":{"role":"user","content":"run the tests"}"#
439 };
440 format!(
441 r#"{{"type":"{kind}","uuid":"{uuid}","timestamp":"2026-08-01T{time}.000Z",
442 "sessionId":"s1","cwd":"/home/me/code/acme-api","isSidechain":false,{request}{message}}}"#
443 )
444 .replace('\n', " ")
445 }
446
447 struct Fixture {
448 _dir: tempfile::TempDir,
449 store: StorePaths,
450 source: PathBuf,
451 config: Config,
452 }
453
454 fn setup() -> Fixture {
455 setup_with_config(Config::default())
456 }
457
458 fn setup_with_config(mut config: Config) -> Fixture {
459 let dir = tempfile::tempdir().unwrap();
460 let logs = dir.path().join("logs/projects/-home-me-code-acme-api");
461 std::fs::create_dir_all(&logs).unwrap();
462 let source = logs.join("s1.jsonl");
463 std::fs::write(&source, fixture()).unwrap();
464
465 config.sources.insert(
466 "claude-code".into(),
467 crate::config::Source {
468 enabled: true,
469 path: Some(dir.path().join("logs/projects")),
470 },
471 );
472 Fixture {
473 store: StorePaths::new(dir.path().join("store")),
474 source,
475 config,
476 _dir: dir,
477 }
478 }
479
480 fn ingest(f: &Fixture) -> IngestReport {
481 run(&f.config, &f.store, &IngestOptions::default()).unwrap()
482 }
483
484 fn events(f: &Fixture) -> String {
485 std::fs::read_to_string(
486 f.store.event_partition(
487 crate::store::Partition::for_timestamp(1_785_924_000_000).unwrap(),
488 ),
489 )
490 .unwrap()
491 }
492
493 fn prompts(f: &Fixture) -> String {
494 std::fs::read_to_string(
495 f.store.prompt_partition(
496 crate::store::Partition::for_timestamp(1_785_924_000_000).unwrap(),
497 ),
498 )
499 .unwrap()
500 }
501
502 #[test]
503 fn ingests_every_supported_record_and_counts_the_bad_line() {
504 let f = setup();
505 let report = ingest(&f);
506 let summary = &report.adapters[0];
507 assert_eq!(summary.adapter, "claude-code");
508 assert_eq!(summary.files_read, 1);
509 assert_eq!(summary.new_events, 4);
511 assert_eq!(summary.new_prompts, 1);
512 assert_eq!(summary.skipped_unparseable, 1);
513
514 let stored = events(&f);
515 assert_eq!(stored.lines().count(), 4);
516 assert!(stored.contains("\"tool_name\":\"Read\""));
517 assert!(stored.contains("\"is_sidechain\":true"));
518 assert!(!stored.contains("duration_ms"), "never invented");
519 assert!(prompts(&f).contains("\"text\":\"run the tests\""));
520 }
521
522 #[test]
523 fn repeated_request_usage_is_counted_once() {
524 let f = setup();
525 ingest(&f);
526 let counted = events(&f)
527 .lines()
528 .filter(|line| line.contains("\"input_tok\":10"))
529 .count();
530 assert_eq!(counted, 2);
532 }
533
534 #[test]
535 fn second_ingest_is_a_byte_identical_no_op() {
536 let f = setup();
537 ingest(&f);
538 let before = events(&f);
539 let report = ingest(&f);
540 assert_eq!(report.new_events(), 0);
541 assert_eq!(
542 report.adapters[0].files_read, 0,
543 "unchanged file not reopened"
544 );
545 assert_eq!(events(&f), before);
546 }
547
548 #[test]
549 fn interrupted_run_replays_to_an_identical_store() {
550 let full = setup();
552 ingest(&full);
553 let expected = events(&full);
554
555 let partial = setup();
558 let all = std::fs::read_to_string(&partial.source).unwrap();
559 let cut = all.match_indices('\n').nth(2).unwrap().0 + 1;
560 std::fs::write(&partial.source, &all[..cut]).unwrap();
561 ingest(&partial);
562 std::fs::write(&partial.source, &all).unwrap();
563 ingest(&partial);
564
565 assert_eq!(events(&partial), expected);
566 assert_eq!(prompts(&partial), prompts(&full));
567 }
568
569 #[test]
570 fn replaying_from_a_stale_offset_writes_nothing_twice() {
571 let f = setup();
572 ingest(&f);
573 let before = events(&f);
574 let mut state = std::fs::OpenOptions::new()
577 .append(true)
578 .open(f.store.ingest_state_file())
579 .unwrap();
580 let mut cursor = load_cursors(&f.store).unwrap().pop().unwrap();
581 cursor.offset = 0;
582 writeln!(state, "{}", serde_json::to_string(&cursor).unwrap()).unwrap();
583 drop(state);
584
585 let report = ingest(&f);
586 assert_eq!(report.new_events(), 0);
587 assert!(report.adapters[0].duplicates > 0);
588 assert_eq!(events(&f), before);
589 }
590
591 #[test]
592 fn a_torn_trailing_line_is_skipped_counted_and_retried() {
593 let f = setup();
594 let all = std::fs::read_to_string(&f.source).unwrap();
595 let cut = all.rfind('\n').unwrap() + 1;
596 let torn = format!("{}{}", &all[..cut], r#"{"type":"assistant","uuid":"u9""#);
597 std::fs::write(&f.source, &torn).unwrap();
598
599 let report = ingest(&f);
600 assert_eq!(report.adapters[0].skipped_unparseable, 2);
602 let stored = events(&f);
603 assert!(!stored.contains("\"u9\""));
604
605 std::fs::write(&f.source, all).unwrap();
607 ingest(&f);
608 assert_eq!(events(&f).lines().count(), 4);
609 }
610
611 #[test]
612 fn index_prompt_text_false_stores_only_the_hash() {
613 let mut config = Config::default();
614 config.general.index_prompt_text = false;
615 let f = setup_with_config(config);
616 ingest(&f);
617 let stored = prompts(&f);
618 assert!(stored.contains("\"text_hash\":"));
619 assert!(!stored.contains("run the tests"), "{stored}");
620 }
621
622 #[test]
624 fn a_torn_cursor_line_is_skipped_but_the_others_still_load() {
625 let f = setup();
626 ingest(&f);
627 let mut state = std::fs::OpenOptions::new()
628 .append(true)
629 .open(f.store.ingest_state_file())
630 .unwrap();
631 write!(state, "{{\"path\":\"/x\",\"mtime\"").unwrap();
632 drop(state);
633
634 assert_eq!(load_cursors(&f.store).unwrap().len(), 1);
635 }
636
637 #[test]
640 fn an_unreadable_cursor_line_is_an_error_not_a_silent_truncation() {
641 let f = setup();
642 ingest(&f);
643 let committed = load_cursors(&f.store).unwrap();
644 assert_eq!(committed.len(), 1, "a cursor was written to truncate");
645
646 let mut state = std::fs::OpenOptions::new()
648 .append(true)
649 .open(f.store.ingest_state_file())
650 .unwrap();
651 state.write_all(&[0xff, 0xfe, b'\n']).unwrap();
652 drop(state);
653
654 let err = load_cursors(&f.store).unwrap_err();
655 let msg = err.to_string();
656 assert!(msg.contains("ingest cursors"), "{msg}");
657 assert!(msg.contains("older offset"), "{msg}");
658
659 assert!(run(&f.config, &f.store, &IngestOptions::default()).is_err());
661 }
662
663 #[test]
664 fn a_usable_mtime_is_required_rather_than_defaulted_to_zero() {
665 let meta = std::fs::metadata(&setup().source).unwrap();
666 let mtime = mtime_ms(&meta).expect("a real file has a readable mtime");
667 assert!(mtime > 1_700_000_000_000, "got {mtime}");
668 }
669
670 #[test]
671 fn a_project_filter_scopes_the_run_and_withholds_cursors() {
672 let f = setup();
673 let options = IngestOptions {
674 project: Some("other".into()),
675 ..IngestOptions::default()
676 };
677 let report = run(&f.config, &f.store, &options).unwrap();
678 assert_eq!(report.new_events(), 0);
679 assert!(report.cursors_withheld);
680 assert!(load_cursors(&f.store).unwrap().is_empty());
681 }
682}