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