Skip to main content

agent_docs/commands/
scaffold_agents.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::env::ResolvedRoots;
6use crate::model::Scope;
7
8const DEFAULT_AGENTS_FILE_NAME: &str = "AGENTS.md";
9const DEFAULT_TEMPLATE: &str = include_str!("../templates/agents_default.md");
10
11#[derive(Debug, Clone)]
12pub struct ScaffoldAgentsRequest {
13    pub target: Scope,
14    pub output: Option<PathBuf>,
15    pub force: bool,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ScaffoldAgentsWriteMode {
20    Created,
21    Overwritten,
22}
23
24impl ScaffoldAgentsWriteMode {
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Created => "created",
28            Self::Overwritten => "overwritten",
29        }
30    }
31}
32
33impl fmt::Display for ScaffoldAgentsWriteMode {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(self.as_str())
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ScaffoldAgentsReport {
41    pub target: Scope,
42    pub output_path: PathBuf,
43    pub write_mode: ScaffoldAgentsWriteMode,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ScaffoldAgentsErrorKind {
48    AlreadyExists,
49    Io,
50}
51
52impl ScaffoldAgentsErrorKind {
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::AlreadyExists => "already-exists",
56            Self::Io => "io",
57        }
58    }
59}
60
61impl fmt::Display for ScaffoldAgentsErrorKind {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(self.as_str())
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ScaffoldAgentsError {
69    pub kind: ScaffoldAgentsErrorKind,
70    pub output_path: PathBuf,
71    pub message: String,
72}
73
74impl ScaffoldAgentsError {
75    fn already_exists(output_path: PathBuf) -> Self {
76        Self {
77            kind: ScaffoldAgentsErrorKind::AlreadyExists,
78            output_path,
79            message: "output file already exists; pass --force to overwrite".to_string(),
80        }
81    }
82
83    fn io(output_path: PathBuf, message: impl Into<String>) -> Self {
84        Self {
85            kind: ScaffoldAgentsErrorKind::Io,
86            output_path,
87            message: message.into(),
88        }
89    }
90}
91
92impl fmt::Display for ScaffoldAgentsError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(
95            f,
96            "{} [{}]: {}",
97            self.output_path.display(),
98            self.kind,
99            self.message
100        )
101    }
102}
103
104impl std::error::Error for ScaffoldAgentsError {}
105
106pub fn scaffold_agents(
107    request: &ScaffoldAgentsRequest,
108    roots: &ResolvedRoots,
109) -> Result<ScaffoldAgentsReport, ScaffoldAgentsError> {
110    let output_path = request
111        .output
112        .clone()
113        .unwrap_or_else(|| default_output_path(request.target, roots));
114
115    let existed_before = output_path.exists();
116    if existed_before && !request.force {
117        return Err(ScaffoldAgentsError::already_exists(output_path));
118    }
119
120    ensure_parent_dir(&output_path)?;
121    fs::write(&output_path, default_template()).map_err(|err| {
122        ScaffoldAgentsError::io(
123            output_path.clone(),
124            format!("failed to write AGENTS.md template: {err}"),
125        )
126    })?;
127
128    Ok(ScaffoldAgentsReport {
129        target: request.target,
130        output_path,
131        write_mode: if existed_before {
132            ScaffoldAgentsWriteMode::Overwritten
133        } else {
134            ScaffoldAgentsWriteMode::Created
135        },
136    })
137}
138
139pub fn default_output_path(target: Scope, roots: &ResolvedRoots) -> PathBuf {
140    let base = match target {
141        Scope::Home => &roots.agent_home,
142        Scope::Project => &roots.project_path,
143    };
144    base.join(DEFAULT_AGENTS_FILE_NAME)
145}
146
147pub const fn default_template() -> &'static str {
148    DEFAULT_TEMPLATE
149}
150
151fn ensure_parent_dir(output_path: &Path) -> Result<(), ScaffoldAgentsError> {
152    let Some(parent) = output_path.parent() else {
153        return Ok(());
154    };
155    if parent.as_os_str().is_empty() {
156        return Ok(());
157    }
158    fs::create_dir_all(parent).map_err(|err| {
159        ScaffoldAgentsError::io(
160            output_path.to_path_buf(),
161            format!(
162                "failed to create parent directory {}: {err}",
163                parent.display()
164            ),
165        )
166    })?;
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use std::fs;
173
174    use tempfile::TempDir;
175
176    use super::{
177        ScaffoldAgentsErrorKind, ScaffoldAgentsRequest, ScaffoldAgentsWriteMode, default_template,
178        scaffold_agents,
179    };
180    use crate::env::ResolvedRoots;
181    use crate::model::Scope;
182
183    fn roots(home: &TempDir, project: &TempDir) -> ResolvedRoots {
184        ResolvedRoots {
185            agent_home: home.path().to_path_buf(),
186            project_path: project.path().to_path_buf(),
187            is_linked_worktree: false,
188            git_common_dir: None,
189            primary_worktree_path: None,
190        }
191    }
192
193    #[test]
194    fn scaffold_agents_creates_default_file_when_missing() {
195        let home = TempDir::new().expect("create home tempdir");
196        let project = TempDir::new().expect("create project tempdir");
197        let roots = roots(&home, &project);
198
199        let request = ScaffoldAgentsRequest {
200            target: Scope::Project,
201            output: None,
202            force: false,
203        };
204
205        let report = scaffold_agents(&request, &roots).expect("scaffold agents");
206        assert_eq!(report.target, Scope::Project);
207        assert_eq!(report.output_path, project.path().join("AGENTS.md"));
208        assert_eq!(report.write_mode, ScaffoldAgentsWriteMode::Created);
209
210        let written = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
211        assert_eq!(written, default_template());
212    }
213
214    #[test]
215    fn scaffold_agents_returns_error_when_target_exists_without_force() {
216        let home = TempDir::new().expect("create home tempdir");
217        let project = TempDir::new().expect("create project tempdir");
218        let roots = roots(&home, &project);
219        let output = project.path().join("AGENTS.md");
220        fs::write(&output, "# custom\n").expect("seed existing file");
221
222        let request = ScaffoldAgentsRequest {
223            target: Scope::Project,
224            output: None,
225            force: false,
226        };
227
228        let err = scaffold_agents(&request, &roots).expect_err("existing target should fail");
229        assert_eq!(err.kind, ScaffoldAgentsErrorKind::AlreadyExists);
230        assert_eq!(err.output_path, output);
231        let persisted = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
232        assert_eq!(persisted, "# custom\n");
233    }
234
235    #[test]
236    fn scaffold_agents_overwrites_existing_file_when_forced() {
237        let home = TempDir::new().expect("create home tempdir");
238        let project = TempDir::new().expect("create project tempdir");
239        let roots = roots(&home, &project);
240        fs::write(project.path().join("AGENTS.md"), "# stale\n").expect("seed stale file");
241
242        let request = ScaffoldAgentsRequest {
243            target: Scope::Project,
244            output: None,
245            force: true,
246        };
247
248        let report = scaffold_agents(&request, &roots).expect("forced overwrite");
249        assert_eq!(report.write_mode, ScaffoldAgentsWriteMode::Overwritten);
250        let written = fs::read_to_string(project.path().join("AGENTS.md")).expect("read output");
251        assert_eq!(written, default_template());
252    }
253
254    #[test]
255    fn scaffold_agents_supports_explicit_output_path() {
256        let home = TempDir::new().expect("create home tempdir");
257        let project = TempDir::new().expect("create project tempdir");
258        let roots = roots(&home, &project);
259        let explicit_output = project.path().join("nested").join("custom-agents.md");
260
261        let request = ScaffoldAgentsRequest {
262            target: Scope::Home,
263            output: Some(explicit_output.clone()),
264            force: false,
265        };
266
267        let report = scaffold_agents(&request, &roots).expect("explicit output");
268        assert_eq!(report.output_path, explicit_output);
269        let written = fs::read_to_string(project.path().join("nested").join("custom-agents.md"))
270            .expect("read output");
271        assert_eq!(written, default_template());
272    }
273
274    #[test]
275    fn default_template_contains_required_guidance() {
276        let template = default_template();
277        assert!(template.contains("agent-docs resolve --context startup"));
278        assert!(template.contains("agent-docs resolve --context project-dev"));
279        assert!(template.contains("AGENT_DOCS.toml"));
280    }
281}