Skip to main content

sloop/
init.rs

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");
9/// The merge-train flow, materialized beside `default` rather than in place of
10/// it. Its `verify` stage is a `{test_cmd}` placeholder that
11/// [`render_train_flow`] fills in, so the shipped copy is not a flow file until
12/// it has been rendered.
13pub const TRAIN_FLOW_TEMPLATE: &str = include_str!("defaults/flows/train.yaml");
14pub const DEFAULT_REVIEW_PROMPT: &str = include_str!("defaults/prompts/review.md");
15
16/// The verify command the train flow falls back to when the repository has
17/// configured none. Named here rather than buried in the template so the
18/// fallback is one thing to find and change.
19const 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
28/// Scaffolds a Sloop repository in `root`: committed configuration, project,
29/// ticket, flow, and prompt files. Idempotent; never starts the daemon and
30/// never rewrites a file that already exists.
31pub 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    // Read back rather than assumed: `init` is idempotent and is routinely run
61    // in a repository that already has a config, so the train's verify stage
62    // can name the test command that repository actually uses.
63    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
80/// Fills the train template's `verify` command in. A JSON array is a YAML flow
81/// sequence, so serializing the argv is also how it is quoted.
82pub 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
92/// The repository's `flow.test_cmd`, read straight from the config file rather
93/// than through `Config::load`. Loading validates the whole repository —
94/// flows, agent targets, projects — and `init` runs precisely when those are
95/// not all in place yet, so a config it cannot fully validate must still be
96/// allowed to answer this one question.
97fn 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    /// The shipped train is the pattern the docs describe, so what it parses
196    /// into is the claim: verification after the sync, a fast-forward-only
197    /// merge, and a backward edge from that merge to the sync it depends on.
198    #[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        // Verification is after the sync, not before it: the point of the
215        // flow is that the tested tree is the tree that lands.
216        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        // The irreversible stage carries no second opinion, on principle.
231        assert_eq!(flow.stages[3].result_check, crate::flow::Check::None);
232    }
233
234    /// A repository that already says how it runs its tests should not be
235    /// handed `cargo test` regardless.
236    #[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    /// The shipped review stage must be a real gate. Under the exec default
287    /// (`result_check: none`) it would pass whenever the reviewer command
288    /// exits 0,
289    /// which `claude --print` always does — approving every run silently.
290    #[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        // Bare `claude --print` cannot run any command, so it could never call
304        // `sloop verdict`; the tool allowance is load-bearing, not decorative.
305        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    /// The prompt, not the flow, is what tells the reviewer how to report.
318    #[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}