Skip to main content

tsift_summarize/
summarize.rs

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    /// The effective cap that produced a `too_large` failure. Older cache rows
176    /// may not have this value, so callers also understand the legacy message.
177    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
314/// Whether a live file can produce a useful extraction. Empty source files have
315/// no summary to ask a model for and must not consume a request.
316pub 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    // Read incrementally so status/candidate discovery does not allocate the
328    // whole file just to reject a whitespace-only source. If the read fails,
329    // retain it as a candidate so the extraction path reports the I/O error.
330    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        // Estimated tokens saved: each summary replaces ~2000 tokens of source reading
707        // with ~75 tokens of cached summary. Net savings per summary = ~1925 tokens.
708        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    /// Remove cached failures that can never be retried because their live
841    /// path is missing, unsupported, empty, or whitespace-only.
842    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    // File-level summary (symbol_name = filename)
1315    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    // Per-entity summaries
1335    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    // Simple ISO-ish timestamp without chrono dependency
1831    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); // 3 * 500
2096        assert_eq!(stats.total_tokens_output, 600); // 3 * 200
2097    }
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); // blake3 hex is 64 chars
2458    }
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}