1use 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
50pub 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 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 pub fn require_clean_tree(mut self) -> Self {
83 self.require_clean = true;
84 self
85 }
86
87 pub fn require_dependencies_met(mut self) -> Self {
89 self.require_deps = true;
90 self
91 }
92
93 pub fn require_all_criteria_checked(mut self) -> Self {
95 self.require_criteria = true;
96 self
97 }
98
99 pub fn require_commits_exist(mut self) -> Self {
101 self.require_commits = true;
102 self
103 }
104
105 pub fn require_no_incomplete_members(mut self) -> Self {
107 self.require_no_incomplete_members = true;
108 self
109 }
110
111 pub fn check_approval(mut self) -> Self {
113 self.check_approval = true;
114 self
115 }
116
117 pub fn force(mut self) -> Self {
120 self.force = true;
121 self
122 }
123
124 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 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 pub fn to(self, target: SpecStatus) -> Result<(), TransitionError> {
138 let current = &self.spec.frontmatter.status;
139
140 if !self.force && !is_valid_transition(current, &target) {
142 return Err(TransitionError::InvalidTransition {
143 from: current.clone(),
144 to: target,
145 });
146 }
147
148 if !self.force {
150 self.check_preconditions(&target)?;
151 }
152
153 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
227fn is_valid_transition(from: &SpecStatus, to: &SpecStatus) -> bool {
229 use SpecStatus::*;
230
231 match (from, to) {
232 (a, b) if a == b => true,
234
235 (Pending, InProgress) => true,
237 (Pending, Blocked) => true,
238 (Pending, Cancelled) => true,
239
240 (Blocked, Pending) => true,
242 (Blocked, InProgress) => true,
243 (Blocked, Cancelled) => true,
244
245 (InProgress, Completed) => true,
247 (InProgress, Failed) => true,
248 (InProgress, NeedsAttention) => true,
249 (InProgress, Paused) => true,
250 (InProgress, Cancelled) => true,
251
252 (Failed, Pending) => true,
254 (Failed, InProgress) => true,
255
256 (NeedsAttention, Pending) => true,
258 (NeedsAttention, InProgress) => true,
259
260 (Paused, InProgress) => true,
262 (Paused, Cancelled) => true,
263
264 (Completed, Pending) => true, (Cancelled, Pending) => true,
269
270 (Ready, _) | (_, Ready) => false,
272
273 _ => false,
275 }
276}
277
278pub fn transition_to_in_progress(
284 spec: &mut Spec,
285 specs_dir: Option<&Path>,
286) -> Result<(), TransitionError> {
287 TransitionBuilder::new(spec)
288 .require_dependencies_met()
289 .with_specs_dir(specs_dir.unwrap_or_else(|| Path::new(".chant/specs")))
290 .to(SpecStatus::InProgress)
291}
292
293pub fn transition_to_failed(spec: &mut Spec) -> Result<(), TransitionError> {
295 TransitionBuilder::new(spec).force().to(SpecStatus::Failed)
296}
297
298pub fn transition_to_paused(spec: &mut Spec) -> Result<(), TransitionError> {
300 TransitionBuilder::new(spec).to(SpecStatus::Paused)
301}
302
303pub fn transition_to_blocked(spec: &mut Spec) -> Result<(), TransitionError> {
305 TransitionBuilder::new(spec).to(SpecStatus::Blocked)
306}
307
308fn has_commits(spec_id: &str) -> Result<bool, TransitionError> {
314 let pattern = format!("chant({}):", spec_id);
315 let output = Command::new("git")
316 .args(["log", "--all", "--grep", &pattern, "--format=%H"])
317 .output()
318 .map_err(|e| TransitionError::Other(format!("Failed to check git log: {}", e)))?;
319
320 if !output.status.success() {
321 return Ok(false);
322 }
323
324 let commits_output = String::from_utf8_lossy(&output.stdout);
325 Ok(!commits_output.trim().is_empty())
326}
327
328fn is_clean(spec_id: &str, project_name: Option<&str>) -> Result<bool, TransitionError> {
330 use crate::worktree;
331
332 let check_path =
334 if let Some(worktree_path) = worktree::get_active_worktree(spec_id, project_name) {
335 worktree_path
336 } else {
337 std::path::PathBuf::from(".")
339 };
340
341 let output = Command::new("git")
342 .args(["status", "--porcelain"])
343 .current_dir(&check_path)
344 .output()
345 .map_err(|e| {
346 TransitionError::Other(format!(
347 "Failed to check git status in {:?}: {}",
348 check_path, e
349 ))
350 })?;
351
352 if !output.status.success() {
353 let stderr = String::from_utf8_lossy(&output.stderr);
354 return Err(TransitionError::Other(format!(
355 "git status failed: {}",
356 stderr
357 )));
358 }
359
360 let status_output = String::from_utf8_lossy(&output.stdout);
361 Ok(status_output.trim().is_empty())
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_valid_transitions_from_pending() {
370 use SpecStatus::*;
371
372 assert!(is_valid_transition(&Pending, &InProgress));
373 assert!(is_valid_transition(&Pending, &Blocked));
374 assert!(is_valid_transition(&Pending, &Cancelled));
375
376 assert!(!is_valid_transition(&Pending, &Completed));
377 assert!(!is_valid_transition(&Pending, &Failed));
378 }
379
380 #[test]
381 fn test_valid_transitions_from_in_progress() {
382 use SpecStatus::*;
383
384 assert!(is_valid_transition(&InProgress, &Completed));
385 assert!(is_valid_transition(&InProgress, &Failed));
386 assert!(is_valid_transition(&InProgress, &NeedsAttention));
387 assert!(is_valid_transition(&InProgress, &Paused));
388 assert!(is_valid_transition(&InProgress, &Cancelled));
389
390 assert!(!is_valid_transition(&InProgress, &Pending));
391 assert!(!is_valid_transition(&InProgress, &Blocked));
392 }
393
394 #[test]
395 fn test_valid_transitions_from_blocked() {
396 use SpecStatus::*;
397
398 assert!(is_valid_transition(&Blocked, &Pending));
399 assert!(is_valid_transition(&Blocked, &InProgress));
400 assert!(is_valid_transition(&Blocked, &Cancelled));
401
402 assert!(!is_valid_transition(&Blocked, &Completed));
403 assert!(!is_valid_transition(&Blocked, &Failed));
404 }
405
406 #[test]
407 fn test_valid_transitions_from_failed() {
408 use SpecStatus::*;
409
410 assert!(is_valid_transition(&Failed, &Pending));
411 assert!(is_valid_transition(&Failed, &InProgress));
412
413 assert!(!is_valid_transition(&Failed, &Completed));
414 }
415
416 #[test]
417 fn test_valid_transitions_from_paused() {
418 use SpecStatus::*;
419
420 assert!(is_valid_transition(&Paused, &InProgress));
421 assert!(is_valid_transition(&Paused, &Cancelled));
422
423 assert!(!is_valid_transition(&Paused, &Pending));
424 assert!(!is_valid_transition(&Paused, &Completed));
425 }
426
427 #[test]
428 fn test_valid_transitions_from_completed() {
429 use SpecStatus::*;
430
431 assert!(is_valid_transition(&Completed, &Pending));
433
434 assert!(!is_valid_transition(&Completed, &InProgress));
436 assert!(!is_valid_transition(&Completed, &Failed));
437 }
438
439 #[test]
440 fn test_invalid_ready_transitions() {
441 use SpecStatus::*;
442
443 assert!(!is_valid_transition(&Ready, &InProgress));
445 assert!(!is_valid_transition(&Pending, &Ready));
446 }
447
448 #[test]
449 fn test_self_transitions() {
450 use SpecStatus::*;
451
452 assert!(is_valid_transition(&Pending, &Pending));
453 assert!(is_valid_transition(&InProgress, &InProgress));
454 assert!(is_valid_transition(&Completed, &Completed));
455 }
456
457 #[test]
458 fn test_builder_basic_transition() {
459 let mut spec = Spec::parse(
460 "test-001",
461 r#"---
462type: code
463status: pending
464---
465# Test
466"#,
467 )
468 .unwrap();
469
470 let result = TransitionBuilder::new(&mut spec).to(SpecStatus::InProgress);
471 assert!(result.is_ok());
472 assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
473 }
474
475 #[test]
476 fn test_builder_invalid_transition() {
477 let mut spec = Spec::parse(
478 "test-002",
479 r#"---
480type: code
481status: pending
482---
483# Test
484"#,
485 )
486 .unwrap();
487
488 let result = TransitionBuilder::new(&mut spec).to(SpecStatus::Completed);
489 assert!(result.is_err());
490 match result {
491 Err(TransitionError::InvalidTransition { from, to }) => {
492 assert_eq!(from, SpecStatus::Pending);
493 assert_eq!(to, SpecStatus::Completed);
494 }
495 _ => panic!("Expected InvalidTransition error"),
496 }
497 }
498
499 #[test]
500 fn test_builder_force_bypass() {
501 let mut spec = Spec::parse(
502 "test-003",
503 r#"---
504type: code
505status: pending
506---
507# Test
508"#,
509 )
510 .unwrap();
511
512 let result = TransitionBuilder::new(&mut spec)
514 .force()
515 .to(SpecStatus::Completed);
516 assert!(result.is_ok());
517 assert_eq!(spec.frontmatter.status, SpecStatus::Completed);
518 }
519
520 #[test]
521 fn test_builder_criteria_check() {
522 let mut spec = Spec::parse(
523 "test-004",
524 r#"---
525type: code
526status: in_progress
527---
528# Test
529
530## Acceptance Criteria
531
532- [ ] Task 1
533- [ ] Task 2
534"#,
535 )
536 .unwrap();
537
538 let result = TransitionBuilder::new(&mut spec)
539 .require_all_criteria_checked()
540 .to(SpecStatus::Completed);
541
542 assert!(result.is_err());
543 match result {
544 Err(TransitionError::IncompleteCriteria) => {}
545 _ => panic!("Expected IncompleteCriteria error"),
546 }
547 }
548
549 #[test]
550 fn test_builder_criteria_check_passes() {
551 let mut spec = Spec::parse(
552 "test-005",
553 r#"---
554type: code
555status: in_progress
556---
557# Test
558
559## Acceptance Criteria
560
561- [x] Task 1
562- [x] Task 2
563"#,
564 )
565 .unwrap();
566
567 let result = TransitionBuilder::new(&mut spec)
569 .require_all_criteria_checked()
570 .require_commits_exist()
571 .to(SpecStatus::Completed);
572
573 match result {
574 Err(TransitionError::NoCommits) => {}
575 _ => panic!("Expected NoCommits error, criteria check passed"),
576 }
577 }
578
579 #[test]
580 fn test_builder_approval_required() {
581 let mut spec = Spec::parse(
582 "test-006",
583 r#"---
584type: code
585status: pending
586approval:
587 required: true
588 status: pending
589---
590# Test
591"#,
592 )
593 .unwrap();
594
595 let result = TransitionBuilder::new(&mut spec)
596 .check_approval()
597 .to(SpecStatus::InProgress);
598
599 assert!(result.is_err());
600 match result {
601 Err(TransitionError::ApprovalRequired) => {}
602 _ => panic!("Expected ApprovalRequired error"),
603 }
604 }
605
606 #[test]
607 fn test_builder_with_project_name() {
608 let mut spec = Spec::parse(
609 "test-007",
610 r#"---
611type: code
612status: pending
613---
614# Test
615"#,
616 )
617 .unwrap();
618
619 let result = TransitionBuilder::new(&mut spec)
621 .with_project_name(Some("myproject"))
622 .to(SpecStatus::InProgress);
623
624 assert!(result.is_ok());
625 assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
626 }
627
628 #[test]
629 fn test_builder_with_specs_dir() {
630 let mut spec = Spec::parse(
631 "test-008",
632 r#"---
633type: code
634status: pending
635---
636# Test
637"#,
638 )
639 .unwrap();
640
641 let specs_dir = Path::new("/custom/specs");
643 let result = TransitionBuilder::new(&mut spec)
644 .with_specs_dir(specs_dir)
645 .to(SpecStatus::InProgress);
646
647 assert!(result.is_ok());
648 assert_eq!(spec.frontmatter.status, SpecStatus::InProgress);
649 }
650}