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}