Skip to main content

tsift_summarize/
summarize.rs

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