Skip to main content

chant/operations/
update.rs

1//! Spec update operation.
2//!
3//! Canonical implementation for updating spec fields with validation.
4
5use anyhow::Result;
6use std::path::Path;
7
8use crate::domain::dependency;
9use crate::spec::{load_all_specs, Spec, SpecStatus, TransitionBuilder};
10
11/// Options for spec update
12#[derive(Debug, Clone, Default)]
13pub struct UpdateOptions {
14    /// New status (validated via state machine)
15    pub status: Option<SpecStatus>,
16    /// Dependencies to set
17    pub depends_on: Option<Vec<String>>,
18    /// Labels to set
19    pub labels: Option<Vec<String>>,
20    /// Target files to set
21    pub target_files: Option<Vec<String>>,
22    /// Model to set
23    pub model: Option<String>,
24    /// Output text to append to body
25    pub output: Option<String>,
26    /// Replace body content instead of appending (default: false)
27    pub replace_body: bool,
28    /// Force status transition (bypass validation and agent log gate)
29    pub force: bool,
30}
31
32/// Update spec fields with validation.
33///
34/// This is the canonical update logic used by both CLI and MCP.
35/// Status transitions are validated via the state machine.
36pub fn update_spec(spec: &mut Spec, spec_path: &Path, options: UpdateOptions) -> Result<()> {
37    let mut updated = false;
38
39    // Update status if provided (use TransitionBuilder with optional force)
40    if let Some(new_status) = options.status {
41        // Guard: reject status=completed without agent log (unless force is true)
42        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    // Update depends_on if provided
58    if let Some(depends_on) = options.depends_on {
59        // Check for cycles before applying the dependency change
60        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        // Create a temporary spec with the new dependencies for cycle detection
66        let mut temp_spec = spec.clone();
67        temp_spec.frontmatter.depends_on = Some(depends_on.clone());
68
69        // Replace the old spec with the temporary one in the list
70        if let Some(idx) = all_specs.iter().position(|s| s.id == spec.id) {
71            all_specs[idx] = temp_spec;
72        } else {
73            // New spec, add it to the list
74            all_specs.push(temp_spec);
75        }
76
77        // Detect cycles with the updated dependencies
78        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    // Update labels if provided
89    if let Some(labels) = options.labels {
90        spec.frontmatter.labels = Some(labels);
91        updated = true;
92    }
93
94    // Update target_files if provided
95    if let Some(target_files) = options.target_files {
96        spec.frontmatter.target_files = Some(target_files);
97        updated = true;
98    }
99
100    // Update model if provided
101    if let Some(model) = options.model {
102        spec.frontmatter.model = Some(model);
103        updated = true;
104    }
105
106    // Append or replace output if provided
107    if let Some(output) = options.output {
108        if !output.is_empty() {
109            if options.replace_body {
110                // Replace body content, preserving title heading if not in new output
111                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                // Append output (backward-compatible default)
126                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    // Save the spec
142    spec.save(spec_path)?;
143
144    Ok(())
145}
146
147/// Check if agent log exists for a spec
148fn 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    // Check for current-generation log file (spec_id.log)
155    let log_path = logs_dir.join(format!("{}.log", spec_id));
156    if log_path.exists() {
157        return true;
158    }
159
160    // Check for versioned log files (spec_id.N.log)
161    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            // Match pattern: spec_id.N.log where N is a number
167            if filename_str.starts_with(&format!("{}.", spec_id)) && filename_str.ends_with(".log")
168            {
169                // Extract middle part to check if it's a number
170                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        // Create a spec with a title (matching what chant_add creates)
194        let initial_content = r#"---
195type: code
196status: pending
197---
198
199# Some title
200"#;
201        fs::write(&spec_path, initial_content).unwrap();
202
203        // Load the spec
204        let mut spec = Spec::load(&spec_path).unwrap();
205        assert_eq!(spec.title, Some("Some title".to_string()));
206
207        // Update with replace_body but no title in output (exact repro from spec)
208        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        // Reload the spec from disk
219        let reloaded_spec = Spec::load(&spec_path).unwrap();
220
221        // Verify title is preserved
222        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        // Create a spec WITHOUT a title in the body
239        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        // Load the spec
249        let mut spec = Spec::load(&spec_path).unwrap();
250        assert_eq!(spec.title, None, "Spec should have no title");
251
252        // Update with replace_body
253        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        // Reload the spec from disk
264        let reloaded_spec = Spec::load(&spec_path).unwrap();
265
266        // In this case, there's no title to preserve, so it should still be None
267        assert_eq!(reloaded_spec.title, None, "Spec should still have no title");
268    }
269}