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}
61
62impl<'a> TransitionBuilder<'a> {
63 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 pub fn require_clean_tree(mut self) -> Self {
79 self.require_clean = true;
80 self
81 }
82
83 pub fn require_dependencies_met(mut self) -> Self {
85 self.require_deps = true;
86 self
87 }
88
89 pub fn require_all_criteria_checked(mut self) -> Self {
91 self.require_criteria = true;
92 self
93 }
94
95 pub fn require_commits_exist(mut self) -> Self {
97 self.require_commits = true;
98 self
99 }
100
101 pub fn require_no_incomplete_members(mut self) -> Self {
103 self.require_no_incomplete_members = true;
104 self
105 }
106
107 pub fn check_approval(mut self) -> Self {
109 self.check_approval = true;
110 self
111 }
112
113 pub fn force(mut self) -> Self {
116 self.force = true;
117 self
118 }
119
120 pub fn to(self, target: SpecStatus) -> Result<(), TransitionError> {
122 let current = &self.spec.frontmatter.status;
123
124 if !self.force && !is_valid_transition(current, &target) {
126 return Err(TransitionError::InvalidTransition {
127 from: current.clone(),
128 to: target,
129 });
130 }
131
132 if !self.force {
134 self.check_preconditions(&target)?;
135 }
136
137 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
205fn is_valid_transition(from: &SpecStatus, to: &SpecStatus) -> bool {
207 use SpecStatus::*;
208
209 match (from, to) {
210 (a, b) if a == b => true,
212
213 (Pending, InProgress) => true,
215 (Pending, Blocked) => true,
216 (Pending, Cancelled) => true,
217
218 (Blocked, Pending) => true,
220 (Blocked, InProgress) => true,
221 (Blocked, Cancelled) => true,
222
223 (InProgress, Completed) => true,
225 (InProgress, Failed) => true,
226 (InProgress, NeedsAttention) => true,
227 (InProgress, Paused) => true,
228 (InProgress, Cancelled) => true,
229
230 (Failed, Pending) => true,
232 (Failed, InProgress) => true,
233
234 (NeedsAttention, Pending) => true,
236 (NeedsAttention, InProgress) => true,
237
238 (Paused, InProgress) => true,
240 (Paused, Cancelled) => true,
241
242 (Completed, Pending) => true, (Cancelled, Pending) => true,
247
248 (Ready, _) | (_, Ready) => false,
250
251 _ => false,
253 }
254}
255
256fn 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
272fn 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 assert!(is_valid_transition(&Completed, &Pending));
374
375 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 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 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 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}