chant/operations/
create.rs1use 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#[derive(Debug, Clone)]
16pub struct CreateOptions {
17 pub prompt: Option<String>,
19 pub needs_approval: bool,
21 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
35pub fn create_spec(
43 description: &str,
44 specs_dir: &Path,
45 config: &Config,
46 options: CreateOptions,
47) -> Result<(Spec, PathBuf)> {
48 let id = id::generate_id(specs_dir)?;
50 let filename = format!("{}.md", id);
51 let filepath = specs_dir.join(&filename);
52
53 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 let (title, body) = if description.len() > 80 {
67 let mut split_pos = None;
69
70 if let Some(newline_pos) = description.find('\n') {
72 split_pos = Some(newline_pos);
73 } else {
74 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 split_pos = Some(next_pos);
81 break;
82 } else if description[next_pos..].starts_with(' ') {
83 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 (description.to_string(), String::new())
102 }
103 } else {
104 (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 if !config.enterprise.derived.is_empty() {
123 let mut spec = Spec::load(&filepath)?;
125
126 let context = derivation::build_context(&id, specs_dir);
128
129 let engine = DerivationEngine::new(config.enterprise.clone());
131 let derived_fields = engine.derive_fields(&context);
132
133 spec.add_derived_fields(derived_fields);
135
136 spec.save(&filepath)?;
138 }
139
140 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 !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 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 let spec = Spec::load(&filepath)?;
172 Ok((spec, filepath))
173}