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, 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]
150    fn init_scaffolds_every_committed_path() {
151        let root = tempdir().unwrap();
152
153        let outcome = init(root.path()).unwrap();
154        assert_eq!(
155            outcome.created,
156            vec![
157                ".agents/sloop/config.yaml",
158                ".agents/sloop/projects",
159                ".agents/sloop/projects/default.md",
160                ".agents/sloop/tickets",
161                ".agents/sloop/flows",
162                ".agents/sloop/flows/default.yaml",
163                ".agents/sloop/prompts",
164                ".agents/sloop/prompts/review.md",
165            ]
166        );
167        assert!(outcome.existing.is_empty());
168        assert!(
169            root.path()
170                .join(".agents/sloop/projects/default.md")
171                .is_file()
172        );
173        assert!(!root.path().join(".gitignore").exists());
174        let config = std::fs::read_to_string(root.path().join(".agents/sloop/config.yaml"))
175            .expect("read default config");
176        assert_eq!(config, DEFAULT_CONFIG);
177        assert!(config.contains("default_target: claude"));
178        assert!(config.contains("model: opus"));
179        assert!(config.contains("effort: high"));
180        assert!(config.contains("worktree_retention: 7d"));
181        assert!(config.contains("- claude"));
182        assert!(config.contains("- opencode"));
183        assert!(config.contains("- codex"));
184        assert!(!config.contains("sloop brief"));
185        let flow =
186            std::fs::read_to_string(root.path().join(".agents/sloop/flows/default.yaml")).unwrap();
187        assert!(flow.contains(".agents/sloop/prompts/review.md"));
188    }
189
190    #[test]
191    fn init_preserves_an_existing_gitignore() {
192        let root = tempdir().unwrap();
193        std::fs::write(root.path().join(".gitignore"), "target/\n").unwrap();
194
195        init(root.path()).unwrap();
196
197        let gitignore = std::fs::read_to_string(root.path().join(".gitignore")).unwrap();
198        assert_eq!(gitignore, "target/\n");
199    }
200
201    #[test]
202    fn init_is_idempotent_and_preserves_existing_files() {
203        let root = tempdir().unwrap();
204        init(root.path()).unwrap();
205        std::fs::write(
206            root.path().join(".agents/sloop/projects/default.md"),
207            "customized\n",
208        )
209        .unwrap();
210
211        let outcome = init(root.path()).unwrap();
212        assert!(outcome.created.is_empty());
213        assert_eq!(
214            std::fs::read_to_string(root.path().join(".agents/sloop/projects/default.md")).unwrap(),
215            "customized\n"
216        );
217    }
218
219    #[test]
220    fn an_obstructing_directory_is_a_conflict() {
221        let root = tempdir().unwrap();
222        std::fs::create_dir_all(root.path().join(".agents/sloop/projects/default.md")).unwrap();
223
224        assert!(matches!(init(root.path()), Err(InitError::Conflict { .. })));
225    }
226}