use std::io;
use std::path::{Path, PathBuf};
use crate::paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvisionReport {
pub plugins: LinkOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkOutcome {
#[cfg_attr(not(unix), allow(dead_code))]
AlreadyLinked,
#[cfg_attr(not(unix), allow(dead_code))]
Created,
#[cfg_attr(not(unix), allow(dead_code))]
SeededShared,
#[cfg_attr(not(unix), allow(dead_code))]
BackedUp(PathBuf),
#[cfg_attr(unix, allow(dead_code))]
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginLinkState {
Ok,
#[cfg_attr(not(unix), allow(dead_code))]
Missing,
#[cfg_attr(not(unix), allow(dead_code))]
RealDir,
#[cfg_attr(not(unix), allow(dead_code))]
WrongLink(PathBuf),
#[cfg_attr(not(unix), allow(dead_code))]
NotADir,
}
impl PluginLinkState {
pub fn is_ok(&self) -> bool {
matches!(self, PluginLinkState::Ok)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileDiagnosis {
pub dir_exists: bool,
pub plugins: PluginLinkState,
}
impl ProfileDiagnosis {
pub fn is_healthy(&self) -> bool {
self.dir_exists && self.plugins.is_ok()
}
}
#[cfg(unix)]
pub fn diagnose_profile(dir: &Path) -> ProfileDiagnosis {
diagnose_profile_with(dir, &paths::shared_plugins_dir())
}
#[cfg(not(unix))]
pub fn diagnose_profile(dir: &Path) -> ProfileDiagnosis {
ProfileDiagnosis {
dir_exists: dir.is_dir(),
plugins: PluginLinkState::Ok,
}
}
#[cfg(unix)]
pub fn diagnose_profile_with(dir: &Path, shared_plugins: &Path) -> ProfileDiagnosis {
let dir_exists = dir.is_dir();
let link = dir.join("plugins");
let plugins = match std::fs::symlink_metadata(&link) {
Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(&link) {
Ok(target) if links_match(&target, &link, shared_plugins) => PluginLinkState::Ok,
Ok(target) => PluginLinkState::WrongLink(target),
Err(_) => PluginLinkState::WrongLink(PathBuf::new()),
},
Ok(meta) if meta.is_dir() => PluginLinkState::RealDir,
Ok(_) => PluginLinkState::NotADir,
Err(e) if e.kind() == io::ErrorKind::NotFound => PluginLinkState::Missing,
Err(_) => PluginLinkState::NotADir,
};
ProfileDiagnosis {
dir_exists,
plugins,
}
}
pub fn ensure_profile_provisioned(name: &str, dir: &Path) -> io::Result<ProvisionReport> {
ensure_profile_provisioned_with(name, dir, &paths::shared_plugins_dir())
}
pub fn ensure_profile_provisioned_with(
name: &str,
dir: &Path,
shared_plugins: &Path,
) -> io::Result<ProvisionReport> {
std::fs::create_dir_all(dir).map_err(|e| {
io::Error::new(
e.kind(),
format!("provision[{name}]: create {} failed: {e}", dir.display()),
)
})?;
let plugins = link_dir_to_shared(&dir.join("plugins"), shared_plugins)
.map_err(|e| io::Error::new(e.kind(), format!("provision[{name}]: plugins: {e}")))?;
Ok(ProvisionReport { plugins })
}
pub fn ensure_provisioned_soft(dir: &Path) {
let name = display_name_for(dir);
if let Err(e) = ensure_profile_provisioned(&name, dir) {
eprintln!("csm: warning: profile provisioning skipped: {e}");
}
}
fn display_name_for(dir: &Path) -> String {
let leaf = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
leaf.strip_prefix(".claude.")
.map(str::to_owned)
.unwrap_or(leaf)
}
#[cfg(unix)]
pub fn link_dir_to_shared(link: &Path, shared: &Path) -> io::Result<LinkOutcome> {
use std::os::unix::fs::symlink;
match std::fs::symlink_metadata(link) {
Ok(meta) if meta.file_type().is_symlink() => {
let cur = std::fs::read_link(link)?;
if links_match(&cur, link, shared) {
return Ok(LinkOutcome::AlreadyLinked);
}
std::fs::create_dir_all(shared)?;
std::fs::remove_file(link)?;
symlink(shared, link)?;
return Ok(LinkOutcome::Created);
}
Ok(meta) if meta.is_dir() => {
if !shared.exists() {
if let Some(parent) = shared.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(link, shared)?;
symlink(shared, link)?;
return Ok(LinkOutcome::SeededShared);
}
let backup = backup_path(link)?;
std::fs::rename(link, &backup)?;
symlink(shared, link)?;
return Ok(LinkOutcome::BackedUp(backup));
}
Ok(_) => {
std::fs::create_dir_all(shared)?;
let backup = backup_path(link)?;
std::fs::rename(link, &backup)?;
symlink(shared, link)?;
return Ok(LinkOutcome::BackedUp(backup));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => { }
Err(e) => return Err(e),
}
std::fs::create_dir_all(shared)?;
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)?;
}
symlink(shared, link)?;
Ok(LinkOutcome::Created)
}
#[cfg(not(unix))]
pub fn link_dir_to_shared(_link: &Path, _shared: &Path) -> io::Result<LinkOutcome> {
Ok(LinkOutcome::Skipped)
}
#[cfg(unix)]
fn links_match(cur: &Path, link: &Path, shared: &Path) -> bool {
let resolved = if cur.is_absolute() {
cur.to_path_buf()
} else {
link.parent().unwrap_or(Path::new(".")).join(cur)
};
match (resolved.canonicalize(), shared.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => resolved == shared,
}
}
#[cfg(unix)]
fn backup_path(path: &Path) -> io::Result<PathBuf> {
let base = path.as_os_str().to_owned();
for n in 0..1000u32 {
let mut candidate = base.clone();
if n == 0 {
candidate.push(".bak");
} else {
candidate.push(format!(".bak.{n}"));
}
let p = PathBuf::from(candidate);
if !p.exists() {
return Ok(p);
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("no free backup slot for {}", path.display()),
))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::fs;
fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
let td = tempfile::tempdir().unwrap();
let profile = td.path().join("profile");
fs::create_dir_all(&profile).unwrap();
let link = profile.join("plugins");
let shared = td.path().join("shared").join("plugins");
(td, link, shared)
}
fn is_symlink_to(link: &Path, shared: &Path) -> bool {
let meta = fs::symlink_metadata(link).unwrap();
if !meta.file_type().is_symlink() {
return false;
}
fs::read_link(link).unwrap() == shared
}
#[test]
fn none_creates_symlink_and_empty_ssot() {
let (_td, link, shared) = fixture();
let out = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(out, LinkOutcome::Created);
assert!(is_symlink_to(&link, &shared), "link must point at SSOT");
assert!(shared.is_dir(), "SSOT dir must exist (empty)");
}
#[test]
fn correct_symlink_is_noop() {
let (_td, link, shared) = fixture();
fs::create_dir_all(&shared).unwrap();
std::os::unix::fs::symlink(&shared, &link).unwrap();
let out = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(out, LinkOutcome::AlreadyLinked);
assert!(is_symlink_to(&link, &shared));
}
#[test]
fn wrong_symlink_is_repointed() {
let (td, link, shared) = fixture();
let other = td.path().join("other");
fs::create_dir_all(&other).unwrap();
std::os::unix::fs::symlink(&other, &link).unwrap();
let out = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(out, LinkOutcome::Created);
assert!(is_symlink_to(&link, &shared), "must repoint at the SSOT");
}
#[test]
fn real_dir_seeds_absent_ssot() {
let (_td, link, shared) = fixture();
fs::create_dir_all(&link).unwrap();
fs::write(link.join("marker.json"), b"{}").unwrap();
assert!(!shared.exists());
let out = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(out, LinkOutcome::SeededShared);
assert!(is_symlink_to(&link, &shared));
assert!(
shared.join("marker.json").exists(),
"content must seed SSOT"
);
assert!(link.join("marker.json").exists());
}
#[test]
fn real_dir_backs_up_when_ssot_exists() {
let (_td, link, shared) = fixture();
fs::create_dir_all(&shared).unwrap();
fs::write(shared.join("canonical.json"), b"{}").unwrap();
fs::create_dir_all(&link).unwrap();
fs::write(link.join("divergent.json"), b"{}").unwrap();
let out = link_dir_to_shared(&link, &shared).unwrap();
match &out {
LinkOutcome::BackedUp(backup) => {
assert!(backup.exists(), "backup dir must exist");
assert!(
backup.join("divergent.json").exists(),
"divergent content must be preserved in backup"
);
}
other => panic!("expected BackedUp, got {other:?}"),
}
assert!(is_symlink_to(&link, &shared));
assert!(link.join("canonical.json").exists());
}
#[test]
fn regular_file_at_link_is_backed_up() {
let (_td, link, shared) = fixture();
fs::write(&link, b"not a dir").unwrap();
let out = link_dir_to_shared(&link, &shared).unwrap();
assert!(matches!(out, LinkOutcome::BackedUp(_)));
assert!(is_symlink_to(&link, &shared));
}
#[test]
fn idempotent_second_call_is_noop() {
let (_td, link, shared) = fixture();
let first = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(first, LinkOutcome::Created);
let second = link_dir_to_shared(&link, &shared).unwrap();
assert_eq!(second, LinkOutcome::AlreadyLinked, "second call is a no-op");
}
#[test]
fn ensure_profile_creates_dir_and_links() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join(".claude.example");
let shared = td.path().join("shared").join("plugins");
assert!(!dir.exists());
let report = ensure_profile_provisioned_with("example", &dir, &shared).unwrap();
assert!(dir.is_dir(), "profile dir must be created");
assert_eq!(report.plugins, LinkOutcome::Created);
assert!(is_symlink_to(&dir.join("plugins"), &shared));
}
#[test]
fn ensure_profile_is_idempotent() {
let td = tempfile::tempdir().unwrap();
let dir = td.path().join(".claude.example");
let shared = td.path().join("shared").join("plugins");
ensure_profile_provisioned_with("example", &dir, &shared).unwrap();
let again = ensure_profile_provisioned_with("example", &dir, &shared).unwrap();
assert_eq!(again.plugins, LinkOutcome::AlreadyLinked);
}
#[test]
fn diagnose_classifies_each_state() {
let td = tempfile::tempdir().unwrap();
let shared = td.path().join("shared").join("plugins");
fs::create_dir_all(&shared).unwrap();
let missing = td.path().join(".claude.missing");
fs::create_dir_all(&missing).unwrap();
let d = diagnose_profile_with(&missing, &shared);
assert!(d.dir_exists);
assert_eq!(d.plugins, PluginLinkState::Missing);
assert!(!d.is_healthy());
let ok = td.path().join(".claude.ok");
fs::create_dir_all(&ok).unwrap();
std::os::unix::fs::symlink(&shared, ok.join("plugins")).unwrap();
let d = diagnose_profile_with(&ok, &shared);
assert_eq!(d.plugins, PluginLinkState::Ok);
assert!(d.is_healthy());
let real = td.path().join(".claude.real");
fs::create_dir_all(real.join("plugins")).unwrap();
let d = diagnose_profile_with(&real, &shared);
assert_eq!(d.plugins, PluginLinkState::RealDir);
assert!(!d.is_healthy());
let wrong = td.path().join(".claude.wrong");
fs::create_dir_all(&wrong).unwrap();
let other = td.path().join("other");
fs::create_dir_all(&other).unwrap();
std::os::unix::fs::symlink(&other, wrong.join("plugins")).unwrap();
let d = diagnose_profile_with(&wrong, &shared);
assert!(matches!(d.plugins, PluginLinkState::WrongLink(_)));
assert!(!d.is_healthy());
let gone = td.path().join(".claude.gone");
let d = diagnose_profile_with(&gone, &shared);
assert!(!d.dir_exists);
}
#[test]
fn diagnose_then_fix_makes_healthy() {
let td = tempfile::tempdir().unwrap();
let shared = td.path().join("shared").join("plugins");
fs::create_dir_all(&shared).unwrap();
let real = td.path().join(".claude.real");
fs::create_dir_all(real.join("plugins")).unwrap();
assert_eq!(
diagnose_profile_with(&real, &shared).plugins,
PluginLinkState::RealDir
);
ensure_profile_provisioned_with("real", &real, &shared).unwrap();
assert!(
diagnose_profile_with(&real, &shared).is_healthy(),
"fix must make the profile healthy"
);
}
#[test]
fn two_profiles_share_one_ssot() {
let td = tempfile::tempdir().unwrap();
let shared = td.path().join("shared").join("plugins");
let a = td.path().join(".claude.a");
let b = td.path().join(".claude.b");
ensure_profile_provisioned_with("a", &a, &shared).unwrap();
ensure_profile_provisioned_with("b", &b, &shared).unwrap();
fs::write(a.join("plugins").join("shared.json"), b"{}").unwrap();
assert!(
b.join("plugins").join("shared.json").exists(),
"both profiles must see the same SSOT"
);
}
}