fdu 0.2.1

Fastest native du replacement and detailed file analytics for Python and Rust
Documentation
//! `fdu --install-skill`: put the agent skill where coding agents look for it.
//!
//! Presentation, like `--docs` and `--skill`: the engine is not involved, and nothing
//! here walks a tree. The skill `--skill` prints is written, byte for byte, to
//! `.agents/skills/fdu/SKILL.md` (the portable location Codex, Gemini CLI, and others
//! read) and `.claude/skills/fdu/SKILL.md` (the only path Claude Code reads) under the
//! project root, or to one agent's user scope with `--agent-base`.
//!
//! Three rules keep a re-run safe. Every target is classified before any is written, so
//! a refusal leaves the tree exactly as it was. A file is replaced through a staged
//! sibling and a rename, so no reader ever sees a half-written skill. And only a file
//! carrying fdu's own marker is ever replaced: a `SKILL.md` somebody wrote by hand at
//! the same path is refused, not overwritten.
//!
//! Two things those rules do not promise. A write that fails on a later target leaves
//! the earlier ones installed: the error names them so the caller can say so, and a
//! rerun reports them `unchanged`. And links are not special: a symbolic link at a
//! target is read through, so it counts as generated when the file it points to is; an
//! update then renames over the link, replacing it with a regular file and leaving that
//! other file as it was, while an unchanged target writes nothing and the link stays. A
//! link on the way to a target is followed like any directory.

use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

/// The start of the comment the rendered skill carries after its frontmatter.
///
/// Only the prefix is matched, so rewording the rest of the comment in a later release
/// does not make that release refuse the files an earlier one wrote.
pub(crate) const GENERATED_MARKER_PREFIX: &str = "<!-- generated by fdu";

/// The agent directories a project-scope install writes under, in output order.
const PROJECT_AGENT_DIRS: [&str; 2] = [".agents", ".claude"];

/// What one target needed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Action {
    /// No file was there.
    Installed,
    /// A file fdu generated was there with different bytes.
    Updated,
    /// The same bytes were already there; nothing was written.
    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",
        })
    }
}

/// One target's outcome, in target order.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Outcome {
    pub(crate) action: Action,
    pub(crate) path: PathBuf,
}

/// Why the install stopped.
#[derive(Debug)]
pub(crate) enum InstallError {
    /// A `SKILL.md` at this target lacks fdu's marker, so fdu did not write it. No
    /// target was written, this one or any other.
    Foreign(PathBuf),
    /// Reading or writing this target failed. `completed` holds the targets finished
    /// before it (written or found unchanged), in order; it is empty when the failure
    /// was found before any target was finished.
    Io { path: PathBuf, source: io::Error, completed: Vec<Outcome> },
}

/// The project root: the nearest ancestor of `cwd`, including itself, holding a `.git`
/// entry (a directory, or the file a worktree or submodule keeps), else `cwd` itself.
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)
}

/// The files an install writes, in the order they are reported.
///
/// With `agent_base`, one file under that base; a relative base is taken from `cwd`.
/// Otherwise both project-scope files under the project root of `cwd`.
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")
}

/// Write `content` to every target, in order.
///
/// A refusal writes nothing. A write that fails leaves the targets before it finished
/// and names them in the error; a rerun reports those `unchanged` and finishes the rest.
///
/// # Errors
///
/// [`InstallError::Foreign`] when any target holds a file without fdu's marker, before
/// anything is written; [`InstallError::Io`] when a target cannot be read or written.
pub(crate) fn install(content: &str, targets: &[PathBuf]) -> Result<Vec<Outcome>, InstallError> {
    // Classify every target first: a refusal on the second must not leave the first
    // freshly written, or a re-run after moving the foreign file aside would report
    // `unchanged` for work the refused run did.
    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)
}

/// Whether these bytes are a file fdu wrote: the marker is ASCII, so no decoding is
/// needed, and a file that is not UTF-8 is simply not ours.
fn is_generated(bytes: &[u8]) -> bool {
    let marker = GENERATED_MARKER_PREFIX.as_bytes();
    bytes.windows(marker.len()).any(|window| window == marker)
}

/// Replace `target` with `content` through a staged sibling and one rename, so a
/// reader sees the old file or the new one and never a prefix of the new one. The new
/// file has the process's default permissions; a mode set on the old file does not
/// carry over (on Unix; a read-only file on Windows is expected to refuse the rename
/// instead, which is reported as a failed install). A staged file that could not be
/// renamed is removed rather than left behind.
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() {
        // Best effort: the write or rename error is the one worth reporting, and a
        // staged file that cannot be removed was most likely never created.
        let _ = fs::remove_file(&staged);
    }
    staged_and_renamed
}

/// The private sibling `replace` writes in `directory` before renaming it into place.
pub(crate) fn staged_path(directory: &Path) -> PathBuf {
    directory.join(format!(".SKILL.md.fdu-{}.tmp", std::process::id()))
}

/// A path as the install reports it: relative, joined with `/`, when it is under `cwd`,
/// and as the platform spells it otherwise. The relative form is the one the
/// documentation names, and it reads the same on every platform.
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");
        // A worktree keeps `.git` as a file; a plain checkout as a directory. Either is
        // the root, so the file form is the one tested here.
        fs::write(repo.join(".git"), "gitdir: elsewhere\n").expect("write .git file");

        assert_eq!(project_root(&nested), repo);
        assert_eq!(project_root(&repo), repo);
        // Nothing above the sandbox is a repository, so a directory outside one is its
        // own root.
        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);
            // The staged sibling is renamed into place, never left beside the skill.
            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"
        );
    }

    /// The failure is injected at the commit boundary, after classification and after
    /// the first target is in place: the second target's staged sibling is a directory,
    /// so its write fails on every platform without touching permission bits.
    #[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");

        // Unblocked, a rerun finishes the job and reports the first target unchanged.
        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());
    }
}