use std::path::{Path, PathBuf};
use crate::core::agent_manifest::ManifestError;
use crate::core::doctor_repair::{RepairMode, RepairStep, StepStatus};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deploy_tiers::project_skill_tier;
use crate::core::skill_manifest::{SkillManifest, SkillManifestSave, with_skill_manifest_lock};
use crate::core::skill_retire::{SkillRemoval, skill_removal_verdict};
use crate::core::skill_tiers::list_source_stems;
use crate::core::skill_unmanaged::bundled_skill_dirs;
const CHECK: &str = "skill_project_tier";
const SWEEP_WHAT: &str = "remove stray bundled skill copies from the project tier";
pub fn remove_project_tier_strays(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
backup_root: &Path,
mode: RepairMode,
) -> Vec<RepairStep> {
let Some(project_dir) = project_dir else {
return Vec::new();
};
let dir = project_skill_tier(project_dir);
match tier_shape(&dir) {
TierShape::Nothing => return Vec::new(),
TierShape::Refuse(why) => return vec![refusal(dir, why)],
TierShape::Sweepable => {}
}
if let Some(why) = resolves_onto_a_reserved_tier(paths, &dir) {
return vec![refusal(dir, why)];
}
let bundled = list_source_stems(&paths.skill_source_dir()).unwrap_or_default();
if bundled.is_empty() {
return vec![refusal(
dir,
"no bundled skill source found — cannot tell which project-tier skills are \
bundled duplicates (run `tm install` to populate it)"
.to_string(),
)];
}
let locked = with_skill_manifest_lock::<_, ManifestError, _>(&dir, || {
Ok(sweep_locked(&dir, &bundled, backup_root, mode))
});
match locked {
Ok(steps) => steps,
Err(e) => vec![RepairStep {
check: CHECK,
path: dir,
what: SWEEP_WHAT.to_string(),
status: StepStatus::Failed(format!(
"could not lock the deploy manifest: {e} — refusing to sweep this tier \
unserialised"
)),
}],
}
}
fn refusal(dir: PathBuf, why: String) -> RepairStep {
RepairStep {
check: CHECK,
path: dir,
what: SWEEP_WHAT.to_string(),
status: StepStatus::Refused(why),
}
}
enum TierShape {
Nothing,
Refuse(String),
Sweepable,
}
fn tier_shape(dir: &Path) -> TierShape {
match std::fs::symlink_metadata(dir) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => TierShape::Nothing,
Err(e) => TierShape::Refuse(format!(
"{} could not be inspected, so tm cannot tell what it would be deleting: {e}",
dir.display()
)),
Ok(meta) if meta.is_symlink() => TierShape::Refuse(format!(
"{} is a symlink — sweeping it would delete whatever it points at, which is \
not this project's tier",
dir.display()
)),
Ok(meta) if !meta.is_dir() => TierShape::Nothing,
Ok(_) => TierShape::Sweepable,
}
}
fn resolves_onto_a_reserved_tier(paths: &FrameworkPaths, dir: &Path) -> Option<String> {
let real = match std::fs::canonicalize(dir) {
Ok(p) => p,
Err(e) => {
return Some(format!(
"{} could not be resolved to a real path, so tm cannot prove it is not a tier \
bundled skills deploy to: {e}",
dir.display()
));
}
};
for reserved in [paths.skill_deploy_dir(), paths.claude_skills_dir()] {
let real_reserved = std::fs::canonicalize(&reserved).unwrap_or(reserved);
if real == real_reserved {
return Some(format!(
"this project's skill tier resolves onto {} — a tier bundled skills are \
deployed to; removing them there would undo the deploy, not repair it",
real_reserved.display()
));
}
}
None
}
fn sweep_locked(
dir: &Path,
bundled: &std::collections::BTreeSet<String>,
backup_root: &Path,
mode: RepairMode,
) -> Vec<RepairStep> {
let mut manifest = match SkillManifest::load(dir) {
Ok(m) => m,
Err(e) => {
return vec![refusal(
dir.to_path_buf(),
format!(
"{e} — refusing to touch this tier; its ownership ledger is what proves \
which copies tm wrote"
),
)];
}
};
if let Err(e) = std::fs::read_dir(dir) {
return vec![refusal(
dir.to_path_buf(),
format!(
"{} could not be listed, so tm cannot tell which copies are in it: {e}",
dir.display()
),
)];
}
let base = manifest.clone();
let mut dirty = false;
let skills = bundled_skill_dirs(dir, bundled);
let classified: std::collections::BTreeSet<&str> =
skills.iter().map(|s| s.stem.as_str()).collect();
let mut steps = unclassifiable_entries(dir, bundled, &classified);
for skill in &skills {
let what = format!(
"remove the stray bundled copy of `{}` from the project tier",
skill.stem
);
let status = if !manifest.is_managed(&skill.stem) {
StepStatus::Refused(
"absent from this tier's deploy ledger — tm cannot prove it wrote this copy, \
and a bundled-named directory it did not write may be a project-custom skill"
.to_string(),
)
} else {
match skill_removal_verdict(&manifest, dir, &skill.stem) {
SkillRemoval::Kept(why) => StepStatus::Refused(format!(
"{why} — the whole directory would be deleted, so tm removes one only when \
every file under it is one tm deployed and nobody has changed"
)),
SkillRemoval::Removable if mode == RepairMode::DryRun => StepStatus::Planned,
SkillRemoval::Removable => {
match back_up_and_remove(&skill.dir, &skill.stem, backup_root) {
Ok(backup) => {
unrecord(&mut manifest, &skill.stem);
dirty = true;
StepStatus::Applied {
backup: Some(backup),
}
}
Err(why) => StepStatus::Failed(why),
}
}
}
};
steps.push(RepairStep {
check: CHECK,
path: skill.dir.clone(),
what,
status,
});
}
steps.sort_by(|a, b| a.path.cmp(&b.path));
if dirty {
debug_assert_eq!(
mode,
RepairMode::Apply,
"a dry run must never mark the ledger dirty — it removed nothing to record"
);
match manifest.save_merging(dir, &base) {
Ok(SkillManifestSave::Written | SkillManifestSave::OverwroteUnreadable) => {}
Ok(SkillManifestSave::Merged) => tracing::warn!(
tier = %dir.display(),
"the skill manifest changed during the project-tier sweep — a writer bypassed \
the ledger lock; its entries were merged rather than dropped"
),
Err(e) => steps.push(RepairStep {
check: CHECK,
path: dir.to_path_buf(),
what: "drop the removed strays from the deploy ledger".to_string(),
status: StepStatus::Failed(format!("could not save the deploy manifest: {e}")),
}),
}
}
steps
}
pub fn unclassifiable_bundled_entries(
dir: &Path,
bundled: &std::collections::BTreeSet<String>,
classified: &std::collections::BTreeSet<&str>,
) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
(bundled.contains(&name) && !classified.contains(name.as_str())).then(|| entry.path())
})
.collect();
found.sort();
found
}
fn unclassifiable_entries(
dir: &Path,
bundled: &std::collections::BTreeSet<String>,
classified: &std::collections::BTreeSet<&str>,
) -> Vec<RepairStep> {
unclassifiable_bundled_entries(dir, bundled, classified)
.into_iter()
.map(|path| {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
RepairStep {
check: CHECK,
path,
what: format!("remove the stray bundled copy of `{name}` from the project tier"),
status: StepStatus::Refused(
"a bundled-named entry that is not a skill directory — no `SKILL.md`, or \
not a directory at all — so tm cannot tell what it is and leaves it alone"
.to_string(),
),
}
})
.collect()
}
pub fn stems_being_removed(steps: &[RepairStep]) -> std::collections::BTreeSet<String> {
steps
.iter()
.filter(|step| {
matches!(
step.status,
StepStatus::Planned | StepStatus::Applied { .. }
)
})
.filter_map(|step| Some(step.path.file_name()?.to_string_lossy().into_owned()))
.collect()
}
fn back_up_and_remove(dir: &Path, stem: &str, backup_root: &Path) -> Result<PathBuf, String> {
let dest = backup_root.join("project").join(stem);
copy_tree(dir, &dest).map_err(|e| {
format!(
"could not back up {} to {}: {e}",
dir.display(),
dest.display()
)
})?;
std::fs::remove_dir_all(dir).map_err(|e| {
format!(
"could not remove {}: {e} — it was backed up to {} first, so nothing is lost",
dir.display(),
dest.display()
)
})?;
if dir.exists() {
return Err(format!(
"removed {} but it is still present — the repair did NOT take; the backup at {} \
holds the copy either way",
dir.display(),
dest.display()
));
}
Ok(dest)
}
fn copy_tree(src: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let target = dest.join(entry.file_name());
let kind = entry.file_type()?;
if kind.is_symlink() {
return Err(std::io::Error::other(format!(
"{} is a symlink — tm backs up bytes, not links, so it cannot restore this \
entry and refuses to remove the directory holding it",
entry.path().display()
)));
}
if kind.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
fn unrecord(manifest: &mut SkillManifest, stem: &str) {
let prefix = format!("{stem}/");
manifest
.managed
.retain(|key, _| key != stem && !key.starts_with(&prefix));
}
#[cfg(test)]
#[path = "project_tier_strays_tests.rs"]
mod tests;