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 TRAIN_FLOW_TEMPLATE: &str = include_str!("defaults/flows/train.yaml");
14pub const DEFAULT_REVIEW_PROMPT: &str = include_str!("defaults/prompts/review.md");
15
16const FALLBACK_TEST_CMD: [&str; 2] = ["cargo", "test"];
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct InitOutcome {
23 pub repository_root: PathBuf,
24 pub created: Vec<String>,
25 pub existing: Vec<String>,
26}
27
28pub fn init(root: &Path) -> Result<InitOutcome, InitError> {
32 let mut outcome = InitOutcome {
33 repository_root: root.to_path_buf(),
34 created: Vec::new(),
35 existing: Vec::new(),
36 };
37
38 ensure_directory(root, ".agents/sloop", &mut outcome, false)?;
39 ensure_file(
40 root,
41 ".agents/sloop/config.yaml",
42 DEFAULT_CONFIG,
43 &mut outcome,
44 )?;
45 ensure_directory(root, ".agents/sloop/projects", &mut outcome, true)?;
46 ensure_file(
47 root,
48 ".agents/sloop/projects/default.md",
49 DEFAULT_PROJECT,
50 &mut outcome,
51 )?;
52 ensure_directory(root, ".agents/sloop/tickets", &mut outcome, true)?;
53 ensure_directory(root, ".agents/sloop/flows", &mut outcome, true)?;
54 ensure_file(
55 root,
56 ".agents/sloop/flows/default.yaml",
57 DEFAULT_FLOW,
58 &mut outcome,
59 )?;
60 ensure_file(
64 root,
65 ".agents/sloop/flows/train.yaml",
66 &render_train_flow(configured_test_cmd(root).as_deref()),
67 &mut outcome,
68 )?;
69 ensure_directory(root, ".agents/sloop/prompts", &mut outcome, true)?;
70 ensure_file(
71 root,
72 crate::worker::REVIEW_PROMPT_PATH,
73 DEFAULT_REVIEW_PROMPT,
74 &mut outcome,
75 )?;
76
77 Ok(outcome)
78}
79
80pub fn render_train_flow(test_cmd: Option<&[String]>) -> String {
83 let fallback: Vec<String> = FALLBACK_TEST_CMD
84 .iter()
85 .map(|part| (*part).into())
86 .collect();
87 let cmd = test_cmd.filter(|cmd| !cmd.is_empty()).unwrap_or(&fallback);
88 let rendered = serde_json::to_string(cmd).expect("an argv of strings serializes");
89 TRAIN_FLOW_TEMPLATE.replace("{test_cmd}", &rendered)
90}
91
92fn configured_test_cmd(root: &Path) -> Option<Vec<String>> {
98 #[derive(serde::Deserialize)]
99 struct ConfigFile {
100 flow: Option<FlowSection>,
101 }
102
103 #[derive(serde::Deserialize)]
104 struct FlowSection {
105 test_cmd: Option<Vec<String>>,
106 }
107
108 let contents = fs::read_to_string(root.join(".agents/sloop/config.yaml")).ok()?;
109 serde_yaml::from_str::<ConfigFile>(&contents)
110 .ok()?
111 .flow?
112 .test_cmd
113 .filter(|cmd| !cmd.is_empty())
114}
115
116fn ensure_directory(
117 root: &Path,
118 relative: &str,
119 outcome: &mut InitOutcome,
120 report: bool,
121) -> Result<(), InitError> {
122 let path = root.join(relative);
123 if path.is_dir() {
124 if report {
125 outcome.existing.push(relative.into());
126 }
127 return Ok(());
128 }
129 if path.exists() {
130 return Err(InitError::Conflict {
131 path: relative.into(),
132 reason: "a non-directory file is in the way".into(),
133 });
134 }
135 fs::create_dir_all(&path).map_err(|source| InitError::Io {
136 path: relative.into(),
137 source,
138 })?;
139 if report {
140 outcome.created.push(relative.into());
141 }
142 Ok(())
143}
144
145fn ensure_file(
146 root: &Path,
147 relative: &str,
148 contents: &str,
149 outcome: &mut InitOutcome,
150) -> Result<(), InitError> {
151 let path = root.join(relative);
152 if path.is_file() {
153 outcome.existing.push(relative.into());
154 return Ok(());
155 }
156 if path.exists() {
157 return Err(InitError::Conflict {
158 path: relative.into(),
159 reason: "a directory is in the way".into(),
160 });
161 }
162 fs::write(&path, contents).map_err(|source| InitError::Io {
163 path: relative.into(),
164 source,
165 })?;
166 outcome.created.push(relative.into());
167 Ok(())
168}
169
170#[derive(Debug)]
171pub enum InitError {
172 Conflict { path: String, reason: String },
173 Io { path: String, source: io::Error },
174}
175
176impl fmt::Display for InitError {
177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178 match self {
179 Self::Conflict { path, reason } => write!(formatter, "{path}: {reason}"),
180 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
181 }
182 }
183}
184
185impl std::error::Error for InitError {}
186
187#[cfg(test)]
188mod tests {
189 use tempfile::tempdir;
190
191 use super::{
192 DEFAULT_CONFIG, DEFAULT_FLOW, DEFAULT_REVIEW_PROMPT, InitError, init, render_train_flow,
193 };
194
195 #[test]
199 fn the_embedded_train_flow_parses_into_the_merge_train() {
200 let rendered = render_train_flow(None);
201 let flow = crate::flow::parse("train", &rendered).expect("train flow must parse");
202
203 let names: Vec<&str> = flow
204 .stages
205 .iter()
206 .map(|stage| stage.name.as_str())
207 .collect();
208 assert_eq!(names, ["build", "sync", "verify", "merge"]);
209
210 assert_eq!(
211 flow.stages[1].action,
212 crate::flow::Actor::Builtin(crate::flow::Builtin::Sync)
213 );
214 assert_eq!(
217 flow.stages[2].action,
218 crate::flow::Actor::Exec {
219 cmd: vec!["cargo".into(), "test".into()],
220 }
221 );
222 assert!(flow.stages[3].ff_only, "the merge stage must be ff_only");
223 assert_eq!(
224 flow.stages[3].fail_action,
225 crate::flow::FailAction::ReturnTo {
226 stage: "sync".into(),
227 attempts: 3,
228 }
229 );
230 assert_eq!(flow.stages[3].result_check, crate::flow::Check::None);
232 }
233
234 #[test]
237 fn the_train_flow_takes_the_repositorys_configured_test_command() {
238 let rendered = render_train_flow(Some(&["just".to_owned(), "test all".to_owned()]));
239 let flow = crate::flow::parse("train", &rendered).expect("train flow must parse");
240
241 assert_eq!(
242 flow.stages[2].action,
243 crate::flow::Actor::Exec {
244 cmd: vec!["just".into(), "test all".into()],
245 }
246 );
247 }
248
249 #[test]
250 fn init_materializes_the_train_flow_beside_the_default_one() {
251 let root = tempdir().unwrap();
252 std::fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
253 std::fs::write(
254 root.path().join(".agents/sloop/config.yaml"),
255 "version: 1\nflow:\n test_cmd: [make, check]\n",
256 )
257 .unwrap();
258
259 init(root.path()).unwrap();
260
261 let default =
262 std::fs::read_to_string(root.path().join(".agents/sloop/flows/default.yaml")).unwrap();
263 assert_eq!(default, DEFAULT_FLOW, "the default flow is not replaced");
264 let train =
265 std::fs::read_to_string(root.path().join(".agents/sloop/flows/train.yaml")).unwrap();
266 let flow = crate::flow::parse("train", &train).expect("materialized train flow parses");
267 assert_eq!(
268 flow.stages[2].action,
269 crate::flow::Actor::Exec {
270 cmd: vec!["make".into(), "check".into()],
271 }
272 );
273 }
274
275 #[test]
276 fn embedded_default_flow_parses() {
277 let flow = crate::flow::parse("default", DEFAULT_FLOW).expect("default flow must parse");
278 let names: Vec<&str> = flow
279 .stages
280 .iter()
281 .map(|stage| stage.name.as_str())
282 .collect();
283 assert_eq!(names, ["build", "review", "merge"]);
284 }
285
286 #[test]
291 fn the_shipped_review_stage_is_a_reported_gate() {
292 let flow = crate::flow::parse("default", DEFAULT_FLOW).expect("default flow must parse");
293 let review = flow
294 .stages
295 .iter()
296 .find(|stage| stage.name == "review")
297 .expect("default flow has a review stage");
298
299 assert_eq!(review.result_check, crate::flow::Check::Reported);
300 let crate::flow::Actor::Exec { cmd } = &review.action else {
301 panic!("review stage must be an exec stage: {:?}", review.action);
302 };
303 assert!(
306 cmd.windows(2)
307 .any(|pair| pair == ["--allowedTools", "Bash"]),
308 "review command must let the reviewer run commands: {cmd:?}"
309 );
310 assert!(
311 cmd.iter()
312 .any(|arg| arg.contains(crate::worker::REVIEW_PROMPT_PATH)),
313 "review command must point the reviewer at the shipped prompt: {cmd:?}"
314 );
315 }
316
317 #[test]
319 fn the_shipped_review_prompt_demands_an_explicit_verdict_call() {
320 assert!(DEFAULT_REVIEW_PROMPT.contains("sloop verdict pass --reason"));
321 assert!(DEFAULT_REVIEW_PROMPT.contains("sloop verdict fail --reason"));
322 assert!(DEFAULT_REVIEW_PROMPT.contains("exactly once"));
323 }
324
325 #[test]
326 fn init_scaffolds_every_committed_path() {
327 let root = tempdir().unwrap();
328
329 let outcome = init(root.path()).unwrap();
330 assert_eq!(
331 outcome.created,
332 vec![
333 ".agents/sloop/config.yaml",
334 ".agents/sloop/projects",
335 ".agents/sloop/projects/default.md",
336 ".agents/sloop/tickets",
337 ".agents/sloop/flows",
338 ".agents/sloop/flows/default.yaml",
339 ".agents/sloop/flows/train.yaml",
340 ".agents/sloop/prompts",
341 ".agents/sloop/prompts/review.md",
342 ]
343 );
344 assert!(outcome.existing.is_empty());
345 assert!(
346 root.path()
347 .join(".agents/sloop/projects/default.md")
348 .is_file()
349 );
350 assert!(!root.path().join(".gitignore").exists());
351 let config = std::fs::read_to_string(root.path().join(".agents/sloop/config.yaml"))
352 .expect("read default config");
353 assert_eq!(config, DEFAULT_CONFIG);
354 assert!(config.contains("default_target: claude"));
355 assert!(config.contains("model: opus"));
356 assert!(config.contains("effort: high"));
357 assert!(config.contains("worktree_retention: 7d"));
358 assert!(config.contains("stall_report_after: 10m"));
359 assert!(config.contains("stall_after: 2h"));
360 assert!(config.contains("- claude"));
361 assert!(config.contains("- opencode"));
362 assert!(config.contains("- codex"));
363 assert!(!config.contains("sloop brief"));
364 let flow =
365 std::fs::read_to_string(root.path().join(".agents/sloop/flows/default.yaml")).unwrap();
366 assert!(flow.contains(".agents/sloop/prompts/review.md"));
367 }
368
369 #[test]
370 fn init_preserves_an_existing_gitignore() {
371 let root = tempdir().unwrap();
372 std::fs::write(root.path().join(".gitignore"), "target/\n").unwrap();
373
374 init(root.path()).unwrap();
375
376 let gitignore = std::fs::read_to_string(root.path().join(".gitignore")).unwrap();
377 assert_eq!(gitignore, "target/\n");
378 }
379
380 #[test]
381 fn init_is_idempotent_and_preserves_existing_files() {
382 let root = tempdir().unwrap();
383 init(root.path()).unwrap();
384 std::fs::write(
385 root.path().join(".agents/sloop/projects/default.md"),
386 "customized\n",
387 )
388 .unwrap();
389
390 let outcome = init(root.path()).unwrap();
391 assert!(outcome.created.is_empty());
392 assert_eq!(
393 std::fs::read_to_string(root.path().join(".agents/sloop/projects/default.md")).unwrap(),
394 "customized\n"
395 );
396 }
397
398 #[test]
399 fn an_obstructing_directory_is_a_conflict() {
400 let root = tempdir().unwrap();
401 std::fs::create_dir_all(root.path().join(".agents/sloop/projects/default.md")).unwrap();
402
403 assert!(matches!(init(root.path()), Err(InitError::Conflict { .. })));
404 }
405}