Skip to main content

chant/operations/
finalize.rs

1//! Spec finalization operation.
2//!
3//! Canonical implementation for finalizing specs with full validation.
4
5use anyhow::{Context, Result};
6
7use crate::config::Config;
8use crate::operations::commits::{
9    detect_agent_in_commit, get_commits_for_spec, get_commits_for_spec_allow_no_commits,
10    get_commits_for_spec_with_branch, get_commits_for_spec_with_branch_allow_no_commits,
11};
12use crate::operations::model::get_model_name;
13use crate::repository::spec_repository::{FileSpecRepository, SpecRepository};
14use crate::spec::{Spec, SpecStatus, TransitionBuilder};
15use crate::worktree;
16
17/// Options for spec finalization
18#[derive(Debug, Clone, Default)]
19pub struct FinalizeOptions {
20    /// Allow finalization without commits
21    pub allow_no_commits: bool,
22    /// Pre-fetched commits (if None, will auto-detect)
23    pub commits: Option<Vec<String>>,
24}
25
26/// Finalize a spec after successful completion.
27///
28/// This is the canonical finalization logic with full validation:
29/// - Checks for uncommitted changes in worktree
30/// - Validates driver/member relationships
31/// - Detects commits (if not provided)
32/// - Checks agent co-authorship for approval requirements
33/// - Updates status, commits, completed_at, and model
34/// - Verifies persistence
35///
36/// This function is idempotent and can be called multiple times safely.
37pub fn finalize_spec(
38    spec: &mut Spec,
39    spec_repo: &FileSpecRepository,
40    config: &Config,
41    all_specs: &[Spec],
42    options: FinalizeOptions,
43) -> Result<()> {
44    use crate::spec;
45
46    // Check for uncommitted changes in worktree before finalization
47    if let Some(worktree_path) = worktree::get_active_worktree(&spec.id, None) {
48        if worktree::has_uncommitted_changes(&worktree_path)? {
49            anyhow::bail!(
50                "Cannot finalize: uncommitted changes in worktree. Commit your changes first.\nWorktree: {}",
51                worktree_path.display()
52            );
53        }
54    }
55
56    // Check if this is a driver spec with incomplete members
57    let incomplete_members = spec::get_incomplete_members(&spec.id, all_specs);
58    if !incomplete_members.is_empty() {
59        anyhow::bail!(
60            "Cannot complete driver spec '{}' while {} member spec(s) are incomplete: {}",
61            spec.id,
62            incomplete_members.len(),
63            incomplete_members.join(", ")
64        );
65    }
66
67    // Use provided commits or fetch them
68    let commits = match options.commits {
69        Some(c) => c,
70        None => {
71            // Auto-detect commits with branch awareness
72            let spec_branch = spec.frontmatter.branch.as_deref();
73            if spec_branch.is_some() && !options.allow_no_commits {
74                get_commits_for_spec_with_branch(&spec.id, spec_branch)?
75            } else if options.allow_no_commits {
76                get_commits_for_spec_allow_no_commits(&spec.id)?
77            } else if let Some(branch) = spec_branch {
78                get_commits_for_spec_with_branch_allow_no_commits(&spec.id, Some(branch))?
79            } else {
80                get_commits_for_spec(&spec.id)?
81            }
82        }
83    };
84
85    // Guard: ensure commits exist when allow_no_commits is false
86    if !options.allow_no_commits && commits.is_empty() {
87        anyhow::bail!(
88            "Cannot finalize spec '{}': no commits found. Create commits or use --allow-no-commits",
89            spec.id
90        );
91    }
92
93    // Check for agent co-authorship if config requires approval for agent work
94    if config.approval.require_approval_for_agent_work {
95        check_and_set_agent_approval(spec, &commits, config)?;
96    }
97
98    // Update spec to completed using state machine with force transition
99    // This allows finalization from any state (e.g. failed specs whose agent completed work)
100    TransitionBuilder::new(spec)
101        .force()
102        .to(SpecStatus::Completed)
103        .context("Failed to transition spec to Completed status")?;
104
105    spec.frontmatter.commits = if commits.is_empty() {
106        None
107    } else {
108        Some(commits)
109    };
110    spec.frontmatter.completed_at = Some(crate::utc_now_iso());
111    spec.frontmatter.model = get_model_name(Some(config));
112
113    // Save the spec
114    spec_repo
115        .save(spec)
116        .context("Failed to save finalized spec")?;
117
118    // Validation: Verify that status was actually changed to Completed
119    anyhow::ensure!(
120        spec.frontmatter.status == SpecStatus::Completed,
121        "Status was not set to Completed after finalization"
122    );
123
124    // Validation: Verify that completed_at timestamp is set and in valid ISO format
125    let completed_at = spec
126        .frontmatter
127        .completed_at
128        .as_ref()
129        .ok_or_else(|| anyhow::anyhow!("completed_at timestamp was not set"))?;
130
131    // Parse timestamp to validate ISO 8601 format
132    chrono::DateTime::parse_from_rfc3339(completed_at).with_context(|| {
133        format!(
134            "completed_at must be valid ISO 8601 format, got: {}",
135            completed_at
136        )
137    })?;
138
139    // Validation: Verify that spec was actually saved (reload and check)
140    let saved_spec = spec_repo
141        .load(&spec.id)
142        .context("Failed to reload spec from disk to verify persistence")?;
143
144    anyhow::ensure!(
145        saved_spec.frontmatter.status == SpecStatus::Completed,
146        "Persisted spec status is not Completed - save may have failed"
147    );
148
149    anyhow::ensure!(
150        saved_spec.frontmatter.completed_at.is_some(),
151        "Persisted spec is missing completed_at - save may have failed"
152    );
153
154    // Check commits match
155    match (&spec.frontmatter.commits, &saved_spec.frontmatter.commits) {
156        (Some(mem_commits), Some(saved_commits)) => {
157            anyhow::ensure!(
158                mem_commits == saved_commits,
159                "Persisted commits don't match memory - save may have failed"
160            );
161        }
162        (None, None) => {
163            // Both None is correct
164        }
165        _ => {
166            anyhow::bail!("Persisted commits don't match memory - save may have failed");
167        }
168    }
169
170    Ok(())
171}
172
173/// Check commits for agent co-authorship and set approval requirement if found.
174fn check_and_set_agent_approval(
175    spec: &mut Spec,
176    commits: &[String],
177    _config: &Config,
178) -> Result<()> {
179    use crate::spec::{Approval, ApprovalStatus};
180
181    // Skip if approval is already set
182    if spec.frontmatter.approval.is_some() {
183        return Ok(());
184    }
185
186    // Check each commit for agent co-authorship
187    for commit in commits {
188        match detect_agent_in_commit(commit) {
189            Ok(result) if result.has_agent => {
190                // Agent detected - set approval requirement
191                spec.frontmatter.approval = Some(Approval {
192                    required: true,
193                    status: ApprovalStatus::Pending,
194                    by: None,
195                    at: None,
196                });
197                return Ok(());
198            }
199            Ok(_) => {
200                // No agent found in this commit, continue
201            }
202            Err(e) => {
203                // Log warning but continue checking other commits
204                eprintln!(
205                    "Warning: Failed to check commit {} for agent: {}",
206                    commit, e
207                );
208            }
209        }
210    }
211
212    Ok(())
213}