chant/operations/
update.rs1use anyhow::Result;
6use std::path::Path;
7
8use crate::domain::dependency;
9use crate::spec::{load_all_specs, Spec, SpecStatus, TransitionBuilder};
10
11#[derive(Debug, Clone, Default)]
13pub struct UpdateOptions {
14 pub status: Option<SpecStatus>,
16 pub depends_on: Option<Vec<String>>,
18 pub labels: Option<Vec<String>>,
20 pub target_files: Option<Vec<String>>,
22 pub model: Option<String>,
24 pub output: Option<String>,
26 pub replace_body: bool,
28 pub force: bool,
30}
31
32pub fn update_spec(spec: &mut Spec, spec_path: &Path, options: UpdateOptions) -> Result<()> {
37 let mut updated = false;
38
39 if let Some(new_status) = options.status {
41 if new_status == SpecStatus::Completed && !options.force && !has_agent_log(&spec.id) {
43 anyhow::bail!(
44 "Cannot mark spec as completed: no agent execution log found. \
45 Use force parameter to override."
46 );
47 }
48
49 let mut builder = TransitionBuilder::new(spec);
50 if options.force {
51 builder = builder.force();
52 }
53 builder.to(new_status)?;
54 updated = true;
55 }
56
57 if let Some(depends_on) = options.depends_on {
59 let specs_dir = spec_path
61 .parent()
62 .ok_or_else(|| anyhow::anyhow!("Invalid spec path"))?;
63 let mut all_specs = load_all_specs(specs_dir)?;
64
65 let mut temp_spec = spec.clone();
67 temp_spec.frontmatter.depends_on = Some(depends_on.clone());
68
69 if let Some(idx) = all_specs.iter().position(|s| s.id == spec.id) {
71 all_specs[idx] = temp_spec;
72 } else {
73 all_specs.push(temp_spec);
75 }
76
77 let cycles = dependency::detect_cycles(&all_specs);
79 if !cycles.is_empty() {
80 let cycle_str = cycles[0].join(" -> ");
81 anyhow::bail!("Circular dependency detected: {}", cycle_str);
82 }
83
84 spec.frontmatter.depends_on = Some(depends_on);
85 updated = true;
86 }
87
88 if let Some(labels) = options.labels {
90 spec.frontmatter.labels = Some(labels);
91 updated = true;
92 }
93
94 if let Some(target_files) = options.target_files {
96 spec.frontmatter.target_files = Some(target_files);
97 updated = true;
98 }
99
100 if let Some(model) = options.model {
102 spec.frontmatter.model = Some(model);
103 updated = true;
104 }
105
106 if let Some(output) = options.output {
108 if !output.is_empty() {
109 if options.replace_body {
110 let has_title_in_output = output.lines().any(|l| l.trim().starts_with("# "));
112 if !has_title_in_output {
113 if let Some(ref title) = spec.title {
114 spec.body = format!("# {}\n\n{}", title, output);
115 } else {
116 spec.body = output.clone();
117 }
118 } else {
119 spec.body = output.clone();
120 }
121 if !spec.body.ends_with('\n') {
122 spec.body.push('\n');
123 }
124 } else {
125 if !spec.body.ends_with('\n') && !spec.body.is_empty() {
127 spec.body.push('\n');
128 }
129 spec.body.push_str("\n## Output\n\n");
130 spec.body.push_str(&output);
131 spec.body.push('\n');
132 }
133 updated = true;
134 }
135 }
136
137 if !updated {
138 anyhow::bail!("No updates specified");
139 }
140
141 spec.save(spec_path)?;
143
144 Ok(())
145}
146
147fn has_agent_log(spec_id: &str) -> bool {
149 use crate::paths::LOGS_DIR;
150 use std::path::PathBuf;
151
152 let logs_dir = PathBuf::from(LOGS_DIR);
153
154 let log_path = logs_dir.join(format!("{}.log", spec_id));
156 if log_path.exists() {
157 return true;
158 }
159
160 if let Ok(entries) = std::fs::read_dir(&logs_dir) {
162 for entry in entries.flatten() {
163 let filename = entry.file_name();
164 let filename_str = filename.to_string_lossy();
165
166 if filename_str.starts_with(&format!("{}.", spec_id)) && filename_str.ends_with(".log")
168 {
169 let middle = &filename_str[spec_id.len() + 1..filename_str.len() - 4];
171 if middle.parse::<u32>().is_ok() {
172 return true;
173 }
174 }
175 }
176 }
177
178 false
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::spec::Spec;
185 use std::fs;
186 use tempfile::TempDir;
187
188 #[test]
189 fn test_replace_body_preserves_title() {
190 let temp_dir = TempDir::new().unwrap();
191 let spec_path = temp_dir.path().join("test-spec.md");
192
193 let initial_content = r#"---
195type: code
196status: pending
197---
198
199# Some title
200"#;
201 fs::write(&spec_path, initial_content).unwrap();
202
203 let mut spec = Spec::load(&spec_path).unwrap();
205 assert_eq!(spec.title, Some("Some title".to_string()));
206
207 let options = UpdateOptions {
209 output: Some(
210 "\n\n## Details\n\nBody text\n\n## Acceptance Criteria\n\n- [ ] test".to_string(),
211 ),
212 replace_body: true,
213 ..Default::default()
214 };
215
216 update_spec(&mut spec, &spec_path, options).unwrap();
217
218 let reloaded_spec = Spec::load(&spec_path).unwrap();
220
221 assert_eq!(
223 reloaded_spec.title,
224 Some("Some title".to_string()),
225 "Title should be preserved after replace_body"
226 );
227 assert!(
228 reloaded_spec.body.contains("# Some title"),
229 "Body should contain title heading"
230 );
231 }
232
233 #[test]
234 fn test_replace_body_when_spec_has_no_title_initially() {
235 let temp_dir = TempDir::new().unwrap();
236 let spec_path = temp_dir.path().join("test-spec-no-title.md");
237
238 let initial_content = r#"---
240type: code
241status: pending
242---
243
244Some body content without a heading
245"#;
246 fs::write(&spec_path, initial_content).unwrap();
247
248 let mut spec = Spec::load(&spec_path).unwrap();
250 assert_eq!(spec.title, None, "Spec should have no title");
251
252 let options = UpdateOptions {
254 output: Some(
255 "\n\n## Details\n\nBody text\n\n## Acceptance Criteria\n\n- [ ] test".to_string(),
256 ),
257 replace_body: true,
258 ..Default::default()
259 };
260
261 update_spec(&mut spec, &spec_path, options).unwrap();
262
263 let reloaded_spec = Spec::load(&spec_path).unwrap();
265
266 assert_eq!(reloaded_spec.title, None, "Spec should still have no title");
268 }
269}