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