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            spec.frontmatter.status = SpecStatus::Blocked;
46        } else if spec.frontmatter.status == SpecStatus::Blocked {
47            // No unmet dependencies and was previously blocked - revert to pending
48            spec.frontmatter.status = SpecStatus::Pending;
49        }
50    }
51}
52
53pub fn load_all_specs(specs_dir: &Path) -> Result<Vec<Spec>> {
54    load_all_specs_with_options(specs_dir, true)
55}
56
57/// Load all specs with optional branch resolution.
58pub fn load_all_specs_with_options(
59    specs_dir: &Path,
60    use_branch_resolution: bool,
61) -> Result<Vec<Spec>> {
62    let mut specs = Vec::new();
63
64    if !specs_dir.exists() {
65        return Ok(specs);
66    }
67
68    load_specs_recursive(specs_dir, &mut specs, use_branch_resolution)?;
69
70    // Apply blocked status to specs with unmet dependencies
71    apply_blocked_status(&mut specs);
72
73    Ok(specs)
74}
75
76/// Recursively load specs from a directory and its subdirectories.
77fn load_specs_recursive(
78    dir: &Path,
79    specs: &mut Vec<Spec>,
80    use_branch_resolution: bool,
81) -> Result<()> {
82    if !dir.exists() {
83        return Ok(());
84    }
85
86    for entry in fs::read_dir(dir)? {
87        let entry = entry?;
88        let path = entry.path();
89        let metadata = entry.metadata()?;
90
91        if metadata.is_dir() {
92            // Recursively load from subdirectories
93            load_specs_recursive(&path, specs, use_branch_resolution)?;
94        } else if path.extension().map(|e| e == "md").unwrap_or(false) {
95            let load_result = if use_branch_resolution {
96                Spec::load_with_branch_resolution(&path)
97            } else {
98                Spec::load(&path)
99            };
100
101            match load_result {
102                Ok(spec) => specs.push(spec),
103                Err(e) => {
104                    eprintln!("Warning: Failed to load spec {:?}: {}", path, e);
105                }
106            }
107        }
108    }
109
110    Ok(())
111}
112
113/// Resolve a partial spec ID to a full spec.
114/// Only searches active specs (in .chant/specs/), not archived specs.
115pub fn resolve_spec(specs_dir: &Path, partial_id: &str) -> Result<Spec> {
116    let specs = load_all_specs(specs_dir)?;
117
118    // Exact match
119    if let Some(spec) = specs.iter().find(|s| s.id == partial_id) {
120        return Ok(spec.clone());
121    }
122
123    // Suffix match (random suffix)
124    let suffix_matches: Vec<_> = specs
125        .iter()
126        .filter(|s| s.id.ends_with(partial_id))
127        .collect();
128    if suffix_matches.len() == 1 {
129        return Ok(suffix_matches[0].clone());
130    }
131
132    // Sequence match for today (e.g., "001")
133    if partial_id.len() == 3 {
134        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
135        let today_pattern = format!("{}-{}-", today, partial_id);
136        let today_matches: Vec<_> = specs
137            .iter()
138            .filter(|s| s.id.starts_with(&today_pattern))
139            .collect();
140        if today_matches.len() == 1 {
141            return Ok(today_matches[0].clone());
142        }
143    }
144
145    // Partial date match (e.g., "22-001" or "01-22-001")
146    let partial_matches: Vec<_> = specs.iter().filter(|s| s.id.contains(partial_id)).collect();
147    if partial_matches.len() == 1 {
148        return Ok(partial_matches[0].clone());
149    }
150
151    if partial_matches.len() > 1 {
152        anyhow::bail!(
153            "Ambiguous spec ID '{}'. Matches: {}",
154            partial_id,
155            partial_matches
156                .iter()
157                .map(|s| s.id.as_str())
158                .collect::<Vec<_>>()
159                .join(", ")
160        );
161    }
162
163    anyhow::bail!("Spec not found: {}", partial_id)
164}
165
166/// Check if a spec is completed (ready for finalization).
167///
168/// A spec is considered completed if:
169/// - Status is `in_progress`
170/// - All acceptance criteria checkboxes are checked (`[x]`)
171/// - Worktree is clean (no uncommitted changes including untracked files)
172///
173/// Edge cases:
174/// - Spec with no acceptance criteria: Treated as completed if worktree clean
175/// - Spec already finalized: Returns false (status not `in_progress`)
176///
177/// # Errors
178///
179/// Returns an error if:
180/// - Spec file is unreadable
181/// - Worktree is inaccessible (git status fails)
182pub fn is_completed(spec_id: &str) -> Result<bool> {
183    // Load the spec
184    let spec_path = Path::new(".chant/specs").join(format!("{}.md", spec_id));
185    let spec = Spec::load(&spec_path)
186        .with_context(|| format!("Failed to read spec file: {}", spec_path.display()))?;
187
188    // Only in_progress specs can be completed
189    if spec.frontmatter.status != SpecStatus::InProgress {
190        return Ok(false);
191    }
192
193    // Check if all criteria are checked
194    let unchecked_count = spec.count_unchecked_checkboxes();
195    if unchecked_count > 0 {
196        return Ok(false);
197    }
198
199    // Check if worktree is clean
200    is_worktree_clean(spec_id)
201}
202
203/// Check if a spec has success signals indicating work was completed.
204///
205/// Success signals include:
206/// - Commits matching the `chant(spec_id):` pattern
207///
208/// # Errors
209///
210/// Returns an error if git command fails
211pub(crate) fn has_success_signals(spec_id: &str) -> Result<bool> {
212    // Check for commits with the chant(spec_id): pattern
213    let pattern = format!("chant({}):", spec_id);
214    let output = Command::new("git")
215        .args(["log", "--all", "--grep", &pattern, "--format=%H"])
216        .output()
217        .context("Failed to check git log for spec commits")?;
218
219    if !output.status.success() {
220        return Ok(false);
221    }
222
223    let commits_output = String::from_utf8_lossy(&output.stdout);
224    let has_commits = !commits_output.trim().is_empty();
225
226    Ok(has_commits)
227}
228
229/// Check if a spec has failed.
230///
231/// A spec is considered failed if:
232/// - Status is `in_progress`
233/// - Agent has exited (no lock file present)
234/// - Some acceptance criteria are still incomplete
235/// - No success signals present (commits matching `chant(spec_id):` pattern)
236///
237/// Edge cases:
238/// - Agent still running: Returns false
239/// - Spec already finalized/failed: Returns false (status not `in_progress`)
240/// - Has commits matching chant(spec_id) pattern: Returns false (agent completed work)
241///
242/// # Errors
243///
244/// Returns an error if:
245/// - Spec file is unreadable
246/// - Git commands fail
247pub fn is_failed(spec_id: &str) -> Result<bool> {
248    // Load the spec
249    let spec_path = Path::new(".chant/specs").join(format!("{}.md", spec_id));
250    let spec = Spec::load(&spec_path)
251        .with_context(|| format!("Failed to read spec file: {}", spec_path.display()))?;
252
253    // Only in_progress specs can fail
254    if spec.frontmatter.status != SpecStatus::InProgress {
255        return Ok(false);
256    }
257
258    // Check if agent is still running (lock file exists)
259    let lock_file = Path::new(crate::paths::LOCKS_DIR).join(format!("{}.lock", spec_id));
260    if lock_file.exists() {
261        return Ok(false);
262    }
263
264    // Check if criteria are incomplete
265    let unchecked_count = spec.count_unchecked_checkboxes();
266    if unchecked_count == 0 {
267        // All criteria checked - not failed
268        return Ok(false);
269    }
270
271    // Check for success signals before flagging as failed
272    // If work was committed, don't mark as failed even if criteria unchecked
273    if has_success_signals(spec_id)? {
274        return Ok(false);
275    }
276
277    // No lock, incomplete criteria, no success signals - failed
278    Ok(true)
279}
280
281/// Check if worktree for a spec is clean (no uncommitted changes).
282///
283/// Uses `git status --porcelain` to check for uncommitted changes.
284/// Untracked files count as dirty for safety.
285///
286/// # Errors
287///
288/// Returns an error if git status command fails or worktree is inaccessible.
289fn is_worktree_clean(spec_id: &str) -> Result<bool> {
290    let worktree_path = Path::new("/tmp").join(format!("chant-{}", spec_id));
291
292    // If worktree doesn't exist, check in current directory
293    let check_path = if worktree_path.exists() {
294        &worktree_path
295    } else {
296        Path::new(".")
297    };
298
299    let output = Command::new("git")
300        .args(["status", "--porcelain"])
301        .current_dir(check_path)
302        .output()
303        .with_context(|| format!("Failed to check git status in {:?}", check_path))?;
304
305    if !output.status.success() {
306        let stderr = String::from_utf8_lossy(&output.stderr);
307        anyhow::bail!("git status failed: {}", stderr);
308    }
309
310    let status_output = String::from_utf8_lossy(&output.stdout);
311    Ok(status_output.trim().is_empty())
312}