Skip to main content

chant/operations/
create.rs

1//! Spec creation operation.
2//!
3//! Canonical implementation for creating new specs with derivation and git commit.
4
5use anyhow::{Context, Result};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use crate::config::Config;
10use crate::derivation::{self, DerivationEngine};
11use crate::id;
12use crate::spec::Spec;
13
14/// Options for spec creation
15#[derive(Debug, Clone)]
16pub struct CreateOptions {
17    /// Optional prompt template name
18    pub prompt: Option<String>,
19    /// Whether spec requires approval before work can begin
20    pub needs_approval: bool,
21    /// Whether to auto-commit to git (default: true)
22    pub auto_commit: bool,
23}
24
25impl Default for CreateOptions {
26    fn default() -> Self {
27        Self {
28            prompt: None,
29            needs_approval: false,
30            auto_commit: true,
31        }
32    }
33}
34
35/// Create a new spec with derivation and optional git commit.
36///
37/// This is the canonical spec creation logic used by both CLI and MCP.
38///
39/// # Returns
40///
41/// Returns the created spec and its file path.
42pub fn create_spec(
43    description: &str,
44    specs_dir: &Path,
45    config: &Config,
46    options: CreateOptions,
47) -> Result<(Spec, PathBuf)> {
48    // Generate ID
49    let id = id::generate_id(specs_dir)?;
50    let filename = format!("{}.md", id);
51    let filepath = specs_dir.join(&filename);
52
53    // Create spec content
54    let prompt_line = match &options.prompt {
55        Some(p) => format!("prompt: {}\n", p),
56        None => String::new(),
57    };
58
59    let approval_line = if options.needs_approval {
60        "approval:\n  required: true\n  status: pending\n"
61    } else {
62        ""
63    };
64
65    // Split description if it's longer than ~80 chars
66    let (title, body) = if description.len() > 80 {
67        // Find first sentence boundary (period followed by space or end, or newline)
68        let mut split_pos = None;
69
70        // Check for newline first
71        if let Some(newline_pos) = description.find('\n') {
72            split_pos = Some(newline_pos);
73        } else {
74            // Look for period followed by space or end of string
75            for (i, c) in description.char_indices() {
76                if c == '.' {
77                    let next_pos = i + c.len_utf8();
78                    if next_pos >= description.len() {
79                        // Period at end
80                        split_pos = Some(next_pos);
81                        break;
82                    } else if description[next_pos..].starts_with(' ') {
83                        // Period followed by space
84                        split_pos = Some(next_pos);
85                        break;
86                    }
87                }
88            }
89        }
90
91        if let Some(pos) = split_pos {
92            let title_part = description[..pos].trim();
93            let body_part = description[pos..].trim();
94            if !body_part.is_empty() {
95                (title_part.to_string(), format!("\n{}", body_part))
96            } else {
97                (description.to_string(), String::new())
98            }
99        } else {
100            // No sentence boundary found, use whole description as title
101            (description.to_string(), String::new())
102        }
103    } else {
104        // Short description, use as-is
105        (description.to_string(), String::new())
106    };
107
108    let content = format!(
109        r#"---
110type: code
111status: pending
112{}{}---
113
114# {}{}
115"#,
116        prompt_line, approval_line, title, body
117    );
118
119    std::fs::write(&filepath, content)?;
120
121    // Parse the spec to add derived fields if enterprise config is present
122    if !config.enterprise.derived.is_empty() {
123        // Load the spec we just created
124        let mut spec = Spec::load(&filepath)?;
125
126        // Build derivation context
127        let context = derivation::build_context(&id, specs_dir);
128
129        // Derive fields using the engine
130        let engine = DerivationEngine::new(config.enterprise.clone());
131        let derived_fields = engine.derive_fields(&context);
132
133        // Add derived fields to spec frontmatter
134        spec.add_derived_fields(derived_fields);
135
136        // Write the spec with derived fields
137        spec.save(&filepath)?;
138    }
139
140    // Auto-commit the spec file to git (skip if .chant/ is gitignored or if disabled)
141    if options.auto_commit {
142        let output = Command::new("git")
143            .args(["add", &filepath.to_string_lossy()])
144            .output()
145            .context("Failed to run git add for spec file")?;
146
147        if !output.status.success() {
148            let stderr = String::from_utf8_lossy(&output.stderr);
149            // If the path is ignored (silent mode), skip git commit silently
150            if !stderr.contains("ignored") {
151                anyhow::bail!("Failed to stage spec file {}: {}", id, stderr);
152            }
153        } else {
154            let commit_message = format!("chant: Add spec {}", id);
155            let output = Command::new("git")
156                .args(["commit", "-m", &commit_message])
157                .output()
158                .context("Failed to run git commit for spec file")?;
159
160            if !output.status.success() {
161                let stderr = String::from_utf8_lossy(&output.stderr);
162                // It's ok if there's nothing to commit (shouldn't happen but be safe)
163                if !stderr.contains("nothing to commit") && !stderr.contains("no changes added") {
164                    anyhow::bail!("Failed to commit spec file {}: {}", id, stderr);
165                }
166            }
167        }
168    }
169
170    // Load and return the final spec
171    let spec = Spec::load(&filepath)?;
172    Ok((spec, filepath))
173}