use std::collections::{HashMap, HashSet};
use anyhow::Result;
use crate::config::Config;
use crate::db::Db;
use crate::models::{DiagnosticStatus, IndexStatus, ParserHealth, Provider, ProviderHealth};
use crate::util::{normalize_path, which};
pub fn collect(config: &Config, db: &Db) -> Result<DiagnosticStatus> {
let inventory = crate::source::inventory_snapshot(config);
let discovery_warnings = inventory.warnings.clone();
let parser_health = db.parser_health()?;
let stale_sources = db.stale_session_sources()?;
let indexed = indexed_source_set(db)?;
let mut index_status = classify_index_status(
parser_health,
&stale_sources,
&inventory.discovered,
&indexed,
);
index_status.readiness = crate::background_refresh::readiness_status(config, db)?;
let repairable_by_provider = stale_sources
.iter()
.filter(|source| inventory.discovered.contains(*source))
.fold(
HashMap::<Provider, i64>::new(),
|mut counts, (provider, _)| {
*counts.entry(*provider).or_default() += 1;
counts
},
);
let providers = inventory
.providers
.into_iter()
.map(|source| {
let provider = source.provider;
let parser = index_status
.parser_health
.providers
.iter()
.find(|item| item.provider == provider);
let (cli_available, resume_command) = match provider {
Provider::Claude => (
which("claude").is_some(),
Some("claude --resume <session-id>"),
),
Provider::Codex => (which("codex").is_some(), Some("codex resume <session-id>")),
Provider::Pi => (which("pi").is_some(), Some("pi --session <session-id>")),
Provider::PrimeAgent => (
which("prime-agent").is_some(),
Some("prime-agent --resume <session-id>"),
),
Provider::Cursor => (which("cursor").is_some(), None),
Provider::Antigravity => (
which("agy").is_some() || which("antigravity").is_some(),
None,
),
Provider::ClaudeDesktop => (false, None),
Provider::AiStudio | Provider::GeminiCli => (false, None),
};
ProviderHealth {
provider,
enabled: source.enabled,
cli_available,
roots: source.roots,
discovered_files: source.discovered_files,
indexed_sessions: parser.map_or(0, |item| item.indexed_sessions),
expected_parse_version: parser.map_or_else(
|| crate::util::provider_parse_version(provider).to_string(),
|item| item.expected_parse_version.clone(),
),
current_sessions: parser.map_or(0, |item| item.current_sessions),
stale_sessions: parser.map_or(0, |item| item.stale_sessions),
repairable_stale_sessions: repairable_by_provider
.get(&provider)
.copied()
.unwrap_or_default(),
unavailable_stale_sessions: 0,
unindexed_files: inventory
.discovered
.iter()
.filter(|source| source.0 == provider && !indexed.contains(*source))
.count() as i64,
resume_command: resume_command.map(str::to_string),
}
})
.collect::<Vec<_>>();
let providers = providers
.into_iter()
.map(|mut provider| {
provider.unavailable_stale_sessions = provider
.stale_sessions
.saturating_sub(provider.repairable_stale_sessions);
provider
})
.collect();
Ok(DiagnosticStatus {
db_path: normalize_path(&config.db_path()),
index_status,
discovery_warnings,
providers,
})
}
pub fn index_status(config: &Config, db: &Db) -> Result<IndexStatus> {
let inventory = crate::source::inventory_snapshot(config);
let mut status = index_status_for_discovered(db, &inventory.discovered)?;
status.readiness = crate::background_refresh::readiness_status(config, db)?;
Ok(status)
}
fn index_status_for_discovered(
db: &Db,
discovered: &HashSet<(Provider, String)>,
) -> Result<IndexStatus> {
let parser_health = db.parser_health()?;
let stale_sources = db.stale_session_sources()?;
let indexed = indexed_source_set(db)?;
Ok(classify_index_status(
parser_health,
&stale_sources,
discovered,
&indexed,
))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnindexedFile {
pub provider: Provider,
pub path: String,
pub resolves_to: String,
pub id_already_held_by: Option<String>,
}
pub fn explain_unindexed(config: &Config, db: &Db) -> Result<Vec<UnindexedFile>> {
let indexed = indexed_source_set(db)?;
let holders: HashMap<(Provider, String), String> = db
.indexed_source_identities()?
.into_iter()
.map(|(provider, source_path, _, id)| ((provider, id), source_path))
.collect();
let providers = crate::source::ProviderSet::new(config);
let mut explained = Vec::new();
for source in providers.discover_enabled(config).sources {
let key = (source.provider, normalize_path(&source.path));
if indexed.contains(&key) {
continue;
}
let parsed = providers.parse(&source).unwrap_or_else(|error| {
crate::util::minimal_record(source.provider, &source.path, format!("{error:#}"))
});
let resolves_to = parsed.session.provider_session_id.clone();
let holder = holders
.get(&(
source.provider,
format!("{}:{resolves_to}", source.provider),
))
.or_else(|| holders.get(&(source.provider, resolves_to.clone())))
.cloned();
explained.push(UnindexedFile {
provider: source.provider,
path: key.1,
resolves_to,
id_already_held_by: holder,
});
}
Ok(explained)
}
fn indexed_source_set(db: &Db) -> Result<HashSet<(Provider, String)>> {
Ok(db
.indexed_source_identities()?
.into_iter()
.map(|(provider, source_path, _, _)| (provider, source_path))
.collect())
}
fn classify_index_status(
parser_health: ParserHealth,
stale_sources: &[(Provider, String)],
discovered: &HashSet<(Provider, String)>,
indexed: &HashSet<(Provider, String)>,
) -> IndexStatus {
let repairable_stale_sessions = stale_sources
.iter()
.filter(|source| discovered.contains(*source))
.count() as i64;
let unavailable_stale_sessions = parser_health
.stale_sessions
.saturating_sub(repairable_stale_sessions);
let unindexed_files = discovered.difference(indexed).count() as i64;
let repair_commands = if !parser_health.schema_current || repairable_stale_sessions > 0 {
vec!["aise reindex --full".to_string()]
} else if unindexed_files > 0 {
vec!["aise reindex".to_string()]
} else {
Vec::new()
};
IndexStatus {
parser_health,
repairable_stale_sessions,
unavailable_stale_sessions,
unindexed_files,
repair_commands,
readiness: crate::models::IndexReadinessStatus::not_started(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::SCHEMA_VERSION;
fn health(schema_current: bool, stale_sessions: i64) -> ParserHealth {
ParserHealth {
schema_version: if schema_current { SCHEMA_VERSION } else { 0 },
expected_schema_version: SCHEMA_VERSION,
schema_current,
indexed_sessions: stale_sessions,
current_sessions: 0,
stale_sessions,
parse_warnings: 0,
providers: Vec::new(),
}
}
#[test]
fn unindexed_discovered_files_are_counted_and_offered_a_repair() {
let discovered = HashSet::from([
(Provider::Codex, "/parent.jsonl".to_string()),
(Provider::Codex, "/fork.jsonl".to_string()),
]);
let indexed = HashSet::from([(Provider::Codex, "/parent.jsonl".to_string())]);
let status = classify_index_status(health(true, 0), &[], &discovered, &indexed);
assert_eq!(status.unindexed_files, 1);
assert_eq!(
status.repair_commands,
["aise reindex"],
"a newly discovered or newly reclaimable file needs the incremental repair"
);
let retained = HashSet::from([
(Provider::Claude, "/live.jsonl".to_string()),
(Provider::Claude, "/deleted.jsonl".to_string()),
]);
let only_live = HashSet::from([(Provider::Claude, "/live.jsonl".to_string())]);
let status = classify_index_status(health(true, 0), &[], &only_live, &retained);
assert_eq!(
status.unindexed_files, 0,
"an indexed source that is no longer discoverable is retained, not unindexed"
);
assert!(status.repair_commands.is_empty());
}
#[test]
fn repair_guidance_only_targets_schema_or_discoverable_stale_sources() {
let stale = vec![
(Provider::Claude, "/live.jsonl".to_string()),
(Provider::Claude, "/archived.jsonl".to_string()),
];
let discovered = HashSet::from([(Provider::Claude, "/live.jsonl".to_string())]);
let mixed = classify_index_status(health(true, 2), &stale, &discovered, &discovered);
assert_eq!(mixed.repairable_stale_sessions, 1);
assert_eq!(mixed.unavailable_stale_sessions, 1);
assert_eq!(mixed.repair_commands, ["aise reindex --full"]);
let archived =
classify_index_status(health(true, 1), &stale[1..], &discovered, &discovered);
assert_eq!(archived.repairable_stale_sessions, 0);
assert_eq!(archived.unavailable_stale_sessions, 1);
assert!(archived.repair_commands.is_empty());
let schema = classify_index_status(health(false, 1), &stale[1..], &discovered, &discovered);
assert_eq!(schema.repair_commands, ["aise reindex --full"]);
}
}