1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{
5 application::services::workspace::PhaseTransitionService, domain::documents::types::Phase,
6};
7
8#[derive(Args)]
9pub struct TransitionCommand {
10 pub short_code: String,
12
13 pub phase: Option<String>,
15
16 #[arg(short = 't', long)]
18 pub document_type: Option<String>,
19}
20
21impl TransitionCommand {
22 pub async fn execute(&self) -> Result<()> {
23 let (workspace_exists, metis_dir) = workspace::has_metis_vault();
25 if !workspace_exists {
26 anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
27 }
28 let metis_dir = metis_dir.unwrap();
29
30 let transition_service = PhaseTransitionService::new(&metis_dir);
32
33 let result = if let Some(phase_str) = &self.phase {
35 let target_phase = self.parse_phase(phase_str)?;
36 transition_service
37 .transition_document(&self.short_code, target_phase)
38 .await?
39 } else {
40 transition_service
41 .transition_to_next_phase(&self.short_code)
42 .await?
43 };
44
45 println!(
47 "✓ Transitioned {} '{}' from {} to {}",
48 result.document_type, result.document_id, result.from_phase, result.to_phase
49 );
50
51 Ok(())
54 }
55
56 fn parse_phase(&self, phase_str: &str) -> Result<Phase> {
57 match phase_str.to_lowercase().as_str() {
58 "draft" => Ok(Phase::Draft),
59 "review" => Ok(Phase::Review),
60 "published" => Ok(Phase::Published),
61 "discussion" => Ok(Phase::Discussion),
62 "decided" => Ok(Phase::Decided),
63 "superseded" => Ok(Phase::Superseded),
64 "backlog" => Ok(Phase::Backlog),
65 "todo" => Ok(Phase::Todo),
66 "active" => Ok(Phase::Active),
67 "blocked" => Ok(Phase::Blocked),
68 "completed" => Ok(Phase::Completed),
69 "shaping" => Ok(Phase::Shaping),
70 "design" => Ok(Phase::Design),
71 "ready" => Ok(Phase::Ready),
72 "decompose" => Ok(Phase::Decompose),
73 "discovery" => Ok(Phase::Discovery),
74 _ => anyhow::bail!("Unknown phase: {}", phase_str),
75 }
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::commands::InitCommand;
83 use metis_core::{Adr, Document, Initiative, Strategy, Task, Vision};
84 use tempfile::tempdir;
85
86 #[test]
87 fn test_parse_phase() {
88 let cmd = TransitionCommand {
89 short_code: "test".to_string(),
90 phase: Some("active".to_string()),
91 document_type: None,
92 };
93
94 assert_eq!(cmd.parse_phase("draft").unwrap(), Phase::Draft);
95 assert_eq!(cmd.parse_phase("ACTIVE").unwrap(), Phase::Active);
96 assert_eq!(cmd.parse_phase("completed").unwrap(), Phase::Completed);
97 assert!(cmd.parse_phase("invalid").is_err());
98 }
99
100 #[tokio::test]
101 async fn test_transition_command_no_workspace() {
102 let temp_dir = tempdir().unwrap();
103 let original_dir = std::env::current_dir().ok();
104
105 if std::env::set_current_dir(temp_dir.path()).is_err() {
107 return; }
109
110 let cmd = TransitionCommand {
111 short_code: "test-doc".to_string(),
112 phase: Some("active".to_string()),
113 document_type: None,
114 };
115
116 let result = cmd.execute().await;
117
118 if let Some(original) = original_dir {
120 let _ = std::env::set_current_dir(&original);
121 }
122
123 assert!(result.is_err());
124 assert!(result
125 .unwrap_err()
126 .to_string()
127 .contains("Not in a Metis workspace"));
128 }
129
130 #[tokio::test]
131 async fn test_find_document_not_found() {
132 let temp_dir = tempdir().unwrap();
133 let original_dir = std::env::current_dir().ok();
134
135 std::env::set_current_dir(temp_dir.path()).unwrap();
137
138 let init_cmd = InitCommand {
140 name: Some("Test Project".to_string()),
141 preset: None,
142 strategies: None,
143 initiatives: None,
144 prefix: None,
145 };
146 init_cmd.execute().await.unwrap();
147
148 let cmd = TransitionCommand {
149 short_code: "TEST-T-9999".to_string(),
150 phase: Some("active".to_string()),
151 document_type: None,
152 };
153
154 let result = cmd.execute().await;
155
156 if let Some(original) = original_dir {
158 let _ = std::env::set_current_dir(&original);
159 }
160
161 assert!(result.is_err());
162 assert!(result.unwrap_err().to_string().contains("not found"));
163 }
164
165 #[tokio::test]
166 async fn test_vision_full_transition_sequence() {
167 let temp_dir = tempdir().unwrap();
168 let original_dir = std::env::current_dir().ok();
169
170 std::env::set_current_dir(temp_dir.path()).unwrap();
172
173 let init_cmd = InitCommand {
175 name: Some("Test Project".to_string()),
176 preset: None,
177 strategies: None,
178 initiatives: None,
179 prefix: None,
180 };
181 init_cmd.execute().await.unwrap();
182
183 let vision_path = temp_dir.path().join(".metis").join("vision.md");
184
185 let vision = Vision::from_file(&vision_path).await.unwrap();
187 assert_eq!(vision.phase().unwrap(), Phase::Draft);
188 let short_code = vision.metadata().short_code.clone();
189
190 let cmd = TransitionCommand {
192 short_code: short_code.clone(),
193 phase: None, document_type: Some("vision".to_string()),
195 };
196 cmd.execute().await.unwrap();
197
198 let vision = Vision::from_file(&vision_path).await.unwrap();
199 assert_eq!(vision.phase().unwrap(), Phase::Review);
200
201 let cmd = TransitionCommand {
203 short_code: short_code.clone(),
204 phase: None, document_type: Some("vision".to_string()),
206 };
207 cmd.execute().await.unwrap();
208
209 let vision = Vision::from_file(&vision_path).await.unwrap();
210 assert_eq!(vision.phase().unwrap(), Phase::Published);
211
212 let cmd = TransitionCommand {
214 short_code: short_code.clone(),
215 phase: None, document_type: Some("vision".to_string()),
217 };
218 let result = cmd.execute().await;
219 assert!(result.is_err()); if let Some(original) = original_dir {
223 let _ = std::env::set_current_dir(&original);
224 }
225 }
226
227 #[tokio::test]
228 async fn test_strategy_full_transition_sequence() {
229 let temp_dir = tempdir().unwrap();
230 let original_dir = std::env::current_dir().ok();
231
232 std::env::set_current_dir(temp_dir.path()).unwrap();
234
235 let init_cmd = InitCommand {
237 name: Some("Test Project".to_string()),
238 preset: None,
239 strategies: None,
240 initiatives: None,
241 prefix: None,
242 };
243 init_cmd.execute().await.unwrap();
244
245 let create_cmd = crate::commands::CreateCommand {
247 document_type: crate::commands::create::CreateCommands::Strategy {
248 title: "Test Strategy".to_string(),
249 vision: None,
250 },
251 };
252 create_cmd.execute().await.unwrap();
253
254 let strategies_dir = temp_dir.path().join(".metis").join("strategies");
256 let strategy_dir = std::fs::read_dir(&strategies_dir)
257 .unwrap()
258 .find(|entry| entry.as_ref().unwrap().path().is_dir())
259 .unwrap()
260 .unwrap()
261 .path();
262 let strategy_path = strategy_dir.join("strategy.md");
263
264 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
266 assert_eq!(strategy.phase().unwrap(), Phase::Shaping);
267 let short_code = strategy.metadata().short_code.clone();
268
269 let cmd = TransitionCommand {
271 short_code: short_code.clone(),
272 phase: None, document_type: Some("strategy".to_string()),
274 };
275 cmd.execute().await.unwrap();
276
277 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
278 assert_eq!(strategy.phase().unwrap(), Phase::Design);
279
280 let cmd = TransitionCommand {
282 short_code: short_code.clone(),
283 phase: None, document_type: Some("strategy".to_string()),
285 };
286 cmd.execute().await.unwrap();
287
288 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
289 assert_eq!(strategy.phase().unwrap(), Phase::Ready);
290
291 let cmd = TransitionCommand {
293 short_code: short_code.clone(),
294 phase: None, document_type: Some("strategy".to_string()),
296 };
297 cmd.execute().await.unwrap();
298
299 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
300 assert_eq!(strategy.phase().unwrap(), Phase::Active);
301
302 let cmd = TransitionCommand {
304 short_code: short_code.clone(),
305 phase: None, document_type: Some("strategy".to_string()),
307 };
308 cmd.execute().await.unwrap();
309
310 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
311 assert_eq!(strategy.phase().unwrap(), Phase::Completed);
312
313 if let Some(original) = original_dir {
315 let _ = std::env::set_current_dir(&original);
316 }
317 }
318
319 #[tokio::test]
320 async fn test_initiative_full_transition_sequence() {
321 let temp_dir = tempdir().unwrap();
322 let original_dir = std::env::current_dir().ok();
323
324 std::env::set_current_dir(temp_dir.path()).unwrap();
326
327 let init_cmd = InitCommand {
329 name: Some("Test Project".to_string()),
330 preset: None,
331 strategies: None,
332 initiatives: None,
333 prefix: None,
334 };
335 init_cmd.execute().await.unwrap();
336
337 let create_strategy_cmd = crate::commands::CreateCommand {
339 document_type: crate::commands::create::CreateCommands::Strategy {
340 title: "Parent Strategy".to_string(),
341 vision: None,
342 },
343 };
344 create_strategy_cmd.execute().await.unwrap();
345
346 let create_initiative_cmd = crate::commands::CreateCommand {
348 document_type: crate::commands::create::CreateCommands::Initiative {
349 title: "Test Initiative".to_string(),
350 strategy: "TEST-S-0001".to_string(),
351 },
352 };
353 create_initiative_cmd.execute().await.unwrap();
354
355 let initiative_path = temp_dir
357 .path()
358 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/initiative.md");
359 let initiative = Initiative::from_file(&initiative_path).await.unwrap();
360 println!("Initiative starts with phase: {:?}", initiative.phase());
361 let short_code = initiative.metadata().short_code.clone();
362
363 let cmd = TransitionCommand {
365 short_code: short_code.clone(),
366 phase: None, document_type: Some("initiative".to_string()),
368 };
369 cmd.execute().await.unwrap();
370
371 let cmd = TransitionCommand {
373 short_code: short_code.clone(),
374 phase: None, document_type: Some("initiative".to_string()),
376 };
377 cmd.execute().await.unwrap();
378
379 let cmd = TransitionCommand {
381 short_code: short_code.clone(),
382 phase: None, document_type: Some("initiative".to_string()),
384 };
385 cmd.execute().await.unwrap();
386
387 let cmd = TransitionCommand {
389 short_code: short_code.clone(),
390 phase: None, document_type: Some("initiative".to_string()),
392 };
393 cmd.execute().await.unwrap();
394
395 if let Some(original) = original_dir {
397 let _ = std::env::set_current_dir(&original);
398 }
399 }
400
401 #[tokio::test]
402 async fn test_task_full_transition_sequence() {
403 let temp_dir = tempdir().unwrap();
404 let original_dir = std::env::current_dir().ok();
405
406 std::env::set_current_dir(temp_dir.path()).unwrap();
408
409 let init_cmd = InitCommand {
411 name: Some("Test Project".to_string()),
412 preset: None,
413 strategies: None,
414 initiatives: None,
415 prefix: None,
416 };
417 init_cmd.execute().await.unwrap();
418
419 let create_strategy_cmd = crate::commands::CreateCommand {
421 document_type: crate::commands::create::CreateCommands::Strategy {
422 title: "Parent Strategy".to_string(),
423 vision: None,
424 },
425 };
426 create_strategy_cmd.execute().await.unwrap();
427
428 let create_initiative_cmd = crate::commands::CreateCommand {
430 document_type: crate::commands::create::CreateCommands::Initiative {
431 title: "Parent Initiative".to_string(),
432 strategy: "TEST-S-0001".to_string(),
433 },
434 };
435 create_initiative_cmd.execute().await.unwrap();
436
437 let create_task_cmd = crate::commands::CreateCommand {
439 document_type: crate::commands::create::CreateCommands::Task {
440 title: "Test Task".to_string(),
441 initiative: "TEST-I-0001".to_string(),
442 },
443 };
444 create_task_cmd.execute().await.unwrap();
445
446 let task_path = temp_dir
448 .path()
449 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/tasks/TEST-T-0001.md");
450 let task = Task::from_file(&task_path).await.unwrap();
451 let short_code = task.metadata().short_code.clone();
452
453 let cmd = TransitionCommand {
455 short_code: short_code.clone(),
456 phase: Some("active".to_string()),
457 document_type: Some("task".to_string()),
458 };
459 cmd.execute().await.unwrap();
460
461 let cmd = TransitionCommand {
463 short_code: short_code.clone(),
464 phase: Some("completed".to_string()),
465 document_type: Some("task".to_string()),
466 };
467 cmd.execute().await.unwrap();
468
469 let create_task_cmd2 = crate::commands::CreateCommand {
472 document_type: crate::commands::create::CreateCommands::Task {
473 title: "Blocked Task".to_string(),
474 initiative: "TEST-I-0001".to_string(),
475 },
476 };
477 create_task_cmd2.execute().await.unwrap();
478
479 let blocked_doc_id = "TEST-T-0002";
480
481 let cmd = TransitionCommand {
483 short_code: blocked_doc_id.to_string(),
484 phase: Some("blocked".to_string()),
485 document_type: Some("task".to_string()),
486 };
487 cmd.execute().await.unwrap();
488
489 let cmd = TransitionCommand {
491 short_code: blocked_doc_id.to_string(),
492 phase: Some("active".to_string()),
493 document_type: Some("task".to_string()),
494 };
495 cmd.execute().await.unwrap();
496
497 if let Some(original) = original_dir {
499 let _ = std::env::set_current_dir(&original);
500 }
501 }
502
503 #[tokio::test]
504 async fn test_adr_full_transition_sequence() {
505 let temp_dir = tempdir().unwrap();
506 let original_dir = std::env::current_dir().ok();
507
508 std::env::set_current_dir(temp_dir.path()).unwrap();
510
511 let init_cmd = InitCommand {
513 name: Some("Test Project".to_string()),
514 preset: None,
515 strategies: None,
516 initiatives: None,
517 prefix: None,
518 };
519 init_cmd.execute().await.unwrap();
520
521 let create_adr_cmd = crate::commands::CreateCommand {
523 document_type: crate::commands::create::CreateCommands::Adr {
524 title: "Test ADR".to_string(),
525 },
526 };
527 create_adr_cmd.execute().await.unwrap();
528
529 let adr_path = temp_dir.path().join(".metis/adrs/TEST-A-0001.md");
531 let adr = Adr::from_file(&adr_path).await.unwrap();
532 let short_code = adr.metadata().short_code.clone();
533
534 let cmd = TransitionCommand {
536 short_code: short_code.clone(),
537 phase: None, document_type: Some("adr".to_string()),
539 };
540 cmd.execute().await.unwrap();
541
542 let cmd = TransitionCommand {
544 short_code: short_code.clone(),
545 phase: None, document_type: Some("adr".to_string()),
547 };
548 cmd.execute().await.unwrap();
549
550 let cmd = TransitionCommand {
552 short_code: short_code.clone(),
553 phase: Some("superseded".to_string()),
554 document_type: Some("adr".to_string()),
555 };
556 cmd.execute().await.unwrap(); let cmd = TransitionCommand {
560 short_code: short_code.clone(),
561 phase: None, document_type: Some("adr".to_string()),
563 };
564 let result = cmd.execute().await;
565 assert!(result.is_err()); if let Some(original) = original_dir {
569 let _ = std::env::set_current_dir(&original);
570 }
571 }
572
573 #[tokio::test]
574 async fn test_invalid_transitions() {
575 let temp_dir = tempdir().unwrap();
576 let original_dir = std::env::current_dir().ok();
577
578 std::env::set_current_dir(temp_dir.path()).unwrap();
580
581 let init_cmd = InitCommand {
583 name: Some("Test Project".to_string()),
584 preset: None,
585 strategies: None,
586 initiatives: None,
587 prefix: None,
588 };
589 init_cmd.execute().await.unwrap();
590
591 let vision_path = temp_dir.path().join(".metis").join("vision.md");
593 let vision = Vision::from_file(&vision_path).await.unwrap();
594 let short_code = vision.metadata().short_code.clone();
595
596 let cmd = TransitionCommand {
598 short_code: short_code.clone(),
599 phase: Some("published".to_string()),
600 document_type: Some("vision".to_string()),
601 };
602 let result = cmd.execute().await;
603 assert!(result.is_err());
604 assert!(result
605 .unwrap_err()
606 .to_string()
607 .contains("Invalid phase transition"));
608
609 let cmd = TransitionCommand {
611 short_code: short_code.clone(),
612 phase: Some("invalid-phase".to_string()),
613 document_type: Some("vision".to_string()),
614 };
615 let result = cmd.execute().await;
616 assert!(result.is_err());
617 assert!(result.unwrap_err().to_string().contains("Unknown phase"));
618
619 if let Some(original) = original_dir {
621 let _ = std::env::set_current_dir(&original);
622 }
623 }
624
625 #[tokio::test]
626 async fn test_auto_transitions() {
627 let temp_dir = tempdir().unwrap();
628 let original_dir = std::env::current_dir().ok();
629
630 std::env::set_current_dir(temp_dir.path()).unwrap();
632
633 let init_cmd = InitCommand {
635 name: Some("Test Project".to_string()),
636 preset: None,
637 strategies: None,
638 initiatives: None,
639 prefix: None,
640 };
641 init_cmd.execute().await.unwrap();
642
643 let vision_path = temp_dir.path().join(".metis").join("vision.md");
644
645 let vision = Vision::from_file(&vision_path).await.unwrap();
647 let short_code = vision.metadata().short_code.clone();
648
649 let cmd = TransitionCommand {
651 short_code: short_code.clone(),
652 phase: None, document_type: Some("vision".to_string()),
654 };
655 cmd.execute().await.unwrap();
656
657 let vision = Vision::from_file(&vision_path).await.unwrap();
658 assert_eq!(vision.phase().unwrap(), Phase::Review);
659
660 let cmd = TransitionCommand {
662 short_code: short_code.clone(),
663 phase: None, document_type: Some("vision".to_string()),
665 };
666 cmd.execute().await.unwrap();
667
668 let vision = Vision::from_file(&vision_path).await.unwrap();
669 assert_eq!(vision.phase().unwrap(), Phase::Published);
670
671 if let Some(original) = original_dir {
673 let _ = std::env::set_current_dir(&original);
674 }
675 }
676}