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 terminal_failures_skipped: usize,
151 pub errors: Vec<String>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ExtractionFailureKind {
157 TooLarge,
158 UnparseableResponse,
159}
160
161impl ExtractionFailureKind {
162 pub fn as_str(self) -> &'static str {
163 match self {
164 Self::TooLarge => "too_large",
165 Self::UnparseableResponse => "unparseable_response",
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171pub struct CachedExtractionFailure {
172 pub file_path: String,
173 pub content_hash: String,
174 pub kind: ExtractionFailureKind,
175 pub max_file_tokens: Option<usize>,
178 pub message: String,
179 pub failed_at: String,
180}
181
182impl CachedExtractionFailure {
183 pub fn applies_at_max_file_tokens(&self, effective_max_file_tokens: usize) -> bool {
184 if self.kind != ExtractionFailureKind::TooLarge {
185 return true;
186 }
187 match self
188 .max_file_tokens
189 .or_else(|| too_large_limit_from_message(&self.message))
190 {
191 Some(failed_limit) => effective_max_file_tokens <= failed_limit,
192 None => true,
193 }
194 }
195
196 pub fn required_max_file_tokens(&self) -> Option<usize> {
197 (self.kind == ExtractionFailureKind::TooLarge)
198 .then(|| too_large_required_tokens_from_message(&self.message))
199 .flatten()
200 }
201}
202
203#[derive(Debug)]
204struct TerminalExtractionError {
205 kind: ExtractionFailureKind,
206 message: String,
207}
208
209impl std::fmt::Display for TerminalExtractionError {
210 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 formatter.write_str(&self.message)
212 }
213}
214
215impl std::error::Error for TerminalExtractionError {}
216
217pub fn terminal_extraction_failure(
218 error: &anyhow::Error,
219) -> Option<(ExtractionFailureKind, String)> {
220 error.chain().find_map(|cause| {
221 cause
222 .downcast_ref::<TerminalExtractionError>()
223 .map(|terminal| (terminal.kind, terminal.message.clone()))
224 })
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct GitChangedFiles {
229 pub existing: Vec<PathBuf>,
230 pub deleted: Vec<PathBuf>,
231}
232
233#[derive(Debug, Clone)]
234pub struct SummarizeConfig {
235 pub model: String,
236 pub max_file_tokens: usize,
237 pub api_key_env: String,
238}
239
240pub struct ExtractionClient {
241 model: String,
242 backend: ExtractionBackend,
243}
244
245enum ExtractionBackend {
246 AnthropicApi { api_key: String },
247 ClaudeCli { command: PathBuf },
248}
249
250const REPLACE_FILE_SAVEPOINT: &str = "tsift_summary_replace";
251
252#[derive(Debug)]
253pub struct SummaryWriteLockGuard {
254 file: File,
255}
256
257#[derive(Debug)]
258struct SnapshotCopyGuard {
259 paths: Vec<PathBuf>,
260}
261
262impl Drop for SummaryWriteLockGuard {
263 fn drop(&mut self) {
264 let _ = clear_lock_metadata(&mut self.file);
265 let _ = self.file.unlock();
266 }
267}
268
269impl Drop for SnapshotCopyGuard {
270 fn drop(&mut self) {
271 for path in &self.paths {
272 let _ = std::fs::remove_file(path);
273 }
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278enum LockFileMarker {
279 Empty,
280 Pid(u32),
281 Invalid,
282}
283
284impl Default for SummarizeConfig {
285 fn default() -> Self {
286 Self {
287 model: "claude-haiku-4-5-20251001".to_string(),
288 max_file_tokens: 8000,
289 api_key_env: "ANTHROPIC_API_KEY".to_string(),
290 }
291 }
292}
293
294pub fn is_extraction_candidate_path(path: &Path) -> bool {
295 matches!(
296 path.extension().and_then(|extension| extension.to_str()),
297 Some(
298 "rs" | "py"
299 | "ts"
300 | "tsx"
301 | "js"
302 | "jsx"
303 | "kt"
304 | "kts"
305 | "zig"
306 | "gd"
307 | "sh"
308 | "bash"
309 | "zsh"
310 )
311 )
312}
313
314pub fn is_extraction_candidate_file(path: &Path) -> bool {
317 if !is_extraction_candidate_path(path) {
318 return false;
319 }
320 let Ok(metadata) = path.metadata() else {
321 return false;
322 };
323 if !metadata.is_file() || metadata.len() == 0 {
324 return false;
325 }
326
327 let Ok(mut file) = File::open(path) else {
331 return true;
332 };
333 let mut buffer = [0_u8; 8 * 1024];
334 loop {
335 match file.read(&mut buffer) {
336 Ok(0) => return false,
337 Ok(read)
338 if buffer[..read]
339 .iter()
340 .any(|byte| !byte.is_ascii_whitespace()) =>
341 {
342 return true;
343 }
344 Ok(_) => {}
345 Err(_) => return true,
346 }
347 }
348}
349
350impl ExtractionClient {
351 pub fn resolve(config: &SummarizeConfig) -> Result<Self> {
352 let api_key = std::env::var(&config.api_key_env)
353 .ok()
354 .filter(|value| !value.trim().is_empty());
355 let claude_command = find_command_on_path("claude");
356 let prefer_claude = [
357 "CLAUDE_CODE_USE_BEDROCK",
358 "CLAUDE_CODE_USE_VERTEX",
359 "CLAUDE_CODE_USE_FOUNDRY",
360 ]
361 .into_iter()
362 .any(env_flag_enabled);
363 let backend = select_extraction_backend(api_key, claude_command, prefer_claude)
364 .with_context(|| {
365 format!(
366 "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",
367 config.api_key_env
368 )
369 })?;
370 if let ExtractionBackend::ClaudeCli { command } = &backend {
371 ensure_claude_cli_authenticated(command).with_context(|| {
372 format!(
373 "tsift summarize --extract: Claude Code CLI at {} is not a usable extraction backend; run `claude auth login` or configure the selected hosted provider",
374 command.display()
375 )
376 })?;
377 }
378 Ok(Self {
379 model: config.model.clone(),
380 backend,
381 })
382 }
383
384 fn complete(&self, prompt: &str) -> Result<(String, i64, i64)> {
385 match &self.backend {
386 ExtractionBackend::AnthropicApi { api_key } => {
387 call_anthropic_api(api_key, &self.model, prompt)
388 }
389 ExtractionBackend::ClaudeCli { command } => {
390 call_claude_cli(command, &self.model, prompt)
391 }
392 }
393 }
394}
395
396fn select_extraction_backend(
397 api_key: Option<String>,
398 claude_command: Option<PathBuf>,
399 prefer_claude: bool,
400) -> Result<ExtractionBackend> {
401 if prefer_claude && let Some(command) = claude_command.as_ref() {
402 return Ok(ExtractionBackend::ClaudeCli {
403 command: command.clone(),
404 });
405 }
406 if let Some(api_key) = api_key {
407 return Ok(ExtractionBackend::AnthropicApi { api_key });
408 }
409 if let Some(command) = claude_command {
410 return Ok(ExtractionBackend::ClaudeCli { command });
411 }
412 bail!("no Anthropic API key or authenticated Claude Code CLI is available")
413}
414
415fn env_flag_enabled(name: &str) -> bool {
416 std::env::var(name)
417 .map(|value| {
418 matches!(
419 value.trim().to_ascii_lowercase().as_str(),
420 "1" | "true" | "yes" | "on"
421 )
422 })
423 .unwrap_or(false)
424}
425
426fn find_command_on_path(command: &str) -> Option<PathBuf> {
427 let path = std::env::var_os("PATH")?;
428 std::env::split_paths(&path)
429 .map(|dir| dir.join(command))
430 .find_map(|candidate| executable_candidate(&candidate))
431}
432
433fn executable_candidate(candidate: &Path) -> Option<PathBuf> {
434 #[cfg(unix)]
435 {
436 use std::os::unix::fs::PermissionsExt;
437 std::fs::metadata(candidate)
438 .ok()
439 .filter(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
440 .map(|_| candidate.to_path_buf())
441 }
442
443 #[cfg(windows)]
444 {
445 if candidate.is_file() {
446 return Some(candidate.to_path_buf());
447 }
448 ["exe", "cmd", "bat", "com"]
449 .into_iter()
450 .map(|extension| candidate.with_extension(extension))
451 .find(|path| path.is_file())
452 }
453
454 #[cfg(not(any(unix, windows)))]
455 {
456 candidate.is_file().then(|| candidate.to_path_buf())
457 }
458}
459
460fn ensure_claude_cli_authenticated(command: &Path) -> Result<()> {
461 let output = Command::new(command)
462 .args(["auth", "status"])
463 .output()
464 .with_context(|| format!("running `{} auth status`", command.display()))?;
465 if output.status.success() {
466 return Ok(());
467 }
468 let stderr = String::from_utf8_lossy(&output.stderr);
469 bail!(
470 "`{} auth status` failed with {}: {}",
471 command.display(),
472 output.status,
473 stderr.trim()
474 )
475}
476
477pub fn acquire_write_lock(db_path: &Path) -> Result<SummaryWriteLockGuard> {
478 let lock_path = writer_lock_path(db_path);
479 if let Some(parent) = lock_path.parent() {
480 std::fs::create_dir_all(parent)
481 .with_context(|| format!("creating lock dir: {}", parent.display()))?;
482 }
483
484 let mut lock_file = OpenOptions::new()
485 .read(true)
486 .write(true)
487 .create(true)
488 .truncate(false)
489 .open(&lock_path)
490 .with_context(|| format!("opening {}", lock_path.display()))?;
491
492 match lock_file.try_lock_exclusive() {
493 Ok(true) => {
494 write_lock_pid(&mut lock_file, &lock_path)?;
495 Ok(SummaryWriteLockGuard { file: lock_file })
496 }
497 Ok(false) => {
498 let holder = match read_lock_marker(&mut lock_file)
499 .with_context(|| format!("reading {}", lock_path.display()))?
500 {
501 LockFileMarker::Pid(pid) => format!(" (pid {})", pid),
502 _ => String::new(),
503 };
504 bail!(
505 "another tsift summarize extractor is already active for {}{} (lock: {}). \
506 A concurrent `tsift summarize --extract` is already updating this summary cache; \
507 wait for it to finish before retrying.",
508 db_path.display(),
509 holder,
510 lock_path.display()
511 );
512 }
513 Err(err) => Err(err).with_context(|| format!("locking {}", lock_path.display())),
514 }
515}
516
517pub fn writer_lock_path(db_path: &Path) -> PathBuf {
518 let stem = db_path
519 .file_stem()
520 .and_then(|stem| stem.to_str())
521 .unwrap_or("summaries");
522 db_path.with_file_name(format!("{stem}.lock"))
523}
524
525impl SummaryDb {
526 pub fn open(path: &Path) -> Result<Self> {
527 if let Some(parent) = path.parent() {
528 std::fs::create_dir_all(parent)
529 .with_context(|| format!("creating directory for {}", path.display()))?;
530 }
531 let conn = Connection::open(path)
532 .with_context(|| format!("opening summaries db: {}", path.display()))?;
533 conn.busy_timeout(Duration::from_secs(5))?;
534 conn.pragma_update(None, "journal_mode", "WAL")?;
535 let mode: String = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
536 if mode.to_lowercase() != "wal" {
537 bail!(
538 "summaries db {} requires WAL mode for concurrent reads, got {}",
539 path.display(),
540 mode
541 );
542 }
543 conn.execute_batch(
544 "CREATE TABLE IF NOT EXISTS summaries (
545 id INTEGER PRIMARY KEY,
546 symbol_name TEXT NOT NULL,
547 file_path TEXT NOT NULL,
548 content_hash TEXT NOT NULL,
549 summary TEXT NOT NULL,
550 entities TEXT,
551 relationships TEXT,
552 concept_labels TEXT,
553 extracted_at TEXT NOT NULL,
554 model TEXT NOT NULL,
555 tokens_input INTEGER,
556 tokens_output INTEGER
557 );
558CREATE INDEX IF NOT EXISTS idx_summaries_symbol ON summaries(symbol_name);
559CREATE INDEX IF NOT EXISTS idx_summaries_file ON summaries(file_path);
560CREATE INDEX IF NOT EXISTS idx_summaries_hash ON summaries(content_hash);
561 CREATE TABLE IF NOT EXISTS extraction_failures (
562 file_path TEXT NOT NULL,
563 content_hash TEXT NOT NULL,
564 kind TEXT NOT NULL,
565 max_file_tokens INTEGER,
566 message TEXT NOT NULL,
567 failed_at TEXT NOT NULL,
568 PRIMARY KEY (file_path, content_hash)
569);
570CREATE INDEX IF NOT EXISTS idx_extraction_failures_file
571 ON extraction_failures(file_path);",
572 )?;
573 let has_max_file_tokens: i64 = conn.query_row(
574 "SELECT COUNT(*) FROM pragma_table_info('extraction_failures') \
575 WHERE name = 'max_file_tokens'",
576 [],
577 |row| row.get(0),
578 )?;
579 if has_max_file_tokens == 0 {
580 conn.execute(
581 "ALTER TABLE extraction_failures ADD COLUMN max_file_tokens INTEGER",
582 [],
583 )?;
584 }
585 Ok(Self {
586 conn,
587 _snapshot_copy: None,
588 })
589 }
590
591 pub fn open_read_only(path: &Path) -> Result<Self> {
592 let conn = Connection::open_with_flags(
593 path,
594 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
595 )
596 .with_context(|| format!("opening summaries db: {}", path.display()))?;
597 conn.busy_timeout(Duration::from_secs(5))?;
598 Ok(Self {
599 conn,
600 _snapshot_copy: None,
601 })
602 }
603
604 pub fn open_read_only_resilient(path: &Path) -> Result<Self> {
605 Self::open_read_only_with_recovery(path).map(|result| result.db)
606 }
607
608 pub fn open_read_only_with_recovery(path: &Path) -> Result<SummaryReadOnlyOpen> {
609 match Self::open_read_only(path).and_then(|db| {
610 db.ensure_readable()?;
611 Ok(db)
612 }) {
613 Ok(db) => Ok(SummaryReadOnlyOpen { db, recovery: None }),
614 Err(err) => {
615 let Some(recovery) = read_only_snapshot_recovery(path, &err) else {
616 return Err(err);
617 };
618 let db = Self::open_read_only_snapshot(path)?;
619 Ok(SummaryReadOnlyOpen {
620 db,
621 recovery: Some(recovery),
622 })
623 }
624 }
625 }
626
627 pub fn get_by_symbol(&self, name: &str) -> Result<Vec<Summary>> {
628 let mut stmt = self.conn.prepare(
629 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
630 concept_labels, extracted_at, model, tokens_input, tokens_output
631 FROM summaries WHERE symbol_name = ?1 ORDER BY extracted_at DESC",
632 )?;
633 let rows = stmt
634 .query_map([name], |row| Ok(row_to_summary(row)))?
635 .collect::<std::result::Result<Vec<_>, _>>()?;
636 Ok(rows)
637 }
638
639 pub fn get_by_file(&self, path: &str) -> Result<Vec<Summary>> {
640 let normalized = normalize_summary_file_key_str(path);
641 let legacy = legacy_windows_summary_file_key(&normalized);
642 let mut stmt = self.conn.prepare(
643 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
644 concept_labels, extracted_at, model, tokens_input, tokens_output
645 FROM summaries WHERE file_path = ?1 OR file_path = ?2 ORDER BY symbol_name",
646 )?;
647 let rows = stmt
648 .query_map(rusqlite::params![normalized, legacy], |row| {
649 Ok(row_to_summary(row))
650 })?
651 .collect::<std::result::Result<Vec<_>, _>>()?;
652 Ok(rows)
653 }
654
655 pub fn all(&self) -> Result<Vec<Summary>> {
656 let mut stmt = self.conn.prepare(
657 "SELECT id, symbol_name, file_path, content_hash, summary, entities, relationships,
658 concept_labels, extracted_at, model, tokens_input, tokens_output
659 FROM summaries ORDER BY file_path, symbol_name, id",
660 )?;
661 let rows = stmt
662 .query_map([], |row| Ok(row_to_summary(row)))?
663 .collect::<std::result::Result<Vec<_>, _>>()?;
664 Ok(rows)
665 }
666
667 pub fn insert(&self, summary: &Summary) -> Result<()> {
668 insert_summary(&self.conn, summary)
669 }
670
671 pub fn replace_file(&self, file_path: &str, summaries: &[Summary]) -> Result<()> {
672 self.replace_file_with_hook(file_path, summaries, |_| Ok(()))
673 }
674
675 pub fn is_current(&self, file_path: &str, content_hash: &str) -> Result<bool> {
676 let normalized = normalize_summary_file_key_str(file_path);
677 let legacy = legacy_windows_summary_file_key(&normalized);
678 let count: i64 = self.conn.query_row(
679 "SELECT COUNT(*) FROM summaries
680 WHERE content_hash = ?2 AND (file_path = ?1 OR file_path = ?3)",
681 rusqlite::params![normalized, content_hash, legacy],
682 |row| row.get(0),
683 )?;
684 Ok(count > 0)
685 }
686
687 pub fn stats(&self, root: &Path) -> Result<SummaryStats> {
688 let total_summaries_raw: i64 =
689 self.conn
690 .query_row("SELECT COUNT(*) FROM summaries", [], |row| row.get(0))?;
691 let total_summaries =
692 usize::try_from(total_summaries_raw).context("summary count out of range")?;
693 let cached_file_paths = self.cached_file_paths()?;
694 let total_files = cached_file_paths.len();
695 let (stale_count, warnings) = self.stale_file_count(root, &cached_file_paths)?;
696 let total_tokens_input: i64 = self.conn.query_row(
697 "SELECT COALESCE(SUM(tokens_input), 0) FROM summaries",
698 [],
699 |row| row.get(0),
700 )?;
701 let total_tokens_output: i64 = self.conn.query_row(
702 "SELECT COALESCE(SUM(tokens_output), 0) FROM summaries",
703 [],
704 |row| row.get(0),
705 )?;
706 let estimated_tokens_saved = (total_summaries as i64) * 1925;
709 Ok(SummaryStats {
710 total_summaries,
711 total_files,
712 stale_count,
713 total_tokens_input,
714 total_tokens_output,
715 estimated_tokens_saved,
716 warnings,
717 })
718 }
719
720 pub fn delete_by_file(&self, file_path: &str) -> Result<usize> {
721 let normalized = normalize_summary_file_key_str(file_path);
722 let legacy = legacy_windows_summary_file_key(&normalized);
723 let count = self.conn.execute(
724 "DELETE FROM summaries WHERE file_path = ?1 OR file_path = ?2",
725 rusqlite::params![normalized, legacy],
726 )?;
727 if self.has_extraction_failures_table()? {
728 self.conn.execute(
729 "DELETE FROM extraction_failures WHERE file_path = ?1 OR file_path = ?2",
730 rusqlite::params![normalized, legacy],
731 )?;
732 }
733 Ok(count)
734 }
735
736 pub fn terminal_failure(
737 &self,
738 file_path: &str,
739 content_hash: &str,
740 ) -> Result<Option<CachedExtractionFailure>> {
741 if !self.has_extraction_failures_table()? {
742 return Ok(None);
743 }
744 let normalized = normalize_summary_file_key_str(file_path);
745 let legacy = legacy_windows_summary_file_key(&normalized);
746 let mut stmt = self.conn.prepare(
747 "SELECT file_path, content_hash, kind, max_file_tokens, message, failed_at
748 FROM extraction_failures
749 WHERE content_hash = ?2 AND (file_path = ?1 OR file_path = ?3)
750 LIMIT 1",
751 )?;
752 let mut rows = stmt.query(rusqlite::params![normalized, content_hash, legacy])?;
753 let Some(row) = rows.next()? else {
754 return Ok(None);
755 };
756 let kind = match row.get::<_, String>(2)?.as_str() {
757 "too_large" => ExtractionFailureKind::TooLarge,
758 "unparseable_response" => ExtractionFailureKind::UnparseableResponse,
759 _ => return Ok(None),
760 };
761 Ok(Some(CachedExtractionFailure {
762 file_path: normalize_summary_file_key_str(&row.get::<_, String>(0)?),
763 content_hash: row.get(1)?,
764 kind,
765 max_file_tokens: row
766 .get::<_, Option<i64>>(3)?
767 .and_then(|value| usize::try_from(value).ok()),
768 message: row.get(4)?,
769 failed_at: row.get(5)?,
770 }))
771 }
772
773 pub fn record_terminal_failure(
774 &self,
775 file_path: &str,
776 content_hash: &str,
777 kind: ExtractionFailureKind,
778 message: &str,
779 ) -> Result<()> {
780 self.record_terminal_failure_with_limit(file_path, content_hash, kind, None, message)
781 }
782
783 pub fn record_terminal_failure_with_limit(
784 &self,
785 file_path: &str,
786 content_hash: &str,
787 kind: ExtractionFailureKind,
788 max_file_tokens: Option<usize>,
789 message: &str,
790 ) -> Result<()> {
791 let normalized = normalize_summary_file_key_str(file_path);
792 self.conn.execute(
793 "INSERT INTO extraction_failures
794 (file_path, content_hash, kind, max_file_tokens, message, failed_at)
795 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
796 ON CONFLICT(file_path, content_hash) DO UPDATE SET
797 kind = excluded.kind,
798 max_file_tokens = excluded.max_file_tokens,
799 message = excluded.message,
800 failed_at = excluded.failed_at",
801 rusqlite::params![
802 normalized,
803 content_hash,
804 kind.as_str(),
805 max_file_tokens.and_then(|value| i64::try_from(value).ok()),
806 message,
807 chrono_now()
808 ],
809 )?;
810 Ok(())
811 }
812
813 pub fn current_terminal_failure_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
814 if !self.has_extraction_failures_table()? {
815 return Ok(BTreeSet::new());
816 }
817 let mut stmt = self.conn.prepare(
818 "SELECT file_path, content_hash FROM extraction_failures ORDER BY file_path",
819 )?;
820 let rows = stmt.query_map([], |row| {
821 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
822 })?;
823 let mut current = BTreeSet::new();
824 for row in rows {
825 let (file_path, expected_hash) = row?;
826 let normalized = normalize_summary_file_key_str(&file_path);
827 let Some(live_path) = Self::stats_live_path(root, &normalized) else {
828 continue;
829 };
830 let Ok(content) = std::fs::read(live_path) else {
831 continue;
832 };
833 if content_hash(&content) == expected_hash {
834 current.insert(normalized);
835 }
836 }
837 Ok(current)
838 }
839
840 pub fn prune_terminal_failures_for_non_candidates(&self, root: &Path) -> Result<usize> {
843 if !self.has_extraction_failures_table()? {
844 return Ok(0);
845 }
846 let paths = {
847 let mut stmt = self
848 .conn
849 .prepare("SELECT DISTINCT file_path FROM extraction_failures ORDER BY file_path")?;
850 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
851 rows.collect::<std::result::Result<Vec<_>, _>>()?
852 };
853
854 let mut pruned = 0;
855 let mut visited = BTreeSet::new();
856 for file_path in paths {
857 let normalized = normalize_summary_file_key_str(&file_path);
858 if !visited.insert(normalized.clone()) {
859 continue;
860 }
861 let is_candidate = Self::stats_live_path(root, &normalized)
862 .is_some_and(|path| is_extraction_candidate_file(&path));
863 if is_candidate {
864 continue;
865 }
866 let legacy = legacy_windows_summary_file_key(&normalized);
867 pruned += self.conn.execute(
868 "DELETE FROM extraction_failures WHERE file_path = ?1 OR file_path = ?2",
869 rusqlite::params![normalized, legacy],
870 )?;
871 }
872 Ok(pruned)
873 }
874
875 fn has_extraction_failures_table(&self) -> Result<bool> {
876 let count: i64 = self.conn.query_row(
877 "SELECT COUNT(*) FROM sqlite_master
878 WHERE type = 'table' AND name = 'extraction_failures'",
879 [],
880 |row| row.get(0),
881 )?;
882 Ok(count > 0)
883 }
884
885 pub fn cached_file_paths(&self) -> Result<BTreeSet<String>> {
886 let mut stmt = self
887 .conn
888 .prepare("SELECT DISTINCT file_path FROM summaries ORDER BY file_path")?;
889 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
890 let paths = rows.collect::<std::result::Result<Vec<_>, _>>()?;
891 Ok(paths
892 .into_iter()
893 .map(|path| normalize_summary_file_key_str(&path))
894 .collect())
895 }
896
897 fn stats_live_path(root: &Path, cached_path: &str) -> Option<PathBuf> {
898 let normalized_cached_path = normalize_lexical_path(Path::new(cached_path));
899 if normalized_cached_path.is_absolute() {
900 return None;
901 }
902
903 let live_path = normalize_lexical_path(&root.join(&normalized_cached_path));
904 if !live_path.starts_with(root) {
905 return None;
906 }
907
908 Some(live_path)
909 }
910
911 fn stale_file_count(
912 &self,
913 root: &Path,
914 cached_file_paths: &BTreeSet<String>,
915 ) -> Result<(usize, Vec<SummaryStatsWarning>)> {
916 let mut stale_count = 0;
917 let mut warnings = Vec::new();
918
919 for cached_path in cached_file_paths {
920 let Some(live_path) = Self::stats_live_path(root, cached_path) else {
921 stale_count += 1;
922 continue;
923 };
924 if !live_path.is_file() {
925 stale_count += 1;
926 continue;
927 }
928
929 let content = match std::fs::read(&live_path) {
930 Ok(content) => content,
931 Err(err) => {
932 stale_count += 1;
933 warnings.push(SummaryStatsWarning {
934 path: PathBuf::from(normalize_summary_file_key_str(cached_path)),
935 message: format!(
936 "counting cached summary as stale because the source file could not be read ({err})"
937 ),
938 });
939 continue;
940 }
941 };
942 let live_hash = content_hash(&content);
943 if !self.is_current(cached_path, &live_hash)? {
944 stale_count += 1;
945 }
946 }
947
948 Ok((stale_count, warnings))
949 }
950
951 fn replace_file_with_hook<F>(
952 &self,
953 file_path: &str,
954 summaries: &[Summary],
955 mut after_insert: F,
956 ) -> Result<()>
957 where
958 F: FnMut(usize) -> Result<()>,
959 {
960 let normalized = normalize_summary_file_key_str(file_path);
961 let legacy = legacy_windows_summary_file_key(&normalized);
962 self.conn
963 .execute_batch(&format!("SAVEPOINT {REPLACE_FILE_SAVEPOINT}"))
964 .context("starting summary replacement transaction")?;
965
966 let result = (|| -> Result<()> {
967 self.conn.execute(
968 "DELETE FROM summaries WHERE file_path = ?1 OR file_path = ?2",
969 rusqlite::params![normalized, legacy],
970 )?;
971 self.conn.execute(
972 "DELETE FROM extraction_failures WHERE file_path = ?1 OR file_path = ?2",
973 rusqlite::params![normalized, legacy],
974 )?;
975 for (idx, summary) in summaries.iter().enumerate() {
976 insert_summary(&self.conn, summary)?;
977 after_insert(idx)?;
978 }
979 Ok(())
980 })();
981
982 match result {
983 Ok(()) => {
984 self.conn
985 .execute_batch(&format!("RELEASE {REPLACE_FILE_SAVEPOINT}"))
986 .context("committing summary replacement transaction")?;
987 Ok(())
988 }
989 Err(err) => {
990 if let Err(rollback_err) = self.conn.execute_batch(&format!(
991 "ROLLBACK TO {REPLACE_FILE_SAVEPOINT}; RELEASE {REPLACE_FILE_SAVEPOINT};"
992 )) {
993 return Err(err.context(format!(
994 "rollback failed for summary replacement transaction: {rollback_err}"
995 )));
996 }
997 Err(err)
998 }
999 }
1000 }
1001
1002 fn ensure_readable(&self) -> Result<()> {
1003 self.conn
1004 .query_row("SELECT COUNT(*) FROM sqlite_master", [], |_row| Ok(()))
1005 .map_err(anyhow::Error::from)
1006 }
1007
1008 fn open_read_only_snapshot(path: &Path) -> Result<Self> {
1009 let (snapshot_path, cleanup_paths) = copy_read_only_snapshot(path, "summaries")?;
1010 let conn = Connection::open_with_flags(
1011 &snapshot_path,
1012 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1013 )
1014 .with_context(|| format!("opening summaries snapshot {}", snapshot_path.display()))?;
1015 conn.busy_timeout(Duration::from_secs(5))?;
1016 Ok(Self {
1017 conn,
1018 _snapshot_copy: Some(SnapshotCopyGuard {
1019 paths: cleanup_paths,
1020 }),
1021 })
1022 }
1023}
1024
1025impl SummaryCache {
1026 pub fn new(db: SummaryDb) -> Self {
1027 Self {
1028 db: Rc::new(db),
1029 ctx: LazyContext::new(),
1030 slots: RefCell::new(HashMap::new()),
1031 hits: Cell::new(0),
1032 misses: Cell::new(0),
1033 }
1034 }
1035
1036 pub fn db(&self) -> &SummaryDb {
1037 &self.db
1038 }
1039
1040 pub fn stats(&self) -> (usize, usize) {
1041 (self.hits.get(), self.misses.get())
1042 }
1043
1044 pub fn file_snapshot(
1045 &self,
1046 file_path: &str,
1047 content_hash: Option<&str>,
1048 ) -> Result<SummaryFileSnapshot> {
1049 let normalized = normalize_summary_file_key_str(file_path);
1050 let requested_content_hash = content_hash.map(str::to_string);
1051 let slot = {
1052 let mut slots = self.slots.borrow_mut();
1053 if let Some(slot) = slots.get(&normalized) {
1054 self.ctx
1055 .set(&slot.content_hash, requested_content_hash.clone());
1056 *slot
1057 } else {
1058 let db = Rc::clone(&self.db);
1059 let file_key = normalized.clone();
1060 let content_hash_cell = self.ctx.source(requested_content_hash.clone());
1061 let epoch = self.ctx.source(0u64);
1062 let snapshot = self.ctx.slot(move |ctx| {
1063 let requested_content_hash = ctx.get(&content_hash_cell);
1064 let _epoch = ctx.get(&epoch);
1065 let summaries = db
1066 .get_by_file(&file_key)
1067 .map_err(|err| format!("{err:#}"))?;
1068 let current = requested_content_hash.as_ref().is_some_and(|hash| {
1069 summaries
1070 .iter()
1071 .any(|summary| summary.content_hash == *hash)
1072 });
1073 Ok(SummaryFileSnapshot {
1074 file_path: file_key.clone(),
1075 requested_content_hash,
1076 summaries,
1077 current,
1078 })
1079 });
1080 let slot = SummaryFileSlot {
1081 content_hash: content_hash_cell,
1082 epoch,
1083 snapshot,
1084 };
1085 slots.insert(normalized.clone(), slot);
1086 slot
1087 }
1088 };
1089
1090 if self.ctx.is_set(&slot.snapshot) {
1091 self.hits.set(self.hits.get() + 1);
1092 } else {
1093 self.misses.set(self.misses.get() + 1);
1094 }
1095 let result = self
1096 .ctx
1097 .get(&slot.snapshot)
1098 .map_err(|message| anyhow::anyhow!("{message}"));
1099 if result.is_err() {
1100 slot.snapshot.clear(&self.ctx);
1101 }
1102 result
1103 }
1104
1105 pub fn current_by_file(
1106 &self,
1107 file_path: &str,
1108 content_hash: &str,
1109 ) -> Result<Option<Vec<Summary>>> {
1110 let snapshot = self.file_snapshot(file_path, Some(content_hash))?;
1111 if snapshot.current {
1112 Ok(Some(snapshot.summaries))
1113 } else {
1114 Ok(None)
1115 }
1116 }
1117
1118 pub fn get_or_extract_file<F>(
1119 &self,
1120 file_path: &str,
1121 content_hash: &str,
1122 extract: F,
1123 ) -> Result<SummaryCacheLookup>
1124 where
1125 F: FnOnce() -> Result<Vec<Summary>>,
1126 {
1127 if let Some(summaries) = self.current_by_file(file_path, content_hash)? {
1128 return Ok(SummaryCacheLookup {
1129 summaries,
1130 source: SummaryCacheSource::Cached,
1131 });
1132 }
1133
1134 let summaries = extract()?;
1135 self.db.replace_file(file_path, &summaries)?;
1136 self.invalidate_file(file_path, Some(content_hash));
1137 Ok(SummaryCacheLookup {
1138 summaries,
1139 source: SummaryCacheSource::Extracted,
1140 })
1141 }
1142
1143 pub fn invalidate_file(&self, file_path: &str, content_hash: Option<&str>) {
1144 let normalized = normalize_summary_file_key_str(file_path);
1145 let Some(slot) = self.slots.borrow().get(&normalized).copied() else {
1146 return;
1147 };
1148 self.ctx
1149 .set(&slot.content_hash, content_hash.map(str::to_string));
1150 let epoch = self.ctx.get(&slot.epoch);
1151 self.ctx.set(&slot.epoch, epoch.wrapping_add(1));
1152 }
1153}
1154
1155fn read_lock_marker(file: &mut File) -> std::io::Result<LockFileMarker> {
1156 file.seek(SeekFrom::Start(0))?;
1157 let mut content = String::new();
1158 file.read_to_string(&mut content)?;
1159 let trimmed = content.trim();
1160 if trimmed.is_empty() {
1161 Ok(LockFileMarker::Empty)
1162 } else if let Ok(pid) = trimmed.parse::<u32>() {
1163 Ok(LockFileMarker::Pid(pid))
1164 } else {
1165 Ok(LockFileMarker::Invalid)
1166 }
1167}
1168
1169fn write_lock_pid(file: &mut File, lock_path: &Path) -> Result<()> {
1170 file.set_len(0)
1171 .with_context(|| format!("clearing {}", lock_path.display()))?;
1172 file.seek(SeekFrom::Start(0))
1173 .with_context(|| format!("seeking {}", lock_path.display()))?;
1174 writeln!(file, "{}", std::process::id())
1175 .with_context(|| format!("writing {}", lock_path.display()))?;
1176 file.sync_data()
1177 .with_context(|| format!("syncing {}", lock_path.display()))?;
1178 Ok(())
1179}
1180
1181fn clear_lock_metadata(file: &mut File) -> std::io::Result<()> {
1182 file.set_len(0)?;
1183 file.seek(SeekFrom::Start(0))?;
1184 file.sync_data()?;
1185 Ok(())
1186}
1187
1188fn insert_summary(conn: &Connection, summary: &Summary) -> Result<()> {
1189 let normalized_file_path = normalize_summary_file_key_str(&summary.file_path);
1190 conn.execute(
1191 "INSERT OR REPLACE INTO summaries
1192 (symbol_name, file_path, content_hash, summary, entities, relationships,
1193 concept_labels, extracted_at, model, tokens_input, tokens_output)
1194 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1195 rusqlite::params![
1196 summary.symbol_name,
1197 normalized_file_path,
1198 summary.content_hash,
1199 summary.summary,
1200 summary
1201 .entities
1202 .as_ref()
1203 .map(|e| serde_json::to_string(e).unwrap_or_default()),
1204 summary
1205 .relationships
1206 .as_ref()
1207 .map(|r| serde_json::to_string(r).unwrap_or_default()),
1208 summary
1209 .concept_labels
1210 .as_ref()
1211 .map(|c| serde_json::to_string(c).unwrap_or_default()),
1212 summary.extracted_at,
1213 summary.model,
1214 summary.tokens_input,
1215 summary.tokens_output,
1216 ],
1217 )?;
1218 Ok(())
1219}
1220
1221fn row_to_summary(row: &rusqlite::Row) -> Summary {
1222 let entities_json: Option<String> = row.get(5).unwrap_or(None);
1223 let relationships_json: Option<String> = row.get(6).unwrap_or(None);
1224 let labels_json: Option<String> = row.get(7).unwrap_or(None);
1225 Summary {
1226 id: row.get(0).unwrap_or(0),
1227 symbol_name: row.get(1).unwrap_or_default(),
1228 file_path: normalize_summary_file_key_str(&row.get::<_, String>(2).unwrap_or_default()),
1229 content_hash: row.get(3).unwrap_or_default(),
1230 summary: row.get(4).unwrap_or_default(),
1231 entities: entities_json.and_then(|j| serde_json::from_str(&j).ok()),
1232 relationships: relationships_json.and_then(|j| serde_json::from_str(&j).ok()),
1233 concept_labels: labels_json.and_then(|j| serde_json::from_str(&j).ok()),
1234 extracted_at: row.get(8).unwrap_or_default(),
1235 model: row.get(9).unwrap_or_default(),
1236 tokens_input: row.get(10).unwrap_or(None),
1237 tokens_output: row.get(11).unwrap_or(None),
1238 }
1239}
1240
1241pub fn normalize_summary_file_key(path: &Path) -> String {
1242 normalize_summary_file_key_str(path.to_string_lossy().as_ref())
1243}
1244
1245pub fn normalize_summary_file_key_str(path: &str) -> String {
1246 path.replace('\\', "/")
1247}
1248
1249fn legacy_windows_summary_file_key(path: &str) -> String {
1250 path.replace('/', "\\")
1251}
1252
1253pub fn content_hash(content: &[u8]) -> String {
1254 blake3::hash(content).to_hex().to_string()
1255}
1256
1257pub fn extract_for_file(
1258 file_path: &Path,
1259 symbols_db_path: Option<&Path>,
1260 symbols_source_root: Option<&Path>,
1261 config: &SummarizeConfig,
1262) -> Result<Vec<Summary>> {
1263 let client = ExtractionClient::resolve(config)?;
1264 extract_for_file_with_client(
1265 file_path,
1266 symbols_db_path,
1267 symbols_source_root,
1268 config,
1269 &client,
1270 )
1271}
1272
1273pub fn extract_for_file_with_client(
1274 file_path: &Path,
1275 symbols_db_path: Option<&Path>,
1276 symbols_source_root: Option<&Path>,
1277 config: &SummarizeConfig,
1278 client: &ExtractionClient,
1279) -> Result<Vec<Summary>> {
1280 let source = std::fs::read_to_string(file_path)
1281 .with_context(|| format!("reading {}", file_path.display()))?;
1282
1283 let token_estimate = source.len() / 4;
1284 if token_estimate > config.max_file_tokens {
1285 return Err(anyhow::Error::new(TerminalExtractionError {
1286 kind: ExtractionFailureKind::TooLarge,
1287 message: format!(
1288 "file {} exceeds max_file_tokens ({} > {}); raise it with --max-file-tokens or .tsift/config.toml [summarize].max_file_tokens",
1289 file_path.display(),
1290 token_estimate,
1291 config.max_file_tokens
1292 ),
1293 }));
1294 }
1295
1296 let hash = content_hash(source.as_bytes());
1297 let file_str = file_path.to_string_lossy().to_string();
1298
1299 let symbols = if let Some(db_path) = symbols_db_path {
1300 load_symbols_for_file(db_path, file_path, symbols_source_root)?
1301 } else {
1302 Vec::new()
1303 };
1304
1305 let prompt = build_extraction_prompt(&file_str, &source, &symbols);
1306
1307 let (response_text, tokens_in, tokens_out) = client.complete(&prompt)?;
1308
1309 let parsed = parse_extraction_response(file_path, &response_text)?;
1310
1311 let now = chrono_now();
1312 let mut summaries = Vec::new();
1313
1314 let file_name = file_path
1316 .file_name()
1317 .map(|n| n.to_string_lossy().to_string())
1318 .unwrap_or_else(|| file_str.clone());
1319 summaries.push(Summary {
1320 id: 0,
1321 symbol_name: file_name,
1322 file_path: file_str.clone(),
1323 content_hash: hash.clone(),
1324 summary: parsed.summary.clone(),
1325 entities: Some(parsed.entities.clone()),
1326 relationships: Some(parsed.relationships.clone()),
1327 concept_labels: Some(parsed.concept_labels.clone()),
1328 extracted_at: now.clone(),
1329 model: config.model.clone(),
1330 tokens_input: Some(tokens_in),
1331 tokens_output: Some(tokens_out),
1332 });
1333
1334 for entity in &parsed.entities {
1336 summaries.push(Summary {
1337 id: 0,
1338 symbol_name: entity.name.clone(),
1339 file_path: file_str.clone(),
1340 content_hash: hash.clone(),
1341 summary: entity.description.clone(),
1342 entities: None,
1343 relationships: None,
1344 concept_labels: None,
1345 extracted_at: now.clone(),
1346 model: config.model.clone(),
1347 tokens_input: None,
1348 tokens_output: None,
1349 });
1350 }
1351
1352 Ok(summaries)
1353}
1354
1355fn parse_extraction_response(file_path: &Path, response_text: &str) -> Result<ExtractionResponse> {
1356 let direct_error = match serde_json::from_str(response_text) {
1357 Ok(parsed) => return Ok(parsed),
1358 Err(error) => error,
1359 };
1360 if let Some(object) = first_balanced_json_object(response_text)
1361 && let Ok(parsed) = serde_json::from_str(object)
1362 {
1363 return Ok(parsed);
1364 }
1365 let preview = response_text.chars().take(240).collect::<String>();
1366 Err(anyhow::Error::new(TerminalExtractionError {
1367 kind: ExtractionFailureKind::UnparseableResponse,
1368 message: format!(
1369 "parsing extraction response for {} failed: {direct_error}; response preview: {preview:?}",
1370 file_path.display()
1371 ),
1372 }))
1373}
1374
1375fn first_balanced_json_object(text: &str) -> Option<&str> {
1376 let mut start = None;
1377 let mut depth = 0usize;
1378 let mut in_string = false;
1379 let mut escaped = false;
1380
1381 for (index, character) in text.char_indices() {
1382 if start.is_none() {
1383 if character == '{' {
1384 start = Some(index);
1385 depth = 1;
1386 }
1387 continue;
1388 }
1389 if in_string {
1390 if escaped {
1391 escaped = false;
1392 } else if character == '\\' {
1393 escaped = true;
1394 } else if character == '"' {
1395 in_string = false;
1396 }
1397 continue;
1398 }
1399 match character {
1400 '"' => in_string = true,
1401 '{' => depth += 1,
1402 '}' => {
1403 depth -= 1;
1404 if depth == 0 {
1405 return Some(
1406 &text[start.expect("set with depth")..index + character.len_utf8()],
1407 );
1408 }
1409 }
1410 _ => {}
1411 }
1412 }
1413 None
1414}
1415
1416fn too_large_limits_from_message(message: &str) -> Option<(usize, usize)> {
1417 let (_, limits) = message.split_once("exceeds max_file_tokens (")?;
1418 let (limits, _) = limits.split_once(')')?;
1419 let (required, failed_limit) = limits.split_once('>')?;
1420 Some((
1421 required.trim().parse().ok()?,
1422 failed_limit.trim().parse().ok()?,
1423 ))
1424}
1425
1426fn too_large_limit_from_message(message: &str) -> Option<usize> {
1427 too_large_limits_from_message(message).map(|(_, failed_limit)| failed_limit)
1428}
1429
1430fn too_large_required_tokens_from_message(message: &str) -> Option<usize> {
1431 too_large_limits_from_message(message).map(|(required, _)| required)
1432}
1433
1434fn normalize_lookup_path(path: &Path) -> String {
1435 normalize_summary_file_key(path)
1436}
1437
1438pub fn normalize_lexical_path(path: &Path) -> PathBuf {
1439 let mut normalized = PathBuf::new();
1440
1441 for component in path.components() {
1442 match component {
1443 Component::CurDir => {}
1444 Component::ParentDir => match normalized.components().next_back() {
1445 Some(Component::Normal(_)) => {
1446 normalized.pop();
1447 }
1448 Some(Component::RootDir | Component::Prefix(_)) => {}
1449 _ => normalized.push(component.as_os_str()),
1450 },
1451 _ => normalized.push(component.as_os_str()),
1452 }
1453 }
1454
1455 if normalized.as_os_str().is_empty() && !path.is_absolute() {
1456 PathBuf::from(".")
1457 } else {
1458 normalized
1459 }
1460}
1461
1462fn push_lookup_candidate(candidates: &mut Vec<String>, candidate: String) {
1463 if !candidates.iter().any(|existing| existing == &candidate) {
1464 candidates.push(candidate);
1465 }
1466}
1467
1468pub fn file_lookup_candidates(
1469 file_query: &Path,
1470 query_base: &Path,
1471 project_root: &Path,
1472) -> Vec<String> {
1473 let mut candidates = Vec::new();
1474 push_lookup_candidate(
1475 &mut candidates,
1476 normalize_lookup_path(&normalize_lexical_path(file_query)),
1477 );
1478
1479 let resolved = if file_query.is_absolute() {
1480 file_query
1481 .canonicalize()
1482 .unwrap_or_else(|_| normalize_lexical_path(file_query))
1483 } else {
1484 normalize_lexical_path(&query_base.join(file_query))
1485 };
1486 let project_relative = resolved.strip_prefix(project_root).unwrap_or(&resolved);
1487 push_lookup_candidate(&mut candidates, normalize_lookup_path(project_relative));
1488
1489 candidates
1490}
1491
1492fn symbol_lookup_candidates(file_path: &Path, source_root: Option<&Path>) -> Vec<String> {
1493 let mut candidates = vec![normalize_lookup_path(file_path)];
1494 if let Some(root) = source_root
1495 && let Ok(relative) = file_path.strip_prefix(root)
1496 {
1497 let relative = normalize_lookup_path(relative);
1498 if !candidates.iter().any(|candidate| candidate == &relative) {
1499 candidates.push(relative);
1500 }
1501 }
1502 candidates
1503}
1504
1505fn load_symbols_for_file(
1506 db_path: &Path,
1507 file_path: &Path,
1508 source_root: Option<&Path>,
1509) -> Result<Vec<(String, String)>> {
1510 if !db_path.exists() {
1511 return Ok(Vec::new());
1512 }
1513 let candidates = symbol_lookup_candidates(file_path, source_root);
1514 IndexDb::file_symbols_read_only(db_path, &candidates)
1515}
1516
1517fn build_extraction_prompt(file_path: &str, source: &str, symbols: &[(String, String)]) -> String {
1518 let mut prompt = format!(
1519 "Analyze this source file and extract structured information.\n\n\
1520 File: {}\n",
1521 file_path
1522 );
1523
1524 if !symbols.is_empty() {
1525 prompt.push_str("\nKnown symbols:\n");
1526 for (name, kind) in symbols {
1527 prompt.push_str(&format!("- {} ({})\n", name, kind));
1528 }
1529 }
1530
1531 prompt.push_str(&format!(
1532 "\nSource:\n```\n{}\n```\n\n\
1533 Respond with ONLY a JSON object (no markdown fences):\n\
1534 {{\n\
1535 \"summary\": \"1-3 sentence description of the file/module purpose\",\n\
1536 \"entities\": [{{\"name\": \"...\", \"kind\": \"function|class|type|trait|module\", \"description\": \"1 sentence\"}}],\n\
1537 \"relationships\": [{{\"from\": \"...\", \"to\": \"...\", \"kind\": \"calls|implements|uses|extends\"}}],\n\
1538 \"concept_labels\": [\"domain concept 1\", \"domain concept 2\"]\n\
1539 }}",
1540 source
1541 ));
1542
1543 prompt
1544}
1545
1546fn parse_anthropic_api_response(
1547 status: u16,
1548 response: serde_json::Value,
1549) -> Result<(String, i64, i64)> {
1550 if !(200..300).contains(&status) {
1551 let message = response["error"]["message"]
1552 .as_str()
1553 .or_else(|| response["message"].as_str())
1554 .map(str::to_owned)
1555 .unwrap_or_else(|| response.to_string());
1556 let error_type = response["error"]["type"].as_str();
1557
1558 match error_type {
1559 Some(error_type) => bail!(
1560 "Anthropic API returned HTTP {} ({}): {}",
1561 status,
1562 error_type,
1563 message
1564 ),
1565 None => bail!("Anthropic API returned HTTP {}: {}", status, message),
1566 }
1567 }
1568
1569 let content = response["content"]
1570 .as_array()
1571 .and_then(|arr| arr.first())
1572 .and_then(|block| block["text"].as_str())
1573 .unwrap_or("")
1574 .to_string();
1575
1576 let tokens_in = response["usage"]["input_tokens"].as_i64().unwrap_or(0);
1577 let tokens_out = response["usage"]["output_tokens"].as_i64().unwrap_or(0);
1578
1579 if content.is_empty() {
1580 bail!("empty response from Anthropic API");
1581 }
1582
1583 Ok((
1584 strip_markdown_fences(&content).to_string(),
1585 tokens_in,
1586 tokens_out,
1587 ))
1588}
1589
1590fn strip_markdown_fences(content: &str) -> &str {
1591 let cleaned = content
1592 .trim()
1593 .strip_prefix("```json")
1594 .or_else(|| content.trim().strip_prefix("```"))
1595 .unwrap_or(content.trim());
1596 cleaned.strip_suffix("```").unwrap_or(cleaned).trim()
1597}
1598
1599fn call_anthropic_api(api_key: &str, model: &str, prompt: &str) -> Result<(String, i64, i64)> {
1600 if let Some(result) = maybe_mock_anthropic_api(prompt)? {
1601 return Ok(result);
1602 }
1603
1604 let body = serde_json::json!({
1605 "model": model,
1606 "max_tokens": 4096,
1607 "messages": [
1608 {"role": "user", "content": prompt}
1609 ]
1610 });
1611
1612 let agent = ureq::Agent::config_builder()
1613 .http_status_as_error(false)
1614 .build()
1615 .new_agent();
1616 let mut response = agent
1617 .post("https://api.anthropic.com/v1/messages")
1618 .header("x-api-key", api_key)
1619 .header("anthropic-version", "2023-06-01")
1620 .header("content-type", "application/json")
1621 .send_json(&body)
1622 .with_context(|| "calling Anthropic API")?;
1623 let status = response.status();
1624 let response_body = response
1625 .body_mut()
1626 .read_to_string()
1627 .with_context(|| format!("reading Anthropic API response body (HTTP {})", status))?;
1628 let response_json: serde_json::Value = serde_json::from_str(&response_body)
1629 .with_context(|| format!("parsing Anthropic API response JSON (HTTP {})", status))?;
1630
1631 parse_anthropic_api_response(status.as_u16(), response_json)
1632}
1633
1634fn call_claude_cli(command: &Path, model: &str, prompt: &str) -> Result<(String, i64, i64)> {
1635 let mut child = Command::new(command)
1636 .arg("-p")
1637 .arg("--model")
1638 .arg(model)
1639 .arg("--safe-mode")
1640 .arg("--tools")
1641 .arg("")
1642 .arg("--no-session-persistence")
1643 .args(["--output-format", "json"])
1644 .stdin(Stdio::piped())
1645 .stdout(Stdio::piped())
1646 .stderr(Stdio::piped())
1647 .spawn()
1648 .with_context(|| format!("starting Claude Code CLI at {}", command.display()))?;
1649
1650 child
1651 .stdin
1652 .take()
1653 .context("opening Claude Code CLI stdin")?
1654 .write_all(prompt.as_bytes())
1655 .context("writing extraction prompt to Claude Code CLI")?;
1656 let output = child
1657 .wait_with_output()
1658 .context("waiting for Claude Code CLI extraction")?;
1659 if !output.status.success() {
1660 let stderr = String::from_utf8_lossy(&output.stderr);
1661 bail!(
1662 "Claude Code CLI extraction failed with {}: {}",
1663 output.status,
1664 stderr.trim()
1665 );
1666 }
1667
1668 let response = String::from_utf8(output.stdout)
1669 .context("Claude Code CLI extraction returned non-UTF-8 output")?;
1670 parse_claude_cli_response(&response)
1671}
1672
1673fn parse_claude_cli_response(response: &str) -> Result<(String, i64, i64)> {
1674 let response: ClaudeCliResponse = serde_json::from_str(response.trim())
1675 .context("parsing Claude Code CLI JSON response and token usage")?;
1676 let content = strip_markdown_fences(response.result.trim());
1677 if content.is_empty() {
1678 bail!("Claude Code CLI extraction returned an empty response");
1679 }
1680 let tokens_input = response
1681 .usage
1682 .input_tokens
1683 .saturating_add(response.usage.cache_creation_input_tokens)
1684 .saturating_add(response.usage.cache_read_input_tokens);
1685 Ok((
1686 content.to_string(),
1687 tokens_input,
1688 response.usage.output_tokens,
1689 ))
1690}
1691
1692fn maybe_mock_anthropic_api(prompt: &str) -> Result<Option<(String, i64, i64)>> {
1693 if let Ok(capture_path) = std::env::var("TSIFT_TEST_ANTHROPIC_CAPTURE_PROMPT") {
1694 std::fs::write(&capture_path, prompt)
1695 .with_context(|| format!("writing prompt capture: {capture_path}"))?;
1696 }
1697
1698 let Ok(response) = std::env::var("TSIFT_TEST_ANTHROPIC_RESPONSE_JSON") else {
1699 return Ok(None);
1700 };
1701 Ok(Some((response, 0, 0)))
1702}
1703
1704pub fn git_changed_files(root: &Path) -> Result<GitChangedFiles> {
1705 let (tracked, deleted) = if git_has_head_commit(root)? {
1706 git_diff_changed_files(root)?
1707 } else {
1708 (Vec::new(), Vec::new())
1709 };
1710 let untracked = git_list_paths(
1711 root,
1712 &["ls-files", "--others", "--exclude-standard"],
1713 "git ls-files",
1714 )?;
1715 let existing = tracked
1716 .into_iter()
1717 .chain(untracked)
1718 .filter(|path| path.is_file())
1719 .collect::<BTreeSet<_>>()
1720 .into_iter()
1721 .collect();
1722 let deleted = deleted
1723 .into_iter()
1724 .collect::<BTreeSet<_>>()
1725 .into_iter()
1726 .collect();
1727 Ok(GitChangedFiles { existing, deleted })
1728}
1729
1730fn git_diff_changed_files(root: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
1731 let output = std::process::Command::new("git")
1732 .args(["diff", "--name-status", "--find-renames", "HEAD"])
1733 .current_dir(root)
1734 .output()
1735 .with_context(|| "running git diff --name-status")?;
1736
1737 if !output.status.success() {
1738 let stderr = String::from_utf8_lossy(&output.stderr);
1739 bail!("git diff --name-status failed: {}", stderr.trim());
1740 }
1741
1742 let mut tracked = Vec::new();
1743 let mut deleted = Vec::new();
1744 for line in String::from_utf8_lossy(&output.stdout).lines() {
1745 if line.is_empty() {
1746 continue;
1747 }
1748 let mut fields = line.split('\t');
1749 let status = fields.next().unwrap_or_default();
1750 match status.chars().next() {
1751 Some('D') => {
1752 let path = fields
1753 .next()
1754 .with_context(|| format!("parsing deleted git diff path: {line}"))?;
1755 deleted.push(root.join(path));
1756 }
1757 Some('R') => {
1758 let old_path = fields
1759 .next()
1760 .with_context(|| format!("parsing renamed git diff old path: {line}"))?;
1761 let new_path = fields
1762 .next()
1763 .with_context(|| format!("parsing renamed git diff new path: {line}"))?;
1764 deleted.push(root.join(old_path));
1765 tracked.push(root.join(new_path));
1766 }
1767 Some(_) => {
1768 let path = fields
1769 .next_back()
1770 .or_else(|| fields.next())
1771 .with_context(|| format!("parsing changed git diff path: {line}"))?;
1772 tracked.push(root.join(path));
1773 }
1774 None => {}
1775 }
1776 }
1777
1778 Ok((tracked, deleted))
1779}
1780
1781fn git_has_head_commit(root: &Path) -> Result<bool> {
1782 let inside_work_tree = std::process::Command::new("git")
1783 .args(["rev-parse", "--is-inside-work-tree"])
1784 .current_dir(root)
1785 .output()
1786 .with_context(|| "running git rev-parse --is-inside-work-tree")?;
1787
1788 if !inside_work_tree.status.success() {
1789 let stderr = String::from_utf8_lossy(&inside_work_tree.stderr);
1790 bail!(
1791 "git rev-parse --is-inside-work-tree failed in {}: {}",
1792 root.display(),
1793 stderr.trim()
1794 );
1795 }
1796
1797 let verify_head = std::process::Command::new("git")
1798 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
1799 .current_dir(root)
1800 .output()
1801 .with_context(|| "running git rev-parse --verify HEAD")?;
1802
1803 Ok(verify_head.status.success())
1804}
1805
1806fn git_list_paths(root: &Path, args: &[&str], label: &str) -> Result<Vec<PathBuf>> {
1807 let output = std::process::Command::new("git")
1808 .args(args)
1809 .current_dir(root)
1810 .output()
1811 .with_context(|| format!("running {label}"))?;
1812
1813 if !output.status.success() {
1814 let stderr = String::from_utf8_lossy(&output.stderr);
1815 bail!("{label} failed: {}", stderr.trim());
1816 }
1817
1818 Ok(String::from_utf8_lossy(&output.stdout)
1819 .lines()
1820 .filter(|line| !line.is_empty())
1821 .map(|line| root.join(line))
1822 .collect())
1823}
1824
1825fn chrono_now() -> String {
1826 let now = std::time::SystemTime::now()
1827 .duration_since(std::time::UNIX_EPOCH)
1828 .unwrap_or_default()
1829 .as_secs();
1830 format!("{}", now)
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::*;
1837 use rusqlite::Connection;
1838 use serde_json::json;
1839 use tempfile::NamedTempFile;
1840 use tsift_sqlite::{rollback_journal_path, wal_sidecar_path};
1841
1842 fn test_db() -> (NamedTempFile, SummaryDb) {
1843 let tmp = NamedTempFile::new().unwrap();
1844 let db = SummaryDb::open(tmp.path()).unwrap();
1845 (tmp, db)
1846 }
1847
1848 fn make_summary(symbol: &str, file: &str, hash: &str) -> Summary {
1849 Summary {
1850 id: 0,
1851 symbol_name: symbol.to_string(),
1852 file_path: file.to_string(),
1853 content_hash: hash.to_string(),
1854 summary: format!("Summary for {}", symbol),
1855 entities: Some(vec![Entity {
1856 name: "helper".to_string(),
1857 kind: "function".to_string(),
1858 description: "A helper function".to_string(),
1859 }]),
1860 relationships: Some(vec![Relationship {
1861 from: "main".to_string(),
1862 to: "helper".to_string(),
1863 kind: "calls".to_string(),
1864 }]),
1865 concept_labels: Some(vec!["cli".to_string(), "parsing".to_string()]),
1866 extracted_at: "1700000000".to_string(),
1867 model: "claude-haiku-4-5-20251001".to_string(),
1868 tokens_input: Some(500),
1869 tokens_output: Some(200),
1870 }
1871 }
1872
1873 fn hold_wal_lock(db_path: &Path) -> Connection {
1874 let conn = Connection::open(db_path).unwrap();
1875 conn.execute_batch(
1876 "PRAGMA journal_mode=WAL;
1877 PRAGMA wal_autocheckpoint=0;
1878 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
1879 INSERT INTO wal_lock_probe DEFAULT VALUES;
1880 PRAGMA locking_mode=EXCLUSIVE;
1881 BEGIN EXCLUSIVE;",
1882 )
1883 .unwrap();
1884 assert!(wal_sidecar_path(db_path).exists());
1885 conn
1886 }
1887
1888 #[test]
1889 fn db_create_and_insert() {
1890 let (_tmp, db) = test_db();
1891 let s = make_summary("main", "src/main.rs", "abc123");
1892 db.insert(&s).unwrap();
1893 let results = db.get_by_symbol("main").unwrap();
1894 assert_eq!(results.len(), 1);
1895 assert_eq!(results[0].symbol_name, "main");
1896 assert_eq!(results[0].summary, "Summary for main");
1897 }
1898
1899 #[test]
1900 fn db_get_by_file() {
1901 let (_tmp, db) = test_db();
1902 db.insert(&make_summary("fn_a", "src/lib.rs", "hash1"))
1903 .unwrap();
1904 db.insert(&make_summary("fn_b", "src/lib.rs", "hash1"))
1905 .unwrap();
1906 db.insert(&make_summary("fn_c", "src/other.rs", "hash2"))
1907 .unwrap();
1908 let results = db.get_by_file("src/lib.rs").unwrap();
1909 assert_eq!(results.len(), 2);
1910 }
1911
1912 #[test]
1913 fn db_get_by_file_normalizes_legacy_windows_separator_rows() {
1914 let (_tmp, db) = test_db();
1915 db.insert(&make_summary("fn_a", r"src\lib.rs", "hash1"))
1916 .unwrap();
1917
1918 let results = db.get_by_file("src/lib.rs").unwrap();
1919
1920 assert_eq!(results.len(), 1);
1921 assert_eq!(results[0].file_path, "src/lib.rs");
1922 }
1923
1924 #[test]
1925 fn replace_file_reaps_legacy_windows_separator_rows() {
1926 let (_tmp, db) = test_db();
1927 db.insert(&make_summary("stale", r"src\lib.rs", "hash1"))
1928 .unwrap();
1929
1930 db.replace_file(
1931 "src/lib.rs",
1932 &[make_summary("fresh", "src/lib.rs", "hash2")],
1933 )
1934 .unwrap();
1935
1936 let results = db.get_by_file("src/lib.rs").unwrap();
1937 assert_eq!(results.len(), 1);
1938 assert_eq!(results[0].symbol_name, "fresh");
1939 assert_eq!(results[0].file_path, "src/lib.rs");
1940 }
1941
1942 #[test]
1943 fn file_lookup_candidates_normalize_dot_prefixed_root_relative_query() {
1944 let candidates = file_lookup_candidates(
1945 Path::new("./src/lib.rs"),
1946 Path::new("/repo"),
1947 Path::new("/repo"),
1948 );
1949
1950 assert_eq!(candidates, vec!["src/lib.rs".to_string()]);
1951 }
1952
1953 #[test]
1954 fn file_lookup_candidates_include_anchor_relative_project_key() {
1955 let candidates = file_lookup_candidates(
1956 Path::new("../lib.rs"),
1957 Path::new("/repo/src/nested"),
1958 Path::new("/repo"),
1959 );
1960
1961 assert_eq!(
1962 candidates,
1963 vec!["../lib.rs".to_string(), "src/lib.rs".to_string()]
1964 );
1965 }
1966
1967 #[cfg(unix)]
1968 #[test]
1969 fn file_lookup_candidates_canonicalize_absolute_symlink_queries() {
1970 use std::os::unix::fs::symlink;
1971
1972 let dir = tempfile::tempdir().unwrap();
1973 let real_root = dir.path().join("real");
1974 std::fs::create_dir_all(real_root.join("src")).unwrap();
1975 std::fs::write(real_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
1976 let link_root = dir.path().join("link");
1977 symlink(&real_root, &link_root).unwrap();
1978
1979 let candidates =
1980 file_lookup_candidates(&link_root.join("src/lib.rs"), &real_root, &real_root);
1981
1982 assert_eq!(
1983 candidates,
1984 vec![
1985 link_root
1986 .join("src/lib.rs")
1987 .to_string_lossy()
1988 .replace('\\', "/"),
1989 "src/lib.rs".to_string()
1990 ]
1991 );
1992 }
1993
1994 #[test]
1995 fn db_is_current() {
1996 let (_tmp, db) = test_db();
1997 db.insert(&make_summary("main", "src/main.rs", "hash_v1"))
1998 .unwrap();
1999 assert!(db.is_current("src/main.rs", "hash_v1").unwrap());
2000 assert!(!db.is_current("src/main.rs", "hash_v2").unwrap());
2001 }
2002
2003 #[test]
2004 fn summary_cache_reuses_file_snapshot_until_content_hash_changes() {
2005 let (_tmp, db) = test_db();
2006 db.insert(&make_summary("stale", "src/lib.rs", "hash_v1"))
2007 .unwrap();
2008 let cache = SummaryCache::new(db);
2009
2010 let first = cache
2011 .current_by_file("src/lib.rs", "hash_v1")
2012 .unwrap()
2013 .unwrap();
2014 assert_eq!(first[0].symbol_name, "stale");
2015 assert_eq!(cache.stats(), (0, 1));
2016
2017 cache
2018 .db()
2019 .replace_file(
2020 "src/lib.rs",
2021 &[make_summary("fresh", "src/lib.rs", "hash_v2")],
2022 )
2023 .unwrap();
2024 let second = cache
2025 .current_by_file("src/lib.rs", "hash_v1")
2026 .unwrap()
2027 .unwrap();
2028 assert_eq!(
2029 second[0].symbol_name, "stale",
2030 "same content hash should reuse the cached Slot"
2031 );
2032 assert_eq!(cache.stats(), (1, 1));
2033
2034 let third = cache
2035 .current_by_file("src/lib.rs", "hash_v2")
2036 .unwrap()
2037 .unwrap();
2038 assert_eq!(third[0].symbol_name, "fresh");
2039 assert_eq!(cache.stats(), (1, 2));
2040 }
2041
2042 #[test]
2043 fn summary_cache_get_or_extract_file_computes_once_until_hash_changes() {
2044 let (_tmp, db) = test_db();
2045 let cache = SummaryCache::new(db);
2046 let extractions = Cell::new(0usize);
2047
2048 let first = cache
2049 .get_or_extract_file("src/lib.rs", "hash_v1", || {
2050 extractions.set(extractions.get() + 1);
2051 Ok(vec![make_summary("first", "src/lib.rs", "hash_v1")])
2052 })
2053 .unwrap();
2054 assert_eq!(first.source, SummaryCacheSource::Extracted);
2055 assert_eq!(first.summaries[0].symbol_name, "first");
2056 assert_eq!(extractions.get(), 1);
2057
2058 let second = cache
2059 .get_or_extract_file("src/lib.rs", "hash_v1", || {
2060 bail!("same hash should reuse cached summaries")
2061 })
2062 .unwrap();
2063 assert_eq!(second.source, SummaryCacheSource::Cached);
2064 assert_eq!(second.summaries[0].symbol_name, "first");
2065 assert_eq!(extractions.get(), 1);
2066
2067 let third = cache
2068 .get_or_extract_file("src/lib.rs", "hash_v2", || {
2069 extractions.set(extractions.get() + 1);
2070 Ok(vec![make_summary("second", "src/lib.rs", "hash_v2")])
2071 })
2072 .unwrap();
2073 assert_eq!(third.source, SummaryCacheSource::Extracted);
2074 assert_eq!(third.summaries[0].symbol_name, "second");
2075 assert_eq!(extractions.get(), 2);
2076 }
2077
2078 #[test]
2079 fn db_stats() {
2080 let root = tempfile::tempdir().unwrap();
2081 let f1 = b"fn a() {}\n";
2082 let f2 = b"fn c() {}\n";
2083 std::fs::write(root.path().join("f1.rs"), f1).unwrap();
2084 std::fs::write(root.path().join("f2.rs"), f2).unwrap();
2085 let (_tmp, db) = test_db();
2086 let f1_hash = content_hash(f1);
2087 let f2_hash = content_hash(f2);
2088 db.insert(&make_summary("a", "f1.rs", &f1_hash)).unwrap();
2089 db.insert(&make_summary("b", "f1.rs", &f1_hash)).unwrap();
2090 db.insert(&make_summary("c", "f2.rs", &f2_hash)).unwrap();
2091 let stats = db.stats(root.path()).unwrap();
2092 assert_eq!(stats.total_summaries, 3);
2093 assert_eq!(stats.total_files, 2);
2094 assert_eq!(stats.stale_count, 0);
2095 assert_eq!(stats.total_tokens_input, 1500); assert_eq!(stats.total_tokens_output, 600); }
2098
2099 #[test]
2100 fn db_stats_counts_missing_and_hash_mismatched_files_as_stale() {
2101 let root = tempfile::tempdir().unwrap();
2102 let fresh = b"fn fresh() {}\n";
2103 let changed_current = b"fn changed() { new_impl(); }\n";
2104 let changed_old = b"fn changed() { old_impl(); }\n";
2105 std::fs::write(root.path().join("fresh.rs"), fresh).unwrap();
2106 std::fs::write(root.path().join("changed.rs"), changed_current).unwrap();
2107
2108 let (_tmp, db) = test_db();
2109 db.insert(&make_summary("fresh", "fresh.rs", &content_hash(fresh)))
2110 .unwrap();
2111 db.insert(&make_summary(
2112 "changed",
2113 "changed.rs",
2114 &content_hash(changed_old),
2115 ))
2116 .unwrap();
2117 db.insert(&make_summary("missing", "missing.rs", "stale-hash"))
2118 .unwrap();
2119
2120 let stats = db.stats(root.path()).unwrap();
2121
2122 assert_eq!(stats.total_files, 3);
2123 assert_eq!(stats.stale_count, 2);
2124 }
2125
2126 #[test]
2127 fn db_cached_file_paths() {
2128 let (_tmp, db) = test_db();
2129 db.insert(&make_summary("a", "f1.rs", "h1")).unwrap();
2130 db.insert(&make_summary("b", "f1.rs", "h1")).unwrap();
2131 db.insert(&make_summary("c", "f2.rs", "h2")).unwrap();
2132
2133 let paths = db.cached_file_paths().unwrap();
2134
2135 assert_eq!(
2136 paths.into_iter().collect::<Vec<_>>(),
2137 vec!["f1.rs".to_string(), "f2.rs".to_string()]
2138 );
2139 }
2140
2141 #[test]
2142 fn stats_live_path_rejects_paths_outside_root() {
2143 let root = Path::new("/tmp/project");
2144
2145 assert_eq!(
2146 SummaryDb::stats_live_path(root, "src/lib.rs").unwrap(),
2147 PathBuf::from("/tmp/project/src/lib.rs")
2148 );
2149 assert_eq!(
2150 SummaryDb::stats_live_path(root, "src/../src/lib.rs").unwrap(),
2151 PathBuf::from("/tmp/project/src/lib.rs")
2152 );
2153 assert!(SummaryDb::stats_live_path(root, "../secret.rs").is_none());
2154 assert!(SummaryDb::stats_live_path(root, "/etc/passwd").is_none());
2155 }
2156
2157 #[cfg(unix)]
2158 #[test]
2159 fn stats_marks_unreadable_files_stale_with_warning() {
2160 use std::os::unix::fs::PermissionsExt;
2161
2162 let root = tempfile::tempdir().unwrap();
2163 let file_path = root.path().join("src/lib.rs");
2164 std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
2165 let source = b"fn alpha_helper() {}\n";
2166 std::fs::write(&file_path, source).unwrap();
2167
2168 let (_tmp, db) = test_db();
2169 db.insert(&make_summary(
2170 "alpha_helper",
2171 "src/lib.rs",
2172 &content_hash(source),
2173 ))
2174 .unwrap();
2175
2176 let metadata = std::fs::metadata(&file_path).unwrap();
2177 let original_mode = metadata.permissions().mode();
2178 let mut unreadable = metadata.permissions();
2179 unreadable.set_mode(0o000);
2180 std::fs::set_permissions(&file_path, unreadable).unwrap();
2181
2182 let stats = db.stats(root.path()).unwrap();
2183
2184 let mut restored = std::fs::metadata(&file_path).unwrap().permissions();
2185 restored.set_mode(original_mode);
2186 std::fs::set_permissions(&file_path, restored).unwrap();
2187
2188 assert_eq!(stats.stale_count, 1);
2189 assert_eq!(stats.warnings.len(), 1);
2190 assert_eq!(stats.warnings[0].path, PathBuf::from("src/lib.rs"));
2191 assert!(
2192 stats.warnings[0]
2193 .message
2194 .contains("counting cached summary as stale"),
2195 "warning was: {}",
2196 stats.warnings[0].message
2197 );
2198 }
2199
2200 #[test]
2201 fn db_delete_by_file() {
2202 let (_tmp, db) = test_db();
2203 db.insert(&make_summary("a", "f1.rs", "h1")).unwrap();
2204 db.insert(&make_summary("b", "f1.rs", "h1")).unwrap();
2205 db.insert(&make_summary("c", "f2.rs", "h2")).unwrap();
2206 let deleted = db.delete_by_file("f1.rs").unwrap();
2207 assert_eq!(deleted, 2);
2208 assert!(db.get_by_file("f1.rs").unwrap().is_empty());
2209 assert_eq!(db.get_by_file("f2.rs").unwrap().len(), 1);
2210 }
2211
2212 #[test]
2213 fn db_replace_file_rolls_back_on_failure() {
2214 let (_tmp, db) = test_db();
2215 db.insert(&make_summary("alpha", "f1.rs", "old_hash"))
2216 .unwrap();
2217 db.insert(&make_summary("beta", "f1.rs", "old_hash"))
2218 .unwrap();
2219
2220 let replacements = vec![
2221 make_summary("gamma", "f1.rs", "new_hash"),
2222 make_summary("delta", "f1.rs", "new_hash"),
2223 ];
2224
2225 let err = db
2226 .replace_file_with_hook("f1.rs", &replacements, |idx| {
2227 if idx == 0 {
2228 bail!("injected summary replace failure");
2229 }
2230 Ok(())
2231 })
2232 .unwrap_err();
2233 assert!(err.to_string().contains("injected summary replace failure"));
2234
2235 let remaining = db.get_by_file("f1.rs").unwrap();
2236 let remaining_symbols = remaining
2237 .iter()
2238 .map(|summary| summary.symbol_name.as_str())
2239 .collect::<Vec<_>>();
2240 assert_eq!(remaining_symbols, vec!["alpha", "beta"]);
2241 assert!(
2242 remaining
2243 .iter()
2244 .all(|summary| summary.content_hash == "old_hash")
2245 );
2246 }
2247
2248 #[test]
2249 fn terminal_extraction_failures_are_keyed_by_content_and_cleared_on_success() {
2250 let root = tempfile::tempdir().unwrap();
2251 std::fs::create_dir_all(root.path().join("src")).unwrap();
2252 let source = b"fn main() {}\n";
2253 std::fs::write(root.path().join("src/main.rs"), source).unwrap();
2254 let hash = content_hash(source);
2255 let (_tmp, db) = test_db();
2256
2257 db.record_terminal_failure(
2258 "src/main.rs",
2259 &hash,
2260 ExtractionFailureKind::TooLarge,
2261 "raise --max-file-tokens",
2262 )
2263 .unwrap();
2264 let cached = db
2265 .terminal_failure("src/main.rs", &hash)
2266 .unwrap()
2267 .expect("same content should retain its terminal failure");
2268 assert_eq!(cached.kind, ExtractionFailureKind::TooLarge);
2269 assert_eq!(cached.max_file_tokens, None);
2270 assert_eq!(cached.message, "raise --max-file-tokens");
2271 assert!(
2272 db.terminal_failure("src/main.rs", "different-hash")
2273 .unwrap()
2274 .is_none()
2275 );
2276 assert_eq!(
2277 db.current_terminal_failure_paths(root.path()).unwrap(),
2278 BTreeSet::from(["src/main.rs".to_string()])
2279 );
2280
2281 db.replace_file("src/main.rs", &[make_summary("main", "src/main.rs", &hash)])
2282 .unwrap();
2283 assert!(db.terminal_failure("src/main.rs", &hash).unwrap().is_none());
2284 }
2285
2286 #[test]
2287 fn too_large_failures_stop_applying_when_the_effective_limit_is_raised() {
2288 let (_tmp, db) = test_db();
2289 db.record_terminal_failure_with_limit(
2290 "src/large.rs",
2291 "same-content",
2292 ExtractionFailureKind::TooLarge,
2293 Some(8_000),
2294 "file src/large.rs exceeds max_file_tokens (9291 > 8000)",
2295 )
2296 .unwrap();
2297 let cached = db
2298 .terminal_failure("src/large.rs", "same-content")
2299 .unwrap()
2300 .unwrap();
2301 assert!(cached.applies_at_max_file_tokens(8_000));
2302 assert!(!cached.applies_at_max_file_tokens(12_000));
2303 assert_eq!(cached.required_max_file_tokens(), Some(9_291));
2304
2305 db.record_terminal_failure(
2306 "src/legacy.rs",
2307 "legacy-content",
2308 ExtractionFailureKind::TooLarge,
2309 "file src/legacy.rs exceeds max_file_tokens (9291 > 8000); raise it",
2310 )
2311 .unwrap();
2312 let legacy = db
2313 .terminal_failure("src/legacy.rs", "legacy-content")
2314 .unwrap()
2315 .unwrap();
2316 assert!(!legacy.applies_at_max_file_tokens(12_000));
2317 assert_eq!(legacy.required_max_file_tokens(), Some(9_291));
2318 }
2319
2320 #[test]
2321 fn terminal_failures_for_non_candidates_are_pruned() {
2322 let root = tempfile::tempdir().unwrap();
2323 std::fs::create_dir_all(root.path().join("src")).unwrap();
2324 std::fs::write(root.path().join("src/empty.rs"), "").unwrap();
2325 std::fs::write(root.path().join("src/whitespace.rs"), "\n\t \n").unwrap();
2326 std::fs::write(root.path().join("src/live.rs"), "fn live() {}\n").unwrap();
2327 let (_tmp, db) = test_db();
2328
2329 for path in ["src/empty.rs", "src/whitespace.rs", "src/live.rs"] {
2330 db.record_terminal_failure(
2331 path,
2332 "legacy-content",
2333 ExtractionFailureKind::UnparseableResponse,
2334 "legacy failure",
2335 )
2336 .unwrap();
2337 }
2338
2339 assert_eq!(
2340 db.prune_terminal_failures_for_non_candidates(root.path())
2341 .unwrap(),
2342 2
2343 );
2344 assert!(
2345 db.terminal_failure("src/empty.rs", "legacy-content")
2346 .unwrap()
2347 .is_none()
2348 );
2349 assert!(
2350 db.terminal_failure("src/whitespace.rs", "legacy-content")
2351 .unwrap()
2352 .is_none()
2353 );
2354 assert!(
2355 db.terminal_failure("src/live.rs", "legacy-content")
2356 .unwrap()
2357 .is_some()
2358 );
2359 }
2360
2361 #[test]
2362 fn db_open_configures_sqlite_for_concurrent_access() {
2363 let (_tmp, db) = test_db();
2364
2365 let mode: String = db
2366 .conn
2367 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
2368 .unwrap();
2369 let timeout_ms: i64 = db
2370 .conn
2371 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
2372 .unwrap();
2373
2374 assert_eq!(mode.to_lowercase(), "wal");
2375 assert_eq!(timeout_ms, 5000);
2376 }
2377
2378 #[test]
2379 fn db_open_read_only_uses_busy_timeout() {
2380 let (tmp, _db) = test_db();
2381 let db = SummaryDb::open_read_only(tmp.path()).unwrap();
2382 let timeout_ms: i64 = db
2383 .conn
2384 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
2385 .unwrap();
2386
2387 assert_eq!(timeout_ms, 5000);
2388 }
2389
2390 #[test]
2391 fn summary_write_lock_records_pid_and_clears_on_drop() {
2392 let dir = tempfile::tempdir().unwrap();
2393 let db_path = dir.path().join(".tsift/summaries.db");
2394 let lock_path = writer_lock_path(&db_path);
2395
2396 {
2397 let _lock = acquire_write_lock(&db_path).unwrap();
2398 let marker = std::fs::read_to_string(&lock_path).unwrap();
2399 assert_eq!(marker.trim(), std::process::id().to_string());
2400 }
2401
2402 let marker = std::fs::read_to_string(&lock_path).unwrap();
2403 assert!(marker.trim().is_empty());
2404 acquire_write_lock(&db_path).unwrap();
2405 }
2406
2407 #[test]
2408 fn summary_write_lock_fails_fast_when_live() {
2409 let dir = tempfile::tempdir().unwrap();
2410 let db_path = dir.path().join(".tsift/summaries.db");
2411 let _lock = acquire_write_lock(&db_path).unwrap();
2412
2413 let err = acquire_write_lock(&db_path).unwrap_err();
2414 let message = err.to_string();
2415
2416 assert!(message.contains("another tsift summarize extractor is already active"));
2417 assert!(message.contains("tsift summarize --extract"));
2418 assert!(message.contains(&writer_lock_path(&db_path).display().to_string()));
2419 }
2420
2421 #[test]
2422 fn db_entities_roundtrip() {
2423 let (_tmp, db) = test_db();
2424 let s = make_summary("main", "src/main.rs", "abc");
2425 db.insert(&s).unwrap();
2426 let results = db.get_by_symbol("main").unwrap();
2427 let entities = results[0].entities.as_ref().unwrap();
2428 assert_eq!(entities.len(), 1);
2429 assert_eq!(entities[0].name, "helper");
2430 let rels = results[0].relationships.as_ref().unwrap();
2431 assert_eq!(rels.len(), 1);
2432 assert_eq!(rels[0].from, "main");
2433 assert_eq!(rels[0].to, "helper");
2434 let labels = results[0].concept_labels.as_ref().unwrap();
2435 assert_eq!(labels, &["cli", "parsing"]);
2436 }
2437
2438 #[test]
2439 fn db_no_results_returns_empty() {
2440 let (_tmp, db) = test_db();
2441 assert!(db.get_by_symbol("nonexistent").unwrap().is_empty());
2442 assert!(db.get_by_file("no/such/file.rs").unwrap().is_empty());
2443 }
2444
2445 #[test]
2446 fn content_hash_deterministic() {
2447 let h1 = content_hash(b"hello world");
2448 let h2 = content_hash(b"hello world");
2449 assert_eq!(h1, h2);
2450 let h3 = content_hash(b"hello world!");
2451 assert_ne!(h1, h3);
2452 }
2453
2454 #[test]
2455 fn content_hash_is_blake3() {
2456 let h = content_hash(b"test");
2457 assert_eq!(h.len(), 64); }
2459
2460 #[test]
2461 fn build_prompt_includes_file_and_source() {
2462 let prompt = build_extraction_prompt("src/lib.rs", "fn main() {}", &[]);
2463 assert!(prompt.contains("src/lib.rs"));
2464 assert!(prompt.contains("fn main() {}"));
2465 assert!(prompt.contains("JSON"));
2466 }
2467
2468 #[test]
2469 fn build_prompt_includes_symbols() {
2470 let symbols = vec![
2471 ("main".to_string(), "function".to_string()),
2472 ("Config".to_string(), "struct".to_string()),
2473 ];
2474 let prompt = build_extraction_prompt("src/lib.rs", "code", &symbols);
2475 assert!(prompt.contains("- main (function)"));
2476 assert!(prompt.contains("- Config (struct)"));
2477 }
2478
2479 #[test]
2480 fn claude_cli_response_extracts_content_and_measured_usage() {
2481 let response = json!({
2482 "result": "```json\n{\"summary\":\"ok\"}\n```",
2483 "usage": {
2484 "input_tokens": 12,
2485 "cache_creation_input_tokens": 3,
2486 "cache_read_input_tokens": 40,
2487 "output_tokens": 7
2488 }
2489 })
2490 .to_string();
2491
2492 let (content, tokens_in, tokens_out) = parse_claude_cli_response(&response).unwrap();
2493 assert_eq!(content, "{\"summary\":\"ok\"}");
2494 assert_eq!(tokens_in, 55);
2495 assert_eq!(tokens_out, 7);
2496 }
2497
2498 #[test]
2499 fn anthropic_api_response_rejects_http_errors() {
2500 let err = parse_anthropic_api_response(
2501 429,
2502 json!({
2503 "error": {
2504 "type": "rate_limit_error",
2505 "message": "too many requests"
2506 }
2507 }),
2508 )
2509 .unwrap_err();
2510 let message = err.to_string();
2511
2512 assert!(message.contains("HTTP 429"));
2513 assert!(message.contains("rate_limit_error"));
2514 assert!(message.contains("too many requests"));
2515 }
2516
2517 #[test]
2518 fn anthropic_api_response_reports_raw_body_when_error_message_missing() {
2519 let response = json!({"unexpected": "shape"});
2520 let err = parse_anthropic_api_response(502, response.clone()).unwrap_err();
2521 let message = err.to_string();
2522
2523 assert!(message.contains("HTTP 502"));
2524 assert!(message.contains(&response.to_string()));
2525 }
2526
2527 #[test]
2528 fn anthropic_api_response_extracts_content_and_usage() {
2529 let (content, tokens_in, tokens_out) = parse_anthropic_api_response(
2530 200,
2531 json!({
2532 "content": [
2533 {
2534 "text": "```json\n{\"summary\":\"ok\"}\n```"
2535 }
2536 ],
2537 "usage": {
2538 "input_tokens": 12,
2539 "output_tokens": 34
2540 }
2541 }),
2542 )
2543 .unwrap();
2544
2545 assert_eq!(content, "{\"summary\":\"ok\"}");
2546 assert_eq!(tokens_in, 12);
2547 assert_eq!(tokens_out, 34);
2548 }
2549
2550 #[test]
2551 fn extract_skips_large_files() {
2552 let dir = tempfile::tempdir().unwrap();
2553 let big_file = dir.path().join("big.rs");
2554 std::fs::write(&big_file, "x".repeat(100_000)).unwrap();
2555 let config = SummarizeConfig {
2556 max_file_tokens: 8000,
2557 api_key_env: "PATH".to_string(),
2558 ..Default::default()
2559 };
2560 let error = extract_for_file(&big_file, None, None, &config).unwrap_err();
2561 assert!(error.to_string().contains("exceeds max_file_tokens"));
2562 assert!(error.to_string().contains("--max-file-tokens"));
2563 assert_eq!(
2564 terminal_extraction_failure(&error).map(|(kind, _)| kind),
2565 Some(ExtractionFailureKind::TooLarge)
2566 );
2567 }
2568
2569 #[test]
2570 fn extraction_parse_errors_name_the_file_reason_and_response_preview() {
2571 let error =
2572 parse_extraction_response(Path::new("src/broken.rs"), "not-json output").unwrap_err();
2573 let message = error.to_string();
2574 assert!(message.contains("src/broken.rs"), "{message}");
2575 assert!(message.contains("expected ident"), "{message}");
2576 assert!(message.contains("not-json output"), "{message}");
2577 assert_eq!(
2578 terminal_extraction_failure(&error).map(|(kind, _)| kind),
2579 Some(ExtractionFailureKind::UnparseableResponse)
2580 );
2581 }
2582
2583 #[test]
2584 fn extraction_parser_accepts_json_fences_and_model_preamble() {
2585 let response = "The file is empty.\n```json\n{\"summary\":\"No declarations {yet}\"}\n```";
2586 let parsed = parse_extraction_response(Path::new("src/empty.rs"), response).unwrap();
2587 assert_eq!(parsed.summary, "No declarations {yet}");
2588 }
2589
2590 #[test]
2591 fn empty_and_whitespace_source_files_are_not_extraction_candidates() {
2592 let dir = tempfile::tempdir().unwrap();
2593 let empty = dir.path().join("empty.rs");
2594 let whitespace = dir.path().join("whitespace.rs");
2595 let nonempty = dir.path().join("nonempty.rs");
2596 std::fs::write(&empty, "").unwrap();
2597 std::fs::write(&whitespace, "\n\t \n").unwrap();
2598 std::fs::write(&nonempty, "fn main() {}\n").unwrap();
2599 assert!(!is_extraction_candidate_file(&empty));
2600 assert!(!is_extraction_candidate_file(&whitespace));
2601 assert!(is_extraction_candidate_file(&nonempty));
2602 }
2603
2604 #[test]
2605 fn extraction_backend_requires_an_api_key_or_claude_cli() {
2606 let result = select_extraction_backend(None, None, false);
2607 assert!(result.is_err());
2608 let error = match result {
2609 Err(error) => error,
2610 Ok(_) => panic!("missing credentials unexpectedly resolved a backend"),
2611 };
2612 assert!(
2613 error
2614 .to_string()
2615 .contains("no Anthropic API key or authenticated Claude Code CLI")
2616 );
2617 }
2618
2619 #[test]
2620 fn hosted_claude_provider_prefers_the_cli_over_a_direct_api_key() {
2621 let command = PathBuf::from("/mock/claude");
2622 let backend =
2623 select_extraction_backend(Some("direct-key".to_string()), Some(command.clone()), true)
2624 .unwrap();
2625 assert!(matches!(
2626 backend,
2627 ExtractionBackend::ClaudeCli { command: selected } if selected == command
2628 ));
2629 }
2630
2631 #[test]
2632 fn direct_api_key_stays_preferred_without_a_hosted_claude_provider() {
2633 let backend = select_extraction_backend(
2634 Some("direct-key".to_string()),
2635 Some(PathBuf::from("/mock/claude")),
2636 false,
2637 )
2638 .unwrap();
2639 assert!(matches!(backend, ExtractionBackend::AnthropicApi { .. }));
2640 }
2641
2642 #[test]
2643 fn load_symbols_for_file_uses_exact_relative_match() {
2644 let dir = tempfile::tempdir().unwrap();
2645 let db_path = dir.path().join("index.db");
2646 let conn = Connection::open(&db_path).unwrap();
2647 conn.execute_batch(
2648 "CREATE TABLE symbols (
2649 id INTEGER PRIMARY KEY,
2650 name TEXT NOT NULL,
2651 kind TEXT NOT NULL,
2652 language TEXT NOT NULL,
2653 signature TEXT,
2654 file TEXT NOT NULL,
2655 line INTEGER NOT NULL,
2656 end_line INTEGER,
2657 parent_module TEXT,
2658 visibility TEXT,
2659 tags TEXT
2660 );",
2661 )
2662 .unwrap();
2663 conn.execute(
2664 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
2665 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
2666 rusqlite::params!["target", "function", "rust", "src/lib.rs", 1_i64],
2667 )
2668 .unwrap();
2669 conn.execute(
2670 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
2671 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
2672 rusqlite::params!["wrong", "function", "rust", "nested/src/lib.rs", 1_i64],
2673 )
2674 .unwrap();
2675
2676 let file_path = Path::new("/workspace/src/lib.rs");
2677 let symbols =
2678 load_symbols_for_file(&db_path, file_path, Some(Path::new("/workspace"))).unwrap();
2679
2680 assert_eq!(
2681 symbols,
2682 vec![("target".to_string(), "function".to_string())]
2683 );
2684 }
2685
2686 #[test]
2687 fn load_symbols_for_file_uses_snapshot_fallback_when_rollback_journal_is_locked() {
2688 let dir = tempfile::tempdir().unwrap();
2689 let db_path = dir.path().join("index.db");
2690 let conn = Connection::open(&db_path).unwrap();
2691 conn.execute_batch(
2692 "PRAGMA journal_mode=DELETE;
2693 CREATE TABLE symbols (
2694 id INTEGER PRIMARY KEY,
2695 name TEXT NOT NULL,
2696 kind TEXT NOT NULL,
2697 language TEXT NOT NULL,
2698 signature TEXT,
2699 file TEXT NOT NULL,
2700 line INTEGER NOT NULL,
2701 end_line INTEGER,
2702 parent_module TEXT,
2703 visibility TEXT,
2704 tags TEXT
2705 );",
2706 )
2707 .unwrap();
2708 conn.execute(
2709 "INSERT INTO symbols (name, kind, language, signature, file, line, end_line, parent_module, visibility, tags)
2710 VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL, NULL, NULL, NULL)",
2711 rusqlite::params!["target", "function", "rust", "src/lib.rs", 1_i64],
2712 )
2713 .unwrap();
2714 conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
2715 std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
2716
2717 let file_path = Path::new("/workspace/src/lib.rs");
2718 let symbols =
2719 load_symbols_for_file(&db_path, file_path, Some(Path::new("/workspace"))).unwrap();
2720
2721 assert_eq!(
2722 symbols,
2723 vec![("target".to_string(), "function".to_string())]
2724 );
2725 }
2726
2727 #[test]
2728 fn summary_read_only_uses_snapshot_fallback_when_rollback_journal_is_locked() {
2729 let dir = tempfile::tempdir().unwrap();
2730 let db_path = dir.path().join("summaries.db");
2731 let conn = Connection::open(&db_path).unwrap();
2732 conn.execute_batch(
2733 "PRAGMA journal_mode=DELETE;
2734 CREATE TABLE summaries (
2735 id INTEGER PRIMARY KEY,
2736 symbol_name TEXT NOT NULL,
2737 file_path TEXT NOT NULL,
2738 content_hash TEXT NOT NULL,
2739 summary TEXT NOT NULL,
2740 entities TEXT,
2741 relationships TEXT,
2742 concept_labels TEXT,
2743 extracted_at TEXT NOT NULL,
2744 model TEXT NOT NULL,
2745 tokens_input INTEGER,
2746 tokens_output INTEGER
2747 );",
2748 )
2749 .unwrap();
2750 conn.execute(
2751 "INSERT INTO summaries
2752 (symbol_name, file_path, content_hash, summary, entities, relationships, concept_labels, extracted_at, model, tokens_input, tokens_output)
2753 VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, ?5, ?6, NULL, NULL)",
2754 rusqlite::params![
2755 "main",
2756 "src/main.rs",
2757 "hash1",
2758 "cached summary",
2759 "1700000000",
2760 "test-model",
2761 ],
2762 )
2763 .unwrap();
2764 conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
2765 std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
2766
2767 let opened = SummaryDb::open_read_only_with_recovery(&db_path).unwrap();
2768
2769 assert_eq!(
2770 opened.recovery,
2771 Some(tsift_sqlite::ReadOnlyRecovery::SnapshotFallback)
2772 );
2773 let results = opened.db.get_by_symbol("main").unwrap();
2774 assert_eq!(results.len(), 1);
2775 assert_eq!(results[0].summary, "cached summary");
2776 }
2777
2778 #[test]
2779 fn summary_read_only_reports_wal_snapshot_fallback_when_wal_db_is_locked() {
2780 let dir = tempfile::tempdir().unwrap();
2781 let db_path = dir.path().join("summaries.db");
2782 let db = SummaryDb::open(&db_path).unwrap();
2783 db.insert(&make_summary("main", "src/main.rs", "hash1"))
2784 .unwrap();
2785 drop(db);
2786
2787 let _lock = hold_wal_lock(&db_path);
2788
2789 let opened = SummaryDb::open_read_only_with_recovery(&db_path).unwrap();
2790 assert_eq!(
2791 opened.recovery,
2792 Some(tsift_sqlite::ReadOnlyRecovery::SnapshotFallbackWal)
2793 );
2794 let results = opened.db.get_by_symbol("main").unwrap();
2795 assert_eq!(results.len(), 1);
2796 }
2797
2798 #[test]
2799 fn db_insert_replaces_on_conflict() {
2800 let (_tmp, db) = test_db();
2801 let mut s = make_summary("main", "src/main.rs", "v1");
2802 s.summary = "version 1".to_string();
2803 db.insert(&s).unwrap();
2804
2805 let mut s2 = make_summary("main", "src/main.rs", "v2");
2806 s2.summary = "version 2".to_string();
2807 db.insert(&s2).unwrap();
2808
2809 let results = db.get_by_symbol("main").unwrap();
2810 assert_eq!(results.len(), 2);
2811 }
2812}