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");
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
18/// Scaffolds a Sloop repository in `root`: committed configuration, project,
19/// ticket, flow, and prompt files. Idempotent; never starts the daemon and
20/// never rewrites a file that already exists.
21pub 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    /// The shipped review stage must be a real gate. Under the exec default
150    /// (`verdict: exit`) it would pass whenever the reviewer command exits 0,
151    /// which `claude --print` always does — approving every run silently.
152    #[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        // Bare `claude --print` cannot run any command, so it could never call
166        // `sloop verdict`; the tool allowance is load-bearing, not decorative.
167        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    /// The prompt, not the flow, is what tells the reviewer how to report.
180    #[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}