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}
61
62impl<'a> TransitionBuilder<'a> {
63    /// Create a new transition builder for a spec.
64    pub fn new(spec: &'a mut Spec) -> Self {
65        Self {
66            spec,
67            require_clean: false,
68            require_deps: false,
69            require_criteria: false,
70            require_commits: false,
71            require_no_incomplete_members: false,
72            check_approval: false,
73            force: false,
74        }
75    }
76
77    /// Require worktree to be clean (no uncommitted changes).
78    pub fn require_clean_tree(mut self) -> Self {
79        self.require_clean = true;
80        self
81    }
82
83    /// Require all dependencies to be met.
84    pub fn require_dependencies_met(mut self) -> Self {
85        self.require_deps = true;
86        self
87    }
88
89    /// Require all acceptance criteria to be checked.
90    pub fn require_all_criteria_checked(mut self) -> Self {
91        self.require_criteria = true;
92        self
93    }
94
95    /// Require at least one commit for the spec.
96    pub fn require_commits_exist(mut self) -> Self {
97        self.require_commits = true;
98        self
99    }
100
101    /// Require no incomplete driver members.
102    pub fn require_no_incomplete_members(mut self) -> Self {
103        self.require_no_incomplete_members = true;
104        self
105    }
106
107    /// Check approval status.
108    pub fn check_approval(mut self) -> Self {
109        self.check_approval = true;
110        self
111    }
112
113    /// Force the transition, bypassing all precondition checks.
114    /// Use with extreme caution - intended for exceptional cases only.
115    pub fn force(mut self) -> Self {
116        self.force = true;
117        self
118    }
119
120    /// Execute the transition to the target status.
121    pub fn to(self, target: SpecStatus) -> Result<(), TransitionError> {
122        let current = &self.spec.frontmatter.status;
123
124        // Check if transition is valid
125        if !self.force && !is_valid_transition(current, &target) {
126            return Err(TransitionError::InvalidTransition {
127                from: current.clone(),
128                to: target,
129            });
130        }
131
132        // Run precondition checks (unless forced)
133        if !self.force {
134            self.check_preconditions(&target)?;
135        }
136
137        // Apply the transition
138        self.spec.frontmatter.status = target;
139        Ok(())
140    }
141
142    fn check_preconditions(&self, _target: &SpecStatus) -> Result<(), TransitionError> {
143        if self.check_approval && self.spec.requires_approval() {
144            return Err(TransitionError::ApprovalRequired);
145        }
146
147        if self.require_deps {
148            let specs_dir = Path::new(".chant/specs");
149            if specs_dir.exists() {
150                let all_specs = super::lifecycle::load_all_specs(specs_dir)
151                    .map_err(|e| TransitionError::Other(format!("Failed to load specs: {}", e)))?;
152
153                if self.spec.is_blocked(&all_specs) {
154                    return Err(TransitionError::UnmetDependencies(
155                        "Spec has unmet dependencies".to_string(),
156                    ));
157                }
158            }
159        }
160
161        if self.require_criteria && self.spec.count_unchecked_checkboxes() > 0 {
162            return Err(TransitionError::IncompleteCriteria);
163        }
164
165        if self.require_commits && !has_commits(&self.spec.id)? {
166            return Err(TransitionError::NoCommits);
167        }
168
169        if self.require_no_incomplete_members {
170            if let Some(members) = &self.spec.frontmatter.members {
171                let specs_dir = Path::new(".chant/specs");
172                if specs_dir.exists() {
173                    let all_specs = super::lifecycle::load_all_specs(specs_dir).map_err(|e| {
174                        TransitionError::Other(format!("Failed to load specs: {}", e))
175                    })?;
176
177                    let incomplete: Vec<_> = members
178                        .iter()
179                        .filter_map(|m| {
180                            all_specs
181                                .iter()
182                                .find(|s| s.id == *m)
183                                .filter(|s| s.frontmatter.status != SpecStatus::Completed)
184                                .map(|s| s.id.clone())
185                        })
186                        .collect();
187
188                    if !incomplete.is_empty() {
189                        return Err(TransitionError::IncompleteMembers(incomplete.join(", ")));
190                    }
191                }
192            }
193        }
194
195        if self.require_clean && !is_clean(&self.spec.id)? {
196            return Err(TransitionError::DirtyWorktree(
197                "Worktree has uncommitted changes".to_string(),
198            ));
199        }
200
201        Ok(())
202    }
203}
204
205/// Check if a transition from one status to another is valid.
206fn is_valid_transition(from: &SpecStatus, to: &SpecStatus) -> bool {
207    use SpecStatus::*;
208
209    match (from, to) {
210        // Self-transitions are always valid
211        (a, b) if a == b => true,
212
213        // From Pending
214        (Pending, InProgress) => true,
215        (Pending, Blocked) => true,
216        (Pending, Cancelled) => true,
217
218        // From Blocked
219        (Blocked, Pending) => true,
220        (Blocked, InProgress) => true,
221        (Blocked, Cancelled) => true,
222
223        // From InProgress
224        (InProgress, Completed) => true,
225        (InProgress, Failed) => true,
226        (InProgress, NeedsAttention) => true,
227        (InProgress, Paused) => true,
228        (InProgress, Cancelled) => true,
229
230        // From Failed
231        (Failed, Pending) => true,
232        (Failed, InProgress) => true,
233
234        // From NeedsAttention
235        (NeedsAttention, Pending) => true,
236        (NeedsAttention, InProgress) => true,
237
238        // From Paused
239        (Paused, InProgress) => true,
240        (Paused, Cancelled) => true,
241
242        // From Completed - generally immutable except for special cases
243        (Completed, Pending) => true, // For replay/verification scenarios
244
245        // From Cancelled
246        (Cancelled, Pending) => true,
247
248        // Ready is a computed state, not a persistent status
249        (Ready, _) | (_, Ready) => false,
250
251        // All other transitions are invalid
252        _ => false,
253    }
254}
255
256/// Check if the spec has commits matching the chant(spec_id): pattern.
257fn has_commits(spec_id: &str) -> Result<bool, TransitionError> {
258    let pattern = format!("chant({}):", spec_id);
259    let output = Command::new("git")
260        .args(["log", "--all", "--grep", &pattern, "--format=%H"])
261        .output()
262        .map_err(|e| TransitionError::Other(format!("Failed to check git log: {}", e)))?;
263
264    if !output.status.success() {
265        return Ok(false);
266    }
267
268    let commits_output = String::from_utf8_lossy(&output.stdout);
269    Ok(!commits_output.trim().is_empty())
270}
271
272/// Check if the worktree is clean (no uncommitted changes).
273fn is_clean(spec_id: &str) -> Result<bool, TransitionError> {
274    let worktree_path = Path::new("/tmp").join(format!("chant-{}", spec_id));
275
276    let check_path = if worktree_path.exists() {
277        &worktree_path
278    } else {
279        Path::new(".")
280    };
281
282    let output = Command::new("git")
283        .args(["status", "--porcelain"])
284        .current_dir(check_path)
285        .output()
286        .map_err(|e| {
287            TransitionError::Other(format!(
288                "Failed to check git status in {:?}: {}",
289                check_path, e
290            ))
291        })?;
292
293    if !output.status.success() {
294        let stderr = String::from_utf8_lossy(&output.stderr);
295        return Err(TransitionError::Other(format!(
296            "git status failed: {}",
297            stderr
298        )));
299    }
300
301    let status_output = String::from_utf8_lossy(&output.stdout);
302    Ok(status_output.trim().is_empty())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_valid_transitions_from_pending() {
311        use SpecStatus::*;
312
313        assert!(is_valid_transition(&Pending, &InProgress));
314        assert!(is_valid_transition(&Pending, &Blocked));
315        assert!(is_valid_transition(&Pending, &Cancelled));
316
317        assert!(!is_valid_transition(&Pending, &Completed));
318        assert!(!is_valid_transition(&Pending, &Failed));
319    }
320
321    #[test]
322    fn test_valid_transitions_from_in_progress() {
323        use SpecStatus::*;
324
325        assert!(is_valid_transition(&InProgress, &Completed));
326        assert!(is_valid_transition(&InProgress, &Failed));
327        assert!(is_valid_transition(&InProgress, &NeedsAttention));
328        assert!(is_valid_transition(&InProgress, &Paused));
329        assert!(is_valid_transition(&InProgress, &Cancelled));
330
331        assert!(!is_valid_transition(&InProgress, &Pending));
332        assert!(!is_valid_transition(&InProgress, &Blocked));
333    }
334
335    #[test]
336    fn test_valid_transitions_from_blocked() {
337        use SpecStatus::*;
338
339        assert!(is_valid_transition(&Blocked, &Pending));
340        assert!(is_valid_transition(&Blocked, &InProgress));
341        assert!(is_valid_transition(&Blocked, &Cancelled));
342
343        assert!(!is_valid_transition(&Blocked, &Completed));
344        assert!(!is_valid_transition(&Blocked, &Failed));
345    }
346
347    #[test]
348    fn test_valid_transitions_from_failed() {
349        use SpecStatus::*;
350
351        assert!(is_valid_transition(&Failed, &Pending));
352        assert!(is_valid_transition(&Failed, &InProgress));
353
354        assert!(!is_valid_transition(&Failed, &Completed));
355    }
356
357    #[test]
358    fn test_valid_transitions_from_paused() {
359        use SpecStatus::*;
360
361        assert!(is_valid_transition(&Paused, &InProgress));
362        assert!(is_valid_transition(&Paused, &Cancelled));
363
364        assert!(!is_valid_transition(&Paused, &Pending));
365        assert!(!is_valid_transition(&Paused, &Completed));
366    }
367
368    #[test]
369    fn test_valid_transitions_from_completed() {
370        use SpecStatus::*;
371
372        // Completed can transition back to Pending for replay
373        assert!(is_valid_transition(&Completed, &Pending));
374
375        // Generally, completed specs don't transition to other states
376        assert!(!is_valid_transition(&Completed, &InProgress));
377        assert!(!is_valid_transition(&Completed, &Failed));
378    }
379
380    #[test]
381    fn test_invalid_ready_transitions() {
382        use SpecStatus::*;
383
384        // Ready is a computed state, not a persistent status
385        assert!(!is_valid_transition(&Ready, &InProgress));
386        assert!(!is_valid_transition(&Pending, &Ready));
387    }
388
389    #[test]
390    fn test_self_transitions() {
391        use SpecStatus::*;
392
393        assert!(is_valid_transition(&Pending, &Pending));
394        assert!(is_valid_transition(&InProgress, &InProgress));
395        assert!(is_valid_transition(&Completed, &Completed));
396    }
397
398    #[test]
399    fn test_builder_basic_transition() {
400        let mut spec = Spec::parse(
401            "test-001",
402            r#"---
403type: code
404status: pending
405---
406# Test
407"#,
408        )
409        .unwrap();
410
411        let result = TransitionBuilder::new(&mut spec).to(SpecStatus::InProgress);
412        assert!(result.is_ok());
413        assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
414    }
415
416    #[test]
417    fn test_builder_invalid_transition() {
418        let mut spec = Spec::parse(
419            "test-002",
420            r#"---
421type: code
422status: pending
423---
424# Test
425"#,
426        )
427        .unwrap();
428
429        let result = TransitionBuilder::new(&mut spec).to(SpecStatus::Completed);
430        assert!(result.is_err());
431        match result {
432            Err(TransitionError::InvalidTransition { from, to }) => {
433                assert_eq!(from, SpecStatus::Pending);
434                assert_eq!(to, SpecStatus::Completed);
435            }
436            _ => panic!("Expected InvalidTransition error"),
437        }
438    }
439
440    #[test]
441    fn test_builder_force_bypass() {
442        let mut spec = Spec::parse(
443            "test-003",
444            r#"---
445type: code
446status: pending
447---
448# Test
449"#,
450        )
451        .unwrap();
452
453        // Force bypass allows invalid transition
454        let result = TransitionBuilder::new(&mut spec)
455            .force()
456            .to(SpecStatus::Completed);
457        assert!(result.is_ok());
458        assert_eq!(spec.frontmatter.status, SpecStatus::Completed);
459    }
460
461    #[test]
462    fn test_builder_criteria_check() {
463        let mut spec = Spec::parse(
464            "test-004",
465            r#"---
466type: code
467status: in_progress
468---
469# Test
470
471## Acceptance Criteria
472
473- [ ] Task 1
474- [ ] Task 2
475"#,
476        )
477        .unwrap();
478
479        let result = TransitionBuilder::new(&mut spec)
480            .require_all_criteria_checked()
481            .to(SpecStatus::Completed);
482
483        assert!(result.is_err());
484        match result {
485            Err(TransitionError::IncompleteCriteria) => {}
486            _ => panic!("Expected IncompleteCriteria error"),
487        }
488    }
489
490    #[test]
491    fn test_builder_criteria_check_passes() {
492        let mut spec = Spec::parse(
493            "test-005",
494            r#"---
495type: code
496status: in_progress
497---
498# Test
499
500## Acceptance Criteria
501
502- [x] Task 1
503- [x] Task 2
504"#,
505        )
506        .unwrap();
507
508        // Should fail with NoCommits instead of IncompleteCriteria
509        let result = TransitionBuilder::new(&mut spec)
510            .require_all_criteria_checked()
511            .require_commits_exist()
512            .to(SpecStatus::Completed);
513
514        match result {
515            Err(TransitionError::NoCommits) => {}
516            _ => panic!("Expected NoCommits error, criteria check passed"),
517        }
518    }
519
520    #[test]
521    fn test_builder_approval_required() {
522        let mut spec = Spec::parse(
523            "test-006",
524            r#"---
525type: code
526status: pending
527approval:
528  required: true
529  status: pending
530---
531# Test
532"#,
533        )
534        .unwrap();
535
536        let result = TransitionBuilder::new(&mut spec)
537            .check_approval()
538            .to(SpecStatus::InProgress);
539
540        assert!(result.is_err());
541        match result {
542            Err(TransitionError::ApprovalRequired) => {}
543            _ => panic!("Expected ApprovalRequired error"),
544        }
545    }
546}