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::spec::{Spec, SpecStatus, TransitionBuilder};
9
10/// Options for spec update
11#[derive(Debug, Clone, Default)]
12pub struct UpdateOptions {
13    /// New status (validated via state machine)
14    pub status: Option<SpecStatus>,
15    /// Dependencies to set
16    pub depends_on: Option<Vec<String>>,
17    /// Labels to set
18    pub labels: Option<Vec<String>>,
19    /// Target files to set
20    pub target_files: Option<Vec<String>>,
21    /// Model to set
22    pub model: Option<String>,
23    /// Output text to append to body
24    pub output: Option<String>,
25    /// Replace body content instead of appending (default: false)
26    pub replace_body: bool,
27    /// Force status transition (bypass validation)
28    pub force: bool,
29}
30
31/// Update spec fields with validation.
32///
33/// This is the canonical update logic used by both CLI and MCP.
34/// Status transitions are validated via the state machine.
35pub fn update_spec(spec: &mut Spec, spec_path: &Path, options: UpdateOptions) -> Result<()> {
36    let mut updated = false;
37
38    // Update status if provided (use TransitionBuilder with optional force)
39    if let Some(new_status) = options.status {
40        let mut builder = TransitionBuilder::new(spec);
41        if options.force {
42            builder = builder.force();
43        }
44        builder.to(new_status)?;
45        updated = true;
46    }
47
48    // Update depends_on if provided
49    if let Some(depends_on) = options.depends_on {
50        spec.frontmatter.depends_on = Some(depends_on);
51        updated = true;
52    }
53
54    // Update labels if provided
55    if let Some(labels) = options.labels {
56        spec.frontmatter.labels = Some(labels);
57        updated = true;
58    }
59
60    // Update target_files if provided
61    if let Some(target_files) = options.target_files {
62        spec.frontmatter.target_files = Some(target_files);
63        updated = true;
64    }
65
66    // Update model if provided
67    if let Some(model) = options.model {
68        spec.frontmatter.model = Some(model);
69        updated = true;
70    }
71
72    // Append or replace output if provided
73    if let Some(output) = options.output {
74        if !output.is_empty() {
75            if options.replace_body {
76                // Replace body content, preserving title heading if not in new output
77                let has_title_in_output = output.lines().any(|l| l.trim().starts_with("# "));
78                if !has_title_in_output {
79                    if let Some(ref title) = spec.title {
80                        spec.body = format!("# {}\n\n{}", title, output);
81                    } else {
82                        spec.body = output.clone();
83                    }
84                } else {
85                    spec.body = output.clone();
86                }
87                if !spec.body.ends_with('\n') {
88                    spec.body.push('\n');
89                }
90            } else {
91                // Append output (backward-compatible default)
92                if !spec.body.ends_with('\n') && !spec.body.is_empty() {
93                    spec.body.push('\n');
94                }
95                spec.body.push_str("\n## Output\n\n");
96                spec.body.push_str(&output);
97                spec.body.push('\n');
98            }
99            updated = true;
100        }
101    }
102
103    if !updated {
104        anyhow::bail!("No updates specified");
105    }
106
107    // Save the spec
108    spec.save(spec_path)?;
109
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::spec::Spec;
117    use std::fs;
118    use tempfile::TempDir;
119
120    #[test]
121    fn test_replace_body_preserves_title() {
122        let temp_dir = TempDir::new().unwrap();
123        let spec_path = temp_dir.path().join("test-spec.md");
124
125        // Create a spec with a title (matching what chant_add creates)
126        let initial_content = r#"---
127type: code
128status: pending
129---
130
131# Some title
132"#;
133        fs::write(&spec_path, initial_content).unwrap();
134
135        // Load the spec
136        let mut spec = Spec::load(&spec_path).unwrap();
137        assert_eq!(spec.title, Some("Some title".to_string()));
138
139        // Update with replace_body but no title in output (exact repro from spec)
140        let options = UpdateOptions {
141            output: Some(
142                "\n\n## Details\n\nBody text\n\n## Acceptance Criteria\n\n- [ ] test".to_string(),
143            ),
144            replace_body: true,
145            ..Default::default()
146        };
147
148        update_spec(&mut spec, &spec_path, options).unwrap();
149
150        // Reload the spec from disk
151        let reloaded_spec = Spec::load(&spec_path).unwrap();
152
153        // Verify title is preserved
154        assert_eq!(
155            reloaded_spec.title,
156            Some("Some title".to_string()),
157            "Title should be preserved after replace_body"
158        );
159        assert!(
160            reloaded_spec.body.contains("# Some title"),
161            "Body should contain title heading"
162        );
163    }
164
165    #[test]
166    fn test_replace_body_when_spec_has_no_title_initially() {
167        let temp_dir = TempDir::new().unwrap();
168        let spec_path = temp_dir.path().join("test-spec-no-title.md");
169
170        // Create a spec WITHOUT a title in the body
171        let initial_content = r#"---
172type: code
173status: pending
174---
175
176Some body content without a heading
177"#;
178        fs::write(&spec_path, initial_content).unwrap();
179
180        // Load the spec
181        let mut spec = Spec::load(&spec_path).unwrap();
182        assert_eq!(spec.title, None, "Spec should have no title");
183
184        // Update with replace_body
185        let options = UpdateOptions {
186            output: Some(
187                "\n\n## Details\n\nBody text\n\n## Acceptance Criteria\n\n- [ ] test".to_string(),
188            ),
189            replace_body: true,
190            ..Default::default()
191        };
192
193        update_spec(&mut spec, &spec_path, options).unwrap();
194
195        // Reload the spec from disk
196        let reloaded_spec = Spec::load(&spec_path).unwrap();
197
198        // In this case, there's no title to preserve, so it should still be None
199        assert_eq!(reloaded_spec.title, None, "Spec should still have no title");
200    }
201}