use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::{link, paths};
const RETIRED_DIR: &str = ".retired";
pub struct RetireOutcome {
pub unlinked: Vec<link::UnlinkResult>,
pub moved_to: PathBuf,
}
pub fn run(skill_name: &str, force: bool) -> Result<()> {
let canonical = paths::skill_dir(skill_name)?;
if !canonical.is_dir() {
bail!(
"skill '{skill_name}' is not installed under {}",
tilde(&paths::skills_root()?)
);
}
if !force && !confirm(skill_name)? {
println!("aborted; '{skill_name}' was not retired.");
return Ok(());
}
let outcome = retire_skill_dir(skill_name)?;
refresh_catalog();
let unlinked: Vec<&link::UnlinkResult> = outcome
.unlinked
.iter()
.filter(|r| r.status == link::UnlinkStatus::Unlinked)
.collect();
if unlinked.is_empty() {
println!("Unlinked from no harness (none held a link to it).");
} else {
println!("Unlinked from {} harness(es):", unlinked.len());
for r in unlinked {
println!(" {} → {}", r.harness, r.link_path);
}
}
for r in &outcome.unlinked {
if r.status == link::UnlinkStatus::Failed {
eprintln!(
"warning: could not remove the {} link at {}",
r.harness, r.link_path
);
}
}
println!(
"{} retired '{skill_name}' → {}",
crate::style::green("✓"),
tilde(&outcome.moved_to)
);
Ok(())
}
pub fn retire_skill_dir(skill_name: &str) -> Result<RetireOutcome> {
let unlinked = link::unlink_skill(skill_name)?;
let canonical = paths::skill_dir(skill_name)?;
let retired_root = paths::skills_root()?.join(RETIRED_DIR);
std::fs::create_dir_all(&retired_root)
.with_context(|| format!("could not create {}", retired_root.display()))?;
let dest = retired_dest(&retired_root, skill_name);
std::fs::rename(&canonical, &dest).with_context(|| {
format!(
"could not move {} to {}",
canonical.display(),
dest.display()
)
})?;
Ok(RetireOutcome {
unlinked,
moved_to: dest,
})
}
fn retired_dest(retired_root: &Path, skill_name: &str) -> PathBuf {
let base = retired_root.join(skill_name);
if !base.exists() {
return base;
}
let mut n = 1u32;
loop {
let candidate = retired_root.join(format!("{skill_name}.{n}"));
if !candidate.exists() {
return candidate;
}
n += 1;
}
}
fn confirm(skill_name: &str) -> Result<bool> {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() {
bail!(
"refusing to retire '{skill_name}' without confirmation; re-run with --force in a non-interactive context"
);
}
print!(
"Retire skill '{skill_name}'? It is unlinked from every harness and moved to {}. [y/N] ",
tilde(&paths::skills_root()?.join(RETIRED_DIR))
);
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
fn refresh_catalog() {
if matches!(
crate::ipc::query(&crate::ipc::Request::Reindex),
Ok(crate::ipc::Response::Reindexed { .. })
) {
return;
}
if let Ok(mut conn) = crate::catalog::open() {
let _ = crate::catalog::reindex(&mut conn);
}
}
fn tilde(path: &Path) -> String {
let shown = path.display().to_string();
match paths::home_dir() {
Some(home) => shown
.strip_prefix(&home.display().to_string())
.map(|rest| format!("~{rest}"))
.unwrap_or(shown),
None => shown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retired_dest_suffixes_a_name_already_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
assert_eq!(retired_dest(root, "demo"), root.join("demo"));
std::fs::create_dir_all(root.join("demo")).unwrap();
assert_eq!(retired_dest(root, "demo"), root.join("demo.1"));
std::fs::create_dir_all(root.join("demo.1")).unwrap();
assert_eq!(retired_dest(root, "demo"), root.join("demo.2"));
}
}