1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6pub const DEFAULT_CONFIG: &str = include_str!("defaults/config.yaml");
7pub const DEFAULT_PROJECT: &str = include_str!("defaults/projects/default.md");
8pub const DEFAULT_FLOW: &str = include_str!("defaults/flows/default.yaml");
9pub const DEFAULT_REVIEW_PROMPT: &str = include_str!("defaults/prompts/review.md");
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct InitOutcome {
13 pub repository_root: PathBuf,
14 pub created: Vec<String>,
15 pub existing: Vec<String>,
16}
17
18pub fn init(root: &Path) -> Result<InitOutcome, InitError> {
22 let mut outcome = InitOutcome {
23 repository_root: root.to_path_buf(),
24 created: Vec::new(),
25 existing: Vec::new(),
26 };
27
28 ensure_directory(root, ".agents/sloop", &mut outcome, false)?;
29 ensure_file(
30 root,
31 ".agents/sloop/config.yaml",
32 DEFAULT_CONFIG,
33 &mut outcome,
34 )?;
35 ensure_directory(root, ".agents/sloop/projects", &mut outcome, true)?;
36 ensure_file(
37 root,
38 ".agents/sloop/projects/default.md",
39 DEFAULT_PROJECT,
40 &mut outcome,
41 )?;
42 ensure_directory(root, ".agents/sloop/tickets", &mut outcome, true)?;
43 ensure_directory(root, ".agents/sloop/flows", &mut outcome, true)?;
44 ensure_file(
45 root,
46 ".agents/sloop/flows/default.yaml",
47 DEFAULT_FLOW,
48 &mut outcome,
49 )?;
50 ensure_directory(root, ".agents/sloop/prompts", &mut outcome, true)?;
51 ensure_file(
52 root,
53 crate::flow::REVIEW_PROMPT_PATH,
54 DEFAULT_REVIEW_PROMPT,
55 &mut outcome,
56 )?;
57
58 Ok(outcome)
59}
60
61fn ensure_directory(
62 root: &Path,
63 relative: &str,
64 outcome: &mut InitOutcome,
65 report: bool,
66) -> Result<(), InitError> {
67 let path = root.join(relative);
68 if path.is_dir() {
69 if report {
70 outcome.existing.push(relative.into());
71 }
72 return Ok(());
73 }
74 if path.exists() {
75 return Err(InitError::Conflict {
76 path: relative.into(),
77 reason: "a non-directory file is in the way".into(),
78 });
79 }
80 fs::create_dir_all(&path).map_err(|source| InitError::Io {
81 path: relative.into(),
82 source,
83 })?;
84 if report {
85 outcome.created.push(relative.into());
86 }
87 Ok(())
88}
89
90fn ensure_file(
91 root: &Path,
92 relative: &str,
93 contents: &str,
94 outcome: &mut InitOutcome,
95) -> Result<(), InitError> {
96 let path = root.join(relative);
97 if path.is_file() {
98 outcome.existing.push(relative.into());
99 return Ok(());
100 }
101 if path.exists() {
102 return Err(InitError::Conflict {
103 path: relative.into(),
104 reason: "a directory is in the way".into(),
105 });
106 }
107 fs::write(&path, contents).map_err(|source| InitError::Io {
108 path: relative.into(),
109 source,
110 })?;
111 outcome.created.push(relative.into());
112 Ok(())
113}
114
115#[derive(Debug)]
116pub enum InitError {
117 Conflict { path: String, reason: String },
118 Io { path: String, source: io::Error },
119}
120
121impl fmt::Display for InitError {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::Conflict { path, reason } => write!(formatter, "{path}: {reason}"),
125 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
126 }
127 }
128}
129
130impl std::error::Error for InitError {}
131
132#[cfg(test)]
133mod tests {
134 use tempfile::tempdir;
135
136 use super::{DEFAULT_CONFIG, DEFAULT_FLOW, DEFAULT_REVIEW_PROMPT, InitError, init};
137
138 #[test]
139 fn embedded_default_flow_parses() {
140 let flow = crate::flow::parse("default", DEFAULT_FLOW).expect("default flow must parse");
141 let names: Vec<&str> = flow
142 .stages
143 .iter()
144 .map(|stage| stage.name.as_str())
145 .collect();
146 assert_eq!(names, ["build", "review", "merge"]);
147 }
148
149 #[test]
153 fn the_shipped_review_stage_is_a_reported_gate() {
154 let flow = crate::flow::parse("default", DEFAULT_FLOW).expect("default flow must parse");
155 let review = flow
156 .stages
157 .iter()
158 .find(|stage| stage.name == "review")
159 .expect("default flow has a review stage");
160
161 assert_eq!(review.verdict, crate::flow::VerdictPolicy::Reported);
162 let crate::flow::StageKind::Exec { cmd } = &review.kind else {
163 panic!("review stage must be an exec stage: {:?}", review.kind);
164 };
165 assert!(
168 cmd.windows(2)
169 .any(|pair| pair == ["--allowedTools", "Bash"]),
170 "review command must let the reviewer run commands: {cmd:?}"
171 );
172 assert!(
173 cmd.iter()
174 .any(|arg| arg.contains(crate::flow::REVIEW_PROMPT_PATH)),
175 "review command must point the reviewer at the shipped prompt: {cmd:?}"
176 );
177 }
178
179 #[test]
181 fn the_shipped_review_prompt_demands_an_explicit_verdict_call() {
182 assert!(DEFAULT_REVIEW_PROMPT.contains("sloop verdict pass --reason"));
183 assert!(DEFAULT_REVIEW_PROMPT.contains("sloop verdict fail --reason"));
184 assert!(DEFAULT_REVIEW_PROMPT.contains("exactly once"));
185 }
186
187 #[test]
188 fn init_scaffolds_every_committed_path() {
189 let root = tempdir().unwrap();
190
191 let outcome = init(root.path()).unwrap();
192 assert_eq!(
193 outcome.created,
194 vec![
195 ".agents/sloop/config.yaml",
196 ".agents/sloop/projects",
197 ".agents/sloop/projects/default.md",
198 ".agents/sloop/tickets",
199 ".agents/sloop/flows",
200 ".agents/sloop/flows/default.yaml",
201 ".agents/sloop/prompts",
202 ".agents/sloop/prompts/review.md",
203 ]
204 );
205 assert!(outcome.existing.is_empty());
206 assert!(
207 root.path()
208 .join(".agents/sloop/projects/default.md")
209 .is_file()
210 );
211 assert!(!root.path().join(".gitignore").exists());
212 let config = std::fs::read_to_string(root.path().join(".agents/sloop/config.yaml"))
213 .expect("read default config");
214 assert_eq!(config, DEFAULT_CONFIG);
215 assert!(config.contains("default_target: claude"));
216 assert!(config.contains("model: opus"));
217 assert!(config.contains("effort: high"));
218 assert!(config.contains("worktree_retention: 7d"));
219 assert!(config.contains("- claude"));
220 assert!(config.contains("- opencode"));
221 assert!(config.contains("- codex"));
222 assert!(!config.contains("sloop brief"));
223 let flow =
224 std::fs::read_to_string(root.path().join(".agents/sloop/flows/default.yaml")).unwrap();
225 assert!(flow.contains(".agents/sloop/prompts/review.md"));
226 }
227
228 #[test]
229 fn init_preserves_an_existing_gitignore() {
230 let root = tempdir().unwrap();
231 std::fs::write(root.path().join(".gitignore"), "target/\n").unwrap();
232
233 init(root.path()).unwrap();
234
235 let gitignore = std::fs::read_to_string(root.path().join(".gitignore")).unwrap();
236 assert_eq!(gitignore, "target/\n");
237 }
238
239 #[test]
240 fn init_is_idempotent_and_preserves_existing_files() {
241 let root = tempdir().unwrap();
242 init(root.path()).unwrap();
243 std::fs::write(
244 root.path().join(".agents/sloop/projects/default.md"),
245 "customized\n",
246 )
247 .unwrap();
248
249 let outcome = init(root.path()).unwrap();
250 assert!(outcome.created.is_empty());
251 assert_eq!(
252 std::fs::read_to_string(root.path().join(".agents/sloop/projects/default.md")).unwrap(),
253 "customized\n"
254 );
255 }
256
257 #[test]
258 fn an_obstructing_directory_is_a_conflict() {
259 let root = tempdir().unwrap();
260 std::fs::create_dir_all(root.path().join(".agents/sloop/projects/default.md")).unwrap();
261
262 assert!(matches!(init(root.path()), Err(InitError::Conflict { .. })));
263 }
264}