1use anyhow::{Context, Result, bail};
2use fs4::fs_std::FileExt;
3use lazily::{CellHandle, Context as LazyContext, SlotHandle};
4use rusqlite::{Connection, OpenFlags};
5use serde::{Deserialize, Serialize};
6use std::cell::{Cell, RefCell};
7use std::collections::{BTreeSet, HashMap};
8use std::fs::{File, OpenOptions};
9use std::io::{Read, Seek, SeekFrom, Write};
10use std::path::{Component, Path, PathBuf};
11use std::rc::Rc;
12use std::time::Duration;
13use tsift_index::index::IndexDb;
14use tsift_sqlite::{ReadOnlyRecovery, copy_read_only_snapshot, read_only_snapshot_recovery};
15
16pub struct SummaryDb {
17 conn: Connection,
18 _snapshot_copy: Option<SnapshotCopyGuard>,
19}
20
21pub struct SummaryReadOnlyOpen {
22 pub db: SummaryDb,
23 pub recovery: Option<ReadOnlyRecovery>,
24}
25
26type CachedSummaryFileSnapshot = std::result::Result<SummaryFileSnapshot, String>;
27
28#[derive(Debug, Clone)]
29pub struct SummaryFileSnapshot {
30 pub file_path: String,
31 pub requested_content_hash: Option<String>,
32 pub summaries: Vec<Summary>,
33 pub current: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SummaryCacheSource {
38 Cached,
39 Extracted,
40}
41
42#[derive(Debug, Clone)]
43pub struct SummaryCacheLookup {
44 pub summaries: Vec<Summary>,
45 pub source: SummaryCacheSource,
46}
47
48#[derive(Clone, Copy)]
49struct SummaryFileSlot {
50 content_hash: CellHandle<Option<String>>,
51 epoch: CellHandle<u64>,
52 snapshot: SlotHandle<CachedSummaryFileSnapshot>,
53}
54
55pub struct SummaryCache {
56 db: Rc<SummaryDb>,
57 ctx: LazyContext,
58 slots: RefCell<HashMap<String, SummaryFileSlot>>,
59 hits: Cell<usize>,
60 misses: Cell<usize>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Summary {
65 pub id: i64,
66 pub symbol_name: String,
67 pub file_path: String,
68 pub content_hash: String,
69 pub summary: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub entities: Option<Vec<Entity>>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub relationships: Option<Vec<Relationship>>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub concept_labels: Option<Vec<String>>,
76 pub extracted_at: String,
77 pub model: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub tokens_input: Option<i64>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub tokens_output: Option<i64>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Entity {
86 pub name: String,
87 pub kind: String,
88 pub description: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Relationship {
93 pub from: String,
94 pub to: String,
95 pub kind: String,
96}
97
98#[derive(Debug, Serialize)]
99pub struct SummaryStats {
100 pub total_summaries: usize,
101 pub total_files: usize,
102 pub stale_count: usize,
103 pub total_tokens_input: i64,
104 pub total_tokens_output: i64,
105 pub estimated_tokens_saved: i64,
106 #[serde(skip_serializing_if = "Vec::is_empty", default)]
107 pub warnings: Vec<SummaryStatsWarning>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct SummaryStatsWarning {
112 pub path: PathBuf,
113 pub message: String,
114}
115
116#[derive(Debug, Deserialize)]
117struct ExtractionResponse {
118 summary: String,
119 #[serde(default)]
120 entities: Vec<Entity>,
121 #[serde(default)]
122 relationships: Vec<Relationship>,
123 #[serde(default)]
124 concept_labels: Vec<String>,
125}
126
127#[derive(Debug, Serialize)]
128pub struct ExtractionReport {
129 pub files_processed: usize,
130 pub symbols_extracted: usize,
131 pub tokens_input: i64,
132 pub tokens_output: i64,
133 pub errors: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct GitChangedFiles {
138 pub existing: Vec<PathBuf>,
139 pub deleted: Vec<PathBuf>,
140}
141
142#[derive(Debug, Clone)]
143pub struct SummarizeConfig {
144 pub model: String,
145 pub max_file_tokens: usize,
146 pub api_key_env: String,
147}
148
149const REPLACE_FILE_SAVEPOINT: &str = "tsift_summary_replace";
150
151#[derive(Debug)]
152pub struct SummaryWriteLockGuard {
153 file: File,
154}
155
156#[derive(Debug)]
157struct SnapshotCopyGuard {
158 paths: Vec<PathBuf>,
159}
160
161impl Drop for SummaryWriteLockGuard {
162 fn drop(&mut self) {
163 let _ = clear_lock_metadata(&mut self.file);
164 let _ = self.file.unlock();
165 }
166}
167
168impl Drop for SnapshotCopyGuard {
169 fn drop(&mut self) {
170 for path in &self.paths {
171 let _ = std::fs::remove_file(path);
172 }
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177enum LockFileMarker {
178 Empty,
179 Pid(u32),
180 Invalid,
181}
182
183impl Default for SummarizeConfig {
184 fn default() -> Self {
185 Self {
186 model: "claude-haiku-4-5-20251001".to_string(),
187 max_file_tokens: 8000,
188 api_key_env: "ANTHROPIC_API_KEY".to_string(),
189 }
190 }
191}
192
193pub fn acquire_write_lock(db_path: &Path) -> Result<SummaryWriteLockGuard> {
194 let lock_path = writer_lock_path(db_path);
195 if let Some(parent) = lock_path.parent() {
196 std::fs::create_dir_all(parent)
197 .with_context(|| format!("creating lock dir: {}", parent.display()))?;
198 }
199
200 let mut lock_file = OpenOptions::new()
201 .read(true)
202 .write(true)
203 .create(true)
204 .truncate(false)
205 .open(&lock_path)
206 .with_context(|| format!("opening {}", lock_path.display()))?;
207
208 match lock_file.try_lock_exclusive() {
209 Ok(true) => {
210 write_lock_pid(&mut lock_file, &lock_path)?;
211 Ok(SummaryWriteLockGuard { file: lock_file })
212 }
213 Ok(false) => {
214 let holder = match read_lock_marker(&mut lock_file)
215 .with_context(|| format!("reading {}", lock_path.display()))?
216 {
217 LockFileMarker::Pid(pid) => format!(" (pid {})", pid),
218 _ => String::new(),
219 };
220 bail!(
221 "another tsift summarize extractor is already active for {}{} (lock: {}). \
222 A concurrent `tsift summarize --extract` is already updating this summary cache; \
223 wait for it to finish before retrying.",
224 db_path.display(),
225 holder,
226 lock_path.display()
227 );
228 }
229 Err(err) => Err(err).with_context(|| format!("locking {}", lock_path.display())),
230 }
231}
232
233pub fn writer_lock_path(db_path: &Path) -> PathBuf {
234 let stem = db_path
235 .file_stem()
236 .and_then(|stem| stem.to_str())
237 .unwrap_or("summaries");
238 db_path.with_file_name(format!("{stem}.lock"))
239}
240
241impl SummaryDb {
242 pub fn open(path: &Path) -> Result<Self> {
243 if let Some(parent) = path.parent() {
244 std::fs::create_dir_all(parent)
245 .with_context(|| format!("creating directory for {}", path.display()))?;
246 }
247 let conn = Connection::open(path)
248 .with_context(|| format!("opening summaries db: {}", path.display()))?;
249 conn.busy_timeout(Duration::from_secs(5))?;
250 conn.pragma_update(None, "journal_mode", "WAL")?;
251 let mode: String = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
252 if mode.to_lowercase() != "wal" {
253 bail!(
254 "summaries db {} requires WAL mode for concurrent reads, got {}",
255 path.display(),
256 mode
257 );
258 }
259 conn.execute_batch(
260 "CREATE TABLE IF NOT EXISTS summaries (
261 id INTEGER PRIMARY KEY,
262 symbol_name TEXT NOT NULL,
263 file_path TEXT NOT NULL,
264 content_hash TEXT NOT NULL,
265 summary TEXT NOT NULL,
266 entities TEXT,
267 relationships TEXT,
268 concept_labels TEXT,
269 extracted_at TEXT NOT NULL,
270 model TEXT NOT NULL,
271 tokens_input INTEGER,
272 tokens_output INTEGER
273 );
274 CREATE INDEX IF NOT EXISTS idx_summaries_symbol ON summaries(symbol_name);
275 CREATE INDEX IF NOT EXISTS idx_summaries_file ON summaries(file_path);
276 CREATE INDEX IF NOT EXISTS idx_summaries_hash ON summaries(content_hash);",
277 )?;
278 Ok(Self {
279 conn,
280 _snapshot_copy: None,
281 })
282 }
283
284 pub fn open_read_only(path: &Path) -> Result<Self> {
285 let conn = Connection::open_with_flags(
286 path,
287 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
288 )
289 .with_context(|| format!("opening summaries db: {}", path.display()))?;
290 conn.busy_timeout(Duration::from_secs(5))?;
291 Ok(Self {
292 conn,
293 _snapshot_copy: None,
294 })
295 }
296
297 pub fn open_read_only_resilient(path: &Path) -> Result<Self> {
298 Self::open_read_only_with_recovery(path).map(|result| result.db)
299 }
300
301 pub fn open_read_only_with_recovery(path: &Path) -> Result<SummaryReadOnlyOpen> {
302 match Self::open_read_only(path).and_then(|db| {
303 db.ensure_readable()?;
304 Ok(db)
305 }) {
306 Ok(db) => Ok(SummaryReadOnlyOpen { db, recovery: None }),
307 Err(err) => {
308 let Some(recovery) = read_only_snapshot_recovery(path, &err) else {
309 return Err(err);
310 };
311 let db = Self::open_read_only_snapshot(path)?;
312 Ok(SummaryReadOnlyOpen {
313 db,
314 recovery: Some(recovery),
315 })
316 }
317 }
318 }
319
320 pub fn get_by_symbol(&self, name: &str) -> Result<Vec<Summary>> {
321 let mut stmt = self.conn.prepare(
322 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
323 concept_labels, extracted_at, model, tokens_input, tokens_output
324 FROM summaries WHERE symbol_name = ?1 ORDER BY extracted_at DESC",
325 )?;
326 let rows = stmt
327 .query_map([name], |row| Ok(row_to_summary(row)))?
328 .collect::<std::result::Result<Vec<_>, _>>()?;
329 Ok(rows)
330 }
331
332 pub fn get_by_file(&self, path: &str) -> Result<Vec<Summary>> {
333 let normalized = normalize_summary_file_key_str(path);
334 let legacy = legacy_windows_summary_file_key(&normalized);
335 let mut stmt = self.conn.prepare(
336 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
337 concept_labels, extracted_at, model, tokens_input, tokens_output
338 FROM summaries WHERE file_path = ?1 OR file_path = ?2 ORDER BY symbol_name",
339 )?;
340 let rows = stmt
341 .query_map(rusqlite::params![normalized, legacy], |row| {
342 Ok(row_to_summary(row))
343 })?
344 .collect::<std::result::Result<Vec<_>, _>>()?;
345 Ok(rows)
346 }
347
348 pub fn all(&self) -> Result<Vec<Summary>> {
349 let mut stmt = self.conn.prepare(
350 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
351 concept_labels, extracted_at, model, tokens_input, tokens_output
352 FROM summaries ORDER BY file_path, symbol_name, id",
353 )?;
354 let rows = stmt
355 .query_map([], |row| Ok(row_to_summary(row)))?
356 .collect::<std::result::Result<Vec<_>, _>>()?;
357 Ok(rows)
358 }
359
360 pub fn insert(&self, summary: &Summary) -> Result<()> {
361 insert_summary(&self.conn, summary)
362 }
363
364 pub fn replace_file(&self, file_path: &str, summaries: &[Summary]) -> Result<()> {
365 self.replace_file_with_hook(file_path, summaries, |_| Ok(()))
366 }
367
368 pub fn is_current(&self, file_path: &str, content_hash: &str) -> Result<bool> {
369 let normalized = normalize_summary_file_key_str(file_path);
370 let legacy = legacy_windows_summary_file_key(&normalized);
371 let count: i64 = self.conn.query_row(
372 "SELECT COUNT(*) FROM summaries
373 WHERE content_hash = ?2 AND (file_path = ?1 OR file_path = ?3)",
374 rusqlite::params![normalized, content_hash, legacy],
375 |row| row.get(0),
376 )?;
377 Ok(count > 0)
378 }
379
380 pub fn stats(&self, root: &Path) -> Result<SummaryStats> {
381 let total_summaries_raw: i64 =
382 self.conn
383 .query_row("SELECT COUNT(*) FROM summaries", [], |row| row.get(0))?;
384 let total_summaries =
385 usize::try_from(total_summaries_raw).context("summary count out of range")?;
386 let cached_file_paths = self.cached_file_paths()?;
387 let total_files = cached_file_paths.len();
388 let (stale_count, warnings) = self.stale_file_count(root, &cached_file_paths)?;
389 let total_tokens_input: i64 = self.conn.query_row(
390 "SELECT COALESCE(SUM(tokens_input), 0) FROM summaries",
391 [],
392 |row| row.get(0),
393 )?;
394 let total_tokens_output: i64 = self.conn.query_row(
395 "SELECT COALESCE(SUM(tokens_output), 0) FROM summaries",
396 [],
397 |row| row.get(0),
398 )?;
399 let estimated_tokens_saved = (total_summaries as i64) * 1925;
402 Ok(SummaryStats {
403 total_summaries,
404 total_files,
405 stale_count,
406 total_tokens_input,
407 total_tokens_output,
408 estimated_tokens_saved,
409 warnings,
410 })
411 }
412
413 pub fn delete_by_file(&self, file_path: &str) -> Result<usize> {
414 let normalized = normalize_summary_file_key_str(file_path);
415 let legacy = legacy_windows_summary_file_key(&normalized);
416 let count = self.conn.execute(
417 "DELETE FROM summaries WHERE file_path = ?1 OR file_path = ?2",
418 rusqlite::params![normalized, legacy],
419 )?;
420 Ok(count)
421 }
422
423 pub fn cached_file_paths(&self) -> Result<BTreeSet<String>> {
424 let mut stmt = self
425 .conn
426 .prepare("SELECT DISTINCT file_path FROM summaries ORDER BY file_path")?;
427 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
428 let paths = rows.collect::<std::result::Result<Vec<_>, _>>()?;
429 Ok(paths
430 .into_iter()
431 .map(|path| normalize_summary_file_key_str(&path))
432 .collect())
433 }
434
435 fn stats_live_path(root: &Path, cached_path: &str) -> Option<PathBuf> {
436 let normalized_cached_path = normalize_lexical_path(Path::new(cached_path));
437 if normalized_cached_path.is_absolute() {
438 return None;
439 }
440
441 let live_path = normalize_lexical_path(&root.join(&normalized_cached_path));
442 if !live_path.starts_with(root) {
443 return None;
444 }
445
446 Some(live_path)
447 }
448
449 fn stale_file_count(
450 &self,
451 root: &Path,
452 cached_file_paths: &BTreeSet<String>,
453 ) -> Result<(usize, Vec<SummaryStatsWarning>)> {
454 let mut stale_count = 0;
455 let mut warnings = Vec::new();
456
457 for cached_path in cached_file_paths {
458 let Some(live_path) = Self::stats_live_path(root, cached_path) else {
459 stale_count += 1;
460 continue;
461 };
462 if !live_path.is_file() {
463 stale_count += 1;
464 continue;
465 }
466
467 let content = match std::fs::read(&live_path) {
468 Ok(content) => content,
469 Err(err) => {
470 stale_count += 1;
471 warnings.push(SummaryStatsWarning {
472 path: PathBuf::from(normalize_summary_file_key_str(cached_path)),
473 message: format!(
474 "counting cached summary as stale because the source file could not be read ({err})"
475 ),
476 });
477 continue;
478 }
479 };
480 let live_hash = content_hash(&content);
481 if !self.is_current(cached_path, &live_hash)? {
482 stale_count += 1;
483 }
484 }
485
486 Ok((stale_count, warnings))
487 }
488
489 fn replace_file_with_hook<F>(
490 &self,
491 file_path: &str,
492 summaries: &[Summary],
493 mut after_insert: F,
494 ) -> Result<()>
495 where
496 F: FnMut(usize) -> Result<()>,
497 {
498 let normalized = normalize_summary_file_key_str(file_path);
499 let legacy = legacy_windows_summary_file_key(&normalized);
500 self.conn
501 .execute_batch(&format!("SAVEPOINT {REPLACE_FILE_SAVEPOINT}"))
502 .context("starting summary replacement transaction")?;
503
504 let result = (|| -> Result<()> {
505 self.conn.execute(
506 "DELETE FROM summaries WHERE file_path = ?1 OR file_path = ?2",
507 rusqlite::params![normalized, legacy],
508 )?;
509 for (idx, summary) in summaries.iter().enumerate() {
510 insert_summary(&self.conn, summary)?;
511 after_insert(idx)?;
512 }
513 Ok(())
514 })();
515
516 match result {
517 Ok(()) => {
518 self.conn
519 .execute_batch(&format!("RELEASE {REPLACE_FILE_SAVEPOINT}"))
520 .context("committing summary replacement transaction")?;
521 Ok(())
522 }
523 Err(err) => {
524 if let Err(rollback_err) = self.conn.execute_batch(&format!(
525 "ROLLBACK TO {REPLACE_FILE_SAVEPOINT}; RELEASE {REPLACE_FILE_SAVEPOINT};"
526 )) {
527 return Err(err.context(format!(
528 "rollback failed for summary replacement transaction: {rollback_err}"
529 )));
530 }
531 Err(err)
532 }
533 }
534 }
535
536 fn ensure_readable(&self) -> Result<()> {
537 self.conn
538 .query_row("SELECT COUNT(*) FROM sqlite_master", [], |_row| Ok(()))
539 .map_err(anyhow::Error::from)
540 }
541
542 fn open_read_only_snapshot(path: &Path) -> Result<Self> {
543 let (snapshot_path, cleanup_paths) = copy_read_only_snapshot(path, "summaries")?;
544 let conn = Connection::open_with_flags(
545 &snapshot_path,
546 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
547 )
548 .with_context(|| format!("opening summaries snapshot {}", snapshot_path.display()))?;
549 conn.busy_timeout(Duration::from_secs(5))?;
550 Ok(Self {
551 conn,
552 _snapshot_copy: Some(SnapshotCopyGuard {
553 paths: cleanup_paths,
554 }),
555 })
556 }
557}
558
559impl SummaryCache {
560 pub fn new(db: SummaryDb) -> Self {
561 Self {
562 db: Rc::new(db),
563 ctx: LazyContext::new(),
564 slots: RefCell::new(HashMap::new()),
565 hits: Cell::new(0),
566 misses: Cell::new(0),
567 }
568 }
569
570 pub fn db(&self) -> &SummaryDb {
571 &self.db
572 }
573
574 pub fn stats(&self) -> (usize, usize) {
575 (self.hits.get(), self.misses.get())
576 }
577
578 pub fn file_snapshot(
579 &self,
580 file_path: &str,
581 content_hash: Option<&str>,
582 ) -> Result<SummaryFileSnapshot> {
583 let normalized = normalize_summary_file_key_str(file_path);
584 let requested_content_hash = content_hash.map(str::to_string);
585 let slot = {
586 let mut slots = self.slots.borrow_mut();
587 if let Some(slot) = slots.get(&normalized) {
588 self.ctx
589 .set_cell(&slot.content_hash, requested_content_hash.clone());
590 *slot
591 } else {
592 let db = Rc::clone(&self.db);
593 let file_key = normalized.clone();
594 let content_hash_cell = self.ctx.cell(requested_content_hash.clone());
595 let epoch = self.ctx.cell(0u64);
596 let snapshot = self.ctx.slot(move |ctx| {
597 let requested_content_hash = ctx.get_cell(&content_hash_cell);
598 let _epoch = ctx.get_cell(&epoch);
599 let summaries = db
600 .get_by_file(&file_key)
601 .map_err(|err| format!("{err:#}"))?;
602 let current = requested_content_hash.as_ref().is_some_and(|hash| {
603 summaries
604 .iter()
605 .any(|summary| summary.content_hash == *hash)
606 });
607 Ok(SummaryFileSnapshot {
608 file_path: file_key.clone(),
609 requested_content_hash,
610 summaries,
611 current,
612 })
613 });
614 let slot = SummaryFileSlot {
615 content_hash: content_hash_cell,
616 epoch,
617 snapshot,
618 };
619 slots.insert(normalized.clone(), slot);
620 slot
621 }
622 };
623
624 if self.ctx.is_set(&slot.snapshot) {
625 self.hits.set(self.hits.get() + 1);
626 } else {
627 self.misses.set(self.misses.get() + 1);
628 }
629 let result = self
630 .ctx
631 .get(&slot.snapshot)
632 .map_err(|message| anyhow::anyhow!("{message}"));
633 if result.is_err() {
634 slot.snapshot.clear(&self.ctx);
635 }
636 result
637 }
638
639 pub fn current_by_file(
640 &self,
641 file_path: &str,
642 content_hash: &str,
643 ) -> Result<Option<Vec<Summary>>> {
644 let snapshot = self.file_snapshot(file_path, Some(content_hash))?;
645 if snapshot.current {
646 Ok(Some(snapshot.summaries))
647 } else {
648 Ok(None)
649 }
650 }
651
652 pub fn get_or_extract_file<F>(
653 &self,
654 file_path: &str,
655 content_hash: &str,
656 extract: F,
657 ) -> Result<SummaryCacheLookup>
658 where
659 F: FnOnce() -> Result<Vec<Summary>>,
660 {
661 if let Some(summaries) = self.current_by_file(file_path, content_hash)? {
662 return Ok(SummaryCacheLookup {
663 summaries,
664 source: SummaryCacheSource::Cached,
665 });
666 }
667
668 let summaries = extract()?;
669 self.db.replace_file(file_path, &summaries)?;
670 self.invalidate_file(file_path, Some(content_hash));
671 Ok(SummaryCacheLookup {
672 summaries,
673 source: SummaryCacheSource::Extracted,
674 })
675 }
676
677 pub fn invalidate_file(&self, file_path: &str, content_hash: Option<&str>) {
678 let normalized = normalize_summary_file_key_str(file_path);
679 let Some(slot) = self.slots.borrow().get(&normalized).copied() else {
680 return;
681 };
682 self.ctx
683 .set_cell(&slot.content_hash, content_hash.map(str::to_string));
684 let epoch = self.ctx.get_cell(&slot.epoch);
685 self.ctx.set_cell(&slot.epoch, epoch.wrapping_add(1));
686 }
687}
688
689fn read_lock_marker(file: &mut File) -> std::io::Result<LockFileMarker> {
690 file.seek(SeekFrom::Start(0))?;
691 let mut content = String::new();
692 file.read_to_string(&mut content)?;
693 let trimmed = content.trim();
694 if trimmed.is_empty() {
695 Ok(LockFileMarker::Empty)
696 } else if let Ok(pid) = trimmed.parse::<u32>() {
697 Ok(LockFileMarker::Pid(pid))
698 } else {
699 Ok(LockFileMarker::Invalid)
700 }
701}
702
703fn write_lock_pid(file: &mut File, lock_path: &Path) -> Result<()> {
704 file.set_len(0)
705 .with_context(|| format!("clearing {}", lock_path.display()))?;
706 file.seek(SeekFrom::Start(0))
707 .with_context(|| format!("seeking {}", lock_path.display()))?;
708 writeln!(file, "{}", std::process::id())
709 .with_context(|| format!("writing {}", lock_path.display()))?;
710 file.sync_data()
711 .with_context(|| format!("syncing {}", lock_path.display()))?;
712 Ok(())
713}
714
715fn clear_lock_metadata(file: &mut File) -> std::io::Result<()> {
716 file.set_len(0)?;
717 file.seek(SeekFrom::Start(0))?;
718 file.sync_data()?;
719 Ok(())
720}
721
722fn insert_summary(conn: &Connection, summary: &Summary) -> Result<()> {
723 let normalized_file_path = normalize_summary_file_key_str(&summary.file_path);
724 conn.execute(
725 "INSERT OR REPLACE INTO summaries
726 (symbol_name, file_path, content_hash, summary, entities, relationships,
727 concept_labels, extracted_at, model, tokens_input, tokens_output)
728 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
729 rusqlite::params![
730 summary.symbol_name,
731 normalized_file_path,
732 summary.content_hash,
733 summary.summary,
734 summary
735 .entities
736 .as_ref()
737 .map(|e| serde_json::to_string(e).unwrap_or_default()),
738 summary
739 .relationships
740 .as_ref()
741 .map(|r| serde_json::to_string(r).unwrap_or_default()),
742 summary
743 .concept_labels
744 .as_ref()
745 .map(|c| serde_json::to_string(c).unwrap_or_default()),
746 summary.extracted_at,
747 summary.model,
748 summary.tokens_input,
749 summary.tokens_output,
750 ],
751 )?;
752 Ok(())
753}
754
755fn row_to_summary(row: &rusqlite::Row) -> Summary {
756 let entities_json: Option<String> = row.get(5).unwrap_or(None);
757 let relationships_json: Option<String> = row.get(6).unwrap_or(None);
758 let labels_json: Option<String> = row.get(7).unwrap_or(None);
759 Summary {
760 id: row.get(0).unwrap_or(0),
761 symbol_name: row.get(1).unwrap_or_default(),
762 file_path: normalize_summary_file_key_str(&row.get::<_, String>(2).unwrap_or_default()),
763 content_hash: row.get(3).unwrap_or_default(),
764 summary: row.get(4).unwrap_or_default(),
765 entities: entities_json.and_then(|j| serde_json::from_str(&j).ok()),
766 relationships: relationships_json.and_then(|j| serde_json::from_str(&j).ok()),
767 concept_labels: labels_json.and_then(|j| serde_json::from_str(&j).ok()),
768 extracted_at: row.get(8).unwrap_or_default(),
769 model: row.get(9).unwrap_or_default(),
770 tokens_input: row.get(10).unwrap_or(None),
771 tokens_output: row.get(11).unwrap_or(None),
772 }
773}
774
775pub fn normalize_summary_file_key(path: &Path) -> String {
776 normalize_summary_file_key_str(path.to_string_lossy().as_ref())
777}
778
779pub fn normalize_summary_file_key_str(path: &str) -> String {
780 path.replace('\\', "/")
781}
782
783fn legacy_windows_summary_file_key(path: &str) -> String {
784 path.replace('/', "\\")
785}
786
787pub fn content_hash(content: &[u8]) -> String {
788 blake3::hash(content).to_hex().to_string()
789}
790
791pub fn extract_for_file(
792 file_path: &Path,
793 symbols_db_path: Option<&Path>,
794 symbols_source_root: Option<&Path>,
795 config: &SummarizeConfig,
796) -> Result<Vec<Summary>> {
797 let source = std::fs::read_to_string(file_path)
798 .with_context(|| format!("reading {}", file_path.display()))?;
799
800 let token_estimate = source.len() / 4;
801 if token_estimate > config.max_file_tokens {
802 bail!(
803 "file {} exceeds max_file_tokens ({} > {})",
804 file_path.display(),
805 token_estimate,
806 config.max_file_tokens
807 );
808 }
809
810 let hash = content_hash(source.as_bytes());
811 let file_str = file_path.to_string_lossy().to_string();
812
813 let symbols = if let Some(db_path) = symbols_db_path {
814 load_symbols_for_file(db_path, file_path, symbols_source_root)?
815 } else {
816 Vec::new()
817 };
818
819 let api_key = std::env::var(&config.api_key_env).with_context(|| {
820 format!(
821 "missing API key: set {} environment variable",
822 config.api_key_env
823 )
824 })?;
825
826 let prompt = build_extraction_prompt(&file_str, &source, &symbols);
827
828 let (response_text, tokens_in, tokens_out) =
829 call_anthropic_api(&api_key, &config.model, &prompt)?;
830
831 let parsed: ExtractionResponse = serde_json::from_str(&response_text)
832 .with_context(|| format!("parsing extraction response for {}", file_path.display()))?;
833
834 let now = chrono_now();
835 let mut summaries = Vec::new();
836
837 let file_name = file_path
839 .file_name()
840 .map(|n| n.to_string_lossy().to_string())
841 .unwrap_or_else(|| file_str.clone());
842 summaries.push(Summary {
843 id: 0,
844 symbol_name: file_name,
845 file_path: file_str.clone(),
846 content_hash: hash.clone(),
847 summary: parsed.summary.clone(),
848 entities: Some(parsed.entities.clone()),
849 relationships: Some(parsed.relationships.clone()),
850 concept_labels: Some(parsed.concept_labels.clone()),
851 extracted_at: now.clone(),
852 model: config.model.clone(),
853 tokens_input: Some(tokens_in),
854 tokens_output: Some(tokens_out),
855 });
856
857 for entity in &parsed.entities {
859 summaries.push(Summary {
860 id: 0,
861 symbol_name: entity.name.clone(),
862 file_path: file_str.clone(),
863 content_hash: hash.clone(),
864 summary: entity.description.clone(),
865 entities: None,
866 relationships: None,
867 concept_labels: None,
868 extracted_at: now.clone(),
869 model: config.model.clone(),
870 tokens_input: None,
871 tokens_output: None,
872 });
873 }
874
875 Ok(summaries)
876}
877
878fn normalize_lookup_path(path: &Path) -> String {
879 normalize_summary_file_key(path)
880}
881
882pub fn normalize_lexical_path(path: &Path) -> PathBuf {
883 let mut normalized = PathBuf::new();
884
885 for component in path.components() {
886 match component {
887 Component::CurDir => {}
888 Component::ParentDir => match normalized.components().next_back() {
889 Some(Component::Normal(_)) => {
890 normalized.pop();
891 }
892 Some(Component::RootDir | Component::Prefix(_)) => {}
893 _ => normalized.push(component.as_os_str()),
894 },
895 _ => normalized.push(component.as_os_str()),
896 }
897 }
898
899 if normalized.as_os_str().is_empty() && !path.is_absolute() {
900 PathBuf::from(".")
901 } else {
902 normalized
903 }
904}
905
906fn push_lookup_candidate(candidates: &mut Vec<String>, candidate: String) {
907 if !candidates.iter().any(|existing| existing == &candidate) {
908 candidates.push(candidate);
909 }
910}
911
912pub fn file_lookup_candidates(
913 file_query: &Path,
914 query_base: &Path,
915 project_root: &Path,
916) -> Vec<String> {
917 let mut candidates = Vec::new();
918 push_lookup_candidate(
919 &mut candidates,
920 normalize_lookup_path(&normalize_lexical_path(file_query)),
921 );
922
923 let resolved = if file_query.is_absolute() {
924 file_query
925 .canonicalize()
926 .unwrap_or_else(|_| normalize_lexical_path(file_query))
927 } else {
928 normalize_lexical_path(&query_base.join(file_query))
929 };
930 let project_relative = resolved.strip_prefix(project_root).unwrap_or(&resolved);
931 push_lookup_candidate(&mut candidates, normalize_lookup_path(project_relative));
932
933 candidates
934}
935
936fn symbol_lookup_candidates(file_path: &Path, source_root: Option<&Path>) -> Vec<String> {
937 let mut candidates = vec![normalize_lookup_path(file_path)];
938 if let Some(root) = source_root
939 && let Ok(relative) = file_path.strip_prefix(root)
940 {
941 let relative = normalize_lookup_path(relative);
942 if !candidates.iter().any(|candidate| candidate == &relative) {
943 candidates.push(relative);
944 }
945 }
946 candidates
947}
948
949fn load_symbols_for_file(
950 db_path: &Path,
951 file_path: &Path,
952 source_root: Option<&Path>,
953) -> Result<Vec<(String, String)>> {
954 if !db_path.exists() {
955 return Ok(Vec::new());
956 }
957 let candidates = symbol_lookup_candidates(file_path, source_root);
958 IndexDb::file_symbols_read_only(db_path, &candidates)
959}
960
961fn build_extraction_prompt(file_path: &str, source: &str, symbols: &[(String, String)]) -> String {
962 let mut prompt = format!(
963 "Analyze this source file and extract structured information.\n\n\
964 File: {}\n",
965 file_path
966 );
967
968 if !symbols.is_empty() {
969 prompt.push_str("\nKnown symbols:\n");
970 for (name, kind) in symbols {
971 prompt.push_str(&format!("- {} ({})\n", name, kind));
972 }
973 }
974
975 prompt.push_str(&format!(
976 "\nSource:\n```\n{}\n```\n\n\
977 Respond with ONLY a JSON object (no markdown fences):\n\
978 {{\n\
979 \"summary\": \"1-3 sentence description of the file/module purpose\",\n\
980 \"entities\": [{{\"name\": \"...\", \"kind\": \"function|class|type|trait|module\", \"description\": \"1 sentence\"}}],\n\
981 \"relationships\": [{{\"from\": \"...\", \"to\": \"...\", \"kind\": \"calls|implements|uses|extends\"}}],\n\
982 \"concept_labels\": [\"domain concept 1\", \"domain concept 2\"]\n\
983 }}",
984 source
985 ));
986
987 prompt
988}
989
990fn parse_anthropic_api_response(
991 status: u16,
992 response: serde_json::Value,
993) -> Result<(String, i64, i64)> {
994 if !(200..300).contains(&status) {
995 let message = response["error"]["message"]
996 .as_str()
997 .or_else(|| response["message"].as_str())
998 .map(str::to_owned)
999 .unwrap_or_else(|| response.to_string());
1000 let error_type = response["error"]["type"].as_str();
1001
1002 match error_type {
1003 Some(error_type) => bail!(
1004 "Anthropic API returned HTTP {} ({}): {}",
1005 status,
1006 error_type,
1007 message
1008 ),
1009 None => bail!("Anthropic API returned HTTP {}: {}", status, message),
1010 }
1011 }
1012
1013 let content = response["content"]
1014 .as_array()
1015 .and_then(|arr| arr.first())
1016 .and_then(|block| block["text"].as_str())
1017 .unwrap_or("")
1018 .to_string();
1019
1020 let tokens_in = response["usage"]["input_tokens"].as_i64().unwrap_or(0);
1021 let tokens_out = response["usage"]["output_tokens"].as_i64().unwrap_or(0);
1022
1023 if content.is_empty() {
1024 bail!("empty response from Anthropic API");
1025 }
1026
1027 let cleaned = content
1029 .trim()
1030 .strip_prefix("```json")
1031 .or_else(|| content.trim().strip_prefix("```"))
1032 .unwrap_or(content.trim());
1033 let cleaned = cleaned
1034 .strip_suffix("```")
1035 .unwrap_or(cleaned)
1036 .trim()
1037 .to_string();
1038
1039 Ok((cleaned, tokens_in, tokens_out))
1040}
1041
1042fn call_anthropic_api(api_key: &str, model: &str, prompt: &str) -> Result<(String, i64, i64)> {
1043 if let Some(result) = maybe_mock_anthropic_api(prompt)? {
1044 return Ok(result);
1045 }
1046
1047 let body = serde_json::json!({
1048 "model": model,
1049 "max_tokens": 4096,
1050 "messages": [
1051 {"role": "user", "content": prompt}
1052 ]
1053 });
1054
1055 let agent = ureq::Agent::config_builder()
1056 .http_status_as_error(false)
1057 .build()
1058 .new_agent();
1059 let mut response = agent
1060 .post("https://api.anthropic.com/v1/messages")
1061 .header("x-api-key", api_key)
1062 .header("anthropic-version", "2023-06-01")
1063 .header("content-type", "application/json")
1064 .send_json(&body)
1065 .with_context(|| "calling Anthropic API")?;
1066 let status = response.status();
1067 let response_body = response
1068 .body_mut()
1069 .read_to_string()
1070 .with_context(|| format!("reading Anthropic API response body (HTTP {})", status))?;
1071 let response_json: serde_json::Value = serde_json::from_str(&response_body)
1072 .with_context(|| format!("parsing Anthropic API response JSON (HTTP {})", status))?;
1073
1074 parse_anthropic_api_response(status.as_u16(), response_json)
1075}
1076
1077fn maybe_mock_anthropic_api(prompt: &str) -> Result<Option<(String, i64, i64)>> {
1078 if let Ok(capture_path) = std::env::var("TSIFT_TEST_ANTHROPIC_CAPTURE_PROMPT") {
1079 std::fs::write(&capture_path, prompt)
1080 .with_context(|| format!("writing prompt capture: {capture_path}"))?;
1081 }
1082
1083 let Ok(response) = std::env::var("TSIFT_TEST_ANTHROPIC_RESPONSE_JSON") else {
1084 return Ok(None);
1085 };
1086 Ok(Some((response, 0, 0)))
1087}
1088
1089pub fn git_changed_files(root: &Path) -> Result<GitChangedFiles> {
1090 let (tracked, deleted) = if git_has_head_commit(root)? {
1091 git_diff_changed_files(root)?
1092 } else {
1093 (Vec::new(), Vec::new())
1094 };
1095 let untracked = git_list_paths(
1096 root,
1097 &["ls-files", "--others", "--exclude-standard"],
1098 "git ls-files",
1099 )?;
1100 let existing = tracked
1101 .into_iter()
1102 .chain(untracked)
1103 .filter(|path| path.is_file())
1104 .collect::<BTreeSet<_>>()
1105 .into_iter()
1106 .collect();
1107 let deleted = deleted
1108 .into_iter()
1109 .collect::<BTreeSet<_>>()
1110 .into_iter()
1111 .collect();
1112 Ok(GitChangedFiles { existing, deleted })
1113}
1114
1115fn git_diff_changed_files(root: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
1116 let output = std::process::Command::new("git")
1117 .args(["diff", "--name-status", "--find-renames", "HEAD"])
1118 .current_dir(root)
1119 .output()
1120 .with_context(|| "running git diff --name-status")?;
1121
1122 if !output.status.success() {
1123 let stderr = String::from_utf8_lossy(&output.stderr);
1124 bail!("git diff --name-status failed: {}", stderr.trim());
1125 }
1126
1127 let mut tracked = Vec::new();
1128 let mut deleted = Vec::new();
1129 for line in String::from_utf8_lossy(&output.stdout).lines() {
1130 if line.is_empty() {
1131 continue;
1132 }
1133 let mut fields = line.split('\t');
1134 let status = fields.next().unwrap_or_default();
1135 match status.chars().next() {
1136 Some('D') => {
1137 let path = fields
1138 .next()
1139 .with_context(|| format!("parsing deleted git diff path: {line}"))?;
1140 deleted.push(root.join(path));
1141 }
1142 Some('R') => {
1143 let old_path = fields
1144 .next()
1145 .with_context(|| format!("parsing renamed git diff old path: {line}"))?;
1146 let new_path = fields
1147 .next()
1148 .with_context(|| format!("parsing renamed git diff new path: {line}"))?;
1149 deleted.push(root.join(old_path));
1150 tracked.push(root.join(new_path));
1151 }
1152 Some(_) => {
1153 let path = fields
1154 .next_back()
1155 .or_else(|| fields.next())
1156 .with_context(|| format!("parsing changed git diff path: {line}"))?;
1157 tracked.push(root.join(path));
1158 }
1159 None => {}
1160 }
1161 }
1162
1163 Ok((tracked, deleted))
1164}
1165
1166fn git_has_head_commit(root: &Path) -> Result<bool> {
1167 let inside_work_tree = std::process::Command::new("git")
1168 .args(["rev-parse", "--is-inside-work-tree"])
1169 .current_dir(root)
1170 .output()
1171 .with_context(|| "running git rev-parse --is-inside-work-tree")?;
1172
1173 if !inside_work_tree.status.success() {
1174 let stderr = String::from_utf8_lossy(&inside_work_tree.stderr);
1175 bail!(
1176 "git rev-parse --is-inside-work-tree failed in {}: {}",
1177 root.display(),
1178 stderr.trim()
1179 );
1180 }
1181
1182 let verify_head = std::process::Command::new("git")
1183 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
1184 .current_dir(root)
1185 .output()
1186 .with_context(|| "running git rev-parse --verify HEAD")?;
1187
1188 Ok(verify_head.status.success())
1189}
1190
1191fn git_list_paths(root: &Path, args: &[&str], label: &str) -> Result<Vec<PathBuf>> {
1192 let output = std::process::Command::new("git")
1193 .args(args)
1194 .current_dir(root)
1195 .output()
1196 .with_context(|| format!("running {label}"))?;
1197
1198 if !output.status.success() {
1199 let stderr = String::from_utf8_lossy(&output.stderr);
1200 bail!("{label} failed: {}", stderr.trim());
1201 }
1202
1203 Ok(String::from_utf8_lossy(&output.stdout)
1204 .lines()
1205 .filter(|line| !line.is_empty())
1206 .map(|line| root.join(line))
1207 .collect())
1208}
1209
1210fn chrono_now() -> String {
1211 let now = std::time::SystemTime::now()
1212 .duration_since(std::time::UNIX_EPOCH)
1213 .unwrap_or_default()
1214 .as_secs();
1215 format!("{}", now)
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use rusqlite::Connection;
1223 use serde_json::json;
1224 use tempfile::NamedTempFile;
1225 use tsift_sqlite::{rollback_journal_path, wal_sidecar_path};
1226
1227 fn test_db() -> (NamedTempFile, SummaryDb) {
1228 let tmp = NamedTempFile::new().unwrap();
1229 let db = SummaryDb::open(tmp.path()).unwrap();
1230 (tmp, db)
1231 }
1232
1233 fn make_summary(symbol: &str, file: &str, hash: &str) -> Summary {
1234 Summary {
1235 id: 0,
1236 symbol_name: symbol.to_string(),
1237 file_path: file.to_string(),
1238 content_hash: hash.to_string(),
1239 summary: format!("Summary for {}", symbol),
1240 entities: Some(vec![Entity {
1241 name: "helper".to_string(),
1242 kind: "function".to_string(),
1243 description: "A helper function".to_string(),
1244 }]),
1245 relationships: Some(vec![Relationship {
1246 from: "main".to_string(),
1247 to: "helper".to_string(),
1248 kind: "calls".to_string(),
1249 }]),
1250 concept_labels: Some(vec!["cli".to_string(), "parsing".to_string()]),
1251 extracted_at: "1700000000".to_string(),
1252 model: "claude-haiku-4-5-20251001".to_string(),
1253 tokens_input: Some(500),
1254 tokens_output: Some(200),
1255 }
1256 }
1257
1258 fn hold_wal_lock(db_path: &Path) -> Connection {
1259 let conn = Connection::open(db_path).unwrap();
1260 conn.execute_batch(
1261 "PRAGMA journal_mode=WAL;
1262 PRAGMA wal_autocheckpoint=0;
1263 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
1264 INSERT INTO wal_lock_probe DEFAULT VALUES;
1265 PRAGMA locking_mode=EXCLUSIVE;
1266 BEGIN EXCLUSIVE;",
1267 )
1268 .unwrap();
1269 assert!(wal_sidecar_path(db_path).exists());
1270 conn
1271 }
1272
1273 #[test]
1274 fn db_create_and_insert() {
1275 let (_tmp, db) = test_db();
1276 let s = make_summary("main", "src/main.rs", "abc123");
1277 db.insert(&s).unwrap();
1278 let results = db.get_by_symbol("main").unwrap();
1279 assert_eq!(results.len(), 1);
1280 assert_eq!(results[0].symbol_name, "main");
1281 assert_eq!(results[0].summary, "Summary for main");
1282 }
1283
1284 #[test]
1285 fn db_get_by_file() {
1286 let (_tmp, db) = test_db();
1287 db.insert(&make_summary("fn_a", "src/lib.rs", "hash1"))
1288 .unwrap();
1289 db.insert(&make_summary("fn_b", "src/lib.rs", "hash1"))
1290 .unwrap();
1291 db.insert(&make_summary("fn_c", "src/other.rs", "hash2"))
1292 .unwrap();
1293 let results = db.get_by_file("src/lib.rs").unwrap();
1294 assert_eq!(results.len(), 2);
1295 }
1296
1297 #[test]
1298 fn db_get_by_file_normalizes_legacy_windows_separator_rows() {
1299 let (_tmp, db) = test_db();
1300 db.insert(&make_summary("fn_a", r"src\lib.rs", "hash1"))
1301 .unwrap();
1302
1303 let results = db.get_by_file("src/lib.rs").unwrap();
1304
1305 assert_eq!(results.len(), 1);
1306 assert_eq!(results[0].file_path, "src/lib.rs");
1307 }
1308
1309 #[test]
1310 fn replace_file_reaps_legacy_windows_separator_rows() {
1311 let (_tmp, db) = test_db();
1312 db.insert(&make_summary("stale", r"src\lib.rs", "hash1"))
1313 .unwrap();
1314
1315 db.replace_file(
1316 "src/lib.rs",
1317 &[make_summary("fresh", "src/lib.rs", "hash2")],
1318 )
1319 .unwrap();
1320
1321 let results = db.get_by_file("src/lib.rs").unwrap();
1322 assert_eq!(results.len(), 1);
1323 assert_eq!(results[0].symbol_name, "fresh");
1324 assert_eq!(results[0].file_path, "src/lib.rs");
1325 }
1326
1327 #[test]
1328 fn file_lookup_candidates_normalize_dot_prefixed_root_relative_query() {
1329 let candidates = file_lookup_candidates(
1330 Path::new("./src/lib.rs"),
1331 Path::new("/repo"),
1332 Path::new("/repo"),
1333 );
1334
1335 assert_eq!(candidates, vec!["src/lib.rs".to_string()]);
1336 }
1337
1338 #[test]
1339 fn file_lookup_candidates_include_anchor_relative_project_key() {
1340 let candidates = file_lookup_candidates(
1341 Path::new("../lib.rs"),
1342 Path::new("/repo/src/nested"),
1343 Path::new("/repo"),
1344 );
1345
1346 assert_eq!(
1347 candidates,
1348 vec!["../lib.rs".to_string(), "src/lib.rs".to_string()]
1349 );
1350 }
1351
1352 #[cfg(unix)]
1353 #[test]
1354 fn file_lookup_candidates_canonicalize_absolute_symlink_queries() {
1355 use std::os::unix::fs::symlink;
1356
1357 let dir = tempfile::tempdir().unwrap();
1358 let real_root = dir.path().join("real");
1359 std::fs::create_dir_all(real_root.join("src")).unwrap();
1360 std::fs::write(real_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
1361 let link_root = dir.path().join("link");
1362 symlink(&real_root, &link_root).unwrap();
1363
1364 let candidates =
1365 file_lookup_candidates(&link_root.join("src/lib.rs"), &real_root, &real_root);
1366
1367 assert_eq!(
1368 candidates,
1369 vec![
1370 link_root
1371 .join("src/lib.rs")
1372 .to_string_lossy()
1373 .replace('\\', "/"),
1374 "src/lib.rs".to_string()
1375 ]
1376 );
1377 }
1378
1379 #[test]
1380 fn db_is_current() {
1381 let (_tmp, db) = test_db();
1382 db.insert(&make_summary("main", "src/main.rs", "hash_v1"))
1383 .unwrap();
1384 assert!(db.is_current("src/main.rs", "hash_v1").unwrap());
1385 assert!(!db.is_current("src/main.rs", "hash_v2").unwrap());
1386 }
1387
1388 #[test]
1389 fn summary_cache_reuses_file_snapshot_until_content_hash_changes() {
1390 let (_tmp, db) = test_db();
1391 db.insert(&make_summary("stale", "src/lib.rs", "hash_v1"))
1392 .unwrap();
1393 let cache = SummaryCache::new(db);
1394
1395 let first = cache
1396 .current_by_file("src/lib.rs", "hash_v1")
1397 .unwrap()
1398 .unwrap();
1399 assert_eq!(first[0].symbol_name, "stale");
1400 assert_eq!(cache.stats(), (0, 1));
1401
1402 cache
1403 .db()
1404 .replace_file(
1405 "src/lib.rs",
1406 &[make_summary("fresh", "src/lib.rs", "hash_v2")],
1407 )
1408 .unwrap();
1409 let second = cache
1410 .current_by_file("src/lib.rs", "hash_v1")
1411 .unwrap()
1412 .unwrap();
1413 assert_eq!(
1414 second[0].symbol_name, "stale",
1415 "same content hash should reuse the cached Slot"
1416 );
1417 assert_eq!(cache.stats(), (1, 1));
1418
1419 let third = cache
1420 .current_by_file("src/lib.rs", "hash_v2")
1421 .unwrap()
1422 .unwrap();
1423 assert_eq!(third[0].symbol_name, "fresh");
1424 assert_eq!(cache.stats(), (1, 2));
1425 }
1426
1427 #[test]
1428 fn summary_cache_get_or_extract_file_computes_once_until_hash_changes() {
1429 let (_tmp, db) = test_db();
1430 let cache = SummaryCache::new(db);
1431 let extractions = Cell::new(0usize);
1432
1433 let first = cache
1434 .get_or_extract_file("src/lib.rs", "hash_v1", || {
1435 extractions.set(extractions.get() + 1);
1436 Ok(vec![make_summary("first", "src/lib.rs", "hash_v1")])
1437 })
1438 .unwrap();
1439 assert_eq!(first.source, SummaryCacheSource::Extracted);
1440 assert_eq!(first.summaries[0].symbol_name, "first");
1441 assert_eq!(extractions.get(), 1);
1442
1443 let second = cache
1444 .get_or_extract_file("src/lib.rs", "hash_v1", || {
1445 bail!("same hash should reuse cached summaries")
1446 })
1447 .unwrap();
1448 assert_eq!(second.source, SummaryCacheSource::Cached);
1449 assert_eq!(second.summaries[0].symbol_name, "first");
1450 assert_eq!(extractions.get(), 1);
1451
1452 let third = cache
1453 .get_or_extract_file("src/lib.rs", "hash_v2", || {
1454 extractions.set(extractions.get() + 1);
1455 Ok(vec![make_summary("second", "src/lib.rs", "hash_v2")])
1456 })
1457 .unwrap();
1458 assert_eq!(third.source, SummaryCacheSource::Extracted);
1459 assert_eq!(third.summaries[0].symbol_name, "second");
1460 assert_eq!(extractions.get(), 2);
1461 }
1462
1463 #[test]
1464 fn db_stats() {
1465 let root = tempfile::tempdir().unwrap();
1466 let f1 = b"fn a() {}\n";
1467 let f2 = b"fn c() {}\n";
1468 std::fs::write(root.path().join("f1.rs"), f1).unwrap();
1469 std::fs::write(root.path().join("f2.rs"), f2).unwrap();
1470 let (_tmp, db) = test_db();
1471 let f1_hash = content_hash(f1);
1472 let f2_hash = content_hash(f2);
1473 db.insert(&make_summary("a", "f1.rs", &f1_hash)).unwrap();
1474 db.insert(&make_summary("b", "f1.rs", &f1_hash)).unwrap();
1475 db.insert(&make_summary("c", "f2.rs", &f2_hash)).unwrap();
1476 let stats = db.stats(root.path()).unwrap();
1477 assert_eq!(stats.total_summaries, 3);
1478 assert_eq!(stats.total_files, 2);
1479 assert_eq!(stats.stale_count, 0);
1480 assert_eq!(stats.total_tokens_input, 1500); assert_eq!(stats.total_tokens_output, 600); }
1483
1484 #[test]
1485 fn db_stats_counts_missing_and_hash_mismatched_files_as_stale() {
1486 let root = tempfile::tempdir().unwrap();
1487 let fresh = b"fn fresh() {}\n";
1488 let changed_current = b"fn changed() { new_impl(); }\n";
1489 let changed_old = b"fn changed() { old_impl(); }\n";
1490 std::fs::write(root.path().join("fresh.rs"), fresh).unwrap();
1491 std::fs::write(root.path().join("changed.rs"), changed_current).unwrap();
1492
1493 let (_tmp, db) = test_db();
1494 db.insert(&make_summary("fresh", "fresh.rs", &content_hash(fresh)))
1495 .unwrap();
1496 db.insert(&make_summary(
1497 "changed",
1498 "changed.rs",
1499 &content_hash(changed_old),
1500 ))
1501 .unwrap();
1502 db.insert(&make_summary("missing", "missing.rs", "stale-hash"))
1503 .unwrap();
1504
1505 let stats = db.stats(root.path()).unwrap();
1506
1507 assert_eq!(stats.total_files, 3);
1508 assert_eq!(stats.stale_count, 2);
1509 }
1510
1511 #[test]
1512 fn db_cached_file_paths() {
1513 let (_tmp, db) = test_db();
1514 db.insert(&make_summary("a", "f1.rs", "h1")).unwrap();
1515 db.insert(&make_summary("b", "f1.rs", "h1")).unwrap();
1516 db.insert(&make_summary("c", "f2.rs", "h2")).unwrap();
1517
1518 let paths = db.cached_file_paths().unwrap();
1519
1520 assert_eq!(
1521 paths.into_iter().collect::<Vec<_>>(),
1522 vec!["f1.rs".to_string(), "f2.rs".to_string()]
1523 );
1524 }
1525
1526 #[test]
1527 fn stats_live_path_rejects_paths_outside_root() {
1528 let root = Path::new("/tmp/project");
1529
1530 assert_eq!(
1531 SummaryDb::stats_live_path(root, "src/lib.rs").unwrap(),
1532 PathBuf::from("/tmp/project/src/lib.rs")
1533 );
1534 assert_eq!(
1535 SummaryDb::stats_live_path(root, "src/../src/lib.rs").unwrap(),
1536 PathBuf::from("/tmp/project/src/lib.rs")
1537 );
1538 assert!(SummaryDb::stats_live_path(root, "../secret.rs").is_none());
1539 assert!(SummaryDb::stats_live_path(root, "/etc/passwd").is_none());
1540 }
1541
1542 #[cfg(unix)]
1543 #[test]
1544 fn stats_marks_unreadable_files_stale_with_warning() {
1545 use std::os::unix::fs::PermissionsExt;
1546
1547 let root = tempfile::tempdir().unwrap();
1548 let file_path = root.path().join("src/lib.rs");
1549 std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
1550 let source = b"fn alpha_helper() {}\n";
1551 std::fs::write(&file_path, source).unwrap();
1552
1553 let (_tmp, db) = test_db();
1554 db.insert(&make_summary(
1555 "alpha_helper",
1556 "src/lib.rs",
1557 &content_hash(source),
1558 ))
1559 .unwrap();
1560
1561 let metadata = std::fs::metadata(&file_path).unwrap();
1562 let original_mode = metadata.permissions().mode();
1563 let mut unreadable = metadata.permissions();
1564 unreadable.set_mode(0o000);
1565 std::fs::set_permissions(&file_path, unreadable).unwrap();
1566
1567 let stats = db.stats(root.path()).unwrap();
1568
1569 let mut restored = std::fs::metadata(&file_path).unwrap().permissions();
1570 restored.set_mode(original_mode);
1571 std::fs::set_permissions(&file_path, restored).unwrap();
1572
1573 assert_eq!(stats.stale_count, 1);
1574 assert_eq!(stats.warnings.len(), 1);
1575 assert_eq!(stats.warnings[0].path, PathBuf::from("src/lib.rs"));
1576 assert!(
1577 stats.warnings[0]
1578 .message
1579 .contains("counting cached summary as stale"),
1580 "warning was: {}",
1581 stats.warnings[0].message
1582 );
1583 }
1584
1585 #[test]
1586 fn db_delete_by_file() {
1587 let (_tmp, db) = test_db();
1588 db.insert(&make_summary("a", "f1.rs", "h1")).unwrap();
1589 db.insert(&make_summary("b", "f1.rs", "h1")).unwrap();
1590 db.insert(&make_summary("c", "f2.rs", "h2")).unwrap();
1591 let deleted = db.delete_by_file("f1.rs").unwrap();
1592 assert_eq!(deleted, 2);
1593 assert!(db.get_by_file("f1.rs").unwrap().is_empty());
1594 assert_eq!(db.get_by_file("f2.rs").unwrap().len(), 1);
1595 }
1596
1597 #[test]
1598 fn db_replace_file_rolls_back_on_failure() {
1599 let (_tmp, db) = test_db();
1600 db.insert(&make_summary("alpha", "f1.rs", "old_hash"))
1601 .unwrap();
1602 db.insert(&make_summary("beta", "f1.rs", "old_hash"))
1603 .unwrap();
1604
1605 let replacements = vec![
1606 make_summary("gamma", "f1.rs", "new_hash"),
1607 make_summary("delta", "f1.rs", "new_hash"),
1608 ];
1609
1610 let err = db
1611 .replace_file_with_hook("f1.rs", &replacements, |idx| {
1612 if idx == 0 {
1613 bail!("injected summary replace failure");
1614 }
1615 Ok(())
1616 })
1617 .unwrap_err();
1618 assert!(err.to_string().contains("injected summary replace failure"));
1619
1620 let remaining = db.get_by_file("f1.rs").unwrap();
1621 let remaining_symbols = remaining
1622 .iter()
1623 .map(|summary| summary.symbol_name.as_str())
1624 .collect::<Vec<_>>();
1625 assert_eq!(remaining_symbols, vec!["alpha", "beta"]);
1626 assert!(
1627 remaining
1628 .iter()
1629 .all(|summary| summary.content_hash == "old_hash")
1630 );
1631 }
1632
1633 #[test]
1634 fn db_open_configures_sqlite_for_concurrent_access() {
1635 let (_tmp, db) = test_db();
1636
1637 let mode: String = db
1638 .conn
1639 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1640 .unwrap();
1641 let timeout_ms: i64 = db
1642 .conn
1643 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1644 .unwrap();
1645
1646 assert_eq!(mode.to_lowercase(), "wal");
1647 assert_eq!(timeout_ms, 5000);
1648 }
1649
1650 #[test]
1651 fn db_open_read_only_uses_busy_timeout() {
1652 let (tmp, _db) = test_db();
1653 let db = SummaryDb::open_read_only(tmp.path()).unwrap();
1654 let timeout_ms: i64 = db
1655 .conn
1656 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1657 .unwrap();
1658
1659 assert_eq!(timeout_ms, 5000);
1660 }
1661
1662 #[test]
1663 fn summary_write_lock_records_pid_and_clears_on_drop() {
1664 let dir = tempfile::tempdir().unwrap();
1665 let db_path = dir.path().join(".tsift/summaries.db");
1666 let lock_path = writer_lock_path(&db_path);
1667
1668 {
1669 let _lock = acquire_write_lock(&db_path).unwrap();
1670 let marker = std::fs::read_to_string(&lock_path).unwrap();
1671 assert_eq!(marker.trim(), std::process::id().to_string());
1672 }
1673
1674 let marker = std::fs::read_to_string(&lock_path).unwrap();
1675 assert!(marker.trim().is_empty());
1676 acquire_write_lock(&db_path).unwrap();
1677 }
1678
1679 #[test]
1680 fn summary_write_lock_fails_fast_when_live() {
1681 let dir = tempfile::tempdir().unwrap();
1682 let db_path = dir.path().join(".tsift/summaries.db");
1683 let _lock = acquire_write_lock(&db_path).unwrap();
1684
1685 let err = acquire_write_lock(&db_path).unwrap_err();
1686 let message = err.to_string();
1687
1688 assert!(message.contains("another tsift summarize extractor is already active"));
1689 assert!(message.contains("tsift summarize --extract"));
1690 assert!(message.contains(&writer_lock_path(&db_path).display().to_string()));
1691 }
1692
1693 #[test]
1694 fn db_entities_roundtrip() {
1695 let (_tmp, db) = test_db();
1696 let s = make_summary("main", "src/main.rs", "abc");
1697 db.insert(&s).unwrap();
1698 let results = db.get_by_symbol("main").unwrap();
1699 let entities = results[0].entities.as_ref().unwrap();
1700 assert_eq!(entities.len(), 1);
1701 assert_eq!(entities[0].name, "helper");
1702 let rels = results[0].relationships.as_ref().unwrap();
1703 assert_eq!(rels.len(), 1);
1704 assert_eq!(rels[0].from, "main");
1705 assert_eq!(rels[0].to, "helper");
1706 let labels = results[0].concept_labels.as_ref().unwrap();
1707 assert_eq!(labels, &["cli", "parsing"]);
1708 }
1709
1710 #[test]
1711 fn db_no_results_returns_empty() {
1712 let (_tmp, db) = test_db();
1713 assert!(db.get_by_symbol("nonexistent").unwrap().is_empty());
1714 assert!(db.get_by_file("no/such/file.rs").unwrap().is_empty());
1715 }
1716
1717 #[test]
1718 fn content_hash_deterministic() {
1719 let h1 = content_hash(b"hello world");
1720 let h2 = content_hash(b"hello world");
1721 assert_eq!(h1, h2);
1722 let h3 = content_hash(b"hello world!");
1723 assert_ne!(h1, h3);
1724 }
1725
1726 #[test]
1727 fn content_hash_is_blake3() {
1728 let h = content_hash(b"test");
1729 assert_eq!(h.len(), 64); }
1731
1732 #[test]
1733 fn build_prompt_includes_file_and_source() {
1734 let prompt = build_extraction_prompt("src/lib.rs", "fn main() {}", &[]);
1735 assert!(prompt.contains("src/lib.rs"));
1736 assert!(prompt.contains("fn main() {}"));
1737 assert!(prompt.contains("JSON"));
1738 }
1739
1740 #[test]
1741 fn build_prompt_includes_symbols() {
1742 let symbols = vec![
1743 ("main".to_string(), "function".to_string()),
1744 ("Config".to_string(), "struct".to_string()),
1745 ];
1746 let prompt = build_extraction_prompt("src/lib.rs", "code", &symbols);
1747 assert!(prompt.contains("- main (function)"));
1748 assert!(prompt.contains("- Config (struct)"));
1749 }
1750
1751 #[test]
1752 fn anthropic_api_response_rejects_http_errors() {
1753 let err = parse_anthropic_api_response(
1754 429,
1755 json!({
1756 "error": {
1757 "type": "rate_limit_error",
1758 "message": "too many requests"
1759 }
1760 }),
1761 )
1762 .unwrap_err();
1763 let message = err.to_string();
1764
1765 assert!(message.contains("HTTP 429"));
1766 assert!(message.contains("rate_limit_error"));
1767 assert!(message.contains("too many requests"));
1768 }
1769
1770 #[test]
1771 fn anthropic_api_response_reports_raw_body_when_error_message_missing() {
1772 let response = json!({"unexpected": "shape"});
1773 let err = parse_anthropic_api_response(502, response.clone()).unwrap_err();
1774 let message = err.to_string();
1775
1776 assert!(message.contains("HTTP 502"));
1777 assert!(message.contains(&response.to_string()));
1778 }
1779
1780 #[test]
1781 fn anthropic_api_response_extracts_content_and_usage() {
1782 let (content, tokens_in, tokens_out) = parse_anthropic_api_response(
1783 200,
1784 json!({
1785 "content": [
1786 {
1787 "text": "```json\n{\"summary\":\"ok\"}\n```"
1788 }
1789 ],
1790 "usage": {
1791 "input_tokens": 12,
1792 "output_tokens": 34
1793 }
1794 }),
1795 )
1796 .unwrap();
1797
1798 assert_eq!(content, "{\"summary\":\"ok\"}");
1799 assert_eq!(tokens_in, 12);
1800 assert_eq!(tokens_out, 34);
1801 }
1802
1803 #[test]
1804 fn extract_skips_large_files() {
1805 let dir = tempfile::tempdir().unwrap();
1806 let big_file = dir.path().join("big.rs");
1807 std::fs::write(&big_file, "x".repeat(100_000)).unwrap();
1808 let config = SummarizeConfig {
1809 max_file_tokens: 8000,
1810 ..Default::default()
1811 };
1812 let result = extract_for_file(&big_file, None, None, &config);
1813 assert!(result.is_err());
1814 assert!(
1815 result
1816 .unwrap_err()
1817 .to_string()
1818 .contains("exceeds max_file_tokens")
1819 );
1820 }
1821
1822 #[test]
1823 fn extract_requires_api_key() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let file = dir.path().join("small.rs");
1826 std::fs::write(&file, "fn main() {}").unwrap();
1827 let config = SummarizeConfig {
1828 api_key_env: "TSIFT_TEST_NONEXISTENT_KEY".to_string(),
1829 ..Default::default()
1830 };
1831 let result = extract_for_file(&file, None, None, &config);
1832 assert!(result.is_err());
1833 assert!(result.unwrap_err().to_string().contains("missing API key"));
1834 }
1835
1836 #[test]
1837 fn load_symbols_for_file_uses_exact_relative_match() {
1838 let dir = tempfile::tempdir().unwrap();
1839 let db_path = dir.path().join("index.db");
1840 let conn = Connection::open(&db_path).unwrap();
1841 conn.execute_batch(
1842 "CREATE TABLE symbols (
1843 id INTEGER PRIMARY KEY,
1844 name TEXT NOT NULL,
1845 kind TEXT NOT NULL,
1846 language TEXT NOT NULL,
1847 signature TEXT,
1848 file TEXT NOT NULL,
1849 line INTEGER NOT NULL,
1850 end_line INTEGER,
1851 parent_module TEXT,
1852 visibility TEXT,
1853 tags TEXT
1854 );",
1855 )
1856 .unwrap();
1857 conn.execute(
1858 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
1859 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
1860 rusqlite::params!["target", "function", "rust", "src/lib.rs", 1_i64],
1861 )
1862 .unwrap();
1863 conn.execute(
1864 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
1865 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
1866 rusqlite::params!["wrong", "function", "rust", "nested/src/lib.rs", 1_i64],
1867 )
1868 .unwrap();
1869
1870 let file_path = Path::new("/workspace/src/lib.rs");
1871 let symbols =
1872 load_symbols_for_file(&db_path, file_path, Some(Path::new("/workspace"))).unwrap();
1873
1874 assert_eq!(
1875 symbols,
1876 vec![("target".to_string(), "function".to_string())]
1877 );
1878 }
1879
1880 #[test]
1881 fn load_symbols_for_file_uses_snapshot_fallback_when_rollback_journal_is_locked() {
1882 let dir = tempfile::tempdir().unwrap();
1883 let db_path = dir.path().join("index.db");
1884 let conn = Connection::open(&db_path).unwrap();
1885 conn.execute_batch(
1886 "PRAGMA journal_mode=DELETE;
1887 CREATE TABLE symbols (
1888 id INTEGER PRIMARY KEY,
1889 name TEXT NOT NULL,
1890 kind TEXT NOT NULL,
1891 language TEXT NOT NULL,
1892 signature TEXT,
1893 file TEXT NOT NULL,
1894 line INTEGER NOT NULL,
1895 end_line INTEGER,
1896 parent_module TEXT,
1897 visibility TEXT,
1898 tags TEXT
1899 );",
1900 )
1901 .unwrap();
1902 conn.execute(
1903 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
1904 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
1905 rusqlite::params!["target", "function", "rust", "src/lib.rs", 1_i64],
1906 )
1907 .unwrap();
1908 conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
1909 std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
1910
1911 let file_path = Path::new("/workspace/src/lib.rs");
1912 let symbols =
1913 load_symbols_for_file(&db_path, file_path, Some(Path::new("/workspace"))).unwrap();
1914
1915 assert_eq!(
1916 symbols,
1917 vec![("target".to_string(), "function".to_string())]
1918 );
1919 }
1920
1921 #[test]
1922 fn summary_read_only_uses_snapshot_fallback_when_rollback_journal_is_locked() {
1923 let dir = tempfile::tempdir().unwrap();
1924 let db_path = dir.path().join("summaries.db");
1925 let conn = Connection::open(&db_path).unwrap();
1926 conn.execute_batch(
1927 "PRAGMA journal_mode=DELETE;
1928 CREATE TABLE summaries (
1929 id INTEGER PRIMARY KEY,
1930 symbol_name TEXT NOT NULL,
1931 file_path TEXT NOT NULL,
1932 content_hash TEXT NOT NULL,
1933 summary TEXT NOT NULL,
1934 entities TEXT,
1935 relationships TEXT,
1936 concept_labels TEXT,
1937 extracted_at TEXT NOT NULL,
1938 model TEXT NOT NULL,
1939 tokens_input INTEGER,
1940 tokens_output INTEGER
1941 );",
1942 )
1943 .unwrap();
1944 conn.execute(
1945 "INSERT INTO summaries
1946 (symbol_name, file_path, content_hash, summary, entities, relationships, concept_labels, extracted_at, model, tokens_input, tokens_output)
1947 VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, ?5, ?6, NULL, NULL)",
1948 rusqlite::params![
1949 "main",
1950 "src/main.rs",
1951 "hash1",
1952 "cached summary",
1953 "1700000000",
1954 "test-model",
1955 ],
1956 )
1957 .unwrap();
1958 conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
1959 std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
1960
1961 let opened = SummaryDb::open_read_only_with_recovery(&db_path).unwrap();
1962
1963 assert_eq!(
1964 opened.recovery,
1965 Some(tsift_sqlite::ReadOnlyRecovery::SnapshotFallback)
1966 );
1967 let results = opened.db.get_by_symbol("main").unwrap();
1968 assert_eq!(results.len(), 1);
1969 assert_eq!(results[0].summary, "cached summary");
1970 }
1971
1972 #[test]
1973 fn summary_read_only_reports_wal_snapshot_fallback_when_wal_db_is_locked() {
1974 let dir = tempfile::tempdir().unwrap();
1975 let db_path = dir.path().join("summaries.db");
1976 let db = SummaryDb::open(&db_path).unwrap();
1977 db.insert(&make_summary("main", "src/main.rs", "hash1"))
1978 .unwrap();
1979 drop(db);
1980
1981 let _lock = hold_wal_lock(&db_path);
1982
1983 let opened = SummaryDb::open_read_only_with_recovery(&db_path).unwrap();
1984 assert_eq!(
1985 opened.recovery,
1986 Some(tsift_sqlite::ReadOnlyRecovery::SnapshotFallbackWal)
1987 );
1988 let results = opened.db.get_by_symbol("main").unwrap();
1989 assert_eq!(results.len(), 1);
1990 }
1991
1992 #[test]
1993 fn db_insert_replaces_on_conflict() {
1994 let (_tmp, db) = test_db();
1995 let mut s = make_summary("main", "src/main.rs", "v1");
1996 s.summary = "version 1".to_string();
1997 db.insert(&s).unwrap();
1998
1999 let mut s2 = make_summary("main", "src/main.rs", "v2");
2000 s2.summary = "version 2".to_string();
2001 db.insert(&s2).unwrap();
2002
2003 let results = db.get_by_symbol("main").unwrap();
2004 assert_eq!(results.len(), 2);
2005 }
2006}