use std::fs;
use std::io;
use std::path::Path;
use super::Skill;
pub(crate) const SECTION_START: &str = "<!-- ARISTO-SKILLS START v1 -->";
pub(crate) const SECTION_END: &str = "<!-- ARISTO-SKILLS END -->";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InstallOutcome {
Created,
Updated,
Unchanged,
}
#[aristo::intent(
"A second invocation with identical content leaves the target \
byte-identical and returns `Unchanged`. Created (file did not \
exist) and Updated (content differed) are distinct outcomes; \
idempotence is the Unchanged case specifically.",
verify = "test",
id = "file_copy_install_idempotent"
)]
pub(crate) fn file_copy_install(target: &Path, skill: &Skill) -> io::Result<InstallOutcome> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let resolved = skill.resolved_content();
if target.exists() {
let existing = fs::read_to_string(target)?;
if existing == resolved {
return Ok(InstallOutcome::Unchanged);
}
fs::write(target, &resolved)?;
return Ok(InstallOutcome::Updated);
}
fs::write(target, &resolved)?;
Ok(InstallOutcome::Created)
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SkillState {
Missing,
UpToDate,
Stale { installed_version: Option<String> },
}
#[aristo::intent(
"Classifying an installed skill is READ-ONLY: file_copy_state and \
agents_md_state read the target and compare, they never write. The \
post-command update notice and `aristo status` call these on every \
interactive run; a write here would mutate the user's skill files as \
a side effect of an unrelated command.",
verify = "test",
id = "skill_state_audit_is_read_only"
)]
pub(crate) fn file_copy_state(target: &Path, skill: &Skill) -> SkillState {
match fs::read_to_string(target) {
Err(_) => SkillState::Missing,
Ok(existing) => classify(&existing, &skill.resolved_content()),
}
}
pub(crate) fn agents_md_state(target: &Path, skills: &[&Skill]) -> SkillState {
let Ok(existing) = fs::read_to_string(target) else {
return SkillState::Missing;
};
let Some((start, end)) = find_block(&existing) else {
return SkillState::Missing;
};
classify(
&existing[start..end],
render_agents_md_block(skills).trim_end(),
)
}
fn classify(installed: &str, current: &str) -> SkillState {
if installed == current {
SkillState::UpToDate
} else {
SkillState::Stale {
installed_version: parse_sdk_version(installed),
}
}
}
pub(crate) fn parse_sdk_version(content: &str) -> Option<String> {
content.lines().find_map(|line| {
line.trim()
.strip_prefix("sdk_version:")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
#[aristo::intent(
"Removes only the file we wrote — no sibling deletion, no \
parent-dir cleanup. Absence of the target is not an error; \
uninstall-of-already-uninstalled is the idempotent case.",
verify = "test",
id = "file_copy_uninstall_idempotent"
)]
pub(crate) fn file_copy_uninstall(target: &Path) -> io::Result<bool> {
if !target.exists() {
return Ok(false);
}
fs::remove_file(target)?;
Ok(true)
}
#[aristo::intent(
"Content outside the marker boundaries is preserved byte-for-byte \
across install and update. Users who hand-edit AGENTS.md alongside \
the auto-generated block don't lose their work to a normalization \
or reformat pass.",
verify = "test",
id = "agents_md_install_preserves_outside_markers"
)]
pub(crate) fn agents_md_install(target: &Path, skills: &[&Skill]) -> io::Result<InstallOutcome> {
let new_block = render_agents_md_block(skills);
if !target.exists() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, &new_block)?;
return Ok(InstallOutcome::Created);
}
let existing = fs::read_to_string(target)?;
let updated = match find_block(&existing) {
Some((start, end)) => {
let mut buf = String::with_capacity(existing.len() + new_block.len());
buf.push_str(&existing[..start]);
buf.push_str(new_block.trim_end());
buf.push_str(&existing[end..]);
buf
}
None => {
let mut buf = existing.clone();
if !buf.ends_with('\n') {
buf.push('\n');
}
buf.push('\n');
buf.push_str(&new_block);
buf
}
};
if updated == existing {
return Ok(InstallOutcome::Unchanged);
}
fs::write(target, updated)?;
Ok(InstallOutcome::Updated)
}
#[aristo::intent(
"Only the marker-delimited block is stripped; surrounding content \
is preserved byte-for-byte. Absent file or absent block is not an \
error — idempotent.",
verify = "test",
id = "agents_md_uninstall_preserves_outside_markers"
)]
pub(crate) fn agents_md_uninstall(target: &Path) -> io::Result<bool> {
if !target.exists() {
return Ok(false);
}
let existing = fs::read_to_string(target)?;
let Some((start, end)) = find_block(&existing) else {
return Ok(false);
};
let mut trim_end = end;
if existing[trim_end..].starts_with('\n') {
trim_end += 1;
}
let mut trim_start = start;
if trim_start > 0 && existing[..trim_start].ends_with('\n') {
trim_start -= 1;
}
let mut buf = String::with_capacity(existing.len());
buf.push_str(&existing[..trim_start]);
buf.push_str(&existing[trim_end..]);
fs::write(target, buf)?;
Ok(true)
}
fn render_agents_md_block(skills: &[&Skill]) -> String {
let mut buf = String::new();
buf.push_str(SECTION_START);
buf.push('\n');
for s in skills {
buf.push_str("\n## ");
buf.push_str(s.name);
buf.push_str("\n\n");
buf.push_str(s.resolved_content().trim());
buf.push('\n');
}
buf.push('\n');
buf.push_str(SECTION_END);
buf.push('\n');
buf
}
fn find_block(content: &str) -> Option<(usize, usize)> {
let start = content.find(SECTION_START)?;
let end_marker_start = content[start..].find(SECTION_END)? + start;
let end = end_marker_start + SECTION_END.len();
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills;
use tempfile::TempDir;
fn skill() -> &'static Skill {
skills::bundled()
.iter()
.find(|s| s.name == "aristo-authoring")
.expect("authoring skill must be bundled")
}
#[test]
fn installed_skill_has_real_sdk_version_not_placeholder() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("SKILL.md");
file_copy_install(&target, skill()).unwrap();
let on_disk = fs::read_to_string(&target).unwrap();
assert!(
!on_disk.contains("{{SDK_VERSION}}"),
"placeholder leaked to installed file"
);
let expected = format!("sdk_version: {}", env!("CARGO_PKG_VERSION"));
assert!(
on_disk.contains(&expected),
"installed frontmatter missing `{expected}`"
);
}
#[test]
fn file_copy_state_missing_when_absent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("SKILL.md");
assert_eq!(file_copy_state(&target, skill()), SkillState::Missing);
}
#[test]
fn file_copy_state_uptodate_after_install() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("SKILL.md");
file_copy_install(&target, skill()).unwrap();
assert_eq!(file_copy_state(&target, skill()), SkillState::UpToDate);
}
#[test]
fn file_copy_state_stale_reports_installed_version() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("SKILL.md");
fs::write(
&target,
"---\nname: aristo-authoring\nsdk_version: 0.0.1\n---\nold body\n",
)
.unwrap();
match file_copy_state(&target, skill()) {
SkillState::Stale { installed_version } => {
assert_eq!(installed_version.as_deref(), Some("0.0.1"));
}
other => panic!("expected Stale, got {other:?}"),
}
}
#[test]
fn skill_state_audit_is_read_only() {
let tmp = TempDir::new().unwrap();
let fresh = tmp.path().join("fresh/SKILL.md");
file_copy_install(&fresh, skill()).unwrap();
let fresh_before = fs::read_to_string(&fresh).unwrap();
let stale = tmp.path().join("stale/SKILL.md");
fs::create_dir_all(stale.parent().unwrap()).unwrap();
let stale_text = "---\nname: aristo-authoring\nsdk_version: 0.0.1\n---\nold\n";
fs::write(&stale, stale_text).unwrap();
let _ = file_copy_state(&fresh, skill());
let _ = file_copy_state(&stale, skill());
assert_eq!(fs::read_to_string(&fresh).unwrap(), fresh_before);
assert_eq!(fs::read_to_string(&stale).unwrap(), stale_text);
}
#[test]
fn agents_md_state_tracks_install_then_drift() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
assert_eq!(agents_md_state(&target, &[skill()]), SkillState::Missing);
agents_md_install(&target, &[skill()]).unwrap();
assert_eq!(agents_md_state(&target, &[skill()]), SkillState::UpToDate);
let stale = format!(
"{SECTION_START}\n## aristo-authoring\n\nsdk_version: 0.0.1\nold\n\n{SECTION_END}\n"
);
fs::write(&target, stale).unwrap();
match agents_md_state(&target, &[skill()]) {
SkillState::Stale { installed_version } => {
assert_eq!(installed_version.as_deref(), Some("0.0.1"));
}
other => panic!("expected Stale, got {other:?}"),
}
}
#[test]
fn parse_sdk_version_extracts_first_value_or_none() {
assert_eq!(
parse_sdk_version("name: x\nsdk_version: 0.2.1\nbody").as_deref(),
Some("0.2.1")
);
assert_eq!(parse_sdk_version("no version anywhere"), None);
assert_eq!(parse_sdk_version("sdk_version: "), None);
}
#[test]
fn file_copy_creates_then_unchanged_then_updated() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("a/b/c/SKILL.md");
let r1 = file_copy_install(&target, skill()).unwrap();
assert_eq!(r1, InstallOutcome::Created);
assert!(target.is_file());
let r2 = file_copy_install(&target, skill()).unwrap();
assert_eq!(r2, InstallOutcome::Unchanged);
fs::write(&target, "tampered").unwrap();
let r3 = file_copy_install(&target, skill()).unwrap();
assert_eq!(r3, InstallOutcome::Updated);
assert_eq!(
fs::read_to_string(&target).unwrap(),
skill().resolved_content()
);
}
#[test]
fn file_copy_uninstall_idempotent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("SKILL.md");
assert!(!file_copy_uninstall(&target).unwrap());
file_copy_install(&target, skill()).unwrap();
assert!(file_copy_uninstall(&target).unwrap());
assert!(!target.exists());
assert!(!file_copy_uninstall(&target).unwrap());
}
#[test]
fn agents_md_creates_when_file_absent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
let r = agents_md_install(&target, &[skill()]).unwrap();
assert_eq!(r, InstallOutcome::Created);
let content = fs::read_to_string(&target).unwrap();
assert!(content.contains(SECTION_START));
assert!(content.contains(SECTION_END));
assert!(content.contains("## aristo-authoring"));
}
#[test]
fn agents_md_appends_block_to_existing_file_preserving_user_content() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
let user_text = "# My agent rules\n\nUse 4-space indent.\n";
fs::write(&target, user_text).unwrap();
agents_md_install(&target, &[skill()]).unwrap();
let content = fs::read_to_string(&target).unwrap();
assert!(
content.starts_with(user_text),
"user content must be preserved at the start"
);
assert!(content.contains(SECTION_START));
}
#[test]
fn agents_md_replaces_only_marker_block_on_update() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
let user_before = "# Before\n\n";
let stale_block =
format!("{SECTION_START}\n## aristo-authoring\n\nold content\n\n{SECTION_END}\n");
let user_after = "\n# After\n\nMore user notes.\n";
fs::write(&target, format!("{user_before}{stale_block}{user_after}")).unwrap();
let r = agents_md_install(&target, &[skill()]).unwrap();
assert_eq!(r, InstallOutcome::Updated);
let content = fs::read_to_string(&target).unwrap();
assert!(content.contains("# Before"));
assert!(content.contains("# After"));
assert!(content.contains("More user notes."));
assert!(
!content.contains("old content"),
"stale content must be replaced"
);
assert!(content.contains(SECTION_START));
}
#[test]
fn agents_md_install_idempotent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
agents_md_install(&target, &[skill()]).unwrap();
let r = agents_md_install(&target, &[skill()]).unwrap();
assert_eq!(r, InstallOutcome::Unchanged);
}
#[test]
fn agents_md_uninstall_strips_block_preserves_surrounding() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
let user_before = "# My rules\n\nUse 4-space indent.\n";
fs::write(&target, user_before).unwrap();
agents_md_install(&target, &[skill()]).unwrap();
let removed = agents_md_uninstall(&target).unwrap();
assert!(removed);
let content = fs::read_to_string(&target).unwrap();
assert!(!content.contains(SECTION_START));
assert!(!content.contains(SECTION_END));
assert!(content.contains("# My rules"));
assert!(content.contains("Use 4-space indent."));
}
#[test]
fn agents_md_uninstall_idempotent_when_file_absent_or_block_absent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("AGENTS.md");
assert!(!agents_md_uninstall(&target).unwrap());
fs::write(&target, "just user content\n").unwrap();
assert!(!agents_md_uninstall(&target).unwrap());
assert_eq!(fs::read_to_string(&target).unwrap(), "just user content\n");
}
}