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    /// Force finalization (bypass agent log gate)
25    pub force: bool,
26}
27
28/// Finalize a spec after successful completion.
29///
30/// This is the canonical finalization logic with full validation:
31/// - Checks for uncommitted changes in worktree
32/// - Validates driver/member relationships
33/// - Detects commits (if not provided)
34/// - Checks agent co-authorship for approval requirements
35/// - Updates status, commits, completed_at, and model
36/// - Verifies persistence
37///
38/// This function is idempotent and can be called multiple times safely.
39pub fn finalize_spec(
40    spec: &mut Spec,
41    spec_repo: &FileSpecRepository,
42    config: &Config,
43    all_specs: &[Spec],
44    options: FinalizeOptions,
45) -> Result<()> {
46    use crate::spec;
47
48    // Check for uncommitted changes in worktree before finalization
49    if let Some(worktree_path) = worktree::get_active_worktree(&spec.id, None) {
50        if worktree::has_uncommitted_changes(&worktree_path)? {
51            anyhow::bail!(
52                "Cannot finalize: uncommitted changes in worktree. Commit your changes first.\nWorktree: {}",
53                worktree_path.display()
54            );
55        }
56    }
57
58    // Check if this is a driver spec with incomplete members
59    let incomplete_members = spec::get_incomplete_members(&spec.id, all_specs);
60    if !incomplete_members.is_empty() {
61        anyhow::bail!(
62            "Cannot complete driver spec '{}' while {} member spec(s) are incomplete: {}",
63            spec.id,
64            incomplete_members.len(),
65            incomplete_members.join(", ")
66        );
67    }
68
69    // Guard: ensure agent log exists (unless force is true)
70    if !options.force && !has_agent_log(&spec.id) {
71        anyhow::bail!(
72            "Cannot mark spec as completed: no agent execution log found. \
73             Use force parameter to override."
74        );
75    }
76
77    // Use provided commits or fetch them
78    let commits = match options.commits {
79        Some(c) => c,
80        None => {
81            // Auto-detect commits with branch awareness
82            let spec_branch = spec.frontmatter.branch.as_deref();
83            if spec_branch.is_some() && !options.allow_no_commits {
84                get_commits_for_spec_with_branch(&spec.id, spec_branch)?
85            } else if options.allow_no_commits {
86                get_commits_for_spec_allow_no_commits(&spec.id)?
87            } else if let Some(branch) = spec_branch {
88                get_commits_for_spec_with_branch_allow_no_commits(&spec.id, Some(branch))?
89            } else {
90                get_commits_for_spec(&spec.id)?
91            }
92        }
93    };
94
95    // Guard: ensure commits exist when allow_no_commits is false
96    if !options.allow_no_commits && commits.is_empty() {
97        anyhow::bail!(
98            "Cannot finalize spec '{}': no commits found. Create commits or use --allow-no-commits",
99            spec.id
100        );
101    }
102
103    // Check for agent co-authorship if config requires approval for agent work
104    if config.approval.require_approval_for_agent_work {
105        check_and_set_agent_approval(spec, &commits, config)?;
106    }
107
108    // Validate acceptance criteria are all checked
109    let unchecked_count = spec.count_unchecked_checkboxes();
110    if unchecked_count > 0 {
111        anyhow::bail!(
112            "Cannot finalize spec '{}': {} acceptance criteria remain unchecked",
113            spec.id,
114            unchecked_count
115        );
116    }
117
118    // Update spec to completed using state machine with force transition
119    // This allows finalization from any state (e.g. failed specs whose agent completed work)
120    TransitionBuilder::new(spec)
121        .force()
122        .to(SpecStatus::Completed)
123        .context("Failed to transition spec to Completed status")?;
124
125    spec.frontmatter.commits = if commits.is_empty() {
126        None
127    } else {
128        Some(commits)
129    };
130    spec.frontmatter.completed_at = Some(crate::utc_now_iso());
131    spec.frontmatter.model = get_model_name(Some(config));
132
133    // Save the spec
134    spec_repo
135        .save(spec)
136        .context("Failed to save finalized spec")?;
137
138    // Validation: Verify that status was actually changed to Completed
139    anyhow::ensure!(
140        spec.frontmatter.status == SpecStatus::Completed,
141        "Status was not set to Completed after finalization"
142    );
143
144    // Validation: Verify that completed_at timestamp is set and in valid ISO format
145    let completed_at = spec
146        .frontmatter
147        .completed_at
148        .as_ref()
149        .ok_or_else(|| anyhow::anyhow!("completed_at timestamp was not set"))?;
150
151    // Parse timestamp to validate ISO 8601 format
152    chrono::DateTime::parse_from_rfc3339(completed_at).with_context(|| {
153        format!(
154            "completed_at must be valid ISO 8601 format, got: {}",
155            completed_at
156        )
157    })?;
158
159    // Validation: Verify that spec was actually saved (reload and check)
160    let saved_spec = spec_repo
161        .load(&spec.id)
162        .context("Failed to reload spec from disk to verify persistence")?;
163
164    anyhow::ensure!(
165        saved_spec.frontmatter.status == SpecStatus::Completed,
166        "Persisted spec status is not Completed - save may have failed"
167    );
168
169    anyhow::ensure!(
170        saved_spec.frontmatter.completed_at.is_some(),
171        "Persisted spec is missing completed_at - save may have failed"
172    );
173
174    // Check commits match
175    match (&spec.frontmatter.commits, &saved_spec.frontmatter.commits) {
176        (Some(mem_commits), Some(saved_commits)) => {
177            anyhow::ensure!(
178                mem_commits == saved_commits,
179                "Persisted commits don't match memory - save may have failed"
180            );
181        }
182        (None, None) => {
183            // Both None is correct
184        }
185        _ => {
186            anyhow::bail!("Persisted commits don't match memory - save may have failed");
187        }
188    }
189
190    Ok(())
191}
192
193/// Check commits for agent co-authorship and set approval requirement if found.
194fn check_and_set_agent_approval(
195    spec: &mut Spec,
196    commits: &[String],
197    _config: &Config,
198) -> Result<()> {
199    use crate::spec::{Approval, ApprovalStatus};
200
201    // Skip if approval is already set
202    if spec.frontmatter.approval.is_some() {
203        return Ok(());
204    }
205
206    // Check each commit for agent co-authorship
207    for commit in commits {
208        match detect_agent_in_commit(commit) {
209            Ok(result) if result.has_agent => {
210                // Agent detected - set approval requirement
211                spec.frontmatter.approval = Some(Approval {
212                    required: true,
213                    status: ApprovalStatus::Pending,
214                    by: None,
215                    at: None,
216                });
217                return Ok(());
218            }
219            Ok(_) => {
220                // No agent found in this commit, continue
221            }
222            Err(e) => {
223                // Log warning but continue checking other commits
224                eprintln!(
225                    "Warning: Failed to check commit {} for agent: {}",
226                    commit, e
227                );
228            }
229        }
230    }
231
232    Ok(())
233}
234
235/// Check if agent log exists for a spec
236fn has_agent_log(spec_id: &str) -> bool {
237    use crate::paths::LOGS_DIR;
238    use std::path::PathBuf;
239
240    let logs_dir = PathBuf::from(LOGS_DIR);
241
242    // Check for current-generation log file (spec_id.log)
243    let log_path = logs_dir.join(format!("{}.log", spec_id));
244    if log_path.exists() {
245        return true;
246    }
247
248    // Check for versioned log files (spec_id.N.log)
249    if let Ok(entries) = std::fs::read_dir(&logs_dir) {
250        for entry in entries.flatten() {
251            let filename = entry.file_name();
252            let filename_str = filename.to_string_lossy();
253
254            // Match pattern: spec_id.N.log where N is a number
255            if filename_str.starts_with(&format!("{}.", spec_id)) && filename_str.ends_with(".log")
256            {
257                // Extract middle part to check if it's a number
258                let middle = &filename_str[spec_id.len() + 1..filename_str.len() - 4];
259                if middle.parse::<u32>().is_ok() {
260                    return true;
261                }
262            }
263        }
264    }
265
266    false
267}