Skip to main content

oven_cli/cli/
prep.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use super::{GlobalOpts, PrepArgs};
6use crate::config::Config;
7
8/// Embedded agent prompts for scaffolding into .claude/agents/.
9const 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
16/// Embedded skill templates for scaffolding into .claude/skills/<name>/SKILL.md.
17const 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    // User-level config (~/.config/oven/recipe.toml) - never overwritten, even with --force
27    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    // recipe.toml
42    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    // .oven/ directories
50    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    // Initialize database
56    let db_path = project_dir.join(".oven").join("oven.db");
57    crate::db::open(&db_path)?;
58    println!("  .oven/oven.db");
59
60    // .claude/agents/
61    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    // .claude/skills/
74    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    // .gitignore additions
87    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        // User config uses direct exists() check, ignoring --force.
197        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        // All six analysis dimensions
243        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        // Report format with severity table
250        assert!(content.contains("| Category | Critical | High | Medium | Low |"));
251        // Phase 5 connects to /cook
252        assert!(content.contains("/cook"));
253    }
254}