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    ("merger.md", include_str!("../../templates/merger.txt")),
15];
16
17/// Embedded skill templates for scaffolding into .claude/skills/<name>/SKILL.md.
18const 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    // User-level config (~/.config/oven/recipe.toml) - never overwritten, even with --force
28    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    // recipe.toml
43    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    // .oven/ directories
51    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    // Initialize database
57    let db_path = project_dir.join(".oven").join("oven.db");
58    crate::db::open(&db_path)?;
59    println!("  .oven/oven.db");
60
61    // .claude/agents/
62    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    // .claude/skills/
75    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    // .gitignore additions
88    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        // User config uses direct exists() check, ignoring --force.
198        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        // All six analysis dimensions
244        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        // Report format with severity table
251        assert!(content.contains("| Category | Critical | High | Medium | Low |"));
252        // Phase 5 connects to /cook
253        assert!(content.contains("/cook"));
254    }
255}