use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub(crate) const GENERATED_MARKER_PREFIX: &str = "<!-- generated by fdu";
const PROJECT_AGENT_DIRS: [&str; 2] = [".agents", ".claude"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Action {
Installed,
Updated,
Unchanged,
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Installed => "installed",
Self::Updated => "updated",
Self::Unchanged => "unchanged",
})
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Outcome {
pub(crate) action: Action,
pub(crate) path: PathBuf,
}
#[derive(Debug)]
pub(crate) enum InstallError {
Foreign(PathBuf),
Io { path: PathBuf, source: io::Error, completed: Vec<Outcome> },
}
pub(crate) fn project_root(cwd: &Path) -> PathBuf {
cwd.ancestors()
.find(|directory| directory.join(".git").exists())
.map_or_else(|| cwd.to_path_buf(), Path::to_path_buf)
}
pub(crate) fn targets(cwd: &Path, agent_base: Option<&Path>) -> Vec<PathBuf> {
if let Some(base) = agent_base {
return vec![skill_file(&cwd.join(base))];
}
let root = project_root(cwd);
PROJECT_AGENT_DIRS.iter().map(|agent| skill_file(&root.join(agent))).collect()
}
fn skill_file(base: &Path) -> PathBuf {
base.join("skills").join("fdu").join("SKILL.md")
}
pub(crate) fn install(content: &str, targets: &[PathBuf]) -> Result<Vec<Outcome>, InstallError> {
let mut existing = Vec::with_capacity(targets.len());
for target in targets {
let current = match fs::read(target) {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(source) => {
return Err(InstallError::Io {
path: target.clone(),
source,
completed: Vec::new(),
});
}
};
if current.as_deref().is_some_and(|bytes| !is_generated(bytes)) {
return Err(InstallError::Foreign(target.clone()));
}
existing.push(current);
}
let mut outcomes = Vec::with_capacity(targets.len());
for (target, current) in targets.iter().zip(existing) {
let action = match current {
Some(bytes) if bytes == content.as_bytes() => Action::Unchanged,
Some(_) => Action::Updated,
None => Action::Installed,
};
if action != Action::Unchanged {
if let Err(source) = replace(target, content) {
return Err(InstallError::Io { path: target.clone(), source, completed: outcomes });
}
}
outcomes.push(Outcome { action, path: target.clone() });
}
Ok(outcomes)
}
fn is_generated(bytes: &[u8]) -> bool {
let marker = GENERATED_MARKER_PREFIX.as_bytes();
bytes.windows(marker.len()).any(|window| window == marker)
}
fn replace(target: &Path, content: &str) -> io::Result<()> {
let directory = target.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "the skill path has no parent directory")
})?;
fs::create_dir_all(directory)?;
let staged = staged_path(directory);
let staged_and_renamed = fs::File::create(&staged)
.and_then(|mut file| file.write_all(content.as_bytes()))
.and_then(|()| fs::rename(&staged, target));
if staged_and_renamed.is_err() {
let _ = fs::remove_file(&staged);
}
staged_and_renamed
}
pub(crate) fn staged_path(directory: &Path) -> PathBuf {
directory.join(format!(".SKILL.md.fdu-{}.tmp", std::process::id()))
}
pub(crate) fn display_path(path: &Path, cwd: &Path) -> String {
match path.strip_prefix(cwd) {
Ok(relative) if !relative.as_os_str().is_empty() => relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/"),
_ => path.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const GENERATED: &str = "---\nname: fdu\n---\n<!-- generated by fdu; re-run fdu --install-skill to update -->\n\n# fdu\n";
#[test]
fn the_project_root_is_the_nearest_ancestor_with_a_git_entry() {
let sandbox = tempfile::tempdir().expect("tempdir");
let repo = sandbox.path().join("repo");
let nested = repo.join("crates").join("fdu");
fs::create_dir_all(&nested).expect("create nested");
fs::write(repo.join(".git"), "gitdir: elsewhere\n").expect("write .git file");
assert_eq!(project_root(&nested), repo);
assert_eq!(project_root(&repo), repo);
let outside = sandbox.path().join("outside");
fs::create_dir(&outside).expect("create outside");
assert_eq!(project_root(&outside), outside);
}
#[test]
fn targets_are_both_project_files_or_one_user_scope_file() {
let sandbox = tempfile::tempdir().expect("tempdir");
let repo = sandbox.path().join("repo");
let nested = repo.join("src");
fs::create_dir_all(&nested).expect("create nested");
fs::create_dir(repo.join(".git")).expect("create .git");
let skill = |base: &Path| base.join("skills").join("fdu").join("SKILL.md");
assert_eq!(
targets(&nested, None),
vec![skill(&repo.join(".agents")), skill(&repo.join(".claude"))],
"a project install lands at the root, not in the subdirectory it ran from"
);
let base = sandbox.path().join("home").join(".claude");
assert_eq!(targets(&nested, Some(&base)), vec![skill(&base)]);
assert_eq!(
targets(&nested, Some(Path::new("agent"))),
vec![skill(&nested.join("agent"))],
"a relative base is taken from the current directory"
);
}
#[test]
fn an_install_is_reported_once_per_file_and_a_rerun_changes_nothing() {
let sandbox = tempfile::tempdir().expect("tempdir");
let files = targets(sandbox.path(), None);
let first = install(GENERATED, &files).expect("install");
assert_eq!(
first.iter().map(|outcome| outcome.action).collect::<Vec<_>>(),
[Action::Installed, Action::Installed]
);
assert_eq!(first[0].path, files[0]);
assert_eq!(first[1].path, files[1]);
for file in &files {
assert_eq!(fs::read_to_string(file).expect("read"), GENERATED);
}
let again = install(GENERATED, &files).expect("reinstall");
assert!(again.iter().all(|outcome| outcome.action == Action::Unchanged));
let newer = GENERATED.replace("# fdu", "# fdu, newer");
let updated = install(&newer, &files).expect("update");
assert!(updated.iter().all(|outcome| outcome.action == Action::Updated));
for file in &files {
assert_eq!(fs::read_to_string(file).expect("read"), newer);
let siblings: Vec<_> = fs::read_dir(file.parent().expect("parent"))
.expect("read dir")
.map(|entry| entry.expect("entry").file_name())
.collect();
assert_eq!(siblings, ["SKILL.md"]);
}
}
#[test]
fn a_file_fdu_did_not_generate_is_refused_before_anything_is_written() {
let sandbox = tempfile::tempdir().expect("tempdir");
let files = targets(sandbox.path(), None);
let foreign = &files[1];
fs::create_dir_all(foreign.parent().expect("parent")).expect("create dir");
fs::write(foreign, "---\nname: fdu\n---\n# Written by hand\n").expect("write foreign");
match install(GENERATED, &files) {
Err(InstallError::Foreign(path)) => assert_eq!(&path, foreign),
other => panic!("expected a refusal, got {other:?}"),
}
assert!(!files[0].exists(), "the first target is not written when the second is refused");
assert_eq!(
fs::read_to_string(foreign).expect("read"),
"---\nname: fdu\n---\n# Written by hand\n"
);
}
#[test]
fn a_write_that_fails_partway_names_what_it_installed_before_it() {
let sandbox = tempfile::tempdir().expect("tempdir");
let files = targets(sandbox.path(), None);
let staged = staged_path(files[1].parent().expect("parent"));
fs::create_dir_all(&staged).expect("block the staged path");
match install(GENERATED, &files) {
Err(InstallError::Io { path, completed, .. }) => {
assert_eq!(&path, &files[1]);
assert_eq!(
completed,
[Outcome { action: Action::Installed, path: files[0].clone() }],
"the target written before the failure is named"
);
}
other => panic!("expected a write failure, got {other:?}"),
}
assert_eq!(fs::read_to_string(&files[0]).expect("read"), GENERATED);
assert!(!files[1].exists(), "the failed target never became visible");
fs::remove_dir(&staged).expect("unblock the staged path");
let again = install(GENERATED, &files).expect("rerun");
assert_eq!(
again.iter().map(|outcome| outcome.action).collect::<Vec<_>>(),
[Action::Unchanged, Action::Installed]
);
}
#[test]
fn a_path_under_the_current_directory_is_shown_relative_with_forward_slashes() {
let cwd = Path::new("/work/repo");
let inside = cwd.join(".agents").join("skills").join("fdu").join("SKILL.md");
assert_eq!(display_path(&inside, cwd), ".agents/skills/fdu/SKILL.md");
let outside = Path::new("/home/me/.claude/skills/fdu/SKILL.md");
assert_eq!(display_path(outside, cwd), outside.display().to_string());
assert_eq!(display_path(cwd, cwd), cwd.display().to_string());
}
}