Skip to main content

chant/spec/
lifecycle.rs

1//! Spec lifecycle operations.
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use super::frontmatter::SpecStatus;
9use super::parse::Spec;
10
11/// Apply blocked status to specs with unmet dependencies.
12/// For pending specs that have incomplete dependencies, updates their status to blocked.
13/// This is a local-only version that only checks dependencies within the current repo.
14fn apply_blocked_status(specs: &mut [Spec]) {
15    apply_blocked_status_with_repos(specs, std::path::Path::new(".chant/specs"), &[]);
16}
17
18/// Apply blocked status considering both local and cross-repo dependencies.
19/// This version supports cross-repo dependency checking when repos config is available.
20pub fn apply_blocked_status_with_repos(
21    specs: &mut [Spec],
22    specs_dir: &std::path::Path,
23    repos: &[crate::config::RepoConfig],
24) {
25    // Build a reference list of specs for dependency checking
26    let specs_snapshot = specs.to_vec();
27
28    for spec in specs.iter_mut() {
29        // Handle both Pending and Blocked specs
30        if spec.frontmatter.status != SpecStatus::Pending
31            && spec.frontmatter.status != SpecStatus::Blocked
32        {
33            continue;
34        }
35
36        // Check if this spec has unmet dependencies (local only)
37        let is_blocked_locally = spec.is_blocked(&specs_snapshot);
38
39        // Check cross-repo dependencies if repos config is available
40        let is_blocked_cross_repo = !repos.is_empty()
41            && crate::deps::is_blocked_by_dependencies(spec, &specs_snapshot, specs_dir, repos);
42
43        if is_blocked_locally || is_blocked_cross_repo {
44            // Has unmet dependencies - mark as blocked
45            // This is a valid Pending→Blocked transition, so it should always succeed
46            let _ = spec.set_status(SpecStatus::Blocked);
47        } else if spec.frontmatter.status == SpecStatus::Blocked {
48            // No unmet dependencies and was previously blocked - revert to pending
49            // This is a valid Blocked→Pending transition, so it should always succeed
50            let _ = spec.set_status(SpecStatus::Pending);
51        }
52    }
53}
54
55pub fn load_all_specs(specs_dir: &Path) -> Result<Vec<Spec>> {
56    load_all_specs_with_options(specs_dir, true)
57}
58
59/// Load all specs with optional branch resolution.
60pub fn load_all_specs_with_options(
61    specs_dir: &Path,
62    use_branch_resolution: bool,
63) -> Result<Vec<Spec>> {
64    let mut specs = Vec::new();
65
66    if !specs_dir.exists() {
67        return Ok(specs);
68    }
69
70    load_specs_recursive(specs_dir, &mut specs, use_branch_resolution)?;
71
72    // Apply blocked status to specs with unmet dependencies
73    apply_blocked_status(&mut specs);
74
75    // Check for orphaned members and warn
76    check_for_orphaned_members(&specs);
77
78    Ok(specs)
79}
80
81/// Check for orphaned member specs (members without a driver) and log warnings.
82fn check_for_orphaned_members(specs: &[Spec]) {
83    use crate::spec_group::extract_driver_id;
84    use std::collections::HashSet;
85
86    // Build a set of all driver IDs
87    let driver_ids: HashSet<String> = specs.iter().map(|s| s.id.clone()).collect();
88
89    // Check each spec to see if it's a member without a driver
90    for spec in specs {
91        if let Some(driver_id) = extract_driver_id(&spec.id) {
92            if !driver_ids.contains(&driver_id) {
93                eprintln!(
94                    "Warning: Orphaned member spec {} — driver {} not found",
95                    spec.id, driver_id
96                );
97            }
98        }
99    }
100}
101
102/// Recursively load specs from a directory and its subdirectories.
103fn load_specs_recursive(
104    dir: &Path,
105    specs: &mut Vec<Spec>,
106    use_branch_resolution: bool,
107) -> Result<()> {
108    if !dir.exists() {
109        return Ok(());
110    }
111
112    for entry in fs::read_dir(dir)? {
113        let entry = entry?;
114        let path = entry.path();
115        let metadata = entry.metadata()?;
116
117        if metadata.is_dir() {
118            // Recursively load from subdirectories
119            load_specs_recursive(&path, specs, use_branch_resolution)?;
120        } else if path.extension().map(|e| e == "md").unwrap_or(false) {
121            let load_result = if use_branch_resolution {
122                Spec::load_with_branch_resolution(&path)
123            } else {
124                Spec::load(&path)
125            };
126
127            match load_result {
128                Ok(spec) => specs.push(spec),
129                Err(e) => {
130                    eprintln!("Warning: Failed to load spec {:?}: {}", path, e);
131                }
132            }
133        }
134    }
135
136    Ok(())
137}
138
139/// Resolve a partial spec ID to a full spec.
140/// Only searches active specs (in .chant/specs/), not archived specs.
141pub fn resolve_spec(specs_dir: &Path, partial_id: &str) -> Result<Spec> {
142    let specs = load_all_specs(specs_dir)?;
143
144    // Exact match
145    if let Some(spec) = specs.iter().find(|s| s.id == partial_id) {
146        return Ok(spec.clone());
147    }
148
149    // Suffix match (random suffix)
150    let suffix_matches: Vec<_> = specs
151        .iter()
152        .filter(|s| s.id.ends_with(partial_id))
153        .collect();
154    if suffix_matches.len() == 1 {
155        return Ok(suffix_matches[0].clone());
156    }
157
158    // Sequence match for today (e.g., "001")
159    if partial_id.len() == 3 {
160        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
161        let today_pattern = format!("{}-{}-", today, partial_id);
162        let today_matches: Vec<_> = specs
163            .iter()
164            .filter(|s| s.id.starts_with(&today_pattern))
165            .collect();
166        if today_matches.len() == 1 {
167            return Ok(today_matches[0].clone());
168        }
169    }
170
171    // Partial date match (e.g., "22-001" or "01-22-001")
172    let partial_matches: Vec<_> = specs.iter().filter(|s| s.id.contains(partial_id)).collect();
173    if partial_matches.len() == 1 {
174        return Ok(partial_matches[0].clone());
175    }
176
177    if partial_matches.len() > 1 {
178        anyhow::bail!(
179            "Ambiguous spec ID '{}'. Matches: {}",
180            partial_id,
181            partial_matches
182                .iter()
183                .map(|s| s.id.as_str())
184                .collect::<Vec<_>>()
185                .join(", ")
186        );
187    }
188
189    anyhow::bail!("Spec not found: {}", partial_id)
190}
191
192/// Load a spec from its worktree if it exists, otherwise from the main repo.
193///
194/// This ensures that watch sees the current state of the spec including
195/// any changes made by the agent in the worktree.
196///
197/// # Errors
198///
199/// Returns an error if the spec file is unreadable from both locations.
200fn load_spec_from_worktree_or_main(spec_id: &str) -> Result<Spec> {
201    // Check if a worktree exists for this spec
202    if let Some(worktree_path) = crate::worktree::get_active_worktree(spec_id, None) {
203        let worktree_spec_path = worktree_path
204            .join(".chant/specs")
205            .join(format!("{}.md", spec_id));
206
207        // Try to load from worktree first
208        if worktree_spec_path.exists() {
209            return Spec::load(&worktree_spec_path).with_context(|| {
210                format!(
211                    "Failed to read spec file from worktree: {}",
212                    worktree_spec_path.display()
213                )
214            });
215        }
216    }
217
218    // Fall back to main repo
219    let spec_path = Path::new(".chant/specs").join(format!("{}.md", spec_id));
220    Spec::load(&spec_path)
221        .with_context(|| format!("Failed to read spec file: {}", spec_path.display()))
222}
223
224/// Check if a spec is completed (ready for finalization).
225///
226/// A spec is considered completed if:
227/// - Status is `in_progress`
228/// - All acceptance criteria checkboxes are checked (`[x]`)
229/// - Worktree is clean (no uncommitted changes including untracked files)
230///
231/// Edge cases:
232/// - Spec with no acceptance criteria: Treated as completed if worktree clean
233/// - Spec already finalized: Returns false (status not `in_progress`)
234///
235/// # Errors
236///
237/// Returns an error if:
238/// - Spec file is unreadable
239/// - Worktree is inaccessible (git status fails)
240pub fn is_completed(spec_id: &str) -> Result<bool> {
241    // Load the spec from worktree if it exists, otherwise from main repo
242    let spec = load_spec_from_worktree_or_main(spec_id)?;
243
244    // Only in_progress specs can be completed
245    if spec.frontmatter.status != SpecStatus::InProgress {
246        return Ok(false);
247    }
248
249    // Check if all criteria are checked
250    let unchecked_count = spec.count_unchecked_checkboxes();
251    if unchecked_count > 0 {
252        return Ok(false);
253    }
254
255    // Fix G: verify commit exists before reporting completion
256    // This prevents watch from reporting false completions
257    if !has_success_signals(spec_id)? {
258        return Ok(false);
259    }
260
261    // Check if worktree is clean
262    is_worktree_clean(spec_id)
263}
264
265/// Check if a spec has success signals indicating work was completed.
266///
267/// Success signals include:
268/// - Commits matching the `chant(spec_id):` pattern
269///
270/// # Errors
271///
272/// Returns an error if git command fails
273pub(crate) fn has_success_signals(spec_id: &str) -> Result<bool> {
274    // Check for commits with the chant(spec_id): pattern
275    let pattern = format!("chant({}):", spec_id);
276    let output = Command::new("git")
277        .args(["log", "--all", "--grep", &pattern, "--format=%H"])
278        .output()
279        .context("Failed to check git log for spec commits")?;
280
281    if !output.status.success() {
282        return Ok(false);
283    }
284
285    let commits_output = String::from_utf8_lossy(&output.stdout);
286    let has_commits = !commits_output.trim().is_empty();
287
288    Ok(has_commits)
289}
290
291/// Check if a spec has failed.
292///
293/// A spec is considered failed if:
294/// - Status is `in_progress`
295/// - Agent has exited (no lock file present or PID is dead)
296/// - Some acceptance criteria are still incomplete
297/// - No success signals present (commits matching `chant(spec_id):` pattern)
298///
299/// Edge cases:
300/// - Agent still running: Returns false
301/// - Spec already finalized/failed: Returns false (status not `in_progress`)
302/// - Has commits matching chant(spec_id) pattern: Returns false (agent completed work)
303///
304/// # Errors
305///
306/// Returns an error if:
307/// - Spec file is unreadable
308/// - Git commands fail
309pub fn is_failed(spec_id: &str) -> Result<bool> {
310    // Load the spec from worktree if it exists, otherwise from main repo
311    let spec = load_spec_from_worktree_or_main(spec_id)?;
312
313    // Only in_progress specs can fail
314    if spec.frontmatter.status != SpecStatus::InProgress {
315        return Ok(false);
316    }
317
318    // Check if agent is still running using PID-aware lock check
319    if crate::lock::is_locked(spec_id) {
320        return Ok(false);
321    }
322
323    // Check if criteria are incomplete
324    let unchecked_count = spec.count_unchecked_checkboxes();
325    if unchecked_count == 0 {
326        // All criteria checked - not failed
327        return Ok(false);
328    }
329
330    // Check for success signals before flagging as failed
331    // If work was committed, don't mark as failed even if criteria unchecked
332    if has_success_signals(spec_id)? {
333        return Ok(false);
334    }
335
336    // No lock, incomplete criteria, no success signals - failed
337    Ok(true)
338}
339
340/// Check if worktree for a spec is clean (no uncommitted changes).
341///
342/// Uses `git status --porcelain` to check for uncommitted changes.
343/// Untracked files count as dirty for safety.
344///
345/// # Errors
346///
347/// Returns an error if git status command fails or worktree is inaccessible.
348fn is_worktree_clean(spec_id: &str) -> Result<bool> {
349    let worktree_path = Path::new("/tmp").join(format!("chant-{}", spec_id));
350
351    // If worktree doesn't exist, check in current directory
352    let check_path = if worktree_path.exists() {
353        &worktree_path
354    } else {
355        Path::new(".")
356    };
357
358    let output = Command::new("git")
359        .args(["status", "--porcelain"])
360        .current_dir(check_path)
361        .output()
362        .with_context(|| format!("Failed to check git status in {:?}", check_path))?;
363
364    if !output.status.success() {
365        let stderr = String::from_utf8_lossy(&output.stderr);
366        anyhow::bail!("git status failed: {}", stderr);
367    }
368
369    let status_output = String::from_utf8_lossy(&output.stdout);
370    Ok(status_output.trim().is_empty())
371}