Skip to main content

chant/spec/
state_machine.rs

1//! State machine for spec lifecycle transitions.
2//!
3//! Provides centralized validation of status transitions with precondition checks.
4
5use anyhow::Result;
6use std::fmt;
7use std::path::Path;
8use std::process::Command;
9
10use super::frontmatter::SpecStatus;
11use super::parse::Spec;
12
13#[derive(Debug)]
14pub enum TransitionError {
15    InvalidTransition { from: SpecStatus, to: SpecStatus },
16    DirtyWorktree(String),
17    UnmetDependencies(String),
18    IncompleteCriteria,
19    NoCommits,
20    IncompleteMembers(String),
21    ApprovalRequired,
22    LintFailed,
23    Other(String),
24}
25
26impl fmt::Display for TransitionError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            TransitionError::InvalidTransition { from, to } => {
30                write!(f, "Invalid transition from {:?} to {:?}", from, to)
31            }
32            TransitionError::DirtyWorktree(msg) => write!(f, "Worktree is not clean: {}", msg),
33            TransitionError::UnmetDependencies(msg) => write!(f, "Dependencies not met: {}", msg),
34            TransitionError::IncompleteCriteria => {
35                write!(f, "All acceptance criteria must be checked")
36            }
37            TransitionError::NoCommits => write!(f, "No commits found for spec"),
38            TransitionError::IncompleteMembers(members) => {
39                write!(f, "Incomplete driver members: {}", members)
40            }
41            TransitionError::ApprovalRequired => write!(f, "Spec requires approval"),
42            TransitionError::LintFailed => write!(f, "Lint validation failed"),
43            TransitionError::Other(msg) => write!(f, "{}", msg),
44        }
45    }
46}
47
48impl std::error::Error for TransitionError {}
49
50/// Builder for validated state transitions.
51pub struct TransitionBuilder<'a> {
52    spec: &'a mut Spec,
53    require_clean: bool,
54    require_deps: bool,
55    require_criteria: bool,
56    require_commits: bool,
57    require_no_incomplete_members: bool,
58    check_approval: bool,
59    force: bool,
60    project_name: Option<String>,
61    specs_dir: Option<std::path::PathBuf>,
62}
63
64impl<'a> TransitionBuilder<'a> {
65    /// Create a new transition builder for a spec.
66    pub fn new(spec: &'a mut Spec) -> Self {
67        Self {
68            spec,
69            require_clean: false,
70            require_deps: false,
71            require_criteria: false,
72            require_commits: false,
73            require_no_incomplete_members: false,
74            check_approval: false,
75            force: false,
76            project_name: None,
77            specs_dir: None,
78        }
79    }
80
81    /// Require worktree to be clean (no uncommitted changes).
82    pub fn require_clean_tree(mut self) -> Self {
83        self.require_clean = true;
84        self
85    }
86
87    /// Require all dependencies to be met.
88    pub fn require_dependencies_met(mut self) -> Self {
89        self.require_deps = true;
90        self
91    }
92
93    /// Require all acceptance criteria to be checked.
94    pub fn require_all_criteria_checked(mut self) -> Self {
95        self.require_criteria = true;
96        self
97    }
98
99    /// Require at least one commit for the spec.
100    pub fn require_commits_exist(mut self) -> Self {
101        self.require_commits = true;
102        self
103    }
104
105    /// Require no incomplete driver members.
106    pub fn require_no_incomplete_members(mut self) -> Self {
107        self.require_no_incomplete_members = true;
108        self
109    }
110
111    /// Check approval status.
112    pub fn check_approval(mut self) -> Self {
113        self.check_approval = true;
114        self
115    }
116
117    /// Force the transition, bypassing all precondition checks.
118    /// Use with extreme caution - intended for exceptional cases only.
119    pub fn force(mut self) -> Self {
120        self.force = true;
121        self
122    }
123
124    /// Set the project name for worktree path resolution.
125    pub fn with_project_name(mut self, project_name: Option<&str>) -> Self {
126        self.project_name = project_name.map(|s| s.to_string());
127        self
128    }
129
130    /// Set the specs directory path (overrides default .chant/specs).
131    pub fn with_specs_dir(mut self, specs_dir: &Path) -> Self {
132        self.specs_dir = Some(specs_dir.to_path_buf());
133        self
134    }
135
136    /// Execute the transition to the target status.
137    pub fn to(self, target: SpecStatus) -> Result<(), TransitionError> {
138        let current = &self.spec.frontmatter.status;
139
140        // Check if transition is valid
141        if !self.force && !is_valid_transition(current, &target) {
142            return Err(TransitionError::InvalidTransition {
143                from: current.clone(),
144                to: target,
145            });
146        }
147
148        // Run precondition checks (unless forced)
149        if !self.force {
150            self.check_preconditions(&target)?;
151        }
152
153        // Apply the transition
154        self.spec.frontmatter.status = target;
155        Ok(())
156    }
157
158    fn check_preconditions(&self, _target: &SpecStatus) -> Result<(), TransitionError> {
159        if self.check_approval && self.spec.requires_approval() {
160            return Err(TransitionError::ApprovalRequired);
161        }
162
163        if self.require_deps {
164            let specs_dir = self
165                .specs_dir
166                .as_deref()
167                .unwrap_or_else(|| Path::new(".chant/specs"));
168            if specs_dir.exists() {
169                let all_specs = super::lifecycle::load_all_specs(specs_dir)
170                    .map_err(|e| TransitionError::Other(format!("Failed to load specs: {}", e)))?;
171
172                if self.spec.is_blocked(&all_specs) {
173                    return Err(TransitionError::UnmetDependencies(
174                        "Spec has unmet dependencies".to_string(),
175                    ));
176                }
177            }
178        }
179
180        if self.require_criteria && self.spec.count_unchecked_checkboxes() > 0 {
181            return Err(TransitionError::IncompleteCriteria);
182        }
183
184        if self.require_commits && !has_commits(&self.spec.id)? {
185            return Err(TransitionError::NoCommits);
186        }
187
188        if self.require_no_incomplete_members {
189            if let Some(members) = &self.spec.frontmatter.members {
190                let specs_dir = self
191                    .specs_dir
192                    .as_deref()
193                    .unwrap_or_else(|| Path::new(".chant/specs"));
194                if specs_dir.exists() {
195                    let all_specs = super::lifecycle::load_all_specs(specs_dir).map_err(|e| {
196                        TransitionError::Other(format!("Failed to load specs: {}", e))
197                    })?;
198
199                    let incomplete: Vec<_> = members
200                        .iter()
201                        .filter_map(|m| {
202                            all_specs
203                                .iter()
204                                .find(|s| s.id == *m)
205                                .filter(|s| s.frontmatter.status != SpecStatus::Completed)
206                                .map(|s| s.id.clone())
207                        })
208                        .collect();
209
210                    if !incomplete.is_empty() {
211                        return Err(TransitionError::IncompleteMembers(incomplete.join(", ")));
212                    }
213                }
214            }
215        }
216
217        if self.require_clean && !is_clean(&self.spec.id, self.project_name.as_deref())? {
218            return Err(TransitionError::DirtyWorktree(
219                "Worktree has uncommitted changes".to_string(),
220            ));
221        }
222
223        Ok(())
224    }
225}
226
227/// Check if a transition from one status to another is valid.
228fn is_valid_transition(from: &SpecStatus, to: &SpecStatus) -> bool {
229    use SpecStatus::*;
230
231    match (from, to) {
232        // Self-transitions are always valid
233        (a, b) if a == b => true,
234
235        // From Pending
236        (Pending, InProgress) => true,
237        (Pending, Blocked) => true,
238        (Pending, Cancelled) => true,
239
240        // From Blocked
241        (Blocked, Pending) => true,
242        (Blocked, InProgress) => true,
243        (Blocked, Cancelled) => true,
244
245        // From InProgress
246        (InProgress, Completed) => true,
247        (InProgress, Failed) => true,
248        (InProgress, NeedsAttention) => true,
249        (InProgress, Paused) => true,
250        (InProgress, Cancelled) => true,
251
252        // From Failed
253        (Failed, Pending) => true,
254        (Failed, InProgress) => true,
255
256        // From NeedsAttention
257        (NeedsAttention, Pending) => true,
258        (NeedsAttention, InProgress) => true,
259
260        // From Paused
261        (Paused, InProgress) => true,
262        (Paused, Cancelled) => true,
263
264        // From Completed - generally immutable except for special cases
265        (Completed, Pending) => true, // For replay/verification scenarios
266
267        // From Cancelled
268        (Cancelled, Pending) => true,
269
270        // Ready is a computed state, not a persistent status
271        (Ready, _) | (_, Ready) => false,
272
273        // All other transitions are invalid
274        _ => false,
275    }
276}
277
278/// Check if the spec has commits matching the chant(spec_id): pattern.
279fn has_commits(spec_id: &str) -> Result<bool, TransitionError> {
280    let pattern = format!("chant({}):", spec_id);
281    let output = Command::new("git")
282        .args(["log", "--all", "--grep", &pattern, "--format=%H"])
283        .output()
284        .map_err(|e| TransitionError::Other(format!("Failed to check git log: {}", e)))?;
285
286    if !output.status.success() {
287        return Ok(false);
288    }
289
290    let commits_output = String::from_utf8_lossy(&output.stdout);
291    Ok(!commits_output.trim().is_empty())
292}
293
294/// Check if the worktree is clean (no uncommitted changes).
295fn is_clean(spec_id: &str, project_name: Option<&str>) -> Result<bool, TransitionError> {
296    use crate::worktree;
297
298    // Check if there's an active worktree for this spec
299    let check_path =
300        if let Some(worktree_path) = worktree::get_active_worktree(spec_id, project_name) {
301            worktree_path
302        } else {
303            // No worktree, check current directory
304            std::path::PathBuf::from(".")
305        };
306
307    let output = Command::new("git")
308        .args(["status", "--porcelain"])
309        .current_dir(&check_path)
310        .output()
311        .map_err(|e| {
312            TransitionError::Other(format!(
313                "Failed to check git status in {:?}: {}",
314                check_path, e
315            ))
316        })?;
317
318    if !output.status.success() {
319        let stderr = String::from_utf8_lossy(&output.stderr);
320        return Err(TransitionError::Other(format!(
321            "git status failed: {}",
322            stderr
323        )));
324    }
325
326    let status_output = String::from_utf8_lossy(&output.stdout);
327    Ok(status_output.trim().is_empty())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_valid_transitions_from_pending() {
336        use SpecStatus::*;
337
338        assert!(is_valid_transition(&Pending, &InProgress));
339        assert!(is_valid_transition(&Pending, &Blocked));
340        assert!(is_valid_transition(&Pending, &Cancelled));
341
342        assert!(!is_valid_transition(&Pending, &Completed));
343        assert!(!is_valid_transition(&Pending, &Failed));
344    }
345
346    #[test]
347    fn test_valid_transitions_from_in_progress() {
348        use SpecStatus::*;
349
350        assert!(is_valid_transition(&InProgress, &Completed));
351        assert!(is_valid_transition(&InProgress, &Failed));
352        assert!(is_valid_transition(&InProgress, &NeedsAttention));
353        assert!(is_valid_transition(&InProgress, &Paused));
354        assert!(is_valid_transition(&InProgress, &Cancelled));
355
356        assert!(!is_valid_transition(&InProgress, &Pending));
357        assert!(!is_valid_transition(&InProgress, &Blocked));
358    }
359
360    #[test]
361    fn test_valid_transitions_from_blocked() {
362        use SpecStatus::*;
363
364        assert!(is_valid_transition(&Blocked, &Pending));
365        assert!(is_valid_transition(&Blocked, &InProgress));
366        assert!(is_valid_transition(&Blocked, &Cancelled));
367
368        assert!(!is_valid_transition(&Blocked, &Completed));
369        assert!(!is_valid_transition(&Blocked, &Failed));
370    }
371
372    #[test]
373    fn test_valid_transitions_from_failed() {
374        use SpecStatus::*;
375
376        assert!(is_valid_transition(&Failed, &Pending));
377        assert!(is_valid_transition(&Failed, &InProgress));
378
379        assert!(!is_valid_transition(&Failed, &Completed));
380    }
381
382    #[test]
383    fn test_valid_transitions_from_paused() {
384        use SpecStatus::*;
385
386        assert!(is_valid_transition(&Paused, &InProgress));
387        assert!(is_valid_transition(&Paused, &Cancelled));
388
389        assert!(!is_valid_transition(&Paused, &Pending));
390        assert!(!is_valid_transition(&Paused, &Completed));
391    }
392
393    #[test]
394    fn test_valid_transitions_from_completed() {
395        use SpecStatus::*;
396
397        // Completed can transition back to Pending for replay
398        assert!(is_valid_transition(&Completed, &Pending));
399
400        // Generally, completed specs don't transition to other states
401        assert!(!is_valid_transition(&Completed, &InProgress));
402        assert!(!is_valid_transition(&Completed, &Failed));
403    }
404
405    #[test]
406    fn test_invalid_ready_transitions() {
407        use SpecStatus::*;
408
409        // Ready is a computed state, not a persistent status
410        assert!(!is_valid_transition(&Ready, &InProgress));
411        assert!(!is_valid_transition(&Pending, &Ready));
412    }
413
414    #[test]
415    fn test_self_transitions() {
416        use SpecStatus::*;
417
418        assert!(is_valid_transition(&Pending, &Pending));
419        assert!(is_valid_transition(&InProgress, &InProgress));
420        assert!(is_valid_transition(&Completed, &Completed));
421    }
422
423    #[test]
424    fn test_builder_basic_transition() {
425        let mut spec = Spec::parse(
426            "test-001",
427            r#"---
428type: code
429status: pending
430---
431# Test
432"#,
433        )
434        .unwrap();
435
436        let result = TransitionBuilder::new(&mut spec).to(SpecStatus::InProgress);
437        assert!(result.is_ok());
438        assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
439    }
440
441    #[test]
442    fn test_builder_invalid_transition() {
443        let mut spec = Spec::parse(
444            "test-002",
445            r#"---
446type: code
447status: pending
448---
449# Test
450"#,
451        )
452        .unwrap();
453
454        let result = TransitionBuilder::new(&mut spec).to(SpecStatus::Completed);
455        assert!(result.is_err());
456        match result {
457            Err(TransitionError::InvalidTransition { from, to }) => {
458                assert_eq!(from, SpecStatus::Pending);
459                assert_eq!(to, SpecStatus::Completed);
460            }
461            _ => panic!("Expected InvalidTransition error"),
462        }
463    }
464
465    #[test]
466    fn test_builder_force_bypass() {
467        let mut spec = Spec::parse(
468            "test-003",
469            r#"---
470type: code
471status: pending
472---
473# Test
474"#,
475        )
476        .unwrap();
477
478        // Force bypass allows invalid transition
479        let result = TransitionBuilder::new(&mut spec)
480            .force()
481            .to(SpecStatus::Completed);
482        assert!(result.is_ok());
483        assert_eq!(spec.frontmatter.status, SpecStatus::Completed);
484    }
485
486    #[test]
487    fn test_builder_criteria_check() {
488        let mut spec = Spec::parse(
489            "test-004",
490            r#"---
491type: code
492status: in_progress
493---
494# Test
495
496## Acceptance Criteria
497
498- [ ] Task 1
499- [ ] Task 2
500"#,
501        )
502        .unwrap();
503
504        let result = TransitionBuilder::new(&mut spec)
505            .require_all_criteria_checked()
506            .to(SpecStatus::Completed);
507
508        assert!(result.is_err());
509        match result {
510            Err(TransitionError::IncompleteCriteria) => {}
511            _ => panic!("Expected IncompleteCriteria error"),
512        }
513    }
514
515    #[test]
516    fn test_builder_criteria_check_passes() {
517        let mut spec = Spec::parse(
518            "test-005",
519            r#"---
520type: code
521status: in_progress
522---
523# Test
524
525## Acceptance Criteria
526
527- [x] Task 1
528- [x] Task 2
529"#,
530        )
531        .unwrap();
532
533        // Should fail with NoCommits instead of IncompleteCriteria
534        let result = TransitionBuilder::new(&mut spec)
535            .require_all_criteria_checked()
536            .require_commits_exist()
537            .to(SpecStatus::Completed);
538
539        match result {
540            Err(TransitionError::NoCommits) => {}
541            _ => panic!("Expected NoCommits error, criteria check passed"),
542        }
543    }
544
545    #[test]
546    fn test_builder_approval_required() {
547        let mut spec = Spec::parse(
548            "test-006",
549            r#"---
550type: code
551status: pending
552approval:
553  required: true
554  status: pending
555---
556# Test
557"#,
558        )
559        .unwrap();
560
561        let result = TransitionBuilder::new(&mut spec)
562            .check_approval()
563            .to(SpecStatus::InProgress);
564
565        assert!(result.is_err());
566        match result {
567            Err(TransitionError::ApprovalRequired) => {}
568            _ => panic!("Expected ApprovalRequired error"),
569        }
570    }
571
572    #[test]
573    fn test_builder_with_project_name() {
574        let mut spec = Spec::parse(
575            "test-007",
576            r#"---
577type: code
578status: pending
579---
580# Test
581"#,
582        )
583        .unwrap();
584
585        // Test that builder accepts project name
586        let result = TransitionBuilder::new(&mut spec)
587            .with_project_name(Some("myproject"))
588            .to(SpecStatus::InProgress);
589
590        assert!(result.is_ok());
591        assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
592    }
593
594    #[test]
595    fn test_builder_with_specs_dir() {
596        let mut spec = Spec::parse(
597            "test-008",
598            r#"---
599type: code
600status: pending
601---
602# Test
603"#,
604        )
605        .unwrap();
606
607        // Test that builder accepts specs_dir
608        let specs_dir = Path::new("/custom/specs");
609        let result = TransitionBuilder::new(&mut spec)
610            .with_specs_dir(specs_dir)
611            .to(SpecStatus::InProgress);
612
613        assert!(result.is_ok());
614        assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
615    }
616}