use anyhow::{Result, bail};
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::claude::mcp;
use crate::config::Target;
use crate::symlink::{self, is_symlink};
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillLinksManifest {
#[serde(default)]
pub links: BTreeMap<String, String>,
#[serde(default)]
pub untracked: Vec<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct TargetPresence {
#[serde(default)]
settings: bool,
#[serde(default)]
skills_dir: bool,
#[serde(default)]
claude_md: bool,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct ConnectorBackup {
#[serde(default)]
field_present: bool,
#[serde(default)]
disabled: Vec<String>,
}
pub fn create_backup(target: &Target, skill_store: &Path) -> Result<PathBuf> {
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
let backup_dir = target.backups_dir.join(format!("pre-switch-{}", timestamp));
std::fs::create_dir_all(&backup_dir)?;
let mut presence = TargetPresence {
settings: target.settings_file.exists(),
..Default::default()
};
if presence.settings {
std::fs::copy(&target.settings_file, backup_dir.join("settings.json"))?;
}
presence.skills_dir = target.skills_dir.exists() || is_symlink(&target.skills_dir);
if presence.skills_dir {
let meta = std::fs::symlink_metadata(&target.skills_dir)?;
if meta.file_type().is_symlink() {
let link_target = std::fs::read_link(&target.skills_dir)?;
std::fs::write(
backup_dir.join("skills-symlink"),
link_target.to_string_lossy().as_bytes(),
)?;
} else {
let manifest = snapshot_skill_links(&target.skills_dir, skill_store)?;
let content = serde_json::to_string_pretty(&manifest)?;
std::fs::write(backup_dir.join("skills-links.json"), content)?;
}
}
match &target.claude_json_project_key {
None => {
if target.claude_json.exists() {
std::fs::copy(&target.claude_json, backup_dir.join("claude.json"))?;
}
}
Some(key) => {
let json = mcp::read_claude_json(&target.claude_json)?;
let disabled = mcp::read_project_disabled(&json, key);
let backup = ConnectorBackup {
field_present: disabled.is_some(),
disabled: disabled.unwrap_or_default(),
};
std::fs::write(
backup_dir.join("connectors.json"),
serde_json::to_string_pretty(&backup)?,
)?;
}
}
if let Some(md_file) = &target.claude_md_file {
presence.claude_md = md_file.exists() || is_symlink(md_file);
if presence.claude_md {
let meta = std::fs::symlink_metadata(md_file)?;
if meta.file_type().is_symlink() {
let link_target = std::fs::read_link(md_file)?;
std::fs::write(
backup_dir.join("claude-md-symlink"),
link_target.to_string_lossy().as_bytes(),
)?;
} else {
std::fs::copy(md_file, backup_dir.join("CLAUDE.md"))?;
}
}
}
std::fs::write(
backup_dir.join("target-presence.json"),
serde_json::to_string_pretty(&presence)?,
)?;
Ok(backup_dir)
}
pub fn snapshot_skill_links(skills_dir: &Path, skill_store: &Path) -> Result<SkillLinksManifest> {
let mut manifest = SkillLinksManifest::default();
if !skills_dir.exists() || is_symlink(skills_dir) {
return Ok(manifest);
}
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if is_symlink(&path) {
let target = std::fs::read_link(&path)?;
match store_relative_target(&target, skill_store) {
Some(rel) => {
manifest.links.insert(name, rel);
}
None => {
}
}
} else if path.is_dir() {
manifest.untracked.push(name);
}
}
manifest.untracked.sort();
Ok(manifest)
}
pub fn restore_skill_links(
skills_dir: &Path,
skill_store: &Path,
manifest: &SkillLinksManifest,
) -> Result<()> {
symlink::ensure_real_dir(skills_dir)?;
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
if is_symlink(&path) {
std::fs::remove_file(&path)?;
}
}
for (name, rel_target) in &manifest.links {
let store_skill = skill_store.join(rel_target);
if !store_skill.exists() {
eprintln!(
" ⚠ Skipping link '{}': store skill '{}' no longer exists.",
name,
store_skill.display()
);
continue;
}
let link = skills_dir.join(name);
if link.exists() || is_symlink(&link) {
if !is_symlink(&link) && link.is_dir() {
continue;
}
std::fs::remove_file(&link).ok();
}
symlink::link_skill(&store_skill, &link)?;
}
Ok(())
}
pub fn restore_latest(target: &Target, skill_store: &Path, lock_path: &Path) -> Result<()> {
let latest = find_latest_backup(&target.backups_dir)?;
let backup_dir = match latest {
Some(d) => d,
None => bail!("No backup found. Nothing to restore."),
};
eprintln!("Restoring from: {}", backup_dir.display());
let presence: Option<TargetPresence> = read_json_opt(&backup_dir.join("target-presence.json"));
let backup_settings = backup_dir.join("settings.json");
if backup_settings.exists() {
std::fs::copy(&backup_settings, &target.settings_file)?;
} else if presence.as_ref().is_some_and(|p| !p.settings) {
remove_file_if_exists(&target.settings_file)?;
}
let skills_links_file = backup_dir.join("skills-links.json");
let skills_symlink_file = backup_dir.join("skills-symlink");
let skills_is_dir = backup_dir.join("skills-is-dir");
if skills_links_file.exists() {
let content = std::fs::read_to_string(&skills_links_file)?;
let manifest: SkillLinksManifest = serde_json::from_str(&content)?;
restore_skill_links(&target.skills_dir, skill_store, &manifest)?;
} else if skills_symlink_file.exists() {
let link_target = std::fs::read_to_string(&skills_symlink_file)?;
remove_symlink_or_dir(&target.skills_dir)?;
std::os::unix::fs::symlink(link_target.trim(), &target.skills_dir)?;
} else if skills_is_dir.exists() {
eprintln!(" Note: skills directory was migrated into cc-persona management.");
eprintln!(" Skills remain accessible via the current skill-set symlink.");
} else if presence.as_ref().is_some_and(|p| !p.skills_dir) {
remove_managed_links(&target.skills_dir, skill_store);
remove_dir_if_empty(&target.skills_dir);
}
let backup_claude_json = backup_dir.join("claude.json");
let backup_connectors = backup_dir.join("connectors.json");
if backup_claude_json.exists() {
let value = mcp::read_claude_json(&backup_claude_json)?;
mcp::update_claude_json(&target.claude_json, lock_path, move |json| {
*json = value;
Ok(())
})?;
} else if let (Some(key), Some(backup)) = (
target.claude_json_project_key.as_deref(),
read_json_opt::<ConnectorBackup>(&backup_connectors),
) {
mcp::update_claude_json(&target.claude_json, lock_path, |json| {
let value = if backup.field_present {
Some(backup.disabled.as_slice())
} else {
None
};
mcp::set_project_disabled(json, key, value)
})?;
}
if let Some(md_file) = &target.claude_md_file {
let claude_md_symlink_file = backup_dir.join("claude-md-symlink");
let claude_md_backup = backup_dir.join("CLAUDE.md");
if claude_md_symlink_file.exists() {
let link_target = std::fs::read_to_string(&claude_md_symlink_file)?;
remove_symlink_or_file(md_file)?;
std::os::unix::fs::symlink(link_target.trim(), md_file)?;
} else if claude_md_backup.exists() {
remove_symlink_or_file(md_file)?;
std::fs::copy(&claude_md_backup, md_file)?;
} else if presence.as_ref().is_some_and(|p| !p.claude_md) {
remove_symlink_or_file(md_file)?;
}
}
Ok(())
}
fn find_latest_backup(backups_dir: &Path) -> Result<Option<PathBuf>> {
if !backups_dir.exists() {
return Ok(None);
}
let mut entries: Vec<_> = std::fs::read_dir(backups_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|e| e.file_name());
Ok(entries.last().map(|e| e.path()))
}
fn read_json_opt<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
if path.exists() || is_symlink(path) {
std::fs::remove_file(path)?;
}
Ok(())
}
fn remove_managed_links(dir: &Path, skill_store: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !is_symlink(&path) {
continue;
}
if let Ok(target) = std::fs::read_link(&path) {
if store_relative_target(&target, skill_store).is_some() {
let _ = std::fs::remove_file(&path);
}
}
}
}
fn remove_dir_if_empty(path: &Path) {
if path.is_dir()
&& std::fs::read_dir(path)
.map(|mut d| d.next().is_none())
.unwrap_or(false)
{
let _ = std::fs::remove_dir(path);
}
}
fn store_relative_target(target: &Path, skill_store: &Path) -> Option<String> {
let (resolved_target, resolved_store) = match (
std::fs::canonicalize(target).ok(),
std::fs::canonicalize(skill_store).ok(),
) {
(Some(t), Some(s)) => (t, s),
_ => {
let rel = target.strip_prefix(skill_store).ok()?;
return Some(rel.to_string_lossy().to_string());
}
};
let rel = resolved_target.strip_prefix(&resolved_store).ok()?;
Some(rel.to_string_lossy().to_string())
}
fn remove_symlink_or_dir(path: &Path) -> Result<()> {
if !path.exists() && !is_symlink(path) {
return Ok(());
}
if is_symlink(path) {
std::fs::remove_file(path)?;
} else if path.is_dir() {
bail!(
"{} is a real directory, not a symlink. Please move it manually.",
path.display()
);
}
Ok(())
}
fn remove_symlink_or_file(path: &Path) -> Result<()> {
if !path.exists() && !is_symlink(path) {
return Ok(());
}
std::fs::remove_file(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestEnv;
#[test]
fn find_latest_backup_returns_latest_directory() {
let env = TestEnv::new();
std::fs::create_dir_all(&env.paths.backups).unwrap();
std::fs::create_dir_all(env.paths.backups.join("pre-switch-20260101-010101")).unwrap();
std::fs::create_dir_all(env.paths.backups.join("pre-switch-20260101-020202")).unwrap();
env.write_file(&env.paths.backups.join("not-a-dir"), "ignore");
let latest = find_latest_backup(&env.paths.backups).unwrap().unwrap();
assert_eq!(
latest.file_name().and_then(|name| name.to_str()),
Some("pre-switch-20260101-020202")
);
}
#[cfg(unix)]
#[test]
fn remove_symlink_or_dir_rejects_real_dirs_and_removes_symlinks() {
let env = TestEnv::new();
let real_dir = env.paths.root.join("real-dir");
std::fs::create_dir_all(&real_dir).unwrap();
let err = remove_symlink_or_dir(&real_dir).unwrap_err();
assert!(format!("{err:#}").contains("real directory"));
let target = env.paths.root.join("target");
std::fs::create_dir_all(&target).unwrap();
let link = env.paths.root.join("skills-link");
env.symlink(&target, &link);
remove_symlink_or_dir(&link).unwrap();
assert!(!link.exists());
assert!(!is_symlink(&link));
}
#[cfg(unix)]
#[test]
fn snapshot_skill_links_captures_managed_links_and_wild_dirs_only() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.link_into_claude_skills("alpha");
let wild = env.paths.claude_skills.join("wild");
std::fs::create_dir_all(&wild).unwrap();
env.write_file(&wild.join("SKILL.md"), "wild body");
let outside = env.paths.root.join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, env.paths.claude_skills.join("foreign")).unwrap();
let manifest =
snapshot_skill_links(&env.paths.claude_skills, &env.paths.skill_store).unwrap();
assert_eq!(
manifest.links.get("alpha").map(String::as_str),
Some("alpha")
);
assert!(!manifest.links.contains_key("foreign"));
assert_eq!(manifest.untracked, vec!["wild"]);
}
#[cfg(unix)]
#[test]
fn create_then_restore_round_trips_managed_links_and_preserves_wild() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.create_store_skill("beta", "---\nname: beta\n---\n");
env.link_into_claude_skills("alpha");
env.link_into_claude_skills("beta");
let wild = env.paths.claude_skills.join("wild");
std::fs::create_dir_all(&wild).unwrap();
env.write_file(&wild.join("SKILL.md"), "wild body");
let target = env.global_target();
let backup_dir = create_backup(&target, &env.paths.skill_store).unwrap();
assert!(backup_dir.join("skills-links.json").exists());
std::fs::remove_file(env.paths.claude_skills.join("beta")).unwrap();
env.create_store_skill("gamma", "---\nname: gamma\n---\n");
env.link_into_claude_skills("gamma");
let lock = env.paths.root.join("claude-json.lock");
restore_latest(&target, &env.paths.skill_store, &lock).unwrap();
assert!(env.paths.claude_skills.join("alpha").is_symlink());
assert!(env.paths.claude_skills.join("beta").is_symlink());
assert!(!env.paths.claude_skills.join("gamma").exists());
assert!(!wild.is_symlink());
assert!(wild.is_dir());
assert_eq!(env.read_file(&wild.join("SKILL.md")), "wild body");
}
#[cfg(unix)]
#[test]
fn restore_skill_links_skips_entry_when_store_target_missing() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
let mut manifest = SkillLinksManifest::default();
manifest
.links
.insert("ghost".to_string(), "ghost".to_string());
restore_skill_links(&env.paths.claude_skills, &env.paths.skill_store, &manifest).unwrap();
assert!(!env.paths.claude_skills.join("ghost").exists());
}
#[cfg(unix)]
#[test]
fn restore_latest_reads_legacy_skills_symlink_backup() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
let backup_dir = env.paths.backups.join("pre-switch-20260101-010101");
std::fs::create_dir_all(&backup_dir).unwrap();
let legacy_target = env.paths.root.join("legacy-skillset");
std::fs::create_dir_all(&legacy_target).unwrap();
env.write_file(
&backup_dir.join("skills-symlink"),
&legacy_target.to_string_lossy(),
);
assert!(!env.paths.claude_skills.exists());
let target = env.global_target();
let lock = env.paths.root.join("claude-json.lock");
restore_latest(&target, &env.paths.skill_store, &lock).unwrap();
assert!(env.paths.claude_skills.is_symlink());
assert_eq!(
std::fs::read_link(&env.paths.claude_skills).unwrap(),
legacy_target
);
}
}