use std::path::{Path, PathBuf};
use crate::skillgen::model::SkillSpec;
use crate::skillgen::platform::PLATFORMS;
use crate::skillgen::render::{render_skill, render_suite};
use crate::skillgen::state::{SkillState, skill_state, write_stamp};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillScope {
Home,
Project,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Wrote,
Removed,
WouldWrite,
WouldRemove,
Skipped,
SkippedNewer,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placed {
pub tool: &'static str,
pub path: PathBuf,
pub action: Action,
}
pub fn place_skills(
spec: &SkillSpec,
home: &Path,
root: &Path,
scope: SkillScope,
tool_filter: Option<&str>,
remove: bool,
dry_run: bool,
) -> std::io::Result<Vec<Placed>> {
let mut out = Vec::new();
for p in PLATFORMS {
if let Some(f) = tool_filter {
if p.key != f {
continue;
}
}
let Some(skill) = p.skill else { continue };
let dir = match scope {
SkillScope::Home => skill.home_dir_under(&spec.slug, home),
SkillScope::Project => match skill.project_dir(&spec.slug, root) {
Some(d) => d,
None => continue, },
};
let path = dir.join("SKILL.md");
if remove {
let action = if dry_run {
Action::WouldRemove
} else if path.exists() {
std::fs::remove_file(&path)?;
let _ = std::fs::remove_dir(&dir); Action::Removed
} else {
Action::Skipped
};
out.push(Placed {
tool: p.key,
path,
action,
});
continue;
}
let Some(content) = render_skill(spec, p) else {
continue;
};
if skill_state(&dir, &spec.version).is_downgrade() {
out.push(Placed {
tool: p.key,
path,
action: Action::SkippedNewer,
});
continue;
}
let action = if dry_run {
Action::WouldWrite
} else {
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, &content)?;
write_stamp(&dir, &spec.version)?;
Action::Wrote
};
out.push(Placed {
tool: p.key,
path,
action,
});
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub fn place_rendered(
slug: &str,
version: &str,
content: &str,
home: &Path,
root: &Path,
scope: SkillScope,
tool_filter: Option<&str>,
dry_run: bool,
) -> std::io::Result<Vec<Placed>> {
let mut out = Vec::new();
for p in PLATFORMS {
if let Some(f) = tool_filter {
if p.key != f {
continue;
}
}
let Some(skill) = p.skill else { continue };
let dir = match scope {
SkillScope::Home => skill.home_dir_under(slug, home),
SkillScope::Project => match skill.project_dir(slug, root) {
Some(d) => d,
None => continue,
},
};
let path = dir.join("SKILL.md");
if skill_state(&dir, version).is_downgrade() {
out.push(Placed {
tool: p.key,
path,
action: Action::SkippedNewer,
});
continue;
}
let action = if dry_run {
Action::WouldWrite
} else {
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, content)?;
write_stamp(&dir, version)?;
Action::Wrote
};
out.push(Placed {
tool: p.key,
path,
action,
});
}
Ok(out)
}
pub fn install_suite(
own: &SkillSpec,
sibling: &SkillSpec,
home: &Path,
root: &Path,
scope: SkillScope,
dry_run: bool,
) -> std::io::Result<(String, Vec<Placed>)> {
let (name, content) = render_suite(own, sibling);
let placed = place_rendered(
&name,
&own.version,
&content,
home,
root,
scope,
None,
dry_run,
)?;
Ok((name, placed))
}
pub fn skill_status(
spec: &SkillSpec,
home: &Path,
root: &Path,
scope: SkillScope,
tool_filter: Option<&str>,
) -> Vec<(&'static str, SkillState)> {
let mut out = Vec::new();
for p in PLATFORMS {
if let Some(f) = tool_filter {
if p.key != f {
continue;
}
}
let Some(skill) = p.skill else { continue };
let dir = match scope {
SkillScope::Home => skill.home_dir_under(&spec.slug, home),
SkillScope::Project => match skill.project_dir(&spec.slug, root) {
Some(d) => d,
None => continue,
},
};
out.push((p.key, skill_state(&dir, &spec.version)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skillgen::{STAMP_FILE, example_spec};
#[test]
fn places_and_stamps_home_scoped() {
let tmp = std::env::temp_dir().join(format!("skgen-install-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let spec = example_spec(); let placed = place_skills(&spec, &tmp, &tmp, SkillScope::Home, None, false, false).unwrap();
assert!(!placed.is_empty());
let claude = tmp.join(".claude/skills/demo/SKILL.md");
assert!(claude.exists());
assert!(tmp.join(".claude/skills/demo").join(STAMP_FILE).exists());
assert!(placed.iter().all(|p| p.action == Action::Wrote));
let states = skill_status(&spec, &tmp, &tmp, SkillScope::Home, None);
assert!(
states
.iter()
.all(|(_, s)| matches!(s, SkillState::Current { .. }))
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn refuses_downgrade_and_dry_run_is_inert() {
let tmp = std::env::temp_dir().join(format!("skgen-install2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let spec = example_spec();
let dry = place_skills(
&spec,
&tmp,
&tmp,
SkillScope::Home,
Some("claude-code"),
false,
true,
)
.unwrap();
assert_eq!(dry[0].action, Action::WouldWrite);
assert!(!tmp.join(".claude/skills/demo/SKILL.md").exists());
let dir = tmp.join(".claude/skills/demo");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), "NEWER").unwrap();
crate::skillgen::state::write_stamp(&dir, "999.0.0").unwrap();
let placed = place_skills(
&spec,
&tmp,
&tmp,
SkillScope::Home,
Some("claude-code"),
false,
false,
)
.unwrap();
assert_eq!(placed[0].action, Action::SkippedNewer);
assert_eq!(
std::fs::read_to_string(dir.join("SKILL.md")).unwrap(),
"NEWER"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}