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