nils-agent-docs 0.6.5

CLI crate for nils-agent-docs in the nils-cli workspace.
Documentation
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use crate::env::ResolvedRoots;
use crate::model::Scope;

const DEFAULT_AGENTS_FILE_NAME: &str = "AGENTS.md";
const DEFAULT_TEMPLATE: &str = include_str!("../templates/agents_default.md");

#[derive(Debug, Clone)]
pub struct ScaffoldAgentsRequest {
    pub target: Scope,
    pub output: Option<PathBuf>,
    pub force: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaffoldAgentsWriteMode {
    Created,
    Overwritten,
}

impl ScaffoldAgentsWriteMode {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Created => "created",
            Self::Overwritten => "overwritten",
        }
    }
}

impl fmt::Display for ScaffoldAgentsWriteMode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScaffoldAgentsReport {
    pub target: Scope,
    pub output_path: PathBuf,
    pub write_mode: ScaffoldAgentsWriteMode,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaffoldAgentsErrorKind {
    AlreadyExists,
    Io,
}

impl ScaffoldAgentsErrorKind {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::AlreadyExists => "already-exists",
            Self::Io => "io",
        }
    }
}

impl fmt::Display for ScaffoldAgentsErrorKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScaffoldAgentsError {
    pub kind: ScaffoldAgentsErrorKind,
    pub output_path: PathBuf,
    pub message: String,
}

impl ScaffoldAgentsError {
    fn already_exists(output_path: PathBuf) -> Self {
        Self {
            kind: ScaffoldAgentsErrorKind::AlreadyExists,
            output_path,
            message: "output file already exists; pass --force to overwrite".to_string(),
        }
    }

    fn io(output_path: PathBuf, message: impl Into<String>) -> Self {
        Self {
            kind: ScaffoldAgentsErrorKind::Io,
            output_path,
            message: message.into(),
        }
    }
}

impl fmt::Display for ScaffoldAgentsError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} [{}]: {}",
            self.output_path.display(),
            self.kind,
            self.message
        )
    }
}

impl std::error::Error for ScaffoldAgentsError {}

pub fn scaffold_agents(
    request: &ScaffoldAgentsRequest,
    roots: &ResolvedRoots,
) -> Result<ScaffoldAgentsReport, ScaffoldAgentsError> {
    let output_path = request
        .output
        .clone()
        .unwrap_or_else(|| default_output_path(request.target, roots));

    let existed_before = output_path.exists();
    if existed_before && !request.force {
        return Err(ScaffoldAgentsError::already_exists(output_path));
    }

    ensure_parent_dir(&output_path)?;
    fs::write(&output_path, default_template()).map_err(|err| {
        ScaffoldAgentsError::io(
            output_path.clone(),
            format!("failed to write AGENTS.md template: {err}"),
        )
    })?;

    Ok(ScaffoldAgentsReport {
        target: request.target,
        output_path,
        write_mode: if existed_before {
            ScaffoldAgentsWriteMode::Overwritten
        } else {
            ScaffoldAgentsWriteMode::Created
        },
    })
}

pub fn default_output_path(target: Scope, roots: &ResolvedRoots) -> PathBuf {
    let base = match target {
        Scope::Home => &roots.agent_home,
        Scope::Project => &roots.project_path,
    };
    base.join(DEFAULT_AGENTS_FILE_NAME)
}

pub const fn default_template() -> &'static str {
    DEFAULT_TEMPLATE
}

fn ensure_parent_dir(output_path: &Path) -> Result<(), ScaffoldAgentsError> {
    let Some(parent) = output_path.parent() else {
        return Ok(());
    };
    if parent.as_os_str().is_empty() {
        return Ok(());
    }
    fs::create_dir_all(parent).map_err(|err| {
        ScaffoldAgentsError::io(
            output_path.to_path_buf(),
            format!(
                "failed to create parent directory {}: {err}",
                parent.display()
            ),
        )
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::TempDir;

    use super::{
        ScaffoldAgentsErrorKind, ScaffoldAgentsRequest, ScaffoldAgentsWriteMode, default_template,
        scaffold_agents,
    };
    use crate::env::ResolvedRoots;
    use crate::model::Scope;

    fn roots(home: &TempDir, project: &TempDir) -> ResolvedRoots {
        ResolvedRoots {
            agent_home: home.path().to_path_buf(),
            project_path: project.path().to_path_buf(),
            is_linked_worktree: false,
            git_common_dir: None,
            primary_worktree_path: None,
        }
    }

    #[test]
    fn scaffold_agents_creates_default_file_when_missing() {
        let home = TempDir::new().expect("create home tempdir");
        let project = TempDir::new().expect("create project tempdir");
        let roots = roots(&home, &project);

        let request = ScaffoldAgentsRequest {
            target: Scope::Project,
            output: None,
            force: false,
        };

        let report = scaffold_agents(&request, &roots).expect("scaffold agents");
        assert_eq!(report.target, Scope::Project);
        assert_eq!(report.output_path, project.path().join("AGENTS.md"));
        assert_eq!(report.write_mode, ScaffoldAgentsWriteMode::Created);

        let written = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
        assert_eq!(written, default_template());
    }

    #[test]
    fn scaffold_agents_returns_error_when_target_exists_without_force() {
        let home = TempDir::new().expect("create home tempdir");
        let project = TempDir::new().expect("create project tempdir");
        let roots = roots(&home, &project);
        let output = project.path().join("AGENTS.md");
        fs::write(&output, "# custom\n").expect("seed existing file");

        let request = ScaffoldAgentsRequest {
            target: Scope::Project,
            output: None,
            force: false,
        };

        let err = scaffold_agents(&request, &roots).expect_err("existing target should fail");
        assert_eq!(err.kind, ScaffoldAgentsErrorKind::AlreadyExists);
        assert_eq!(err.output_path, output);
        let persisted = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
        assert_eq!(persisted, "# custom\n");
    }

    #[test]
    fn scaffold_agents_overwrites_existing_file_when_forced() {
        let home = TempDir::new().expect("create home tempdir");
        let project = TempDir::new().expect("create project tempdir");
        let roots = roots(&home, &project);
        fs::write(project.path().join("AGENTS.md"), "# stale\n").expect("seed stale file");

        let request = ScaffoldAgentsRequest {
            target: Scope::Project,
            output: None,
            force: true,
        };

        let report = scaffold_agents(&request, &roots).expect("forced overwrite");
        assert_eq!(report.write_mode, ScaffoldAgentsWriteMode::Overwritten);
        let written = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
        assert_eq!(written, default_template());
    }

    #[test]
    fn scaffold_agents_supports_explicit_output_path() {
        let home = TempDir::new().expect("create home tempdir");
        let project = TempDir::new().expect("create project tempdir");
        let roots = roots(&home, &project);
        let explicit_output = project.path().join("nested").join("custom-agents.md");

        let request = ScaffoldAgentsRequest {
            target: Scope::Home,
            output: Some(explicit_output.clone()),
            force: false,
        };

        let report = scaffold_agents(&request, &roots).expect("explicit output");
        assert_eq!(report.output_path, explicit_output);
        let written = fs::read_to_string(project.path().join("nested").join("custom-agents.md"))
            .expect("read output");
        assert_eq!(written, default_template());
    }

    #[test]
    fn default_template_contains_required_guidance() {
        let template = default_template();
        assert!(template.contains("agent-docs resolve --context startup"));
        assert!(template.contains("agent-docs resolve --context project-dev"));
        assert!(template.contains("AGENT_DOCS.toml"));
    }
}