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
278fn 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
294fn is_clean(spec_id: &str, project_name: Option<&str>) -> Result<bool, TransitionError> {
296 use crate::worktree;
297
298 let check_path =
300 if let Some(worktree_path) = worktree::get_active_worktree(spec_id, project_name) {
301 worktree_path
302 } else {
303 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 assert!(is_valid_transition(&Completed, &Pending));
399
400 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 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 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 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 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 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}