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