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