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