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