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}