1use anyhow::Result;
2use lazily::{Computed, Context as LazyContext, Source};
3use serde::Serialize;
4use std::cell::RefCell;
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8use tsift_index::config;
9use tsift_index::index::{
10 IndexDb, ReadOnlyInspectResult, WriterLockProbe, probe_writer_lock, writer_lock_path,
11};
12use tsift_index::init::{self, InstructionStatus};
13use tsift_sqlite::{
14 ReadOnlyRecovery, rollback_journal_path, shared_memory_sidecar_path, wal_sidecar_path,
15};
16use tsift_summarize::summarize::SummaryDb;
17
18type CachedInspectResult = std::result::Result<ReadOnlyInspectResult, String>;
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21struct StatusInspectKey {
22 db_path: PathBuf,
23 root: PathBuf,
24 prune: bool,
25}
26
27pub struct StatusCheckCache {
28 ctx: LazyContext,
29 epoch: Source<u64>,
30 inspect_slots: RefCell<HashMap<StatusInspectKey, Computed<CachedInspectResult>>>,
31}
32
33impl Default for StatusCheckCache {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl StatusCheckCache {
40 pub fn new() -> Self {
41 let ctx = LazyContext::new();
42 let epoch = ctx.source(0u64);
43 Self {
44 ctx,
45 epoch,
46 inspect_slots: RefCell::new(HashMap::new()),
47 }
48 }
49
50 pub fn invalidate_all(&self) {
51 let epoch = self.ctx.get(&self.epoch);
52 self.ctx.set(&self.epoch, epoch.wrapping_add(1));
53 }
54
55 fn inspect_read_only(
56 &self,
57 db_path: &Path,
58 root: &Path,
59 prune: bool,
60 ) -> Result<ReadOnlyInspectResult> {
61 let key = StatusInspectKey {
62 db_path: db_path.to_path_buf(),
63 root: root.to_path_buf(),
64 prune,
65 };
66 let slot = {
67 let mut slots = self.inspect_slots.borrow_mut();
68 if let Some(slot) = slots.get(&key) {
69 *slot
70 } else {
71 let slot_key = key.clone();
72 let epoch = self.epoch;
73 let slot = self.ctx.slot(move |ctx| {
74 let _epoch = ctx.get(&epoch);
75 IndexDb::inspect_read_only(&slot_key.db_path, &slot_key.root, slot_key.prune)
76 .map_err(|err| format!("{err:#}"))
77 });
78 slots.insert(key, slot);
79 slot
80 }
81 };
82 self.ctx
83 .get(&slot)
84 .map_err(|message| anyhow::anyhow!("{message}"))
85 }
86}
87
88#[derive(Debug, Serialize)]
89pub struct StatusReport {
90 pub index: IndexStatus,
91 pub summaries: SummaryStatus,
92 pub instructions: InstructionStatus,
93 pub recommendations: Recommendations,
94 #[serde(skip_serializing_if = "Vec::is_empty", default)]
95 pub reminders: Vec<String>,
96}
97
98#[derive(Debug, Serialize)]
99#[serde(tag = "state")]
100pub enum IndexStatus {
101 #[serde(rename = "fresh")]
102 Fresh {
103 total_files: usize,
104 stale_files: usize,
105 last_indexed_secs_ago: u64,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 recovery: Option<ReadOnlyRecovery>,
108 #[serde(skip_serializing_if = "Vec::is_empty", default)]
109 workspace_scopes: Vec<WorkspaceScopeStatus>,
110 #[serde(skip_serializing_if = "Vec::is_empty", default)]
111 missing_scopes: Vec<MissingWorkspaceScopeStatus>,
112 },
113 #[serde(rename = "stale")]
114 Stale {
115 total_files: usize,
116 stale_files: usize,
117 last_indexed_secs_ago: u64,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 recovery: Option<ReadOnlyRecovery>,
120 #[serde(skip_serializing_if = "Vec::is_empty", default)]
121 workspace_scopes: Vec<WorkspaceScopeStatus>,
122 #[serde(skip_serializing_if = "Vec::is_empty", default)]
123 missing_scopes: Vec<MissingWorkspaceScopeStatus>,
124 },
125 #[serde(rename = "missing")]
126 Missing {
127 #[serde(skip_serializing_if = "Vec::is_empty", default)]
128 missing_scopes: Vec<MissingWorkspaceScopeStatus>,
129 },
130}
131
132#[derive(Debug, Serialize, PartialEq, Eq)]
133pub struct WorkspaceScopeStatus {
134 pub scope: String,
135 pub db_path: PathBuf,
136 pub total_files: usize,
137 pub stale_files: usize,
138 pub last_indexed_secs_ago: u64,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub recovery: Option<ReadOnlyRecovery>,
141}
142
143#[derive(Debug, Serialize, PartialEq, Eq)]
144pub struct MissingWorkspaceScopeStatus {
145 pub scope: String,
146 pub db_path: PathBuf,
147}
148
149#[derive(Debug, Serialize)]
150#[serde(tag = "state")]
151pub enum SummaryStatus {
152 #[serde(rename = "available")]
153 Available {
154 cached_files: usize,
155 total_indexed_files: usize,
156 coverage_pct: u8,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 recovery: Option<ReadOnlyRecovery>,
159 },
160 #[serde(rename = "none")]
161 None {
162 #[serde(skip_serializing_if = "Option::is_none")]
163 recovery: Option<ReadOnlyRecovery>,
164 },
165 #[serde(rename = "unavailable")]
166 Unavailable,
167}
168
169#[derive(Debug, Serialize)]
170pub struct Recommendations {
171 #[serde(rename = "use")]
172 pub use_commands: Vec<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub run: Option<String>,
175}
176
177#[derive(Debug, Serialize, PartialEq, Eq)]
178pub struct LockReport {
179 pub label: String,
180 pub source_root: PathBuf,
181 pub db_path: PathBuf,
182 pub writer_lock: WriterLockStatus,
183 pub rollback_journal: SidecarStatus,
184 pub wal_sidecar: SidecarStatus,
185 pub shared_memory_sidecar: SidecarStatus,
186 pub reindex_command: String,
187 pub recommended_action: String,
188}
189
190#[derive(Debug, Serialize, PartialEq, Eq)]
191#[serde(tag = "state", rename_all = "snake_case")]
192pub enum WriterLockStatus {
193 Absent { path: PathBuf },
194 Live { path: PathBuf, pid: Option<u32> },
195 Stale { path: PathBuf, pid: Option<u32> },
196 Unknown { path: PathBuf },
197}
198
199#[derive(Debug, Serialize, PartialEq, Eq)]
200pub struct SidecarStatus {
201 pub path: PathBuf,
202 pub present: bool,
203}
204
205pub fn check_status(root: &Path) -> Result<StatusReport> {
206 let cache = StatusCheckCache::new();
207 check_status_with_cache(root, &cache)
208}
209
210pub fn check_status_with_cache(root: &Path, cache: &StatusCheckCache) -> Result<StatusReport> {
211 let workspace_scopes = config::Config::submodule_dirs(root)?;
212 let workspace = !workspace_scopes.is_empty();
213 let summaries_db_path = root.join(".tsift/summaries.db");
214
215 let index = check_index(root, cache)?;
216 let summaries = check_summaries(root, &summaries_db_path, &index, cache)?;
217 let summarize_extract = recommended_summarize_extract_path(root, &index, &workspace_scopes);
218 let instructions = init::check_instruction_version(root);
219 let kg_present = root.join(".tsift/graph.db").exists();
220 let recommendations = build_recommendations(
221 &index,
222 &summaries,
223 &instructions,
224 workspace,
225 &summarize_extract,
226 kg_present,
227 );
228 let reminders = build_reminders(&index, &summaries, &recommendations, &summarize_extract);
229
230 Ok(StatusReport {
231 index,
232 summaries,
233 instructions,
234 recommendations,
235 reminders,
236 })
237}
238
239fn check_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
240 if !config::Config::submodule_dirs(root)?.is_empty() {
241 return check_workspace_index(root, cache);
242 }
243
244 check_single_index(root, cache)
245}
246
247fn check_single_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
248 let db_path = root.join(".tsift/index.db");
249 if !db_path.exists() {
250 return check_workspace_index(root, cache);
251 }
252
253 let last_indexed_secs_ago = db_path
254 .metadata()
255 .and_then(|m| m.modified())
256 .ok()
257 .and_then(|t| SystemTime::now().duration_since(t).ok())
258 .map(|d| d.as_secs())
259 .unwrap_or(0);
260
261 let inspection = cache.inspect_read_only(&db_path, root, false)?;
262 let stale_files =
263 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
264
265 if stale_files > 0 {
266 Ok(IndexStatus::Stale {
267 total_files: inspection.total_files,
268 stale_files,
269 last_indexed_secs_ago,
270 recovery: inspection.recovery,
271 workspace_scopes: Vec::new(),
272 missing_scopes: Vec::new(),
273 })
274 } else {
275 Ok(IndexStatus::Fresh {
276 total_files: inspection.total_files,
277 stale_files: 0,
278 last_indexed_secs_ago,
279 recovery: inspection.recovery,
280 workspace_scopes: Vec::new(),
281 missing_scopes: Vec::new(),
282 })
283 }
284}
285
286fn check_workspace_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
287 let cfg = config::Config::load(root)?;
288 let mut scopes = Vec::new();
289 let mut missing_scopes = Vec::new();
290 for scope in config::Config::submodule_dirs(root)? {
291 let db_path = cfg.db_path_for(root, &scope.id);
292 if !scope.source_root.exists() || !db_path.exists() {
293 missing_scopes.push(MissingWorkspaceScopeStatus {
294 scope: scope.id,
295 db_path,
296 });
297 continue;
298 }
299
300 let last_indexed_secs_ago = db_path
301 .metadata()
302 .and_then(|m| m.modified())
303 .ok()
304 .and_then(|t| SystemTime::now().duration_since(t).ok())
305 .map(|d| d.as_secs())
306 .unwrap_or(0);
307 let inspection = cache.inspect_read_only(&db_path, &scope.source_root, false)?;
308 let stale_files =
309 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
310 scopes.push(WorkspaceScopeStatus {
311 scope: scope.id,
312 db_path,
313 total_files: inspection.total_files,
314 stale_files,
315 last_indexed_secs_ago,
316 recovery: inspection.recovery,
317 });
318 }
319
320 if scopes.is_empty() {
321 return Ok(IndexStatus::Missing { missing_scopes });
322 }
323
324 scopes.sort_by(|left, right| left.scope.cmp(&right.scope));
325 let total_files = scopes.iter().map(|scope| scope.total_files).sum();
326 let stale_files = scopes.iter().map(|scope| scope.stale_files).sum();
327 let last_indexed_secs_ago = scopes
328 .iter()
329 .map(|scope| scope.last_indexed_secs_ago)
330 .min()
331 .unwrap_or(0);
332 let recovery = scopes.iter().find_map(|scope| scope.recovery);
333
334 if stale_files > 0 || !missing_scopes.is_empty() {
335 Ok(IndexStatus::Stale {
336 total_files,
337 stale_files,
338 last_indexed_secs_ago,
339 recovery,
340 workspace_scopes: scopes,
341 missing_scopes,
342 })
343 } else {
344 Ok(IndexStatus::Fresh {
345 total_files,
346 stale_files: 0,
347 last_indexed_secs_ago,
348 recovery,
349 workspace_scopes: scopes,
350 missing_scopes,
351 })
352 }
353}
354
355pub fn check_locks(
356 root: &Path,
357 path_hint: Option<&Path>,
358 scope: Option<&str>,
359) -> Result<LockReport> {
360 let (label, source_root, db_path, reindex_command) =
361 resolve_lock_target(root, path_hint, scope)?;
362 let lock_path = writer_lock_path(&db_path);
363 let writer_lock = match probe_writer_lock(&lock_path)? {
364 WriterLockProbe::Absent { path } => WriterLockStatus::Absent { path },
365 WriterLockProbe::Live { path, pid } => WriterLockStatus::Live { path, pid },
366 WriterLockProbe::Stale { path, pid } => WriterLockStatus::Stale { path, pid },
367 WriterLockProbe::Unknown { path } => WriterLockStatus::Unknown { path },
368 };
369 let rollback_journal = SidecarStatus {
370 path: rollback_journal_path(&db_path),
371 present: rollback_journal_path(&db_path).exists(),
372 };
373 let wal_sidecar = SidecarStatus {
374 path: wal_sidecar_path(&db_path),
375 present: wal_sidecar_path(&db_path).exists(),
376 };
377 let shared_memory_sidecar = SidecarStatus {
378 path: shared_memory_sidecar_path(&db_path),
379 present: shared_memory_sidecar_path(&db_path).exists(),
380 };
381 let recommended_action = build_lock_recommendation(
382 &writer_lock,
383 &rollback_journal,
384 &wal_sidecar,
385 &shared_memory_sidecar,
386 &reindex_command,
387 );
388
389 Ok(LockReport {
390 label,
391 source_root,
392 db_path,
393 writer_lock,
394 rollback_journal,
395 wal_sidecar,
396 shared_memory_sidecar,
397 reindex_command,
398 recommended_action,
399 })
400}
401
402fn check_summaries(
403 root: &Path,
404 db_path: &Path,
405 index: &IndexStatus,
406 cache: &StatusCheckCache,
407) -> Result<SummaryStatus> {
408 if matches!(index, IndexStatus::Missing { .. }) {
409 return Ok(SummaryStatus::Unavailable);
410 }
411 if !db_path.exists() {
412 return Ok(SummaryStatus::None { recovery: None });
413 }
414
415 let read_only = SummaryDb::open_read_only_with_recovery(db_path)?;
416 let recovery = read_only.recovery;
417 let db = read_only.db;
418 let cached_summary_paths = db.cached_file_paths()?.into_iter().collect::<HashSet<_>>();
419 let live_indexed_files = live_indexed_summary_paths(root, index, cache)?;
420 let total_indexed_files = live_indexed_files.len();
421 let cached_files = cached_summary_paths
422 .intersection(&live_indexed_files)
423 .count();
424
425 if cached_files == 0 {
426 return Ok(SummaryStatus::None { recovery });
427 }
428
429 let coverage_pct = if total_indexed_files > 0 {
430 ((cached_files as f64 / total_indexed_files as f64) * 100.0).min(100.0) as u8
431 } else {
432 0
433 };
434
435 Ok(SummaryStatus::Available {
436 cached_files,
437 total_indexed_files,
438 coverage_pct,
439 recovery,
440 })
441}
442
443fn live_indexed_summary_paths(
444 root: &Path,
445 index: &IndexStatus,
446 cache: &StatusCheckCache,
447) -> Result<HashSet<String>> {
448 match index {
449 IndexStatus::Fresh {
450 workspace_scopes, ..
451 }
452 | IndexStatus::Stale {
453 workspace_scopes, ..
454 } => {
455 if workspace_scopes.is_empty() {
456 tracked_summary_paths_from_inspection(
457 cache,
458 &root.join(".tsift/index.db"),
459 root,
460 root,
461 )
462 } else {
463 let mut paths = HashSet::new();
464 let source_roots = config::Config::submodule_dirs(root)?
465 .into_iter()
466 .map(|scope| (scope.id, scope.source_root))
467 .collect::<HashMap<_, _>>();
468 for scope in workspace_scopes {
469 let source_root = source_roots
470 .get(&scope.scope)
471 .map(PathBuf::as_path)
472 .unwrap_or(root);
473 paths.extend(tracked_summary_paths_from_inspection(
474 cache,
475 &scope.db_path,
476 root,
477 source_root,
478 )?);
479 }
480 Ok(paths)
481 }
482 }
483 IndexStatus::Missing { .. } => Ok(HashSet::new()),
484 }
485}
486
487fn tracked_summary_paths_from_inspection(
488 cache: &StatusCheckCache,
489 db_path: &Path,
490 report_root: &Path,
491 inspect_root: &Path,
492) -> Result<HashSet<String>> {
493 let inspection = cache.inspect_read_only(db_path, inspect_root, false)?;
494 Ok(inspection
495 .tracked_file_paths
496 .into_iter()
497 .map(PathBuf::from)
498 .filter(|path| path.is_file())
499 .map(|path| {
500 path.strip_prefix(report_root)
501 .unwrap_or(path.as_path())
502 .to_string_lossy()
503 .to_string()
504 })
505 .collect())
506}
507
508fn build_recommendations(
509 index: &IndexStatus,
510 summaries: &SummaryStatus,
511 instructions: &InstructionStatus,
512 workspace: bool,
513 summarize_extract: &str,
514 kg_present: bool,
515) -> Recommendations {
516 let refresh = !matches!(instructions, InstructionStatus::Current { .. });
517 let index_cmd = if workspace {
518 "tsift index --workspace ."
519 } else {
520 "tsift index ."
521 };
522 let init_cmd = if workspace {
523 "tsift init --workspace"
524 } else {
525 "tsift init"
526 };
527
528 match index {
529 IndexStatus::Missing { missing_scopes } => Recommendations {
530 use_commands: vec![],
531 run: if refresh {
532 Some(format!(
533 "{init_cmd} && {}",
534 format_index_run_with_gap(index_cmd, 0, missing_scopes.len())
535 ))
536 } else {
537 Some(format_index_run_with_gap(
538 index_cmd,
539 0,
540 missing_scopes.len(),
541 ))
542 },
543 },
544 IndexStatus::Stale {
545 stale_files,
546 missing_scopes,
547 ..
548 } => {
549 let mut use_cmds = vec![
550 "search".to_string(),
551 "explain".to_string(),
552 "graph".to_string(),
553 ];
554 if kg_present {
555 use_cmds.push("kg".to_string());
556 }
557 if matches!(summaries, SummaryStatus::Available { .. }) {
558 use_cmds.push("summarize".to_string());
559 }
560 let run_msg = format_index_run_with_gap(index_cmd, *stale_files, missing_scopes.len());
561 let run_msg = if refresh {
562 format!("{init_cmd} && {run_msg}")
563 } else {
564 run_msg
565 };
566 Recommendations {
567 use_commands: use_cmds,
568 run: Some(run_msg),
569 }
570 }
571 IndexStatus::Fresh { .. } => {
572 let mut use_cmds = vec![
573 "search".to_string(),
574 "explain".to_string(),
575 "graph".to_string(),
576 ];
577 if kg_present {
578 use_cmds.push("kg".to_string());
579 }
580 let mut run = match summaries {
581 SummaryStatus::Available {
582 cached_files,
583 total_indexed_files,
584 ..
585 } => {
586 use_cmds.push("summarize".to_string());
587 let uncached = total_indexed_files.saturating_sub(*cached_files);
588 if uncached > 0 {
589 Some(format!(
590 "tsift summarize --extract {} ({} uncached file{})",
591 summarize_extract,
592 uncached,
593 if uncached == 1 { "" } else { "s" }
594 ))
595 } else {
596 None
597 }
598 }
599 SummaryStatus::None { .. } => {
600 Some(format!("tsift summarize --extract {}", summarize_extract))
601 }
602 SummaryStatus::Unavailable => None,
603 };
604 if refresh {
605 run = Some(match run {
606 Some(existing) => format!("{init_cmd} && {existing}"),
607 None => init_cmd.to_string(),
608 });
609 }
610 Recommendations {
611 use_commands: use_cmds,
612 run,
613 }
614 }
615 }
616}
617
618fn build_reminders(
619 index: &IndexStatus,
620 summaries: &SummaryStatus,
621 recommendations: &Recommendations,
622 summarize_extract: &str,
623) -> Vec<String> {
624 let IndexStatus::Stale {
625 stale_files,
626 missing_scopes,
627 ..
628 } = index
629 else {
630 return Vec::new();
631 };
632
633 let run =
634 status_recommendation_command(recommendations.run.as_deref().unwrap_or("tsift index ."));
635 let mut reminder = format!(
636 "index stale: run `{}` before relying on tsift search/explain/graph",
637 run
638 );
639 if *stale_files > 0 {
640 reminder.push_str(&format!(
641 " ({} stale file{})",
642 stale_files,
643 if *stale_files == 1 { "" } else { "s" }
644 ));
645 }
646 if !missing_scopes.is_empty() {
647 reminder.push_str(&format!(
648 " ({} missing workspace scope{})",
649 missing_scopes.len(),
650 if missing_scopes.len() == 1 { "" } else { "s" }
651 ));
652 }
653 if matches!(summaries, SummaryStatus::None { .. }) {
654 reminder.push_str(&format!(
655 "; no summaries are cached, so run `tsift summarize --extract {}` after the index is fresh when summary refs are needed",
656 summarize_extract
657 ));
658 }
659 vec![reminder]
660}
661
662fn status_recommendation_command(run: &str) -> &str {
663 run.split_once(" (")
664 .map(|(command, _)| command)
665 .unwrap_or(run)
666}
667
668fn recommended_summarize_extract_path(
669 root: &Path,
670 index: &IndexStatus,
671 workspace_scopes: &[config::WorkspaceScope],
672) -> String {
673 if !workspace_scopes.is_empty() {
674 return common_extract_scope(
675 workspace_scopes
676 .iter()
677 .map(|scope| Path::new(&scope.relative_path)),
678 false,
679 )
680 .unwrap_or_else(|| ".".to_string());
681 }
682
683 match index {
684 IndexStatus::Fresh { .. } | IndexStatus::Stale { .. } => common_extract_scope(
685 IndexDb::file_paths_read_only(&root.join(".tsift/index.db"))
686 .ok()
687 .into_iter()
688 .flatten()
689 .map(PathBuf::from)
690 .map(|path| path.strip_prefix(root).map(PathBuf::from).unwrap_or(path)),
691 true,
692 )
693 .unwrap_or_else(|| ".".to_string()),
694 IndexStatus::Missing { .. } => ".".to_string(),
695 }
696}
697
698fn common_extract_scope<I, P>(paths: I, treat_inputs_as_files: bool) -> Option<String>
699where
700 I: IntoIterator<Item = P>,
701 P: AsRef<Path>,
702{
703 let mut common: Option<Vec<String>> = None;
704
705 for raw_path in paths {
706 let path = raw_path.as_ref();
707 let scope = if treat_inputs_as_files {
708 path.parent().unwrap_or_else(|| Path::new("."))
709 } else {
710 path
711 };
712 let components = scope
713 .components()
714 .filter_map(|component| match component {
715 std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
716 _ => None,
717 })
718 .collect::<Vec<_>>();
719
720 match &mut common {
721 None => common = Some(components),
722 Some(existing) => {
723 let shared_len = existing
724 .iter()
725 .zip(components.iter())
726 .take_while(|(left, right)| left == right)
727 .count();
728 existing.truncate(shared_len);
729 }
730 }
731 }
732
733 Some(match common {
734 None => ".".to_string(),
735 Some(components) if components.is_empty() => ".".to_string(),
736 Some(components) => format!("{}/", components.join("/")),
737 })
738}
739
740fn format_duration(secs: u64) -> String {
741 if secs < 60 {
742 format!("{}s ago", secs)
743 } else if secs < 3600 {
744 format!("{}m ago", secs / 60)
745 } else if secs < 86400 {
746 format!("{}h ago", secs / 3600)
747 } else {
748 format!("{}d ago", secs / 86400)
749 }
750}
751
752fn resolve_lock_target(
753 root: &Path,
754 path_hint: Option<&Path>,
755 scope: Option<&str>,
756) -> Result<(String, PathBuf, PathBuf, String)> {
757 let cfg = config::Config::load(root)?;
758 if let Some(scope_name) = scope {
759 let scope = config::Config::resolve_submodule(root, scope_name)?;
760 Ok((
761 format!("submodule `{}` index", scope.id),
762 scope.source_root.clone(),
763 cfg.db_path_for(root, &scope.id),
764 format!("tsift index --submodule {} {}", scope.id, root.display()),
765 ))
766 } else if let Some(path_hint) = path_hint {
767 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
768 return Ok((
769 format!("submodule `{}` index", scope.id),
770 scope.source_root.clone(),
771 cfg.db_path_for(root, &scope.id),
772 format!("tsift index --submodule {} {}", scope.id, root.display()),
773 ));
774 }
775 Ok((
776 "index".to_string(),
777 root.to_path_buf(),
778 root.join(".tsift/index.db"),
779 format!("tsift index {}", root.display()),
780 ))
781 } else {
782 Ok((
783 "index".to_string(),
784 root.to_path_buf(),
785 root.join(".tsift/index.db"),
786 format!("tsift index {}", root.display()),
787 ))
788 }
789}
790
791fn build_lock_recommendation(
792 writer_lock: &WriterLockStatus,
793 rollback_journal: &SidecarStatus,
794 wal_sidecar: &SidecarStatus,
795 shared_memory_sidecar: &SidecarStatus,
796 reindex_command: &str,
797) -> String {
798 let has_live_wal_state = wal_sidecar.present || shared_memory_sidecar.present;
799 match writer_lock {
800 WriterLockStatus::Live { pid, .. } => {
801 let pid_hint = pid
802 .map(|value| format!(" (pid {})", value))
803 .unwrap_or_default();
804 if has_live_wal_state {
805 format!(
806 "wait for the active tsift writer{} to finish, then run `{}` to rebuild a clean WAL-mode index after the live sidecars clear.",
807 pid_hint, reindex_command
808 )
809 } else if rollback_journal.present {
810 format!(
811 "wait for the active tsift writer{} to finish, then run `{}` to rebuild a clean WAL-mode index.",
812 pid_hint, reindex_command
813 )
814 } else {
815 format!(
816 "wait for the active tsift writer{} to finish before rerunning `{}`.",
817 pid_hint, reindex_command
818 )
819 }
820 }
821 WriterLockStatus::Stale { path, .. } | WriterLockStatus::Unknown { path } => {
822 format!(
823 "the lock sidecar at `{}` is stale metadata only; rerun `{}` and tsift will reuse it automatically.",
824 path.display(),
825 reindex_command
826 )
827 }
828 WriterLockStatus::Absent { .. } if has_live_wal_state => {
829 format!(
830 "inspect the host for a wedged writer holding live WAL sidecars, then run `{}` once writes are healthy. Read-only status checks can use snapshot fallback in the meantime.",
831 reindex_command
832 )
833 }
834 WriterLockStatus::Absent { .. } if rollback_journal.present => {
835 format!(
836 "inspect the host for a wedged rollback-journal writer, then run `{}` once writes are healthy. Read-only status checks can use snapshot fallback in the meantime.",
837 reindex_command
838 )
839 }
840 WriterLockStatus::Absent { .. } => "no lock remediation needed".to_string(),
841 }
842}
843
844fn format_recovery_line(recovery: ReadOnlyRecovery, compact: bool) -> String {
845 match (recovery, compact) {
846 (ReadOnlyRecovery::SnapshotFallback, true) => "recovery:snapshot_fallback\n".to_string(),
847 (ReadOnlyRecovery::SnapshotFallback, false) => {
848 "recovery: snapshot fallback (rollback-journal lock on live index)\n".to_string()
849 }
850 (ReadOnlyRecovery::SnapshotFallbackWal, true) => {
851 "recovery:snapshot_fallback_wal\n".to_string()
852 }
853 (ReadOnlyRecovery::SnapshotFallbackWal, false) => {
854 "recovery: snapshot fallback (copied live WAL sidecars from index db)\n".to_string()
855 }
856 }
857}
858
859fn index_recovery(index: &IndexStatus) -> Option<ReadOnlyRecovery> {
860 match index {
861 IndexStatus::Fresh { recovery, .. } | IndexStatus::Stale { recovery, .. } => *recovery,
862 IndexStatus::Missing { .. } => None,
863 }
864}
865
866fn summary_recovery(summaries: &SummaryStatus) -> Option<ReadOnlyRecovery> {
867 match summaries {
868 SummaryStatus::Available { recovery, .. } | SummaryStatus::None { recovery } => *recovery,
869 SummaryStatus::Unavailable => None,
870 }
871}
872
873fn format_summary_recovery_line(recovery: ReadOnlyRecovery, compact: bool) -> String {
874 match (recovery, compact) {
875 (ReadOnlyRecovery::SnapshotFallback, true) => {
876 "summaries_recovery:snapshot_fallback\n".to_string()
877 }
878 (ReadOnlyRecovery::SnapshotFallback, false) => {
879 "summaries recovery: snapshot fallback (rollback-journal lock on live summaries db)\n"
880 .to_string()
881 }
882 (ReadOnlyRecovery::SnapshotFallbackWal, true) => {
883 "summaries_recovery:snapshot_fallback_wal\n".to_string()
884 }
885 (ReadOnlyRecovery::SnapshotFallbackWal, false) => {
886 "summaries recovery: snapshot fallback (copied live WAL sidecars from summaries db)\n"
887 .to_string()
888 }
889 }
890}
891
892fn workspace_scopes(index: &IndexStatus) -> &[WorkspaceScopeStatus] {
893 match index {
894 IndexStatus::Fresh {
895 workspace_scopes, ..
896 }
897 | IndexStatus::Stale {
898 workspace_scopes, ..
899 } => workspace_scopes.as_slice(),
900 IndexStatus::Missing { .. } => &[],
901 }
902}
903
904fn missing_workspace_scopes(index: &IndexStatus) -> &[MissingWorkspaceScopeStatus] {
905 match index {
906 IndexStatus::Fresh { missing_scopes, .. }
907 | IndexStatus::Stale { missing_scopes, .. }
908 | IndexStatus::Missing { missing_scopes } => missing_scopes.as_slice(),
909 }
910}
911
912fn format_workspace_scope_line(scope: &WorkspaceScopeStatus, compact: bool) -> String {
913 let state = if scope.stale_files > 0 {
914 "stale"
915 } else {
916 "fresh"
917 };
918 if compact {
919 format!(
920 "scope:{} state:{} tracked:{} stale:{} age:{}\n",
921 scope.scope,
922 state,
923 scope.total_files,
924 scope.stale_files,
925 format_duration(scope.last_indexed_secs_ago)
926 )
927 } else if scope.stale_files > 0 {
928 format!(
929 " scope {}: stale (last indexed {}, {} files tracked, {} stale)\n",
930 scope.scope,
931 format_duration(scope.last_indexed_secs_ago),
932 scope.total_files,
933 scope.stale_files
934 )
935 } else {
936 format!(
937 " scope {}: fresh (last indexed {}, {} files tracked)\n",
938 scope.scope,
939 format_duration(scope.last_indexed_secs_ago),
940 scope.total_files
941 )
942 }
943}
944
945fn format_missing_workspace_scope_line(
946 scope: &MissingWorkspaceScopeStatus,
947 compact: bool,
948) -> String {
949 if compact {
950 format!("scope:{} state:missing\n", scope.scope)
951 } else {
952 format!(
953 " scope {}: missing index ({})\n",
954 scope.scope,
955 scope.db_path.display()
956 )
957 }
958}
959
960fn format_index_run_with_gap(index_cmd: &str, stale_files: usize, missing_scopes: usize) -> String {
961 let mut notes = Vec::new();
962 if stale_files > 0 {
963 notes.push(format!(
964 "{} stale file{}",
965 stale_files,
966 if stale_files == 1 { "" } else { "s" }
967 ));
968 }
969 if missing_scopes > 0 {
970 notes.push(format!(
971 "{} missing scope{}",
972 missing_scopes,
973 if missing_scopes == 1 { "" } else { "s" }
974 ));
975 }
976 if notes.is_empty() {
977 index_cmd.to_string()
978 } else {
979 format!("{} ({})", index_cmd, notes.join(", "))
980 }
981}
982
983pub fn format_human(report: &StatusReport, compact: bool) -> String {
984 let mut out = String::new();
985
986 match &report.index {
987 IndexStatus::Missing { missing_scopes } => {
988 if compact {
989 if missing_scopes.is_empty() {
990 out.push_str("index: missing\n");
991 } else {
992 out.push_str(&format!(
993 "index: missing workspace_missing:{}\n",
994 missing_scopes.len()
995 ));
996 }
997 } else if missing_scopes.is_empty() {
998 out.push_str("index: missing\n");
999 } else {
1000 out.push_str(&format!(
1001 "index: missing (workspace, {} scope{} not indexed)\n",
1002 missing_scopes.len(),
1003 if missing_scopes.len() == 1 { "" } else { "s" }
1004 ));
1005 }
1006 }
1007 IndexStatus::Fresh {
1008 total_files,
1009 last_indexed_secs_ago,
1010 workspace_scopes,
1011 ..
1012 } => {
1013 if compact {
1014 if workspace_scopes.is_empty() {
1015 out.push_str(&format!(
1016 "index: fresh tracked:{} age:{}\n",
1017 total_files,
1018 format_duration(*last_indexed_secs_ago)
1019 ));
1020 } else {
1021 out.push_str(&format!(
1022 "index: fresh workspace:{} tracked:{} age:{}\n",
1023 workspace_scopes.len(),
1024 total_files,
1025 format_duration(*last_indexed_secs_ago)
1026 ));
1027 }
1028 } else {
1029 if workspace_scopes.is_empty() {
1030 out.push_str(&format!(
1031 "index: fresh (last indexed {}, {} files tracked)\n",
1032 format_duration(*last_indexed_secs_ago),
1033 total_files
1034 ));
1035 } else {
1036 out.push_str(&format!(
1037 "index: fresh (workspace, {} scopes, last indexed {}, {} files tracked)\n",
1038 workspace_scopes.len(),
1039 format_duration(*last_indexed_secs_ago),
1040 total_files
1041 ));
1042 }
1043 }
1044 }
1045 IndexStatus::Stale {
1046 total_files,
1047 stale_files,
1048 last_indexed_secs_ago,
1049 workspace_scopes,
1050 missing_scopes,
1051 ..
1052 } => {
1053 if compact {
1054 if workspace_scopes.is_empty() {
1055 out.push_str(&format!(
1056 "index: stale tracked:{} stale:{} age:{}\n",
1057 total_files,
1058 stale_files,
1059 format_duration(*last_indexed_secs_ago)
1060 ));
1061 } else {
1062 let missing_suffix = if missing_scopes.is_empty() {
1063 String::new()
1064 } else {
1065 format!(" missing:{}", missing_scopes.len())
1066 };
1067 out.push_str(&format!(
1068 "index: stale workspace:{}{} tracked:{} stale:{} age:{}\n",
1069 workspace_scopes.len(),
1070 missing_suffix,
1071 total_files,
1072 stale_files,
1073 format_duration(*last_indexed_secs_ago)
1074 ));
1075 }
1076 } else {
1077 if workspace_scopes.is_empty() {
1078 out.push_str(&format!(
1079 "index: stale (last indexed {}, {} files tracked, {} stale)\n",
1080 format_duration(*last_indexed_secs_ago),
1081 total_files,
1082 stale_files
1083 ));
1084 } else {
1085 out.push_str(&format!(
1086 "index: stale (workspace, {} indexed scope{}, {} missing scope{}, last indexed {}, {} files tracked, {} stale)\n",
1087 workspace_scopes.len(),
1088 if workspace_scopes.len() == 1 { "" } else { "s" },
1089 missing_scopes.len(),
1090 if missing_scopes.len() == 1 { "" } else { "s" },
1091 format_duration(*last_indexed_secs_ago),
1092 total_files,
1093 stale_files
1094 ));
1095 }
1096 }
1097 }
1098 }
1099
1100 for scope in workspace_scopes(&report.index) {
1101 out.push_str(&format_workspace_scope_line(scope, compact));
1102 }
1103 for scope in missing_workspace_scopes(&report.index) {
1104 out.push_str(&format_missing_workspace_scope_line(scope, compact));
1105 }
1106
1107 if let Some(recovery) = index_recovery(&report.index) {
1108 out.push_str(&format_recovery_line(recovery, compact));
1109 }
1110
1111 match &report.instructions {
1112 InstructionStatus::Current { version } => {
1113 if compact {
1114 out.push_str(&format!("instructions: current v={}\n", version));
1115 } else {
1116 out.push_str(&format!("instructions: current (v{})\n", version));
1117 }
1118 }
1119 InstructionStatus::Stale {
1120 found: Some(v),
1121 expected,
1122 } => {
1123 if compact {
1124 out.push_str(&format!(
1125 "instructions: stale v={} expected={}\n",
1126 v, expected
1127 ));
1128 } else {
1129 out.push_str(&format!(
1130 "instructions: stale (v{} installed, v{} available — run tsift init)\n",
1131 v, expected
1132 ));
1133 }
1134 }
1135 InstructionStatus::Stale {
1136 found: None,
1137 expected,
1138 } => {
1139 if compact {
1140 out.push_str(&format!(
1141 "instructions: stale pre-versioned expected={}\n",
1142 expected
1143 ));
1144 } else {
1145 out.push_str(&format!(
1146 "instructions: stale (pre-versioned, v{} available — run tsift init)\n",
1147 expected
1148 ));
1149 }
1150 }
1151 InstructionStatus::Missing => {
1152 out.push_str("instructions: missing (run tsift init)\n");
1153 }
1154 }
1155
1156 match &report.summaries {
1157 SummaryStatus::Available {
1158 cached_files,
1159 total_indexed_files,
1160 coverage_pct,
1161 ..
1162 } => {
1163 if compact {
1164 out.push_str(&format!(
1165 "summaries: {}/{} ({}%)\n",
1166 cached_files, total_indexed_files, coverage_pct
1167 ));
1168 } else {
1169 out.push_str(&format!(
1170 "summaries: {}/{} files cached ({}%)\n",
1171 cached_files, total_indexed_files, coverage_pct
1172 ));
1173 }
1174 }
1175 SummaryStatus::None { .. } => {
1176 out.push_str("summaries: none\n");
1177 }
1178 SummaryStatus::Unavailable => {
1179 out.push_str("summaries: unavailable (no index)\n");
1180 }
1181 }
1182
1183 if let Some(recovery) = summary_recovery(&report.summaries) {
1184 out.push_str(&format_summary_recovery_line(recovery, compact));
1185 }
1186
1187 if compact {
1188 for reminder in &report.reminders {
1189 out.push_str(&format!("reminder: {}\n", reminder));
1190 }
1191 if report.recommendations.use_commands.is_empty() {
1192 out.push_str("use: none\n");
1193 } else {
1194 out.push_str(&format!(
1195 "use: {}\n",
1196 report.recommendations.use_commands.join(", ")
1197 ));
1198 }
1199 if let Some(run) = &report.recommendations.run {
1200 out.push_str(&format!("run: {}\n", run));
1201 }
1202 } else {
1203 if !report.reminders.is_empty() {
1204 out.push_str("reminders:\n");
1205 for reminder in &report.reminders {
1206 out.push_str(&format!(" - {}\n", reminder));
1207 }
1208 }
1209 out.push_str("recommendations:\n");
1210 if report.recommendations.use_commands.is_empty() {
1211 out.push_str(" use: (none — run tsift index first)\n");
1212 } else {
1213 out.push_str(&format!(
1214 " use: {}\n",
1215 report.recommendations.use_commands.join(", ")
1216 ));
1217 }
1218 if let Some(run) = &report.recommendations.run {
1219 out.push_str(&format!(" run: {}\n", run));
1220 }
1221 }
1222
1223 out
1224}
1225
1226pub fn format_locks_human(report: &LockReport, compact: bool) -> String {
1227 let lock_line = match &report.writer_lock {
1228 WriterLockStatus::Absent { path } => format!("lock: absent {}\n", path.display()),
1229 WriterLockStatus::Live { path, pid } => match pid {
1230 Some(value) => format!("lock: live pid:{} {}\n", value, path.display()),
1231 None => format!("lock: live {}\n", path.display()),
1232 },
1233 WriterLockStatus::Stale { path, pid } => match pid {
1234 Some(value) => format!("lock: stale pid:{} {}\n", value, path.display()),
1235 None => format!("lock: stale {}\n", path.display()),
1236 },
1237 WriterLockStatus::Unknown { path } => format!("lock: unknown {}\n", path.display()),
1238 };
1239 let journal_line = if report.rollback_journal.present {
1240 format!(
1241 "journal: present {}\n",
1242 report.rollback_journal.path.display()
1243 )
1244 } else {
1245 format!(
1246 "journal: absent {}\n",
1247 report.rollback_journal.path.display()
1248 )
1249 };
1250 let wal_line = if report.wal_sidecar.present {
1251 format!("wal: present {}\n", report.wal_sidecar.path.display())
1252 } else {
1253 format!("wal: absent {}\n", report.wal_sidecar.path.display())
1254 };
1255 let shm_line = if report.shared_memory_sidecar.present {
1256 format!(
1257 "shm: present {}\n",
1258 report.shared_memory_sidecar.path.display()
1259 )
1260 } else {
1261 format!(
1262 "shm: absent {}\n",
1263 report.shared_memory_sidecar.path.display()
1264 )
1265 };
1266
1267 let mut out = String::new();
1268 if compact {
1269 out.push_str(&format!(
1270 "target:{} db:{}\n",
1271 report.label,
1272 report.db_path.display()
1273 ));
1274 out.push_str(&lock_line);
1275 out.push_str(&journal_line);
1276 out.push_str(&wal_line);
1277 out.push_str(&shm_line);
1278 out.push_str(&format!("run:{}\n", report.reindex_command));
1279 out.push_str(&format!("next:{}\n", report.recommended_action));
1280 } else {
1281 out.push_str(&format!("target: {}\n", report.label));
1282 out.push_str(&format!("source: {}\n", report.source_root.display()));
1283 out.push_str(&format!("db: {}\n", report.db_path.display()));
1284 out.push_str(&lock_line);
1285 out.push_str(&journal_line);
1286 out.push_str(&wal_line);
1287 out.push_str(&shm_line);
1288 out.push_str(&format!("run: {}\n", report.reindex_command));
1289 out.push_str(&format!("next: {}\n", report.recommended_action));
1290 }
1291 out
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297 use fs4::fs_std::FileExt;
1298 use rusqlite::Connection;
1299 use std::fs::OpenOptions;
1300 use tempfile::TempDir;
1301 use tsift_index::config::Config;
1302 use tsift_sqlite::wal_sidecar_path;
1303
1304 fn setup_workspace() -> TempDir {
1305 let dir = TempDir::new().unwrap();
1306 std::fs::write(
1307 dir.path().join(".gitmodules"),
1308 r#"[submodule "src/alpha"]
1309 path = src/alpha
1310 url = https://example.com/alpha
1311[submodule "src/beta"]
1312 path = src/beta
1313 url = https://example.com/beta
1314"#,
1315 )
1316 .unwrap();
1317 std::fs::create_dir_all(dir.path().join("src/alpha")).unwrap();
1318 std::fs::create_dir_all(dir.path().join("src/beta")).unwrap();
1319 std::fs::write(
1320 dir.path().join("src/alpha/lib.rs"),
1321 "fn alpha_helper() {}\n",
1322 )
1323 .unwrap();
1324 std::fs::write(dir.path().join("src/beta/lib.rs"), "fn beta_helper() {}\n").unwrap();
1325 dir
1326 }
1327
1328 fn hold_wal_lock(db_path: &Path) -> Connection {
1329 let conn = Connection::open(db_path).unwrap();
1330 conn.execute_batch(
1331 "PRAGMA journal_mode=WAL;
1332 PRAGMA wal_autocheckpoint=0;
1333 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
1334 INSERT INTO wal_lock_probe DEFAULT VALUES;
1335 PRAGMA locking_mode=EXCLUSIVE;
1336 BEGIN EXCLUSIVE;",
1337 )
1338 .unwrap();
1339 assert!(wal_sidecar_path(db_path).exists());
1340 conn
1341 }
1342
1343 #[test]
1344 fn status_no_index() {
1345 let dir = TempDir::new().unwrap();
1346 let report = check_status(dir.path()).unwrap();
1347 assert!(matches!(
1348 report.index,
1349 IndexStatus::Missing { ref missing_scopes } if missing_scopes.is_empty()
1350 ));
1351 assert!(matches!(report.summaries, SummaryStatus::Unavailable));
1352 assert!(matches!(report.instructions, InstructionStatus::Missing));
1353 assert!(report.recommendations.use_commands.is_empty());
1354 assert_eq!(
1355 report.recommendations.run.as_deref(),
1356 Some("tsift init && tsift index .")
1357 );
1358 }
1359
1360 #[test]
1361 fn status_fresh_index_no_summaries() {
1362 let dir = TempDir::new().unwrap();
1363 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1364 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1365 db.apply_changes(dir.path()).unwrap();
1366
1367 let report = check_status(dir.path()).unwrap();
1368 assert!(matches!(
1369 report.index,
1370 IndexStatus::Fresh { stale_files: 0, .. }
1371 ));
1372 assert!(matches!(report.summaries, SummaryStatus::None { .. }));
1373 let cmds = &report.recommendations.use_commands;
1374 assert!(cmds.contains(&"search".to_string()));
1375 assert!(cmds.contains(&"explain".to_string()));
1376 assert!(cmds.contains(&"graph".to_string()));
1377 assert!(!cmds.contains(&"summarize".to_string()));
1378 assert_eq!(
1379 report.recommendations.run.as_deref(),
1380 Some("tsift init && tsift summarize --extract .")
1381 );
1382 }
1383
1384 #[test]
1385 fn status_fresh_index_with_graph_db_recommends_kg() {
1386 let dir = TempDir::new().unwrap();
1387 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1388 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1389 db.apply_changes(dir.path()).unwrap();
1390 std::fs::write(dir.path().join(".tsift/graph.db"), b"").unwrap();
1392
1393 let report = check_status(dir.path()).unwrap();
1394 let cmds = &report.recommendations.use_commands;
1395 assert!(cmds.contains(&"kg".to_string()));
1396 let graph_idx = cmds.iter().position(|c| c == "graph").unwrap();
1398 let kg_idx = cmds.iter().position(|c| c == "kg").unwrap();
1399 assert!(kg_idx > graph_idx);
1400 }
1401
1402 #[test]
1403 fn status_fresh_index_without_graph_db_omits_kg() {
1404 let dir = TempDir::new().unwrap();
1405 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1406 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1407 db.apply_changes(dir.path()).unwrap();
1408
1409 let report = check_status(dir.path()).unwrap();
1410 assert!(!report.recommendations.use_commands.contains(&"kg".to_string()));
1411 }
1412
1413 #[test]
1414 fn status_cache_reuses_index_inspection_until_invalidated() {
1415 let dir = TempDir::new().unwrap();
1416 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1417 let db_path = dir.path().join(".tsift/index.db");
1418 let db = IndexDb::open(&db_path).unwrap();
1419 db.apply_changes(dir.path()).unwrap();
1420 drop(db);
1421
1422 let cache = StatusCheckCache::new();
1423 let report = check_status_with_cache(dir.path(), &cache).unwrap();
1424 assert!(matches!(
1425 report.index,
1426 IndexStatus::Fresh { recovery: None, .. }
1427 ));
1428
1429 let _lock = hold_wal_lock(&db_path);
1430 let cached_report = check_status_with_cache(dir.path(), &cache).unwrap();
1431 assert!(matches!(
1432 cached_report.index,
1433 IndexStatus::Fresh { recovery: None, .. }
1434 ));
1435
1436 cache.invalidate_all();
1437 let refreshed_report = check_status_with_cache(dir.path(), &cache).unwrap();
1438 assert!(matches!(
1439 refreshed_report.index,
1440 IndexStatus::Fresh {
1441 recovery: Some(ReadOnlyRecovery::SnapshotFallbackWal),
1442 ..
1443 }
1444 ));
1445 }
1446
1447 #[test]
1448 fn status_fresh_src_layout_recommends_src_extract() {
1449 let dir = TempDir::new().unwrap();
1450 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1451 std::fs::write(dir.path().join("src/lib.rs"), "fn alpha() {}").unwrap();
1452 std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
1453 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1454 db.apply_changes(dir.path()).unwrap();
1455
1456 let report = check_status(dir.path()).unwrap();
1457 assert_eq!(
1458 report.recommendations.run.as_deref(),
1459 Some("tsift init && tsift summarize --extract src/")
1460 );
1461 }
1462
1463 #[test]
1464 fn status_workspace_scoped_indexes_report_fresh() {
1465 let dir = setup_workspace();
1466 let cfg = Config::load(dir.path()).unwrap();
1467 for scope in Config::submodule_dirs(dir.path()).unwrap() {
1468 let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
1469 db.apply_changes(&scope.source_root).unwrap();
1470 }
1471
1472 let report = check_status(dir.path()).unwrap();
1473 match &report.index {
1474 IndexStatus::Fresh {
1475 total_files,
1476 workspace_scopes,
1477 ..
1478 } => {
1479 assert_eq!(*total_files, 2);
1480 assert_eq!(workspace_scopes.len(), 2);
1481 assert_eq!(workspace_scopes[0].scope, "alpha");
1482 assert_eq!(workspace_scopes[1].scope, "beta");
1483 }
1484 other => panic!("expected fresh workspace status, got {other:?}"),
1485 }
1486 assert!(matches!(report.summaries, SummaryStatus::None { .. }));
1487 assert_eq!(
1488 report.recommendations.run.as_deref(),
1489 Some("tsift init --workspace && tsift summarize --extract src/")
1490 );
1491 }
1492
1493 #[test]
1494 fn status_workspace_non_src_layout_recommends_dot_extract() {
1495 let dir = TempDir::new().unwrap();
1496 std::fs::write(
1497 dir.path().join(".gitmodules"),
1498 r#"[submodule "alpha"]
1499 path = alpha
1500 url = https://example.com/alpha
1501[submodule "crates/beta"]
1502 path = crates/beta
1503 url = https://example.com/beta
1504"#,
1505 )
1506 .unwrap();
1507 std::fs::create_dir_all(dir.path().join("alpha/src")).unwrap();
1508 std::fs::create_dir_all(dir.path().join("crates/beta/src")).unwrap();
1509 std::fs::write(
1510 dir.path().join("alpha/src/lib.rs"),
1511 "fn alpha_helper() {}\n",
1512 )
1513 .unwrap();
1514 std::fs::write(
1515 dir.path().join("crates/beta/src/lib.rs"),
1516 "fn beta_helper() {}\n",
1517 )
1518 .unwrap();
1519
1520 let cfg = Config::load(dir.path()).unwrap();
1521 for scope in Config::submodule_dirs(dir.path()).unwrap() {
1522 let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
1523 db.apply_changes(&scope.source_root).unwrap();
1524 }
1525
1526 let report = check_status(dir.path()).unwrap();
1527 assert_eq!(
1528 report.recommendations.run.as_deref(),
1529 Some("tsift init --workspace && tsift summarize --extract .")
1530 );
1531 }
1532
1533 #[test]
1534 fn status_workspace_missing_recommends_workspace_index() {
1535 let dir = setup_workspace();
1536
1537 let report = check_status(dir.path()).unwrap();
1538 match &report.index {
1539 IndexStatus::Missing { missing_scopes } => {
1540 assert_eq!(missing_scopes.len(), 2);
1541 assert_eq!(missing_scopes[0].scope, "alpha");
1542 assert_eq!(missing_scopes[1].scope, "beta");
1543 }
1544 other => panic!("expected missing workspace status, got {other:?}"),
1545 }
1546 assert_eq!(
1547 report.recommendations.run.as_deref(),
1548 Some("tsift init --workspace && tsift index --workspace . (2 missing scopes)")
1549 );
1550 }
1551
1552 #[test]
1553 fn status_workspace_partial_indexes_report_missing_scopes() {
1554 let dir = setup_workspace();
1555 let cfg = Config::load(dir.path()).unwrap();
1556 let alpha = Config::resolve_submodule(dir.path(), "alpha").unwrap();
1557 let db = IndexDb::open(&cfg.db_path_for(dir.path(), &alpha.id)).unwrap();
1558 db.apply_changes(&alpha.source_root).unwrap();
1559
1560 let report = check_status(dir.path()).unwrap();
1561 match &report.index {
1562 IndexStatus::Stale {
1563 total_files,
1564 stale_files,
1565 workspace_scopes,
1566 missing_scopes,
1567 ..
1568 } => {
1569 assert_eq!(*total_files, 1);
1570 assert_eq!(*stale_files, 0);
1571 assert_eq!(workspace_scopes.len(), 1);
1572 assert_eq!(workspace_scopes[0].scope, "alpha");
1573 assert_eq!(missing_scopes.len(), 1);
1574 assert_eq!(missing_scopes[0].scope, "beta");
1575 }
1576 other => panic!("expected partial workspace status, got {other:?}"),
1577 }
1578 assert_eq!(
1579 report.recommendations.run.as_deref(),
1580 Some("tsift init --workspace && tsift index --workspace . (1 missing scope)")
1581 );
1582 }
1583
1584 #[test]
1585 fn status_workspace_prefers_scoped_indexes_when_root_index_also_exists() {
1586 let dir = setup_workspace();
1587 let root_db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1588 root_db.apply_changes(dir.path()).unwrap();
1589
1590 let cfg = Config::load(dir.path()).unwrap();
1591 let alpha = Config::resolve_submodule(dir.path(), "alpha").unwrap();
1592 let alpha_db = IndexDb::open(&cfg.db_path_for(dir.path(), &alpha.id)).unwrap();
1593 alpha_db.apply_changes(&alpha.source_root).unwrap();
1594
1595 let report = check_status(dir.path()).unwrap();
1596 match &report.index {
1597 IndexStatus::Stale {
1598 total_files,
1599 stale_files,
1600 workspace_scopes,
1601 missing_scopes,
1602 ..
1603 } => {
1604 assert_eq!(*total_files, 1);
1605 assert_eq!(*stale_files, 0);
1606 assert_eq!(workspace_scopes.len(), 1);
1607 assert_eq!(workspace_scopes[0].scope, "alpha");
1608 assert_eq!(missing_scopes.len(), 1);
1609 assert_eq!(missing_scopes[0].scope, "beta");
1610 }
1611 other => panic!("expected mixed workspace status to stay scope-aware, got {other:?}"),
1612 }
1613 assert_eq!(
1614 report.recommendations.run.as_deref(),
1615 Some("tsift init --workspace && tsift index --workspace . (1 missing scope)")
1616 );
1617 }
1618
1619 #[test]
1620 fn status_workspace_scoped_indexes_report_stale() {
1621 let dir = setup_workspace();
1622 let cfg = Config::load(dir.path()).unwrap();
1623 for scope in Config::submodule_dirs(dir.path()).unwrap() {
1624 let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
1625 db.apply_changes(&scope.source_root).unwrap();
1626 }
1627 std::fs::write(dir.path().join("src/beta/new.rs"), "fn late() {}\n").unwrap();
1628
1629 let report = check_status(dir.path()).unwrap();
1630 match &report.index {
1631 IndexStatus::Stale {
1632 total_files,
1633 stale_files,
1634 workspace_scopes,
1635 ..
1636 } => {
1637 assert_eq!(*total_files, 2);
1638 assert_eq!(*stale_files, 1);
1639 assert_eq!(workspace_scopes.len(), 2);
1640 assert_eq!(
1641 workspace_scopes
1642 .iter()
1643 .find(|scope| scope.scope == "beta")
1644 .unwrap()
1645 .stale_files,
1646 1
1647 );
1648 }
1649 other => panic!("expected stale workspace status, got {other:?}"),
1650 }
1651 assert_eq!(
1652 report.recommendations.run.as_deref(),
1653 Some("tsift init --workspace && tsift index --workspace . (1 stale file)")
1654 );
1655 }
1656
1657 #[test]
1658 fn status_stale_index() {
1659 let dir = TempDir::new().unwrap();
1660 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1661 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1662 db.apply_changes(dir.path()).unwrap();
1663
1664 std::fs::write(dir.path().join("lib.rs"), "fn helper() {}").unwrap();
1665
1666 let report = check_status(dir.path()).unwrap();
1667 assert!(matches!(
1668 report.index,
1669 IndexStatus::Stale { stale_files: 1, .. }
1670 ));
1671 assert!(
1672 report
1673 .recommendations
1674 .run
1675 .as_deref()
1676 .unwrap()
1677 .contains("tsift index")
1678 );
1679 }
1680
1681 #[test]
1682 fn status_fresh_index_with_summaries() {
1683 let dir = TempDir::new().unwrap();
1684 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1685 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1686 db.apply_changes(dir.path()).unwrap();
1687
1688 let sdb = SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
1689 sdb.insert(&tsift_summarize::summarize::Summary {
1690 id: 0,
1691 symbol_name: "main".to_string(),
1692 file_path: "main.rs".to_string(),
1693 content_hash: "abc123".to_string(),
1694 summary: "Entry point".to_string(),
1695 entities: None,
1696 relationships: None,
1697 concept_labels: None,
1698 extracted_at: "2026-01-01".to_string(),
1699 model: "test".to_string(),
1700 tokens_input: Some(100),
1701 tokens_output: Some(50),
1702 })
1703 .unwrap();
1704
1705 let report = check_status(dir.path()).unwrap();
1706 assert!(matches!(report.index, IndexStatus::Fresh { .. }));
1707 assert!(matches!(report.summaries, SummaryStatus::Available { .. }));
1708 assert!(
1709 report
1710 .recommendations
1711 .use_commands
1712 .contains(&"summarize".to_string())
1713 );
1714 }
1715
1716 #[test]
1717 fn status_summaries_use_snapshot_fallback_when_rollback_journal_is_locked() {
1718 let dir = TempDir::new().unwrap();
1719 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1720 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1721 db.apply_changes(dir.path()).unwrap();
1722
1723 let db_path = dir.path().join(".tsift/summaries.db");
1724 let conn = Connection::open(&db_path).unwrap();
1725 conn.execute_batch(
1726 "PRAGMA journal_mode=DELETE;
1727 CREATE TABLE summaries (
1728 id INTEGER PRIMARY KEY,
1729 symbol_name TEXT NOT NULL,
1730 file_path TEXT NOT NULL,
1731 content_hash TEXT NOT NULL,
1732 summary TEXT NOT NULL,
1733 entities TEXT,
1734 relationships TEXT,
1735 concept_labels TEXT,
1736 extracted_at TEXT NOT NULL,
1737 model TEXT NOT NULL,
1738 tokens_input INTEGER,
1739 tokens_output INTEGER
1740 );",
1741 )
1742 .unwrap();
1743 conn.execute(
1744 "INSERT INTO summaries
1745 (symbol_name, file_path, content_hash, summary, entities, relationships, concept_labels, extracted_at, model, tokens_input, tokens_output)
1746 VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, ?5, ?6, NULL, NULL)",
1747 rusqlite::params![
1748 "main",
1749 "main.rs",
1750 "abc123",
1751 "Entry point",
1752 "2026-01-01",
1753 "test",
1754 ],
1755 )
1756 .unwrap();
1757 conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
1758 std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
1759
1760 let report = check_status(dir.path()).unwrap();
1761
1762 match report.summaries {
1763 SummaryStatus::Available {
1764 cached_files,
1765 total_indexed_files,
1766 coverage_pct,
1767 recovery,
1768 } => {
1769 assert_eq!(cached_files, 1);
1770 assert_eq!(total_indexed_files, 1);
1771 assert_eq!(coverage_pct, 100);
1772 assert_eq!(recovery, Some(ReadOnlyRecovery::SnapshotFallback));
1773 }
1774 other => panic!("expected available summaries, got {other:?}"),
1775 }
1776 }
1777
1778 #[test]
1779 fn status_summaries_report_wal_snapshot_recovery_when_wal_db_is_locked() {
1780 let dir = TempDir::new().unwrap();
1781 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1782 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1783 db.apply_changes(dir.path()).unwrap();
1784
1785 let db_path = dir.path().join(".tsift/summaries.db");
1786 let sdb = SummaryDb::open(&db_path).unwrap();
1787 sdb.insert(&tsift_summarize::summarize::Summary {
1788 id: 0,
1789 symbol_name: "main".to_string(),
1790 file_path: "main.rs".to_string(),
1791 content_hash: "abc123".to_string(),
1792 summary: "Entry point".to_string(),
1793 entities: None,
1794 relationships: None,
1795 concept_labels: None,
1796 extracted_at: "2026-01-01".to_string(),
1797 model: "test".to_string(),
1798 tokens_input: None,
1799 tokens_output: None,
1800 })
1801 .unwrap();
1802 drop(sdb);
1803
1804 let _lock = hold_wal_lock(&db_path);
1805
1806 let report = check_status(dir.path()).unwrap();
1807 match report.summaries {
1808 SummaryStatus::Available { recovery, .. } => {
1809 assert_eq!(recovery, Some(ReadOnlyRecovery::SnapshotFallbackWal));
1810 }
1811 other => panic!("expected available summaries, got {other:?}"),
1812 }
1813 }
1814
1815 #[test]
1816 fn status_summary_coverage_ignores_deleted_summary_rows() {
1817 let dir = TempDir::new().unwrap();
1818 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
1819 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1820 db.apply_changes(dir.path()).unwrap();
1821
1822 let sdb = SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
1823 sdb.insert(&tsift_summarize::summarize::Summary {
1824 id: 0,
1825 symbol_name: "main".to_string(),
1826 file_path: "main.rs".to_string(),
1827 content_hash: "abc123".to_string(),
1828 summary: "Entry point".to_string(),
1829 entities: None,
1830 relationships: None,
1831 concept_labels: None,
1832 extracted_at: "2026-01-01".to_string(),
1833 model: "test".to_string(),
1834 tokens_input: Some(100),
1835 tokens_output: Some(50),
1836 })
1837 .unwrap();
1838 sdb.insert(&tsift_summarize::summarize::Summary {
1839 id: 0,
1840 symbol_name: "ghost".to_string(),
1841 file_path: "removed.rs".to_string(),
1842 content_hash: "def456".to_string(),
1843 summary: "Stale summary".to_string(),
1844 entities: None,
1845 relationships: None,
1846 concept_labels: None,
1847 extracted_at: "2026-01-01".to_string(),
1848 model: "test".to_string(),
1849 tokens_input: Some(100),
1850 tokens_output: Some(50),
1851 })
1852 .unwrap();
1853
1854 let report = check_status(dir.path()).unwrap();
1855 match report.summaries {
1856 SummaryStatus::Available {
1857 cached_files,
1858 total_indexed_files,
1859 coverage_pct,
1860 ..
1861 } => {
1862 assert_eq!(cached_files, 1);
1863 assert_eq!(total_indexed_files, 1);
1864 assert_eq!(coverage_pct, 100);
1865 }
1866 other => panic!("expected available summaries, got {other:?}"),
1867 }
1868 }
1869
1870 #[test]
1871 fn status_json_roundtrip() {
1872 let dir = TempDir::new().unwrap();
1873 let report = check_status(dir.path()).unwrap();
1874 let json = serde_json::to_string(&report).unwrap();
1875 assert!(json.contains("\"state\""));
1876 assert!(json.contains("\"missing\""));
1877 }
1878
1879 #[test]
1880 fn status_reports_stale_index_reminder() {
1881 let dir = TempDir::new().unwrap();
1882 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
1883 let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
1884 db.apply_changes(dir.path()).unwrap();
1885 std::thread::sleep(std::time::Duration::from_millis(50));
1886 std::fs::write(
1887 dir.path().join("main.rs"),
1888 "fn main() { println!(\"hi\"); }\n",
1889 )
1890 .unwrap();
1891
1892 let report = check_status(dir.path()).unwrap();
1893
1894 assert_eq!(report.reminders.len(), 1);
1895 assert!(report.reminders[0].contains("index stale"));
1896 assert!(report.reminders[0].contains("tsift index ."));
1897 assert!(report.reminders[0].contains("no summaries are cached"));
1898 let json = serde_json::to_string(&report).unwrap();
1899 assert!(json.contains("\"reminders\""));
1900 }
1901
1902 #[test]
1903 fn status_human_format_missing() {
1904 let report = StatusReport {
1905 index: IndexStatus::Missing {
1906 missing_scopes: Vec::new(),
1907 },
1908 summaries: SummaryStatus::Unavailable,
1909 instructions: InstructionStatus::Missing,
1910 recommendations: Recommendations {
1911 use_commands: vec![],
1912 run: Some("tsift init && tsift index .".to_string()),
1913 },
1914 reminders: Vec::new(),
1915 };
1916 let output = format_human(&report, false);
1917 assert!(output.contains("index: missing"));
1918 assert!(output.contains("instructions: missing"));
1919 assert!(output.contains("summaries: unavailable"));
1920 assert!(output.contains("use: (none"));
1921 }
1922
1923 #[test]
1924 fn status_human_format_fresh() {
1925 let report = StatusReport {
1926 index: IndexStatus::Fresh {
1927 total_files: 42,
1928 stale_files: 0,
1929 last_indexed_secs_ago: 120,
1930 recovery: None,
1931 workspace_scopes: Vec::new(),
1932 missing_scopes: Vec::new(),
1933 },
1934 summaries: SummaryStatus::Available {
1935 cached_files: 30,
1936 total_indexed_files: 42,
1937 coverage_pct: 71,
1938 recovery: None,
1939 },
1940 instructions: InstructionStatus::Current {
1941 version: "0.1.0".to_string(),
1942 },
1943 recommendations: Recommendations {
1944 use_commands: vec![
1945 "search".to_string(),
1946 "explain".to_string(),
1947 "graph".to_string(),
1948 "summarize".to_string(),
1949 ],
1950 run: None,
1951 },
1952 reminders: Vec::new(),
1953 };
1954 let output = format_human(&report, false);
1955 assert!(output.contains("index: fresh"));
1956 assert!(output.contains("42 files"));
1957 assert!(output.contains("instructions: current (v0.1.0)"));
1958 assert!(output.contains("30/42 files cached (71%)"));
1959 assert!(output.contains("use: search, explain, graph, summarize"));
1960 }
1961
1962 #[test]
1963 fn status_human_format_compact() {
1964 let report = StatusReport {
1965 index: IndexStatus::Stale {
1966 total_files: 42,
1967 stale_files: 3,
1968 last_indexed_secs_ago: 120,
1969 recovery: None,
1970 workspace_scopes: Vec::new(),
1971 missing_scopes: Vec::new(),
1972 },
1973 summaries: SummaryStatus::None { recovery: None },
1974 instructions: InstructionStatus::Stale {
1975 found: Some("0.0.9".to_string()),
1976 expected: "0.1.0".to_string(),
1977 },
1978 recommendations: Recommendations {
1979 use_commands: vec![
1980 "search".to_string(),
1981 "explain".to_string(),
1982 "graph".to_string(),
1983 ],
1984 run: Some("tsift init && tsift index .".to_string()),
1985 },
1986 reminders: build_reminders(
1987 &IndexStatus::Stale {
1988 total_files: 42,
1989 stale_files: 3,
1990 last_indexed_secs_ago: 120,
1991 recovery: None,
1992 workspace_scopes: Vec::new(),
1993 missing_scopes: Vec::new(),
1994 },
1995 &SummaryStatus::None { recovery: None },
1996 &Recommendations {
1997 use_commands: vec![
1998 "search".to_string(),
1999 "explain".to_string(),
2000 "graph".to_string(),
2001 ],
2002 run: Some("tsift init && tsift index .".to_string()),
2003 },
2004 ".",
2005 ),
2006 };
2007 let output = format_human(&report, true);
2008 assert!(output.contains("index: stale tracked:42 stale:3"));
2009 assert!(output.contains("instructions: stale v=0.0.9 expected=0.1.0"));
2010 assert!(output.contains("reminder: index stale"));
2011 assert!(output.contains("use: search, explain, graph"));
2012 assert!(!output.contains("recommendations:"));
2013 }
2014
2015 #[test]
2016 fn status_human_format_mentions_snapshot_recovery() {
2017 let report = StatusReport {
2018 index: IndexStatus::Fresh {
2019 total_files: 3,
2020 stale_files: 0,
2021 last_indexed_secs_ago: 5,
2022 recovery: Some(ReadOnlyRecovery::SnapshotFallback),
2023 workspace_scopes: Vec::new(),
2024 missing_scopes: Vec::new(),
2025 },
2026 summaries: SummaryStatus::None { recovery: None },
2027 instructions: InstructionStatus::Current {
2028 version: "0.1.0".to_string(),
2029 },
2030 recommendations: Recommendations {
2031 use_commands: vec!["search".to_string()],
2032 run: None,
2033 },
2034 reminders: Vec::new(),
2035 };
2036 let output = format_human(&report, false);
2037 assert!(output.contains("recovery: snapshot fallback"));
2038 }
2039
2040 #[test]
2041 fn status_human_format_mentions_wal_snapshot_recovery() {
2042 let report = StatusReport {
2043 index: IndexStatus::Fresh {
2044 total_files: 3,
2045 stale_files: 0,
2046 last_indexed_secs_ago: 5,
2047 recovery: Some(ReadOnlyRecovery::SnapshotFallbackWal),
2048 workspace_scopes: Vec::new(),
2049 missing_scopes: Vec::new(),
2050 },
2051 summaries: SummaryStatus::None { recovery: None },
2052 instructions: InstructionStatus::Current {
2053 version: "0.1.0".to_string(),
2054 },
2055 recommendations: Recommendations {
2056 use_commands: vec!["search".to_string()],
2057 run: None,
2058 },
2059 reminders: Vec::new(),
2060 };
2061 let output = format_human(&report, false);
2062 assert!(output.contains("copied live WAL sidecars"));
2063 }
2064
2065 #[test]
2066 fn status_json_includes_recovery_when_snapshot_fallback_is_used() {
2067 let report = StatusReport {
2068 index: IndexStatus::Fresh {
2069 total_files: 1,
2070 stale_files: 0,
2071 last_indexed_secs_ago: 1,
2072 recovery: Some(ReadOnlyRecovery::SnapshotFallback),
2073 workspace_scopes: Vec::new(),
2074 missing_scopes: Vec::new(),
2075 },
2076 summaries: SummaryStatus::None { recovery: None },
2077 instructions: InstructionStatus::Current {
2078 version: "0.1.0".to_string(),
2079 },
2080 recommendations: Recommendations {
2081 use_commands: vec!["search".to_string()],
2082 run: None,
2083 },
2084 reminders: Vec::new(),
2085 };
2086 let json = serde_json::to_string(&report).unwrap();
2087 assert!(json.contains("\"recovery\":\"snapshot_fallback\""));
2088 }
2089
2090 #[test]
2091 fn status_human_format_mentions_summary_snapshot_recovery() {
2092 let report = StatusReport {
2093 index: IndexStatus::Fresh {
2094 total_files: 3,
2095 stale_files: 0,
2096 last_indexed_secs_ago: 5,
2097 recovery: None,
2098 workspace_scopes: Vec::new(),
2099 missing_scopes: Vec::new(),
2100 },
2101 summaries: SummaryStatus::Available {
2102 cached_files: 2,
2103 total_indexed_files: 3,
2104 coverage_pct: 66,
2105 recovery: Some(ReadOnlyRecovery::SnapshotFallback),
2106 },
2107 instructions: InstructionStatus::Current {
2108 version: "0.1.0".to_string(),
2109 },
2110 recommendations: Recommendations {
2111 use_commands: vec!["search".to_string(), "summarize".to_string()],
2112 run: None,
2113 },
2114 reminders: Vec::new(),
2115 };
2116 let output = format_human(&report, false);
2117 assert!(output.contains("summaries recovery: snapshot fallback"));
2118 }
2119
2120 #[test]
2121 fn status_json_includes_summary_recovery_when_snapshot_fallback_is_used() {
2122 let report = StatusReport {
2123 index: IndexStatus::Fresh {
2124 total_files: 1,
2125 stale_files: 0,
2126 last_indexed_secs_ago: 1,
2127 recovery: None,
2128 workspace_scopes: Vec::new(),
2129 missing_scopes: Vec::new(),
2130 },
2131 summaries: SummaryStatus::None {
2132 recovery: Some(ReadOnlyRecovery::SnapshotFallback),
2133 },
2134 instructions: InstructionStatus::Current {
2135 version: "0.1.0".to_string(),
2136 },
2137 recommendations: Recommendations {
2138 use_commands: vec!["search".to_string()],
2139 run: None,
2140 },
2141 reminders: Vec::new(),
2142 };
2143 let json = serde_json::to_string(&report).unwrap();
2144 assert!(json.contains("\"state\":\"none\""));
2145 assert!(json.contains("\"recovery\":\"snapshot_fallback\""));
2146 }
2147
2148 #[test]
2149 fn lock_report_marks_live_writer_and_journal() {
2150 let dir = TempDir::new().unwrap();
2151 let lock_path = dir.path().join(".tsift/index.lock");
2152 let journal_path = dir.path().join(".tsift/index.db-journal");
2153 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2154 let mut lock_file = OpenOptions::new()
2155 .read(true)
2156 .write(true)
2157 .create(true)
2158 .truncate(false)
2159 .open(&lock_path)
2160 .unwrap();
2161 assert!(lock_file.try_lock_exclusive().unwrap());
2162 use std::io::Write;
2163 writeln!(lock_file, "{}", std::process::id()).unwrap();
2164 std::fs::write(&journal_path, "locked").unwrap();
2165
2166 let report = check_locks(dir.path(), None, None).unwrap();
2167 assert!(matches!(
2168 report.writer_lock,
2169 WriterLockStatus::Live { pid: Some(_), .. }
2170 ));
2171 assert!(report.rollback_journal.present);
2172 assert!(
2173 report
2174 .recommended_action
2175 .contains("wait for the active tsift writer")
2176 );
2177 assert!(report.recommended_action.contains("tsift index"));
2178 }
2179
2180 #[test]
2181 fn lock_report_marks_live_writer_and_wal_sidecars() {
2182 let dir = TempDir::new().unwrap();
2183 let db_path = dir.path().join(".tsift/index.db");
2184 let db = IndexDb::open(&db_path).unwrap();
2185 drop(db);
2186
2187 let _lock = hold_wal_lock(&db_path);
2188
2189 let report = check_locks(dir.path(), None, None).unwrap();
2190 assert!(report.wal_sidecar.present);
2191 assert!(
2192 report
2193 .recommended_action
2194 .contains("wedged writer holding live WAL sidecars")
2195 );
2196 }
2197
2198 #[test]
2199 fn lock_report_marks_stale_writer_lock() {
2200 let dir = TempDir::new().unwrap();
2201 let lock_path = dir.path().join(".tsift/index.lock");
2202 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2203 std::fs::write(&lock_path, "999999").unwrap();
2204
2205 let report = check_locks(dir.path(), None, None).unwrap();
2206 assert!(matches!(
2207 report.writer_lock,
2208 WriterLockStatus::Stale {
2209 pid: Some(999999),
2210 ..
2211 }
2212 ));
2213 assert!(report.recommended_action.contains("reuse it automatically"));
2214 assert!(report.recommended_action.contains("tsift index"));
2215 }
2216
2217 #[test]
2218 fn status_instructions_stale_recommends_init() {
2219 let dir = TempDir::new().unwrap();
2220 std::fs::write(
2221 dir.path().join("AGENTS.md"),
2222 "<!-- tsift:code-navigation -->\n## Code Navigation\nOld.\n<!-- /tsift:code-navigation -->\n",
2223 )
2224 .unwrap();
2225 let report = check_status(dir.path()).unwrap();
2226 assert!(matches!(
2227 report.instructions,
2228 InstructionStatus::Stale { found: None, .. }
2229 ));
2230 assert!(
2231 report
2232 .recommendations
2233 .run
2234 .as_deref()
2235 .unwrap()
2236 .contains("tsift init")
2237 );
2238 }
2239
2240 #[test]
2241 fn status_instructions_current_after_init() {
2242 let dir = TempDir::new().unwrap();
2243 init::init(dir.path(), false, false).unwrap();
2244 let report = check_status(dir.path()).unwrap();
2245 assert!(matches!(
2246 report.instructions,
2247 InstructionStatus::Current { .. }
2248 ));
2249 }
2250}