1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use super::{GlobalOpts, PrepArgs};
6use crate::config::Config;
7
8const AGENT_PROMPTS: &[(&str, &str)] = &[
10 ("implementer.md", include_str!("../../templates/implementer.txt")),
11 ("reviewer.md", include_str!("../../templates/reviewer.txt")),
12 ("fixer.md", include_str!("../../templates/fixer.txt")),
13 ("planner.md", include_str!("../../templates/planner.txt")),
14 ("merger.md", include_str!("../../templates/merger.txt")),
15];
16
17const SKILL_TEMPLATES: &[(&str, &str, &str)] = &[
19 ("cook", "SKILL.md", include_str!("../../templates/skills/cook.md")),
20 ("refine", "SKILL.md", include_str!("../../templates/skills/refine.md")),
21];
22
23#[allow(clippy::unused_async)]
24pub async fn run(args: PrepArgs, _global: &GlobalOpts) -> Result<()> {
25 let project_dir = std::env::current_dir().context("getting current directory")?;
26
27 if let Some(config_dir) = dirs::config_dir() {
29 let user_config_dir = config_dir.join("oven");
30 let user_config_path = user_config_dir.join("recipe.toml");
31 if user_config_path.exists() {
32 println!(" ~/.config/oven/recipe.toml (exists, skipped)");
33 } else {
34 std::fs::create_dir_all(&user_config_dir)
35 .with_context(|| format!("creating {}", user_config_dir.display()))?;
36 std::fs::write(&user_config_path, Config::default_user_toml())
37 .with_context(|| format!("writing {}", user_config_path.display()))?;
38 println!(" ~/.config/oven/recipe.toml");
39 }
40 }
41
42 write_if_new_or_forced(
44 &project_dir.join("recipe.toml"),
45 &Config::default_project_toml(),
46 args.force,
47 "recipe.toml",
48 )?;
49
50 for sub in ["", "logs", "worktrees", "issues"] {
52 let dir = project_dir.join(".oven").join(sub);
53 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
54 }
55
56 let db_path = project_dir.join(".oven").join("oven.db");
58 crate::db::open(&db_path)?;
59 println!(" .oven/oven.db");
60
61 let agents_dir = project_dir.join(".claude").join("agents");
63 std::fs::create_dir_all(&agents_dir).context("creating .claude/agents/")?;
64
65 for (filename, content) in AGENT_PROMPTS {
66 write_if_new_or_forced(
67 &agents_dir.join(filename),
68 content,
69 args.force,
70 &format!(".claude/agents/{filename}"),
71 )?;
72 }
73
74 for (skill_name, filename, content) in SKILL_TEMPLATES {
76 let skill_dir = project_dir.join(".claude").join("skills").join(skill_name);
77 std::fs::create_dir_all(&skill_dir)
78 .with_context(|| format!("creating .claude/skills/{skill_name}/"))?;
79 write_if_new_or_forced(
80 &skill_dir.join(filename),
81 content,
82 args.force,
83 &format!(".claude/skills/{skill_name}/{filename}"),
84 )?;
85 }
86
87 ensure_gitignore(&project_dir)?;
89
90 println!("project ready");
91 Ok(())
92}
93
94fn write_if_new_or_forced(path: &Path, content: &str, force: bool, label: &str) -> Result<()> {
95 if path.exists() && !force {
96 println!(" {label} (exists, skipped)");
97 return Ok(());
98 }
99 let overwriting = path.exists();
100 std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
101 if overwriting {
102 println!(" {label} (overwritten)");
103 } else {
104 println!(" {label}");
105 }
106 Ok(())
107}
108
109fn ensure_gitignore(project_dir: &Path) -> Result<()> {
110 let gitignore_path = project_dir.join(".gitignore");
111 let entries = [".oven/"];
112
113 let existing = if gitignore_path.exists() {
114 std::fs::read_to_string(&gitignore_path).context("reading .gitignore")?
115 } else {
116 String::new()
117 };
118
119 let mut to_add = Vec::new();
120 for entry in &entries {
121 if !existing.lines().any(|line| line.trim() == *entry) {
122 to_add.push(*entry);
123 }
124 }
125
126 if !to_add.is_empty() {
127 let mut content = existing;
128 if !content.is_empty() && !content.ends_with('\n') {
129 content.push('\n');
130 }
131 for entry in &to_add {
132 content.push_str(entry);
133 content.push('\n');
134 }
135 std::fs::write(&gitignore_path, content).context("writing .gitignore")?;
136 println!(" .gitignore (updated)");
137 }
138
139 Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn write_if_new_creates_file() {
148 let dir = tempfile::tempdir().unwrap();
149 let path = dir.path().join("test.txt");
150 write_if_new_or_forced(&path, "hello", false, "test").unwrap();
151 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
152 }
153
154 #[test]
155 fn write_if_new_skips_existing() {
156 let dir = tempfile::tempdir().unwrap();
157 let path = dir.path().join("test.txt");
158 std::fs::write(&path, "original").unwrap();
159 write_if_new_or_forced(&path, "new", false, "test").unwrap();
160 assert_eq!(std::fs::read_to_string(&path).unwrap(), "original");
161 }
162
163 #[test]
164 fn write_if_new_force_overwrites() {
165 let dir = tempfile::tempdir().unwrap();
166 let path = dir.path().join("test.txt");
167 std::fs::write(&path, "original").unwrap();
168 write_if_new_or_forced(&path, "new", true, "test").unwrap();
169 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
170 }
171
172 #[test]
173 fn ensure_gitignore_adds_entries() {
174 let dir = tempfile::tempdir().unwrap();
175 ensure_gitignore(dir.path()).unwrap();
176 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
177 assert!(content.contains(".oven/"));
178 }
179
180 #[test]
181 fn ensure_gitignore_doesnt_duplicate() {
182 let dir = tempfile::tempdir().unwrap();
183 std::fs::write(dir.path().join(".gitignore"), ".oven/\n").unwrap();
184 ensure_gitignore(dir.path()).unwrap();
185 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
186 assert_eq!(content.matches(".oven/").count(), 1);
187 }
188
189 #[test]
190 fn user_config_not_overwritten() {
191 let dir = tempfile::tempdir().unwrap();
192 let oven_dir = dir.path().join("oven");
193 std::fs::create_dir_all(&oven_dir).unwrap();
194 let config_path = oven_dir.join("recipe.toml");
195 std::fs::write(&config_path, "# my custom config\n").unwrap();
196
197 assert!(config_path.exists());
199 assert_eq!(std::fs::read_to_string(&config_path).unwrap(), "# my custom config\n");
200 }
201
202 #[test]
203 fn agent_prompts_are_embedded() {
204 assert_eq!(AGENT_PROMPTS.len(), 5);
205 for (name, content) in AGENT_PROMPTS {
206 assert!(!name.is_empty());
207 assert!(!content.is_empty());
208 }
209 }
210
211 #[test]
212 fn cook_skill_template_is_embeddable() {
213 let content = include_str!("../../templates/skills/cook.md");
214 assert!(!content.is_empty());
215 assert!(content.contains("name: cook"));
216 assert!(content.contains("Phase 1"));
217 assert!(content.contains("Phase 2"));
218 assert!(content.contains("Phase 3"));
219 assert!(content.contains("Phase 4"));
220 assert!(content.contains("Acceptance Criteria"));
221 assert!(content.contains("Implementation Guide"));
222 assert!(content.contains("Security Considerations"));
223 assert!(content.contains("Test Requirements"));
224 assert!(content.contains("Out of Scope"));
225 assert!(content.contains("Dependencies"));
226 }
227
228 #[test]
229 fn skill_templates_are_embedded() {
230 assert_eq!(SKILL_TEMPLATES.len(), 2);
231 for (name, filename, content) in SKILL_TEMPLATES {
232 assert!(!name.is_empty());
233 assert!(!filename.is_empty());
234 assert!(!content.is_empty());
235 }
236 }
237
238 #[test]
239 fn refine_skill_template_is_embeddable() {
240 let content = include_str!("../../templates/skills/refine.md");
241 assert!(!content.is_empty());
242 assert!(content.contains("name: refine"));
243 assert!(content.contains("### Security"));
245 assert!(content.contains("### Error Handling"));
246 assert!(content.contains("### Bad Patterns"));
247 assert!(content.contains("### Test Gaps"));
248 assert!(content.contains("### Data Issues"));
249 assert!(content.contains("### Dependencies"));
250 assert!(content.contains("| Category | Critical | High | Medium | Low |"));
252 assert!(content.contains("/cook"));
254 }
255}