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, 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: opencode"));
178 assert!(config.contains("- claude"));
179 assert!(config.contains("- opencode"));
180 assert!(config.contains("- codex"));
181 assert!(!config.contains("sloop brief"));
182 let flow =
183 std::fs::read_to_string(root.path().join(".agents/sloop/flows/default.yaml")).unwrap();
184 assert!(flow.contains(".agents/sloop/prompts/review.md"));
185 }
186
187 #[test]
188 fn init_preserves_an_existing_gitignore() {
189 let root = tempdir().unwrap();
190 std::fs::write(root.path().join(".gitignore"), "target/\n").unwrap();
191
192 init(root.path()).unwrap();
193
194 let gitignore = std::fs::read_to_string(root.path().join(".gitignore")).unwrap();
195 assert_eq!(gitignore, "target/\n");
196 }
197
198 #[test]
199 fn init_is_idempotent_and_preserves_existing_files() {
200 let root = tempdir().unwrap();
201 init(root.path()).unwrap();
202 std::fs::write(
203 root.path().join(".agents/sloop/projects/default.md"),
204 "customized\n",
205 )
206 .unwrap();
207
208 let outcome = init(root.path()).unwrap();
209 assert!(outcome.created.is_empty());
210 assert_eq!(
211 std::fs::read_to_string(root.path().join(".agents/sloop/projects/default.md")).unwrap(),
212 "customized\n"
213 );
214 }
215
216 #[test]
217 fn an_obstructing_directory_is_a_conflict() {
218 let root = tempdir().unwrap();
219 std::fs::create_dir_all(root.path().join(".agents/sloop/projects/default.md")).unwrap();
220
221 assert!(matches!(init(root.path()), Err(InitError::Conflict { .. })));
222 }
223}