use anyhow::{Context, Result};
use std::path::PathBuf;
use crate::claude::{mcp, settings};
use crate::config::Paths;
use crate::persona::{self, Persona};
use crate::symlink::is_symlink;
const CC_PERSONA_SKILL: &str = "cc-persona";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DriftReport {
pub untracked: Vec<String>,
pub ghosts: Vec<String>,
pub drifted_missing_link: Vec<String>,
pub drifted_extra_link: Vec<String>,
pub foreign_links: Vec<String>,
pub skills_dir_is_symlink: bool,
}
pub fn inspect_skills(paths: &Paths, active: Option<&Persona>) -> Result<DriftReport> {
let mut report = DriftReport {
skills_dir_is_symlink: is_symlink(&paths.claude_skills),
..Default::default()
};
let mut desired: Vec<String> = active
.and_then(|p| p.skills.as_ref())
.map(|s| s.active.clone())
.unwrap_or_default();
if !desired.iter().any(|n| n == CC_PERSONA_SKILL) {
desired.push(CC_PERSONA_SKILL.to_string());
}
if report.skills_dir_is_symlink || !paths.claude_skills.exists() {
for name in &desired {
if !paths.skill_store.join(name).exists() {
report.ghosts.push(name.clone());
}
}
report.ghosts.sort();
return Ok(report);
}
let managed = list_managed_links(paths)?;
let managed_names: Vec<String> = managed.iter().map(|(n, _)| n.clone()).collect();
for entry in std::fs::read_dir(&paths.claude_skills)? {
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)?;
if !target_in_store(&target, paths) {
report.foreign_links.push(name);
continue;
}
if !desired.iter().any(|d| d == &name) {
report.drifted_extra_link.push(name);
}
} else if path.is_dir() && name != CC_PERSONA_SKILL {
report.untracked.push(name);
}
}
for name in &desired {
if !paths.skill_store.join(name).exists() {
report.ghosts.push(name.clone());
continue;
}
if !managed_names.iter().any(|m| m == name) {
report.drifted_missing_link.push(name.clone());
}
}
report.untracked.sort();
report.ghosts.sort();
report.drifted_missing_link.sort();
report.drifted_extra_link.sort();
report.foreign_links.sort();
Ok(report)
}
pub fn list_untracked_skills(paths: &Paths) -> Result<Vec<String>> {
let mut names = Vec::new();
if !paths.claude_skills.exists() {
return Ok(names);
}
for entry in std::fs::read_dir(&paths.claude_skills)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if is_symlink(&path) {
continue;
}
if name == CC_PERSONA_SKILL {
continue;
}
if path.is_dir() {
names.push(name);
}
}
names.sort();
Ok(names)
}
pub fn list_managed_links(paths: &Paths) -> Result<Vec<(String, PathBuf)>> {
let mut links = Vec::new();
if !paths.claude_skills.exists() || is_symlink(&paths.claude_skills) {
return Ok(links);
}
for entry in std::fs::read_dir(&paths.claude_skills)? {
let entry = entry?;
let path = entry.path();
if !is_symlink(&path) {
continue;
}
let target = std::fs::read_link(&path)?;
if !target_in_store(&target, paths) {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
links.push((name, target));
}
links.sort_by(|a, b| a.0.cmp(&b.0));
Ok(links)
}
pub fn untracked_plugins(paths: &Paths) -> Result<Vec<String>> {
let live = settings::read_settings(&paths.claude_settings)?;
let mut enabled = Vec::new();
if let Some(map) = live.get("enabledPlugins").and_then(|v| v.as_object()) {
for (name, value) in map {
if value.as_bool().unwrap_or(false) {
enabled.push(name.clone());
}
}
}
let declared = declared_plugins(paths)?;
let mut untracked: Vec<String> = enabled
.into_iter()
.filter(|name| !declared.iter().any(|d| d == name))
.collect();
untracked.sort();
untracked.dedup();
Ok(untracked)
}
pub fn untracked_mcp(paths: &Paths) -> Result<Vec<String>> {
let servers = mcp::list_mcp_servers(&paths.claude_json)?;
let patterns = declared_mcp_patterns(paths)?;
let mut untracked: Vec<String> = servers
.into_iter()
.map(|(name, _)| name)
.filter(|name| !patterns.iter().any(|p| name.contains(p.as_str())))
.collect();
untracked.sort();
untracked.dedup();
Ok(untracked)
}
pub fn backups_usage(paths: &Paths) -> Result<(usize, u64)> {
if !paths.backups.exists() {
return Ok((0, 0));
}
let mut count = 0usize;
let mut bytes = 0u64;
for entry in std::fs::read_dir(&paths.backups)? {
let entry = entry?;
if entry.path().is_dir() {
count += 1;
bytes += dir_size(&entry.path())?;
}
}
Ok((count, bytes))
}
pub fn snapshot_missing(paths: &Paths) -> bool {
!paths.active_persona_state.exists()
}
fn declared_plugins(paths: &Paths) -> Result<Vec<String>> {
let mut declared = Vec::new();
for name in persona::list_personas(&paths.personas)? {
let path = persona::persona_path(&paths.personas, &name);
let Ok(p) = Persona::load(&path) else {
continue;
};
if let Some(map) = p
.settings
.as_ref()
.and_then(|s| s.get("enabledPlugins"))
.and_then(|v| v.as_object())
{
for (plugin, value) in map {
if value.as_bool().unwrap_or(false) {
declared.push(plugin.clone());
}
}
}
}
declared.sort();
declared.dedup();
Ok(declared)
}
fn declared_mcp_patterns(paths: &Paths) -> Result<Vec<String>> {
let mut patterns = Vec::new();
for name in persona::list_personas(&paths.personas)? {
let path = persona::persona_path(&paths.personas, &name);
let Ok(p) = Persona::load(&path) else {
continue;
};
if let Some(mcp) = p.mcp.as_ref() {
patterns.extend(mcp.enable.iter().cloned());
patterns.extend(mcp.disable.iter().cloned());
}
}
patterns.sort();
patterns.dedup();
Ok(patterns)
}
fn target_in_store(target: &std::path::Path, paths: &Paths) -> bool {
match (
std::fs::canonicalize(target).ok(),
std::fs::canonicalize(&paths.skill_store).ok(),
) {
(Some(t), Some(store)) => t.starts_with(&store),
_ => target.starts_with(&paths.skill_store),
}
}
fn dir_size(dir: &std::path::Path) -> Result<u64> {
let mut total = 0u64;
for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
total += dir_size(&entry.path())?;
} else {
total += meta.len();
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestEnv;
#[cfg(unix)]
#[test]
fn list_untracked_skills_excludes_symlinks_and_cc_persona() {
let env = TestEnv::new();
std::fs::create_dir_all(&env.paths.claude_skills).unwrap();
env.create_skill(&env.paths.claude_skills, "wild", "---\nname: wild\n---\n");
env.create_skill(
&env.paths.claude_skills,
"cc-persona",
"---\nname: cc-persona\n---\n",
);
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.link_into_claude_skills("alpha");
let untracked = list_untracked_skills(&env.paths).unwrap();
assert_eq!(untracked, vec!["wild".to_string()]);
}
#[cfg(unix)]
#[test]
fn inspect_skills_reports_legacy_symlink_dir() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
let target = env.paths.skill_sets.join("engineer");
std::fs::create_dir_all(&target).unwrap();
env.symlink(&target, &env.paths.claude_skills);
let report = inspect_skills(&env.paths, None).unwrap();
assert!(report.skills_dir_is_symlink);
}
}