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_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_allow_no_commits(&spec.id)?
81            }
82        }
83    };
84
85    // Check for agent co-authorship if config requires approval for agent work
86    if config.approval.require_approval_for_agent_work {
87        check_and_set_agent_approval(spec, &commits, config)?;
88    }
89
90    // Update spec to completed using state machine
91    TransitionBuilder::new(spec)
92        .to(SpecStatus::Completed)
93        .context("Failed to transition spec to Completed status")?;
94
95    spec.frontmatter.commits = if commits.is_empty() {
96        None
97    } else {
98        Some(commits)
99    };
100    spec.frontmatter.completed_at = Some(crate::utc_now_iso());
101    spec.frontmatter.model = get_model_name(Some(config));
102
103    // Save the spec
104    spec_repo
105        .save(spec)
106        .context("Failed to save finalized spec")?;
107
108    // Validation: Verify that status was actually changed to Completed
109    anyhow::ensure!(
110        spec.frontmatter.status == SpecStatus::Completed,
111        "Status was not set to Completed after finalization"
112    );
113
114    // Validation: Verify that completed_at timestamp is set and in valid ISO format
115    let completed_at = spec
116        .frontmatter
117        .completed_at
118        .as_ref()
119        .ok_or_else(|| anyhow::anyhow!("completed_at timestamp was not set"))?;
120
121    // Parse timestamp to validate ISO 8601 format
122    chrono::DateTime::parse_from_rfc3339(completed_at).with_context(|| {
123        format!(
124            "completed_at must be valid ISO 8601 format, got: {}",
125            completed_at
126        )
127    })?;
128
129    // Validation: Verify that spec was actually saved (reload and check)
130    let saved_spec = spec_repo
131        .load(&spec.id)
132        .context("Failed to reload spec from disk to verify persistence")?;
133
134    anyhow::ensure!(
135        saved_spec.frontmatter.status == SpecStatus::Completed,
136        "Persisted spec status is not Completed - save may have failed"
137    );
138
139    anyhow::ensure!(
140        saved_spec.frontmatter.completed_at.is_some(),
141        "Persisted spec is missing completed_at - save may have failed"
142    );
143
144    // Check commits match
145    match (&spec.frontmatter.commits, &saved_spec.frontmatter.commits) {
146        (Some(mem_commits), Some(saved_commits)) => {
147            anyhow::ensure!(
148                mem_commits == saved_commits,
149                "Persisted commits don't match memory - save may have failed"
150            );
151        }
152        (None, None) => {
153            // Both None is correct
154        }
155        _ => {
156            anyhow::bail!("Persisted commits don't match memory - save may have failed");
157        }
158    }
159
160    Ok(())
161}
162
163/// Check commits for agent co-authorship and set approval requirement if found.
164fn check_and_set_agent_approval(
165    spec: &mut Spec,
166    commits: &[String],
167    _config: &Config,
168) -> Result<()> {
169    use crate::spec::{Approval, ApprovalStatus};
170
171    // Skip if approval is already set
172    if spec.frontmatter.approval.is_some() {
173        return Ok(());
174    }
175
176    // Check each commit for agent co-authorship
177    for commit in commits {
178        match detect_agent_in_commit(commit) {
179            Ok(result) if result.has_agent => {
180                // Agent detected - set approval requirement
181                spec.frontmatter.approval = Some(Approval {
182                    required: true,
183                    status: ApprovalStatus::Pending,
184                    by: None,
185                    at: None,
186                });
187                return Ok(());
188            }
189            Ok(_) => {
190                // No agent found in this commit, continue
191            }
192            Err(e) => {
193                // Log warning but continue checking other commits
194                eprintln!(
195                    "Warning: Failed to check commit {} for agent: {}",
196                    commit, e
197                );
198            }
199        }
200    }
201
202    Ok(())
203}