Skip to main content

tsift_status/
status.rs

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        // A present graph.db is the "KG in use" signal that promotes `kg`.
1391        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        // kg follows graph in the ordering
1397        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}