use anyhow::Result;
use lazily::{Computed, Context as LazyContext, Source};
use serde::Serialize;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tsift_index::config;
use tsift_index::index::{
IndexDb, ReadOnlyInspectResult, WriterLockProbe, probe_writer_lock, writer_lock_path,
};
use tsift_index::init::{self, InstructionStatus};
use tsift_sqlite::{
ReadOnlyRecovery, rollback_journal_path, shared_memory_sidecar_path, wal_sidecar_path,
};
use tsift_summarize::summarize::SummaryDb;
type CachedInspectResult = std::result::Result<ReadOnlyInspectResult, String>;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct StatusInspectKey {
db_path: PathBuf,
root: PathBuf,
prune: bool,
}
pub struct StatusCheckCache {
ctx: LazyContext,
epoch: Source<u64>,
inspect_slots: RefCell<HashMap<StatusInspectKey, Computed<CachedInspectResult>>>,
}
impl Default for StatusCheckCache {
fn default() -> Self {
Self::new()
}
}
impl StatusCheckCache {
pub fn new() -> Self {
let ctx = LazyContext::new();
let epoch = ctx.source(0u64);
Self {
ctx,
epoch,
inspect_slots: RefCell::new(HashMap::new()),
}
}
pub fn invalidate_all(&self) {
let epoch = self.ctx.get(&self.epoch);
self.ctx.set(&self.epoch, epoch.wrapping_add(1));
}
fn inspect_read_only(
&self,
db_path: &Path,
root: &Path,
prune: bool,
) -> Result<ReadOnlyInspectResult> {
let key = StatusInspectKey {
db_path: db_path.to_path_buf(),
root: root.to_path_buf(),
prune,
};
let slot = {
let mut slots = self.inspect_slots.borrow_mut();
if let Some(slot) = slots.get(&key) {
*slot
} else {
let slot_key = key.clone();
let epoch = self.epoch;
let slot = self.ctx.slot(move |ctx| {
let _epoch = ctx.get(&epoch);
IndexDb::inspect_read_only(&slot_key.db_path, &slot_key.root, slot_key.prune)
.map_err(|err| format!("{err:#}"))
});
slots.insert(key, slot);
slot
}
};
self.ctx
.get(&slot)
.map_err(|message| anyhow::anyhow!("{message}"))
}
}
#[derive(Debug, Serialize)]
pub struct StatusReport {
pub index: IndexStatus,
pub summaries: SummaryStatus,
pub instructions: InstructionStatus,
pub recommendations: Recommendations,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub reminders: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "state")]
pub enum IndexStatus {
#[serde(rename = "fresh")]
Fresh {
total_files: usize,
stale_files: usize,
last_indexed_secs_ago: u64,
#[serde(skip_serializing_if = "Option::is_none")]
recovery: Option<ReadOnlyRecovery>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
workspace_scopes: Vec<WorkspaceScopeStatus>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
missing_scopes: Vec<MissingWorkspaceScopeStatus>,
},
#[serde(rename = "stale")]
Stale {
total_files: usize,
stale_files: usize,
last_indexed_secs_ago: u64,
#[serde(skip_serializing_if = "Option::is_none")]
recovery: Option<ReadOnlyRecovery>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
workspace_scopes: Vec<WorkspaceScopeStatus>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
missing_scopes: Vec<MissingWorkspaceScopeStatus>,
},
#[serde(rename = "missing")]
Missing {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
missing_scopes: Vec<MissingWorkspaceScopeStatus>,
},
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct WorkspaceScopeStatus {
pub scope: String,
pub db_path: PathBuf,
pub total_files: usize,
pub stale_files: usize,
pub last_indexed_secs_ago: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery: Option<ReadOnlyRecovery>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct MissingWorkspaceScopeStatus {
pub scope: String,
pub db_path: PathBuf,
}
#[derive(Debug, Serialize)]
#[serde(tag = "state")]
pub enum SummaryStatus {
#[serde(rename = "available")]
Available {
cached_files: usize,
total_indexed_files: usize,
coverage_pct: u8,
#[serde(skip_serializing_if = "Option::is_none")]
recovery: Option<ReadOnlyRecovery>,
},
#[serde(rename = "none")]
None {
#[serde(skip_serializing_if = "Option::is_none")]
recovery: Option<ReadOnlyRecovery>,
},
#[serde(rename = "unavailable")]
Unavailable,
}
#[derive(Debug, Serialize)]
pub struct Recommendations {
#[serde(rename = "use")]
pub use_commands: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct LockReport {
pub label: String,
pub source_root: PathBuf,
pub db_path: PathBuf,
pub writer_lock: WriterLockStatus,
pub rollback_journal: SidecarStatus,
pub wal_sidecar: SidecarStatus,
pub shared_memory_sidecar: SidecarStatus,
pub reindex_command: String,
pub recommended_action: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum WriterLockStatus {
Absent { path: PathBuf },
Live { path: PathBuf, pid: Option<u32> },
Stale { path: PathBuf, pid: Option<u32> },
Unknown { path: PathBuf },
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct SidecarStatus {
pub path: PathBuf,
pub present: bool,
}
pub fn check_status(root: &Path) -> Result<StatusReport> {
let cache = StatusCheckCache::new();
check_status_with_cache(root, &cache)
}
pub fn check_status_with_cache(root: &Path, cache: &StatusCheckCache) -> Result<StatusReport> {
let workspace_scopes = config::Config::submodule_dirs(root)?;
let workspace = !workspace_scopes.is_empty();
let summaries_db_path = root.join(".tsift/summaries.db");
let index = check_index(root, cache)?;
let summaries = check_summaries(root, &summaries_db_path, &index, cache)?;
let summarize_extract = recommended_summarize_extract_path(root, &index, &workspace_scopes);
let instructions = init::check_instruction_version(root);
let kg_present = root.join(".tsift/graph.db").exists();
let recommendations = build_recommendations(
&index,
&summaries,
&instructions,
workspace,
&summarize_extract,
kg_present,
);
let reminders = build_reminders(&index, &summaries, &recommendations, &summarize_extract);
Ok(StatusReport {
index,
summaries,
instructions,
recommendations,
reminders,
})
}
fn check_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
if !config::Config::submodule_dirs(root)?.is_empty() {
return check_workspace_index(root, cache);
}
check_single_index(root, cache)
}
fn check_single_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
let db_path = root.join(".tsift/index.db");
if !db_path.exists() {
return check_workspace_index(root, cache);
}
let last_indexed_secs_ago = db_path
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let inspection = cache.inspect_read_only(&db_path, root, false)?;
let stale_files =
inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
if stale_files > 0 {
Ok(IndexStatus::Stale {
total_files: inspection.total_files,
stale_files,
last_indexed_secs_ago,
recovery: inspection.recovery,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
})
} else {
Ok(IndexStatus::Fresh {
total_files: inspection.total_files,
stale_files: 0,
last_indexed_secs_ago,
recovery: inspection.recovery,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
})
}
}
fn check_workspace_index(root: &Path, cache: &StatusCheckCache) -> Result<IndexStatus> {
let cfg = config::Config::load(root)?;
let mut scopes = Vec::new();
let mut missing_scopes = Vec::new();
for scope in config::Config::submodule_dirs(root)? {
let db_path = cfg.db_path_for(root, &scope.id);
if !scope.source_root.exists() || !db_path.exists() {
missing_scopes.push(MissingWorkspaceScopeStatus {
scope: scope.id,
db_path,
});
continue;
}
let last_indexed_secs_ago = db_path
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let inspection = cache.inspect_read_only(&db_path, &scope.source_root, false)?;
let stale_files =
inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
scopes.push(WorkspaceScopeStatus {
scope: scope.id,
db_path,
total_files: inspection.total_files,
stale_files,
last_indexed_secs_ago,
recovery: inspection.recovery,
});
}
if scopes.is_empty() {
return Ok(IndexStatus::Missing { missing_scopes });
}
scopes.sort_by(|left, right| left.scope.cmp(&right.scope));
let total_files = scopes.iter().map(|scope| scope.total_files).sum();
let stale_files = scopes.iter().map(|scope| scope.stale_files).sum();
let last_indexed_secs_ago = scopes
.iter()
.map(|scope| scope.last_indexed_secs_ago)
.min()
.unwrap_or(0);
let recovery = scopes.iter().find_map(|scope| scope.recovery);
if stale_files > 0 || !missing_scopes.is_empty() {
Ok(IndexStatus::Stale {
total_files,
stale_files,
last_indexed_secs_ago,
recovery,
workspace_scopes: scopes,
missing_scopes,
})
} else {
Ok(IndexStatus::Fresh {
total_files,
stale_files: 0,
last_indexed_secs_ago,
recovery,
workspace_scopes: scopes,
missing_scopes,
})
}
}
pub fn check_locks(
root: &Path,
path_hint: Option<&Path>,
scope: Option<&str>,
) -> Result<LockReport> {
let (label, source_root, db_path, reindex_command) =
resolve_lock_target(root, path_hint, scope)?;
let lock_path = writer_lock_path(&db_path);
let writer_lock = match probe_writer_lock(&lock_path)? {
WriterLockProbe::Absent { path } => WriterLockStatus::Absent { path },
WriterLockProbe::Live { path, pid } => WriterLockStatus::Live { path, pid },
WriterLockProbe::Stale { path, pid } => WriterLockStatus::Stale { path, pid },
WriterLockProbe::Unknown { path } => WriterLockStatus::Unknown { path },
};
let rollback_journal = SidecarStatus {
path: rollback_journal_path(&db_path),
present: rollback_journal_path(&db_path).exists(),
};
let wal_sidecar = SidecarStatus {
path: wal_sidecar_path(&db_path),
present: wal_sidecar_path(&db_path).exists(),
};
let shared_memory_sidecar = SidecarStatus {
path: shared_memory_sidecar_path(&db_path),
present: shared_memory_sidecar_path(&db_path).exists(),
};
let recommended_action = build_lock_recommendation(
&writer_lock,
&rollback_journal,
&wal_sidecar,
&shared_memory_sidecar,
&reindex_command,
);
Ok(LockReport {
label,
source_root,
db_path,
writer_lock,
rollback_journal,
wal_sidecar,
shared_memory_sidecar,
reindex_command,
recommended_action,
})
}
fn check_summaries(
root: &Path,
db_path: &Path,
index: &IndexStatus,
cache: &StatusCheckCache,
) -> Result<SummaryStatus> {
if matches!(index, IndexStatus::Missing { .. }) {
return Ok(SummaryStatus::Unavailable);
}
if !db_path.exists() {
return Ok(SummaryStatus::None { recovery: None });
}
let read_only = SummaryDb::open_read_only_with_recovery(db_path)?;
let recovery = read_only.recovery;
let db = read_only.db;
let cached_summary_paths = db.cached_file_paths()?.into_iter().collect::<HashSet<_>>();
let live_indexed_files = live_indexed_summary_paths(root, index, cache)?;
let total_indexed_files = live_indexed_files.len();
let cached_files = cached_summary_paths
.intersection(&live_indexed_files)
.count();
if cached_files == 0 {
return Ok(SummaryStatus::None { recovery });
}
let coverage_pct = if total_indexed_files > 0 {
((cached_files as f64 / total_indexed_files as f64) * 100.0).min(100.0) as u8
} else {
0
};
Ok(SummaryStatus::Available {
cached_files,
total_indexed_files,
coverage_pct,
recovery,
})
}
fn live_indexed_summary_paths(
root: &Path,
index: &IndexStatus,
cache: &StatusCheckCache,
) -> Result<HashSet<String>> {
match index {
IndexStatus::Fresh {
workspace_scopes, ..
}
| IndexStatus::Stale {
workspace_scopes, ..
} => {
if workspace_scopes.is_empty() {
tracked_summary_paths_from_inspection(
cache,
&root.join(".tsift/index.db"),
root,
root,
)
} else {
let mut paths = HashSet::new();
let source_roots = config::Config::submodule_dirs(root)?
.into_iter()
.map(|scope| (scope.id, scope.source_root))
.collect::<HashMap<_, _>>();
for scope in workspace_scopes {
let source_root = source_roots
.get(&scope.scope)
.map(PathBuf::as_path)
.unwrap_or(root);
paths.extend(tracked_summary_paths_from_inspection(
cache,
&scope.db_path,
root,
source_root,
)?);
}
Ok(paths)
}
}
IndexStatus::Missing { .. } => Ok(HashSet::new()),
}
}
fn tracked_summary_paths_from_inspection(
cache: &StatusCheckCache,
db_path: &Path,
report_root: &Path,
inspect_root: &Path,
) -> Result<HashSet<String>> {
let inspection = cache.inspect_read_only(db_path, inspect_root, false)?;
Ok(inspection
.tracked_file_paths
.into_iter()
.map(PathBuf::from)
.filter(|path| path.is_file())
.map(|path| {
path.strip_prefix(report_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.to_string()
})
.collect())
}
fn build_recommendations(
index: &IndexStatus,
summaries: &SummaryStatus,
instructions: &InstructionStatus,
workspace: bool,
summarize_extract: &str,
kg_present: bool,
) -> Recommendations {
let refresh = !matches!(instructions, InstructionStatus::Current { .. });
let index_cmd = if workspace {
"tsift index --workspace ."
} else {
"tsift index ."
};
let init_cmd = if workspace {
"tsift init --workspace"
} else {
"tsift init"
};
match index {
IndexStatus::Missing { missing_scopes } => Recommendations {
use_commands: vec![],
run: if refresh {
Some(format!(
"{init_cmd} && {}",
format_index_run_with_gap(index_cmd, 0, missing_scopes.len())
))
} else {
Some(format_index_run_with_gap(
index_cmd,
0,
missing_scopes.len(),
))
},
},
IndexStatus::Stale {
stale_files,
missing_scopes,
..
} => {
let mut use_cmds = vec![
"search".to_string(),
"explain".to_string(),
"graph".to_string(),
];
if kg_present {
use_cmds.push("kg".to_string());
}
if matches!(summaries, SummaryStatus::Available { .. }) {
use_cmds.push("summarize".to_string());
}
let run_msg = format_index_run_with_gap(index_cmd, *stale_files, missing_scopes.len());
let run_msg = if refresh {
format!("{init_cmd} && {run_msg}")
} else {
run_msg
};
Recommendations {
use_commands: use_cmds,
run: Some(run_msg),
}
}
IndexStatus::Fresh { .. } => {
let mut use_cmds = vec![
"search".to_string(),
"explain".to_string(),
"graph".to_string(),
];
if kg_present {
use_cmds.push("kg".to_string());
}
let mut run = match summaries {
SummaryStatus::Available {
cached_files,
total_indexed_files,
..
} => {
use_cmds.push("summarize".to_string());
let uncached = total_indexed_files.saturating_sub(*cached_files);
if uncached > 0 {
Some(format!(
"tsift summarize --extract {} ({} uncached file{})",
summarize_extract,
uncached,
if uncached == 1 { "" } else { "s" }
))
} else {
None
}
}
SummaryStatus::None { .. } => {
Some(format!("tsift summarize --extract {}", summarize_extract))
}
SummaryStatus::Unavailable => None,
};
if refresh {
run = Some(match run {
Some(existing) => format!("{init_cmd} && {existing}"),
None => init_cmd.to_string(),
});
}
Recommendations {
use_commands: use_cmds,
run,
}
}
}
}
fn build_reminders(
index: &IndexStatus,
summaries: &SummaryStatus,
recommendations: &Recommendations,
summarize_extract: &str,
) -> Vec<String> {
let IndexStatus::Stale {
stale_files,
missing_scopes,
..
} = index
else {
return Vec::new();
};
let run =
status_recommendation_command(recommendations.run.as_deref().unwrap_or("tsift index ."));
let mut reminder = format!(
"index stale: run `{}` before relying on tsift search/explain/graph",
run
);
if *stale_files > 0 {
reminder.push_str(&format!(
" ({} stale file{})",
stale_files,
if *stale_files == 1 { "" } else { "s" }
));
}
if !missing_scopes.is_empty() {
reminder.push_str(&format!(
" ({} missing workspace scope{})",
missing_scopes.len(),
if missing_scopes.len() == 1 { "" } else { "s" }
));
}
if matches!(summaries, SummaryStatus::None { .. }) {
reminder.push_str(&format!(
"; no summaries are cached, so run `tsift summarize --extract {}` after the index is fresh when summary refs are needed",
summarize_extract
));
}
vec![reminder]
}
fn status_recommendation_command(run: &str) -> &str {
run.split_once(" (")
.map(|(command, _)| command)
.unwrap_or(run)
}
fn recommended_summarize_extract_path(
root: &Path,
index: &IndexStatus,
workspace_scopes: &[config::WorkspaceScope],
) -> String {
if !workspace_scopes.is_empty() {
return common_extract_scope(
workspace_scopes
.iter()
.map(|scope| Path::new(&scope.relative_path)),
false,
)
.unwrap_or_else(|| ".".to_string());
}
match index {
IndexStatus::Fresh { .. } | IndexStatus::Stale { .. } => common_extract_scope(
IndexDb::file_paths_read_only(&root.join(".tsift/index.db"))
.ok()
.into_iter()
.flatten()
.map(PathBuf::from)
.map(|path| path.strip_prefix(root).map(PathBuf::from).unwrap_or(path)),
true,
)
.unwrap_or_else(|| ".".to_string()),
IndexStatus::Missing { .. } => ".".to_string(),
}
}
fn common_extract_scope<I, P>(paths: I, treat_inputs_as_files: bool) -> Option<String>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut common: Option<Vec<String>> = None;
for raw_path in paths {
let path = raw_path.as_ref();
let scope = if treat_inputs_as_files {
path.parent().unwrap_or_else(|| Path::new("."))
} else {
path
};
let components = scope
.components()
.filter_map(|component| match component {
std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>();
match &mut common {
None => common = Some(components),
Some(existing) => {
let shared_len = existing
.iter()
.zip(components.iter())
.take_while(|(left, right)| left == right)
.count();
existing.truncate(shared_len);
}
}
}
Some(match common {
None => ".".to_string(),
Some(components) if components.is_empty() => ".".to_string(),
Some(components) => format!("{}/", components.join("/")),
})
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
fn resolve_lock_target(
root: &Path,
path_hint: Option<&Path>,
scope: Option<&str>,
) -> Result<(String, PathBuf, PathBuf, String)> {
let cfg = config::Config::load(root)?;
if let Some(scope_name) = scope {
let scope = config::Config::resolve_submodule(root, scope_name)?;
Ok((
format!("submodule `{}` index", scope.id),
scope.source_root.clone(),
cfg.db_path_for(root, &scope.id),
format!("tsift index --submodule {} {}", scope.id, root.display()),
))
} else if let Some(path_hint) = path_hint {
if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
return Ok((
format!("submodule `{}` index", scope.id),
scope.source_root.clone(),
cfg.db_path_for(root, &scope.id),
format!("tsift index --submodule {} {}", scope.id, root.display()),
));
}
Ok((
"index".to_string(),
root.to_path_buf(),
root.join(".tsift/index.db"),
format!("tsift index {}", root.display()),
))
} else {
Ok((
"index".to_string(),
root.to_path_buf(),
root.join(".tsift/index.db"),
format!("tsift index {}", root.display()),
))
}
}
fn build_lock_recommendation(
writer_lock: &WriterLockStatus,
rollback_journal: &SidecarStatus,
wal_sidecar: &SidecarStatus,
shared_memory_sidecar: &SidecarStatus,
reindex_command: &str,
) -> String {
let has_live_wal_state = wal_sidecar.present || shared_memory_sidecar.present;
match writer_lock {
WriterLockStatus::Live { pid, .. } => {
let pid_hint = pid
.map(|value| format!(" (pid {})", value))
.unwrap_or_default();
if has_live_wal_state {
format!(
"wait for the active tsift writer{} to finish, then run `{}` to rebuild a clean WAL-mode index after the live sidecars clear.",
pid_hint, reindex_command
)
} else if rollback_journal.present {
format!(
"wait for the active tsift writer{} to finish, then run `{}` to rebuild a clean WAL-mode index.",
pid_hint, reindex_command
)
} else {
format!(
"wait for the active tsift writer{} to finish before rerunning `{}`.",
pid_hint, reindex_command
)
}
}
WriterLockStatus::Stale { path, .. } | WriterLockStatus::Unknown { path } => {
format!(
"the lock sidecar at `{}` is stale metadata only; rerun `{}` and tsift will reuse it automatically.",
path.display(),
reindex_command
)
}
WriterLockStatus::Absent { .. } if has_live_wal_state => {
format!(
"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.",
reindex_command
)
}
WriterLockStatus::Absent { .. } if rollback_journal.present => {
format!(
"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.",
reindex_command
)
}
WriterLockStatus::Absent { .. } => "no lock remediation needed".to_string(),
}
}
fn format_recovery_line(recovery: ReadOnlyRecovery, compact: bool) -> String {
match (recovery, compact) {
(ReadOnlyRecovery::SnapshotFallback, true) => "recovery:snapshot_fallback\n".to_string(),
(ReadOnlyRecovery::SnapshotFallback, false) => {
"recovery: snapshot fallback (rollback-journal lock on live index)\n".to_string()
}
(ReadOnlyRecovery::SnapshotFallbackWal, true) => {
"recovery:snapshot_fallback_wal\n".to_string()
}
(ReadOnlyRecovery::SnapshotFallbackWal, false) => {
"recovery: snapshot fallback (copied live WAL sidecars from index db)\n".to_string()
}
}
}
fn index_recovery(index: &IndexStatus) -> Option<ReadOnlyRecovery> {
match index {
IndexStatus::Fresh { recovery, .. } | IndexStatus::Stale { recovery, .. } => *recovery,
IndexStatus::Missing { .. } => None,
}
}
fn summary_recovery(summaries: &SummaryStatus) -> Option<ReadOnlyRecovery> {
match summaries {
SummaryStatus::Available { recovery, .. } | SummaryStatus::None { recovery } => *recovery,
SummaryStatus::Unavailable => None,
}
}
fn format_summary_recovery_line(recovery: ReadOnlyRecovery, compact: bool) -> String {
match (recovery, compact) {
(ReadOnlyRecovery::SnapshotFallback, true) => {
"summaries_recovery:snapshot_fallback\n".to_string()
}
(ReadOnlyRecovery::SnapshotFallback, false) => {
"summaries recovery: snapshot fallback (rollback-journal lock on live summaries db)\n"
.to_string()
}
(ReadOnlyRecovery::SnapshotFallbackWal, true) => {
"summaries_recovery:snapshot_fallback_wal\n".to_string()
}
(ReadOnlyRecovery::SnapshotFallbackWal, false) => {
"summaries recovery: snapshot fallback (copied live WAL sidecars from summaries db)\n"
.to_string()
}
}
}
fn workspace_scopes(index: &IndexStatus) -> &[WorkspaceScopeStatus] {
match index {
IndexStatus::Fresh {
workspace_scopes, ..
}
| IndexStatus::Stale {
workspace_scopes, ..
} => workspace_scopes.as_slice(),
IndexStatus::Missing { .. } => &[],
}
}
fn missing_workspace_scopes(index: &IndexStatus) -> &[MissingWorkspaceScopeStatus] {
match index {
IndexStatus::Fresh { missing_scopes, .. }
| IndexStatus::Stale { missing_scopes, .. }
| IndexStatus::Missing { missing_scopes } => missing_scopes.as_slice(),
}
}
fn format_workspace_scope_line(scope: &WorkspaceScopeStatus, compact: bool) -> String {
let state = if scope.stale_files > 0 {
"stale"
} else {
"fresh"
};
if compact {
format!(
"scope:{} state:{} tracked:{} stale:{} age:{}\n",
scope.scope,
state,
scope.total_files,
scope.stale_files,
format_duration(scope.last_indexed_secs_ago)
)
} else if scope.stale_files > 0 {
format!(
" scope {}: stale (last indexed {}, {} files tracked, {} stale)\n",
scope.scope,
format_duration(scope.last_indexed_secs_ago),
scope.total_files,
scope.stale_files
)
} else {
format!(
" scope {}: fresh (last indexed {}, {} files tracked)\n",
scope.scope,
format_duration(scope.last_indexed_secs_ago),
scope.total_files
)
}
}
fn format_missing_workspace_scope_line(
scope: &MissingWorkspaceScopeStatus,
compact: bool,
) -> String {
if compact {
format!("scope:{} state:missing\n", scope.scope)
} else {
format!(
" scope {}: missing index ({})\n",
scope.scope,
scope.db_path.display()
)
}
}
fn format_index_run_with_gap(index_cmd: &str, stale_files: usize, missing_scopes: usize) -> String {
let mut notes = Vec::new();
if stale_files > 0 {
notes.push(format!(
"{} stale file{}",
stale_files,
if stale_files == 1 { "" } else { "s" }
));
}
if missing_scopes > 0 {
notes.push(format!(
"{} missing scope{}",
missing_scopes,
if missing_scopes == 1 { "" } else { "s" }
));
}
if notes.is_empty() {
index_cmd.to_string()
} else {
format!("{} ({})", index_cmd, notes.join(", "))
}
}
pub fn format_human(report: &StatusReport, compact: bool) -> String {
let mut out = String::new();
match &report.index {
IndexStatus::Missing { missing_scopes } => {
if compact {
if missing_scopes.is_empty() {
out.push_str("index: missing\n");
} else {
out.push_str(&format!(
"index: missing workspace_missing:{}\n",
missing_scopes.len()
));
}
} else if missing_scopes.is_empty() {
out.push_str("index: missing\n");
} else {
out.push_str(&format!(
"index: missing (workspace, {} scope{} not indexed)\n",
missing_scopes.len(),
if missing_scopes.len() == 1 { "" } else { "s" }
));
}
}
IndexStatus::Fresh {
total_files,
last_indexed_secs_ago,
workspace_scopes,
..
} => {
if compact {
if workspace_scopes.is_empty() {
out.push_str(&format!(
"index: fresh tracked:{} age:{}\n",
total_files,
format_duration(*last_indexed_secs_ago)
));
} else {
out.push_str(&format!(
"index: fresh workspace:{} tracked:{} age:{}\n",
workspace_scopes.len(),
total_files,
format_duration(*last_indexed_secs_ago)
));
}
} else {
if workspace_scopes.is_empty() {
out.push_str(&format!(
"index: fresh (last indexed {}, {} files tracked)\n",
format_duration(*last_indexed_secs_ago),
total_files
));
} else {
out.push_str(&format!(
"index: fresh (workspace, {} scopes, last indexed {}, {} files tracked)\n",
workspace_scopes.len(),
format_duration(*last_indexed_secs_ago),
total_files
));
}
}
}
IndexStatus::Stale {
total_files,
stale_files,
last_indexed_secs_ago,
workspace_scopes,
missing_scopes,
..
} => {
if compact {
if workspace_scopes.is_empty() {
out.push_str(&format!(
"index: stale tracked:{} stale:{} age:{}\n",
total_files,
stale_files,
format_duration(*last_indexed_secs_ago)
));
} else {
let missing_suffix = if missing_scopes.is_empty() {
String::new()
} else {
format!(" missing:{}", missing_scopes.len())
};
out.push_str(&format!(
"index: stale workspace:{}{} tracked:{} stale:{} age:{}\n",
workspace_scopes.len(),
missing_suffix,
total_files,
stale_files,
format_duration(*last_indexed_secs_ago)
));
}
} else {
if workspace_scopes.is_empty() {
out.push_str(&format!(
"index: stale (last indexed {}, {} files tracked, {} stale)\n",
format_duration(*last_indexed_secs_ago),
total_files,
stale_files
));
} else {
out.push_str(&format!(
"index: stale (workspace, {} indexed scope{}, {} missing scope{}, last indexed {}, {} files tracked, {} stale)\n",
workspace_scopes.len(),
if workspace_scopes.len() == 1 { "" } else { "s" },
missing_scopes.len(),
if missing_scopes.len() == 1 { "" } else { "s" },
format_duration(*last_indexed_secs_ago),
total_files,
stale_files
));
}
}
}
}
for scope in workspace_scopes(&report.index) {
out.push_str(&format_workspace_scope_line(scope, compact));
}
for scope in missing_workspace_scopes(&report.index) {
out.push_str(&format_missing_workspace_scope_line(scope, compact));
}
if let Some(recovery) = index_recovery(&report.index) {
out.push_str(&format_recovery_line(recovery, compact));
}
match &report.instructions {
InstructionStatus::Current { version } => {
if compact {
out.push_str(&format!("instructions: current v={}\n", version));
} else {
out.push_str(&format!("instructions: current (v{})\n", version));
}
}
InstructionStatus::Stale {
found: Some(v),
expected,
} => {
if compact {
out.push_str(&format!(
"instructions: stale v={} expected={}\n",
v, expected
));
} else {
out.push_str(&format!(
"instructions: stale (v{} installed, v{} available — run tsift init)\n",
v, expected
));
}
}
InstructionStatus::Stale {
found: None,
expected,
} => {
if compact {
out.push_str(&format!(
"instructions: stale pre-versioned expected={}\n",
expected
));
} else {
out.push_str(&format!(
"instructions: stale (pre-versioned, v{} available — run tsift init)\n",
expected
));
}
}
InstructionStatus::Missing => {
out.push_str("instructions: missing (run tsift init)\n");
}
}
match &report.summaries {
SummaryStatus::Available {
cached_files,
total_indexed_files,
coverage_pct,
..
} => {
if compact {
out.push_str(&format!(
"summaries: {}/{} ({}%)\n",
cached_files, total_indexed_files, coverage_pct
));
} else {
out.push_str(&format!(
"summaries: {}/{} files cached ({}%)\n",
cached_files, total_indexed_files, coverage_pct
));
}
}
SummaryStatus::None { .. } => {
out.push_str("summaries: none\n");
}
SummaryStatus::Unavailable => {
out.push_str("summaries: unavailable (no index)\n");
}
}
if let Some(recovery) = summary_recovery(&report.summaries) {
out.push_str(&format_summary_recovery_line(recovery, compact));
}
if compact {
for reminder in &report.reminders {
out.push_str(&format!("reminder: {}\n", reminder));
}
if report.recommendations.use_commands.is_empty() {
out.push_str("use: none\n");
} else {
out.push_str(&format!(
"use: {}\n",
report.recommendations.use_commands.join(", ")
));
}
if let Some(run) = &report.recommendations.run {
out.push_str(&format!("run: {}\n", run));
}
} else {
if !report.reminders.is_empty() {
out.push_str("reminders:\n");
for reminder in &report.reminders {
out.push_str(&format!(" - {}\n", reminder));
}
}
out.push_str("recommendations:\n");
if report.recommendations.use_commands.is_empty() {
out.push_str(" use: (none — run tsift index first)\n");
} else {
out.push_str(&format!(
" use: {}\n",
report.recommendations.use_commands.join(", ")
));
}
if let Some(run) = &report.recommendations.run {
out.push_str(&format!(" run: {}\n", run));
}
}
out
}
pub fn format_locks_human(report: &LockReport, compact: bool) -> String {
let lock_line = match &report.writer_lock {
WriterLockStatus::Absent { path } => format!("lock: absent {}\n", path.display()),
WriterLockStatus::Live { path, pid } => match pid {
Some(value) => format!("lock: live pid:{} {}\n", value, path.display()),
None => format!("lock: live {}\n", path.display()),
},
WriterLockStatus::Stale { path, pid } => match pid {
Some(value) => format!("lock: stale pid:{} {}\n", value, path.display()),
None => format!("lock: stale {}\n", path.display()),
},
WriterLockStatus::Unknown { path } => format!("lock: unknown {}\n", path.display()),
};
let journal_line = if report.rollback_journal.present {
format!(
"journal: present {}\n",
report.rollback_journal.path.display()
)
} else {
format!(
"journal: absent {}\n",
report.rollback_journal.path.display()
)
};
let wal_line = if report.wal_sidecar.present {
format!("wal: present {}\n", report.wal_sidecar.path.display())
} else {
format!("wal: absent {}\n", report.wal_sidecar.path.display())
};
let shm_line = if report.shared_memory_sidecar.present {
format!(
"shm: present {}\n",
report.shared_memory_sidecar.path.display()
)
} else {
format!(
"shm: absent {}\n",
report.shared_memory_sidecar.path.display()
)
};
let mut out = String::new();
if compact {
out.push_str(&format!(
"target:{} db:{}\n",
report.label,
report.db_path.display()
));
out.push_str(&lock_line);
out.push_str(&journal_line);
out.push_str(&wal_line);
out.push_str(&shm_line);
out.push_str(&format!("run:{}\n", report.reindex_command));
out.push_str(&format!("next:{}\n", report.recommended_action));
} else {
out.push_str(&format!("target: {}\n", report.label));
out.push_str(&format!("source: {}\n", report.source_root.display()));
out.push_str(&format!("db: {}\n", report.db_path.display()));
out.push_str(&lock_line);
out.push_str(&journal_line);
out.push_str(&wal_line);
out.push_str(&shm_line);
out.push_str(&format!("run: {}\n", report.reindex_command));
out.push_str(&format!("next: {}\n", report.recommended_action));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use fs4::fs_std::FileExt;
use rusqlite::Connection;
use std::fs::OpenOptions;
use tempfile::TempDir;
use tsift_index::config::Config;
use tsift_sqlite::wal_sidecar_path;
fn setup_workspace() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".gitmodules"),
r#"[submodule "src/alpha"]
path = src/alpha
url = https://example.com/alpha
[submodule "src/beta"]
path = src/beta
url = https://example.com/beta
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src/alpha")).unwrap();
std::fs::create_dir_all(dir.path().join("src/beta")).unwrap();
std::fs::write(
dir.path().join("src/alpha/lib.rs"),
"fn alpha_helper() {}\n",
)
.unwrap();
std::fs::write(dir.path().join("src/beta/lib.rs"), "fn beta_helper() {}\n").unwrap();
dir
}
fn hold_wal_lock(db_path: &Path) -> Connection {
let conn = Connection::open(db_path).unwrap();
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA wal_autocheckpoint=0;
CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
INSERT INTO wal_lock_probe DEFAULT VALUES;
PRAGMA locking_mode=EXCLUSIVE;
BEGIN EXCLUSIVE;",
)
.unwrap();
assert!(wal_sidecar_path(db_path).exists());
conn
}
#[test]
fn status_no_index() {
let dir = TempDir::new().unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(
report.index,
IndexStatus::Missing { ref missing_scopes } if missing_scopes.is_empty()
));
assert!(matches!(report.summaries, SummaryStatus::Unavailable));
assert!(matches!(report.instructions, InstructionStatus::Missing));
assert!(report.recommendations.use_commands.is_empty());
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init && tsift index .")
);
}
#[test]
fn status_fresh_index_no_summaries() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(
report.index,
IndexStatus::Fresh { stale_files: 0, .. }
));
assert!(matches!(report.summaries, SummaryStatus::None { .. }));
let cmds = &report.recommendations.use_commands;
assert!(cmds.contains(&"search".to_string()));
assert!(cmds.contains(&"explain".to_string()));
assert!(cmds.contains(&"graph".to_string()));
assert!(!cmds.contains(&"summarize".to_string()));
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init && tsift summarize --extract .")
);
}
#[test]
fn status_fresh_index_with_graph_db_recommends_kg() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
std::fs::write(dir.path().join(".tsift/graph.db"), b"").unwrap();
let report = check_status(dir.path()).unwrap();
let cmds = &report.recommendations.use_commands;
assert!(cmds.contains(&"kg".to_string()));
let graph_idx = cmds.iter().position(|c| c == "graph").unwrap();
let kg_idx = cmds.iter().position(|c| c == "kg").unwrap();
assert!(kg_idx > graph_idx);
}
#[test]
fn status_fresh_index_without_graph_db_omits_kg() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let report = check_status(dir.path()).unwrap();
assert!(!report.recommendations.use_commands.contains(&"kg".to_string()));
}
#[test]
fn status_cache_reuses_index_inspection_until_invalidated() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db_path = dir.path().join(".tsift/index.db");
let db = IndexDb::open(&db_path).unwrap();
db.apply_changes(dir.path()).unwrap();
drop(db);
let cache = StatusCheckCache::new();
let report = check_status_with_cache(dir.path(), &cache).unwrap();
assert!(matches!(
report.index,
IndexStatus::Fresh { recovery: None, .. }
));
let _lock = hold_wal_lock(&db_path);
let cached_report = check_status_with_cache(dir.path(), &cache).unwrap();
assert!(matches!(
cached_report.index,
IndexStatus::Fresh { recovery: None, .. }
));
cache.invalidate_all();
let refreshed_report = check_status_with_cache(dir.path(), &cache).unwrap();
assert!(matches!(
refreshed_report.index,
IndexStatus::Fresh {
recovery: Some(ReadOnlyRecovery::SnapshotFallbackWal),
..
}
));
}
#[test]
fn status_fresh_src_layout_recommends_src_extract() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "fn alpha() {}").unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let report = check_status(dir.path()).unwrap();
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init && tsift summarize --extract src/")
);
}
#[test]
fn status_workspace_scoped_indexes_report_fresh() {
let dir = setup_workspace();
let cfg = Config::load(dir.path()).unwrap();
for scope in Config::submodule_dirs(dir.path()).unwrap() {
let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
db.apply_changes(&scope.source_root).unwrap();
}
let report = check_status(dir.path()).unwrap();
match &report.index {
IndexStatus::Fresh {
total_files,
workspace_scopes,
..
} => {
assert_eq!(*total_files, 2);
assert_eq!(workspace_scopes.len(), 2);
assert_eq!(workspace_scopes[0].scope, "alpha");
assert_eq!(workspace_scopes[1].scope, "beta");
}
other => panic!("expected fresh workspace status, got {other:?}"),
}
assert!(matches!(report.summaries, SummaryStatus::None { .. }));
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift summarize --extract src/")
);
}
#[test]
fn status_workspace_non_src_layout_recommends_dot_extract() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".gitmodules"),
r#"[submodule "alpha"]
path = alpha
url = https://example.com/alpha
[submodule "crates/beta"]
path = crates/beta
url = https://example.com/beta
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("alpha/src")).unwrap();
std::fs::create_dir_all(dir.path().join("crates/beta/src")).unwrap();
std::fs::write(
dir.path().join("alpha/src/lib.rs"),
"fn alpha_helper() {}\n",
)
.unwrap();
std::fs::write(
dir.path().join("crates/beta/src/lib.rs"),
"fn beta_helper() {}\n",
)
.unwrap();
let cfg = Config::load(dir.path()).unwrap();
for scope in Config::submodule_dirs(dir.path()).unwrap() {
let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
db.apply_changes(&scope.source_root).unwrap();
}
let report = check_status(dir.path()).unwrap();
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift summarize --extract .")
);
}
#[test]
fn status_workspace_missing_recommends_workspace_index() {
let dir = setup_workspace();
let report = check_status(dir.path()).unwrap();
match &report.index {
IndexStatus::Missing { missing_scopes } => {
assert_eq!(missing_scopes.len(), 2);
assert_eq!(missing_scopes[0].scope, "alpha");
assert_eq!(missing_scopes[1].scope, "beta");
}
other => panic!("expected missing workspace status, got {other:?}"),
}
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift index --workspace . (2 missing scopes)")
);
}
#[test]
fn status_workspace_partial_indexes_report_missing_scopes() {
let dir = setup_workspace();
let cfg = Config::load(dir.path()).unwrap();
let alpha = Config::resolve_submodule(dir.path(), "alpha").unwrap();
let db = IndexDb::open(&cfg.db_path_for(dir.path(), &alpha.id)).unwrap();
db.apply_changes(&alpha.source_root).unwrap();
let report = check_status(dir.path()).unwrap();
match &report.index {
IndexStatus::Stale {
total_files,
stale_files,
workspace_scopes,
missing_scopes,
..
} => {
assert_eq!(*total_files, 1);
assert_eq!(*stale_files, 0);
assert_eq!(workspace_scopes.len(), 1);
assert_eq!(workspace_scopes[0].scope, "alpha");
assert_eq!(missing_scopes.len(), 1);
assert_eq!(missing_scopes[0].scope, "beta");
}
other => panic!("expected partial workspace status, got {other:?}"),
}
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift index --workspace . (1 missing scope)")
);
}
#[test]
fn status_workspace_prefers_scoped_indexes_when_root_index_also_exists() {
let dir = setup_workspace();
let root_db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
root_db.apply_changes(dir.path()).unwrap();
let cfg = Config::load(dir.path()).unwrap();
let alpha = Config::resolve_submodule(dir.path(), "alpha").unwrap();
let alpha_db = IndexDb::open(&cfg.db_path_for(dir.path(), &alpha.id)).unwrap();
alpha_db.apply_changes(&alpha.source_root).unwrap();
let report = check_status(dir.path()).unwrap();
match &report.index {
IndexStatus::Stale {
total_files,
stale_files,
workspace_scopes,
missing_scopes,
..
} => {
assert_eq!(*total_files, 1);
assert_eq!(*stale_files, 0);
assert_eq!(workspace_scopes.len(), 1);
assert_eq!(workspace_scopes[0].scope, "alpha");
assert_eq!(missing_scopes.len(), 1);
assert_eq!(missing_scopes[0].scope, "beta");
}
other => panic!("expected mixed workspace status to stay scope-aware, got {other:?}"),
}
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift index --workspace . (1 missing scope)")
);
}
#[test]
fn status_workspace_scoped_indexes_report_stale() {
let dir = setup_workspace();
let cfg = Config::load(dir.path()).unwrap();
for scope in Config::submodule_dirs(dir.path()).unwrap() {
let db = IndexDb::open(&cfg.db_path_for(dir.path(), &scope.id)).unwrap();
db.apply_changes(&scope.source_root).unwrap();
}
std::fs::write(dir.path().join("src/beta/new.rs"), "fn late() {}\n").unwrap();
let report = check_status(dir.path()).unwrap();
match &report.index {
IndexStatus::Stale {
total_files,
stale_files,
workspace_scopes,
..
} => {
assert_eq!(*total_files, 2);
assert_eq!(*stale_files, 1);
assert_eq!(workspace_scopes.len(), 2);
assert_eq!(
workspace_scopes
.iter()
.find(|scope| scope.scope == "beta")
.unwrap()
.stale_files,
1
);
}
other => panic!("expected stale workspace status, got {other:?}"),
}
assert_eq!(
report.recommendations.run.as_deref(),
Some("tsift init --workspace && tsift index --workspace . (1 stale file)")
);
}
#[test]
fn status_stale_index() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn helper() {}").unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(
report.index,
IndexStatus::Stale { stale_files: 1, .. }
));
assert!(
report
.recommendations
.run
.as_deref()
.unwrap()
.contains("tsift index")
);
}
#[test]
fn status_fresh_index_with_summaries() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let sdb = SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
sdb.insert(&tsift_summarize::summarize::Summary {
id: 0,
symbol_name: "main".to_string(),
file_path: "main.rs".to_string(),
content_hash: "abc123".to_string(),
summary: "Entry point".to_string(),
entities: None,
relationships: None,
concept_labels: None,
extracted_at: "2026-01-01".to_string(),
model: "test".to_string(),
tokens_input: Some(100),
tokens_output: Some(50),
})
.unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(report.index, IndexStatus::Fresh { .. }));
assert!(matches!(report.summaries, SummaryStatus::Available { .. }));
assert!(
report
.recommendations
.use_commands
.contains(&"summarize".to_string())
);
}
#[test]
fn status_summaries_use_snapshot_fallback_when_rollback_journal_is_locked() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let db_path = dir.path().join(".tsift/summaries.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"PRAGMA journal_mode=DELETE;
CREATE TABLE summaries (
id INTEGER PRIMARY KEY,
symbol_name TEXT NOT NULL,
file_path TEXT NOT NULL,
content_hash TEXT NOT NULL,
summary TEXT NOT NULL,
entities TEXT,
relationships TEXT,
concept_labels TEXT,
extracted_at TEXT NOT NULL,
model TEXT NOT NULL,
tokens_input INTEGER,
tokens_output INTEGER
);",
)
.unwrap();
conn.execute(
"INSERT INTO summaries
(symbol_name, file_path, content_hash, summary, entities, relationships, concept_labels, extracted_at, model, tokens_input, tokens_output)
VALUES (?1, ?2, ?3, ?4, NULL, NULL, NULL, ?5, ?6, NULL, NULL)",
rusqlite::params![
"main",
"main.rs",
"abc123",
"Entry point",
"2026-01-01",
"test",
],
)
.unwrap();
conn.execute_batch("BEGIN EXCLUSIVE;").unwrap();
std::fs::write(rollback_journal_path(&db_path), "locked").unwrap();
let report = check_status(dir.path()).unwrap();
match report.summaries {
SummaryStatus::Available {
cached_files,
total_indexed_files,
coverage_pct,
recovery,
} => {
assert_eq!(cached_files, 1);
assert_eq!(total_indexed_files, 1);
assert_eq!(coverage_pct, 100);
assert_eq!(recovery, Some(ReadOnlyRecovery::SnapshotFallback));
}
other => panic!("expected available summaries, got {other:?}"),
}
}
#[test]
fn status_summaries_report_wal_snapshot_recovery_when_wal_db_is_locked() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let db_path = dir.path().join(".tsift/summaries.db");
let sdb = SummaryDb::open(&db_path).unwrap();
sdb.insert(&tsift_summarize::summarize::Summary {
id: 0,
symbol_name: "main".to_string(),
file_path: "main.rs".to_string(),
content_hash: "abc123".to_string(),
summary: "Entry point".to_string(),
entities: None,
relationships: None,
concept_labels: None,
extracted_at: "2026-01-01".to_string(),
model: "test".to_string(),
tokens_input: None,
tokens_output: None,
})
.unwrap();
drop(sdb);
let _lock = hold_wal_lock(&db_path);
let report = check_status(dir.path()).unwrap();
match report.summaries {
SummaryStatus::Available { recovery, .. } => {
assert_eq!(recovery, Some(ReadOnlyRecovery::SnapshotFallbackWal));
}
other => panic!("expected available summaries, got {other:?}"),
}
}
#[test]
fn status_summary_coverage_ignores_deleted_summary_rows() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
let sdb = SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
sdb.insert(&tsift_summarize::summarize::Summary {
id: 0,
symbol_name: "main".to_string(),
file_path: "main.rs".to_string(),
content_hash: "abc123".to_string(),
summary: "Entry point".to_string(),
entities: None,
relationships: None,
concept_labels: None,
extracted_at: "2026-01-01".to_string(),
model: "test".to_string(),
tokens_input: Some(100),
tokens_output: Some(50),
})
.unwrap();
sdb.insert(&tsift_summarize::summarize::Summary {
id: 0,
symbol_name: "ghost".to_string(),
file_path: "removed.rs".to_string(),
content_hash: "def456".to_string(),
summary: "Stale summary".to_string(),
entities: None,
relationships: None,
concept_labels: None,
extracted_at: "2026-01-01".to_string(),
model: "test".to_string(),
tokens_input: Some(100),
tokens_output: Some(50),
})
.unwrap();
let report = check_status(dir.path()).unwrap();
match report.summaries {
SummaryStatus::Available {
cached_files,
total_indexed_files,
coverage_pct,
..
} => {
assert_eq!(cached_files, 1);
assert_eq!(total_indexed_files, 1);
assert_eq!(coverage_pct, 100);
}
other => panic!("expected available summaries, got {other:?}"),
}
}
#[test]
fn status_json_roundtrip() {
let dir = TempDir::new().unwrap();
let report = check_status(dir.path()).unwrap();
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"state\""));
assert!(json.contains("\"missing\""));
}
#[test]
fn status_reports_stale_index_reminder() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let db = IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
db.apply_changes(dir.path()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(
dir.path().join("main.rs"),
"fn main() { println!(\"hi\"); }\n",
)
.unwrap();
let report = check_status(dir.path()).unwrap();
assert_eq!(report.reminders.len(), 1);
assert!(report.reminders[0].contains("index stale"));
assert!(report.reminders[0].contains("tsift index ."));
assert!(report.reminders[0].contains("no summaries are cached"));
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"reminders\""));
}
#[test]
fn status_human_format_missing() {
let report = StatusReport {
index: IndexStatus::Missing {
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::Unavailable,
instructions: InstructionStatus::Missing,
recommendations: Recommendations {
use_commands: vec![],
run: Some("tsift init && tsift index .".to_string()),
},
reminders: Vec::new(),
};
let output = format_human(&report, false);
assert!(output.contains("index: missing"));
assert!(output.contains("instructions: missing"));
assert!(output.contains("summaries: unavailable"));
assert!(output.contains("use: (none"));
}
#[test]
fn status_human_format_fresh() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 42,
stale_files: 0,
last_indexed_secs_ago: 120,
recovery: None,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::Available {
cached_files: 30,
total_indexed_files: 42,
coverage_pct: 71,
recovery: None,
},
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec![
"search".to_string(),
"explain".to_string(),
"graph".to_string(),
"summarize".to_string(),
],
run: None,
},
reminders: Vec::new(),
};
let output = format_human(&report, false);
assert!(output.contains("index: fresh"));
assert!(output.contains("42 files"));
assert!(output.contains("instructions: current (v0.1.0)"));
assert!(output.contains("30/42 files cached (71%)"));
assert!(output.contains("use: search, explain, graph, summarize"));
}
#[test]
fn status_human_format_compact() {
let report = StatusReport {
index: IndexStatus::Stale {
total_files: 42,
stale_files: 3,
last_indexed_secs_ago: 120,
recovery: None,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::None { recovery: None },
instructions: InstructionStatus::Stale {
found: Some("0.0.9".to_string()),
expected: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec![
"search".to_string(),
"explain".to_string(),
"graph".to_string(),
],
run: Some("tsift init && tsift index .".to_string()),
},
reminders: build_reminders(
&IndexStatus::Stale {
total_files: 42,
stale_files: 3,
last_indexed_secs_ago: 120,
recovery: None,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
&SummaryStatus::None { recovery: None },
&Recommendations {
use_commands: vec![
"search".to_string(),
"explain".to_string(),
"graph".to_string(),
],
run: Some("tsift init && tsift index .".to_string()),
},
".",
),
};
let output = format_human(&report, true);
assert!(output.contains("index: stale tracked:42 stale:3"));
assert!(output.contains("instructions: stale v=0.0.9 expected=0.1.0"));
assert!(output.contains("reminder: index stale"));
assert!(output.contains("use: search, explain, graph"));
assert!(!output.contains("recommendations:"));
}
#[test]
fn status_human_format_mentions_snapshot_recovery() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 3,
stale_files: 0,
last_indexed_secs_ago: 5,
recovery: Some(ReadOnlyRecovery::SnapshotFallback),
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::None { recovery: None },
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec!["search".to_string()],
run: None,
},
reminders: Vec::new(),
};
let output = format_human(&report, false);
assert!(output.contains("recovery: snapshot fallback"));
}
#[test]
fn status_human_format_mentions_wal_snapshot_recovery() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 3,
stale_files: 0,
last_indexed_secs_ago: 5,
recovery: Some(ReadOnlyRecovery::SnapshotFallbackWal),
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::None { recovery: None },
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec!["search".to_string()],
run: None,
},
reminders: Vec::new(),
};
let output = format_human(&report, false);
assert!(output.contains("copied live WAL sidecars"));
}
#[test]
fn status_json_includes_recovery_when_snapshot_fallback_is_used() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 1,
stale_files: 0,
last_indexed_secs_ago: 1,
recovery: Some(ReadOnlyRecovery::SnapshotFallback),
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::None { recovery: None },
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec!["search".to_string()],
run: None,
},
reminders: Vec::new(),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"recovery\":\"snapshot_fallback\""));
}
#[test]
fn status_human_format_mentions_summary_snapshot_recovery() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 3,
stale_files: 0,
last_indexed_secs_ago: 5,
recovery: None,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::Available {
cached_files: 2,
total_indexed_files: 3,
coverage_pct: 66,
recovery: Some(ReadOnlyRecovery::SnapshotFallback),
},
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec!["search".to_string(), "summarize".to_string()],
run: None,
},
reminders: Vec::new(),
};
let output = format_human(&report, false);
assert!(output.contains("summaries recovery: snapshot fallback"));
}
#[test]
fn status_json_includes_summary_recovery_when_snapshot_fallback_is_used() {
let report = StatusReport {
index: IndexStatus::Fresh {
total_files: 1,
stale_files: 0,
last_indexed_secs_ago: 1,
recovery: None,
workspace_scopes: Vec::new(),
missing_scopes: Vec::new(),
},
summaries: SummaryStatus::None {
recovery: Some(ReadOnlyRecovery::SnapshotFallback),
},
instructions: InstructionStatus::Current {
version: "0.1.0".to_string(),
},
recommendations: Recommendations {
use_commands: vec!["search".to_string()],
run: None,
},
reminders: Vec::new(),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"state\":\"none\""));
assert!(json.contains("\"recovery\":\"snapshot_fallback\""));
}
#[test]
fn lock_report_marks_live_writer_and_journal() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".tsift/index.lock");
let journal_path = dir.path().join(".tsift/index.db-journal");
std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
let mut lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.unwrap();
assert!(lock_file.try_lock_exclusive().unwrap());
use std::io::Write;
writeln!(lock_file, "{}", std::process::id()).unwrap();
std::fs::write(&journal_path, "locked").unwrap();
let report = check_locks(dir.path(), None, None).unwrap();
assert!(matches!(
report.writer_lock,
WriterLockStatus::Live { pid: Some(_), .. }
));
assert!(report.rollback_journal.present);
assert!(
report
.recommended_action
.contains("wait for the active tsift writer")
);
assert!(report.recommended_action.contains("tsift index"));
}
#[test]
fn lock_report_marks_live_writer_and_wal_sidecars() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join(".tsift/index.db");
let db = IndexDb::open(&db_path).unwrap();
drop(db);
let _lock = hold_wal_lock(&db_path);
let report = check_locks(dir.path(), None, None).unwrap();
assert!(report.wal_sidecar.present);
assert!(
report
.recommended_action
.contains("wedged writer holding live WAL sidecars")
);
}
#[test]
fn lock_report_marks_stale_writer_lock() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".tsift/index.lock");
std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
std::fs::write(&lock_path, "999999").unwrap();
let report = check_locks(dir.path(), None, None).unwrap();
assert!(matches!(
report.writer_lock,
WriterLockStatus::Stale {
pid: Some(999999),
..
}
));
assert!(report.recommended_action.contains("reuse it automatically"));
assert!(report.recommended_action.contains("tsift index"));
}
#[test]
fn status_instructions_stale_recommends_init() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("AGENTS.md"),
"<!-- tsift:code-navigation -->\n## Code Navigation\nOld.\n<!-- /tsift:code-navigation -->\n",
)
.unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(
report.instructions,
InstructionStatus::Stale { found: None, .. }
));
assert!(
report
.recommendations
.run
.as_deref()
.unwrap()
.contains("tsift init")
);
}
#[test]
fn status_instructions_current_after_init() {
let dir = TempDir::new().unwrap();
init::init(dir.path(), false, false).unwrap();
let report = check_status(dir.path()).unwrap();
assert!(matches!(
report.instructions,
InstructionStatus::Current { .. }
));
}
}