use anyhow::Result;
use colored::Colorize;
use std::io::{BufRead, Write};
use crate::service::persistence::{
indexes_toml_path, load_index_registry_at, save_index_registry_at, PersistedIndex,
};
struct OrphanEntry {
id: String,
root_path: String,
}
pub fn handle_prune_orphans(dry_run: bool, yes: bool) -> Result<()> {
let toml_path = indexes_toml_path()?;
handle_prune_orphans_at(&toml_path, dry_run, yes, true)
}
pub(crate) fn handle_prune_orphans_at(
toml_path: &std::path::Path,
dry_run: bool,
yes: bool,
interactive: bool,
) -> Result<()> {
let entries = load_index_registry_at(toml_path)?;
if entries.is_empty() {
println!("Registry is empty — nothing to prune.");
return Ok(());
}
let (orphans, live): (Vec<PersistedIndex>, Vec<PersistedIndex>) =
entries.into_iter().partition(|e| !e.root_path.exists());
if orphans.is_empty() {
println!(
"{} All {} registered index(es) have live root paths — nothing to prune.",
"✓".green(),
live.len()
);
return Ok(());
}
let orphan_records: Vec<OrphanEntry> = orphans
.iter()
.map(|e| OrphanEntry {
id: e.id.clone(),
root_path: e.root_path.display().to_string(),
})
.collect();
let count = orphan_records.len();
println!(
"{} {} orphaned index registration(s) (root_path missing):",
"Found".bold(),
count.to_string().bold()
);
let name_width = orphan_records
.iter()
.map(|e| e.id.len())
.max()
.unwrap_or(0)
.max(4);
for e in &orphan_records {
println!(
" {:<width$} {}",
e.id.bold(),
e.root_path.dimmed(),
width = name_width
);
}
if dry_run {
println!(
"{} dry-run: {} registration(s) would be removed. Re-run without --dry-run to apply.",
"ℹ".cyan(),
count
);
return Ok(());
}
if !yes {
if !interactive {
println!("Aborted (non-interactive mode).");
return Ok(());
}
if !confirm(&format!(
"Remove {} orphaned registration(s) from indexes.toml?",
count
))? {
println!("Aborted.");
return Ok(());
}
}
save_index_registry_at(toml_path, &live)?;
println!(
"{} Removed {} orphaned registration(s) from indexes.toml. {} registration(s) remain.",
"✓".green(),
count.to_string().bold(),
live.len()
);
Ok(())
}
fn confirm(prompt: &str) -> Result<bool> {
print!("{} [y/N] ", prompt);
std::io::stdout().flush().ok();
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
let answer = line.trim();
Ok(matches!(answer.chars().next(), Some('y') | Some('Y')))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::persistence::{save_index_registry_at, PersistedIndex};
use std::path::PathBuf;
use tempfile::tempdir;
fn entry(id: &str, root: &str) -> PersistedIndex {
PersistedIndex {
id: id.to_string(),
root_path: PathBuf::from(root),
..Default::default()
}
}
#[test]
fn prune_orphans_removes_dead_root_entries() {
let tmp = tempdir().unwrap();
let toml_path = tmp.path().join("indexes.toml");
let dead = entry("ghost", "/tmp/trusty-prune-orphans-dead-root-xyz9999");
let live_root = tmp.path().to_path_buf();
let live = PersistedIndex {
id: "live".into(),
root_path: live_root.clone(),
..Default::default()
};
save_index_registry_at(&toml_path, &[dead, live]).unwrap();
assert_eq!(
load_index_registry_at(&toml_path).unwrap().len(),
2,
"setup: both entries must be in the registry"
);
handle_prune_orphans_at(
&toml_path, false, true, false,
)
.unwrap();
let remaining = load_index_registry_at(&toml_path).unwrap();
assert_eq!(remaining.len(), 1, "dead-root entry must be removed");
assert_eq!(remaining[0].id, "live", "live entry must be preserved");
}
#[test]
fn prune_orphans_preserves_live_root_entries() {
let tmp = tempdir().unwrap();
let toml_path = tmp.path().join("indexes.toml");
let live = PersistedIndex {
id: "myproject".into(),
root_path: tmp.path().to_path_buf(),
..Default::default()
};
save_index_registry_at(&toml_path, &[live]).unwrap();
handle_prune_orphans_at(&toml_path, false, true, false).unwrap();
let remaining = load_index_registry_at(&toml_path).unwrap();
assert_eq!(remaining.len(), 1, "live entry must not be removed");
assert_eq!(remaining[0].id, "myproject");
}
#[test]
fn prune_orphans_dry_run_mutates_nothing() {
let tmp = tempdir().unwrap();
let toml_path = tmp.path().join("indexes.toml");
let dead = entry("ghost", "/tmp/trusty-dry-run-dead-xyz8888");
save_index_registry_at(&toml_path, &[dead]).unwrap();
handle_prune_orphans_at(
&toml_path, true, true, false,
)
.unwrap();
let after = load_index_registry_at(&toml_path).unwrap();
assert_eq!(
after.len(),
1,
"dry-run must not modify indexes.toml: found {} entries",
after.len()
);
assert_eq!(
after[0].id, "ghost",
"dry-run must leave the orphan in place"
);
}
#[test]
fn prune_orphans_empty_registry_is_noop() {
let tmp = tempdir().unwrap();
let toml_path = tmp.path().join("indexes.toml");
let result = handle_prune_orphans_at(&toml_path, false, true, false);
assert!(result.is_ok(), "empty registry must not error");
}
}