chant/operations/
update.rs1use anyhow::Result;
6use std::path::Path;
7
8use crate::spec::{Spec, SpecStatus, TransitionBuilder};
9
10#[derive(Debug, Clone, Default)]
12pub struct UpdateOptions {
13 pub status: Option<SpecStatus>,
15 pub depends_on: Option<Vec<String>>,
17 pub labels: Option<Vec<String>>,
19 pub target_files: Option<Vec<String>>,
21 pub model: Option<String>,
23 pub output: Option<String>,
25 pub replace_body: bool,
27 pub force: bool,
29}
30
31pub fn update_spec(spec: &mut Spec, spec_path: &Path, options: UpdateOptions) -> Result<()> {
36 let mut updated = false;
37
38 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 if let Some(depends_on) = options.depends_on {
50 spec.frontmatter.depends_on = Some(depends_on);
51 updated = true;
52 }
53
54 if let Some(labels) = options.labels {
56 spec.frontmatter.labels = Some(labels);
57 updated = true;
58 }
59
60 if let Some(target_files) = options.target_files {
62 spec.frontmatter.target_files = Some(target_files);
63 updated = true;
64 }
65
66 if let Some(model) = options.model {
68 spec.frontmatter.model = Some(model);
69 updated = true;
70 }
71
72 if let Some(output) = options.output {
74 if !output.is_empty() {
75 if options.replace_body {
76 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 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 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 let initial_content = r#"---
127type: code
128status: pending
129---
130
131# Some title
132"#;
133 fs::write(&spec_path, initial_content).unwrap();
134
135 let mut spec = Spec::load(&spec_path).unwrap();
137 assert_eq!(spec.title, Some("Some title".to_string()));
138
139 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 let reloaded_spec = Spec::load(&spec_path).unwrap();
152
153 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 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 let mut spec = Spec::load(&spec_path).unwrap();
182 assert_eq!(spec.title, None, "Spec should have no title");
183
184 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 let reloaded_spec = Spec::load(&spec_path).unwrap();
197
198 assert_eq!(reloaded_spec.title, None, "Spec should still have no title");
200 }
201}