1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{
5 application::services::workspace::PhaseTransitionService, domain::documents::types::Phase,
6 Application, Database,
7};
8
9#[derive(Args)]
10pub struct TransitionCommand {
11 pub short_code: String,
13
14 pub phase: Option<String>,
16
17 #[arg(short = 't', long)]
19 pub document_type: Option<String>,
20}
21
22impl TransitionCommand {
23 pub async fn execute(&self) -> Result<()> {
24 let (workspace_exists, metis_dir) = workspace::has_metis_vault();
26 if !workspace_exists {
27 anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
28 }
29 let metis_dir = metis_dir.unwrap();
30
31 let transition_service = PhaseTransitionService::new(&metis_dir);
33
34 let result = if let Some(phase_str) = &self.phase {
36 let target_phase = self.parse_phase(phase_str)?;
37 transition_service
38 .transition_document(&self.short_code, target_phase)
39 .await?
40 } else {
41 transition_service
42 .transition_to_next_phase(&self.short_code)
43 .await?
44 };
45
46 println!(
48 "✓ Transitioned {} '{}' from {} to {}",
49 result.document_type, result.document_id, result.from_phase, result.to_phase
50 );
51
52 let db_path = metis_dir.join("metis.db");
54 let database = Database::new(db_path.to_str().unwrap())
55 .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
56 let app = Application::new(database);
57 app.sync_directory(&metis_dir)
58 .await
59 .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
60
61 Ok(())
62 }
63
64 fn parse_phase(&self, phase_str: &str) -> Result<Phase> {
65 match phase_str.to_lowercase().as_str() {
66 "draft" => Ok(Phase::Draft),
67 "review" => Ok(Phase::Review),
68 "published" => Ok(Phase::Published),
69 "discussion" => Ok(Phase::Discussion),
70 "decided" => Ok(Phase::Decided),
71 "superseded" => Ok(Phase::Superseded),
72 "backlog" => Ok(Phase::Backlog),
73 "todo" => Ok(Phase::Todo),
74 "active" => Ok(Phase::Active),
75 "blocked" => Ok(Phase::Blocked),
76 "completed" => Ok(Phase::Completed),
77 "shaping" => Ok(Phase::Shaping),
78 "design" => Ok(Phase::Design),
79 "ready" => Ok(Phase::Ready),
80 "decompose" => Ok(Phase::Decompose),
81 "discovery" => Ok(Phase::Discovery),
82 _ => anyhow::bail!("Unknown phase: {}", phase_str),
83 }
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::commands::InitCommand;
91 use metis_core::{Adr, Document, Initiative, Strategy, Task, Vision};
92 use tempfile::tempdir;
93
94 #[test]
95 fn test_parse_phase() {
96 let cmd = TransitionCommand {
97 short_code: "test".to_string(),
98 phase: Some("active".to_string()),
99 document_type: None,
100 };
101
102 assert_eq!(cmd.parse_phase("draft").unwrap(), Phase::Draft);
103 assert_eq!(cmd.parse_phase("ACTIVE").unwrap(), Phase::Active);
104 assert_eq!(cmd.parse_phase("completed").unwrap(), Phase::Completed);
105 assert!(cmd.parse_phase("invalid").is_err());
106 }
107
108 #[tokio::test]
109 async fn test_transition_command_no_workspace() {
110 let temp_dir = tempdir().unwrap();
111 let original_dir = std::env::current_dir().ok();
112
113 if std::env::set_current_dir(temp_dir.path()).is_err() {
115 return; }
117
118 let cmd = TransitionCommand {
119 short_code: "test-doc".to_string(),
120 phase: Some("active".to_string()),
121 document_type: None,
122 };
123
124 let result = cmd.execute().await;
125
126 if let Some(original) = original_dir {
128 let _ = std::env::set_current_dir(&original);
129 }
130
131 assert!(result.is_err());
132 assert!(result
133 .unwrap_err()
134 .to_string()
135 .contains("Not in a Metis workspace"));
136 }
137
138 #[tokio::test]
139 async fn test_find_document_not_found() {
140 let temp_dir = tempdir().unwrap();
141 let original_dir = std::env::current_dir().ok();
142
143 std::env::set_current_dir(temp_dir.path()).unwrap();
145
146 let init_cmd = InitCommand {
148 name: Some("Test Project".to_string()),
149 preset: None,
150 strategies: None,
151 initiatives: None,
152 prefix: None,
153 };
154 init_cmd.execute().await.unwrap();
155
156 let cmd = TransitionCommand {
157 short_code: "TEST-T-9999".to_string(),
158 phase: Some("active".to_string()),
159 document_type: None,
160 };
161
162 let result = cmd.execute().await;
163
164 if let Some(original) = original_dir {
166 let _ = std::env::set_current_dir(&original);
167 }
168
169 assert!(result.is_err());
170 assert!(result.unwrap_err().to_string().contains("not found"));
171 }
172
173 #[tokio::test]
174 async fn test_vision_full_transition_sequence() {
175 let temp_dir = tempdir().unwrap();
176 let original_dir = std::env::current_dir().ok();
177
178 std::env::set_current_dir(temp_dir.path()).unwrap();
180
181 let init_cmd = InitCommand {
183 name: Some("Test Project".to_string()),
184 preset: None,
185 strategies: None,
186 initiatives: None,
187 prefix: None,
188 };
189 init_cmd.execute().await.unwrap();
190
191 let vision_path = temp_dir.path().join(".metis").join("vision.md");
192
193 let vision = Vision::from_file(&vision_path).await.unwrap();
195 assert_eq!(vision.phase().unwrap(), Phase::Draft);
196 let short_code = vision.metadata().short_code.clone();
197
198 let cmd = TransitionCommand {
200 short_code: short_code.clone(),
201 phase: None, document_type: Some("vision".to_string()),
203 };
204 cmd.execute().await.unwrap();
205
206 let vision = Vision::from_file(&vision_path).await.unwrap();
207 assert_eq!(vision.phase().unwrap(), Phase::Review);
208
209 let cmd = TransitionCommand {
211 short_code: short_code.clone(),
212 phase: None, document_type: Some("vision".to_string()),
214 };
215 cmd.execute().await.unwrap();
216
217 let vision = Vision::from_file(&vision_path).await.unwrap();
218 assert_eq!(vision.phase().unwrap(), Phase::Published);
219
220 let cmd = TransitionCommand {
222 short_code: short_code.clone(),
223 phase: None, document_type: Some("vision".to_string()),
225 };
226 let result = cmd.execute().await;
227 assert!(result.is_err()); if let Some(original) = original_dir {
231 let _ = std::env::set_current_dir(&original);
232 }
233 }
234
235 #[tokio::test]
236 async fn test_strategy_full_transition_sequence() {
237 let temp_dir = tempdir().unwrap();
238 let original_dir = std::env::current_dir().ok();
239
240 std::env::set_current_dir(temp_dir.path()).unwrap();
242
243 let init_cmd = InitCommand {
245 name: Some("Test Project".to_string()),
246 preset: None,
247 strategies: None,
248 initiatives: None,
249 prefix: None,
250 };
251 init_cmd.execute().await.unwrap();
252
253 let create_cmd = crate::commands::CreateCommand {
255 document_type: crate::commands::create::CreateCommands::Strategy {
256 title: "Test Strategy".to_string(),
257 vision: None,
258 },
259 };
260 create_cmd.execute().await.unwrap();
261
262 let strategies_dir = temp_dir.path().join(".metis").join("strategies");
264 let strategy_dir = std::fs::read_dir(&strategies_dir)
265 .unwrap()
266 .find(|entry| entry.as_ref().unwrap().path().is_dir())
267 .unwrap()
268 .unwrap()
269 .path();
270 let strategy_path = strategy_dir.join("strategy.md");
271
272 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
274 assert_eq!(strategy.phase().unwrap(), Phase::Shaping);
275 let short_code = strategy.metadata().short_code.clone();
276
277 let cmd = TransitionCommand {
279 short_code: short_code.clone(),
280 phase: None, document_type: Some("strategy".to_string()),
282 };
283 cmd.execute().await.unwrap();
284
285 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
286 assert_eq!(strategy.phase().unwrap(), Phase::Design);
287
288 let cmd = TransitionCommand {
290 short_code: short_code.clone(),
291 phase: None, document_type: Some("strategy".to_string()),
293 };
294 cmd.execute().await.unwrap();
295
296 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
297 assert_eq!(strategy.phase().unwrap(), Phase::Ready);
298
299 let cmd = TransitionCommand {
301 short_code: short_code.clone(),
302 phase: None, document_type: Some("strategy".to_string()),
304 };
305 cmd.execute().await.unwrap();
306
307 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
308 assert_eq!(strategy.phase().unwrap(), Phase::Active);
309
310 let cmd = TransitionCommand {
312 short_code: short_code.clone(),
313 phase: None, document_type: Some("strategy".to_string()),
315 };
316 cmd.execute().await.unwrap();
317
318 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
319 assert_eq!(strategy.phase().unwrap(), Phase::Completed);
320
321 if let Some(original) = original_dir {
323 let _ = std::env::set_current_dir(&original);
324 }
325 }
326
327 #[tokio::test]
328 async fn test_initiative_full_transition_sequence() {
329 let temp_dir = tempdir().unwrap();
330 let original_dir = std::env::current_dir().ok();
331
332 std::env::set_current_dir(temp_dir.path()).unwrap();
334
335 let init_cmd = InitCommand {
337 name: Some("Test Project".to_string()),
338 preset: None,
339 strategies: None,
340 initiatives: None,
341 prefix: None,
342 };
343 init_cmd.execute().await.unwrap();
344
345 let create_strategy_cmd = crate::commands::CreateCommand {
347 document_type: crate::commands::create::CreateCommands::Strategy {
348 title: "Parent Strategy".to_string(),
349 vision: None,
350 },
351 };
352 create_strategy_cmd.execute().await.unwrap();
353
354 let create_initiative_cmd = crate::commands::CreateCommand {
356 document_type: crate::commands::create::CreateCommands::Initiative {
357 title: "Test Initiative".to_string(),
358 strategy: "TEST-S-0001".to_string(),
359 },
360 };
361 create_initiative_cmd.execute().await.unwrap();
362
363 let initiative_path = temp_dir
365 .path()
366 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/initiative.md");
367 let initiative = Initiative::from_file(&initiative_path).await.unwrap();
368 println!("Initiative starts with phase: {:?}", initiative.phase());
369 let short_code = initiative.metadata().short_code.clone();
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 let cmd = TransitionCommand {
397 short_code: short_code.clone(),
398 phase: None, document_type: Some("initiative".to_string()),
400 };
401 cmd.execute().await.unwrap();
402
403 if let Some(original) = original_dir {
405 let _ = std::env::set_current_dir(&original);
406 }
407 }
408
409 #[tokio::test]
410 async fn test_task_full_transition_sequence() {
411 let temp_dir = tempdir().unwrap();
412 let original_dir = std::env::current_dir().ok();
413
414 std::env::set_current_dir(temp_dir.path()).unwrap();
416
417 let init_cmd = InitCommand {
419 name: Some("Test Project".to_string()),
420 preset: None,
421 strategies: None,
422 initiatives: None,
423 prefix: None,
424 };
425 init_cmd.execute().await.unwrap();
426
427 let create_strategy_cmd = crate::commands::CreateCommand {
429 document_type: crate::commands::create::CreateCommands::Strategy {
430 title: "Parent Strategy".to_string(),
431 vision: None,
432 },
433 };
434 create_strategy_cmd.execute().await.unwrap();
435
436 let create_initiative_cmd = crate::commands::CreateCommand {
438 document_type: crate::commands::create::CreateCommands::Initiative {
439 title: "Parent Initiative".to_string(),
440 strategy: "TEST-S-0001".to_string(),
441 },
442 };
443 create_initiative_cmd.execute().await.unwrap();
444
445 let create_task_cmd = crate::commands::CreateCommand {
447 document_type: crate::commands::create::CreateCommands::Task {
448 title: "Test Task".to_string(),
449 initiative: "TEST-I-0001".to_string(),
450 },
451 };
452 create_task_cmd.execute().await.unwrap();
453
454 let task_path = temp_dir
456 .path()
457 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/tasks/TEST-T-0001.md");
458 let task = Task::from_file(&task_path).await.unwrap();
459 let short_code = task.metadata().short_code.clone();
460
461 let cmd = TransitionCommand {
463 short_code: short_code.clone(),
464 phase: Some("active".to_string()),
465 document_type: Some("task".to_string()),
466 };
467 cmd.execute().await.unwrap();
468
469 let cmd = TransitionCommand {
471 short_code: short_code.clone(),
472 phase: Some("completed".to_string()),
473 document_type: Some("task".to_string()),
474 };
475 cmd.execute().await.unwrap();
476
477 let create_task_cmd2 = crate::commands::CreateCommand {
480 document_type: crate::commands::create::CreateCommands::Task {
481 title: "Blocked Task".to_string(),
482 initiative: "TEST-I-0001".to_string(),
483 },
484 };
485 create_task_cmd2.execute().await.unwrap();
486
487 let blocked_doc_id = "TEST-T-0002";
488
489 let cmd = TransitionCommand {
491 short_code: blocked_doc_id.to_string(),
492 phase: Some("blocked".to_string()),
493 document_type: Some("task".to_string()),
494 };
495 cmd.execute().await.unwrap();
496
497 let cmd = TransitionCommand {
499 short_code: blocked_doc_id.to_string(),
500 phase: Some("active".to_string()),
501 document_type: Some("task".to_string()),
502 };
503 cmd.execute().await.unwrap();
504
505 if let Some(original) = original_dir {
507 let _ = std::env::set_current_dir(&original);
508 }
509 }
510
511 #[tokio::test]
512 async fn test_adr_full_transition_sequence() {
513 let temp_dir = tempdir().unwrap();
514 let original_dir = std::env::current_dir().ok();
515
516 std::env::set_current_dir(temp_dir.path()).unwrap();
518
519 let init_cmd = InitCommand {
521 name: Some("Test Project".to_string()),
522 preset: None,
523 strategies: None,
524 initiatives: None,
525 prefix: None,
526 };
527 init_cmd.execute().await.unwrap();
528
529 let create_adr_cmd = crate::commands::CreateCommand {
531 document_type: crate::commands::create::CreateCommands::Adr {
532 title: "Test ADR".to_string(),
533 },
534 };
535 create_adr_cmd.execute().await.unwrap();
536
537 let adr_path = temp_dir.path().join(".metis/adrs/TEST-A-0001.md");
539 let adr = Adr::from_file(&adr_path).await.unwrap();
540 let short_code = adr.metadata().short_code.clone();
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: None, document_type: Some("adr".to_string()),
555 };
556 cmd.execute().await.unwrap();
557
558 let cmd = TransitionCommand {
560 short_code: short_code.clone(),
561 phase: Some("superseded".to_string()),
562 document_type: Some("adr".to_string()),
563 };
564 cmd.execute().await.unwrap(); let cmd = TransitionCommand {
568 short_code: short_code.clone(),
569 phase: None, document_type: Some("adr".to_string()),
571 };
572 let result = cmd.execute().await;
573 assert!(result.is_err()); if let Some(original) = original_dir {
577 let _ = std::env::set_current_dir(&original);
578 }
579 }
580
581 #[tokio::test]
582 async fn test_invalid_transitions() {
583 let temp_dir = tempdir().unwrap();
584 let original_dir = std::env::current_dir().ok();
585
586 std::env::set_current_dir(temp_dir.path()).unwrap();
588
589 let init_cmd = InitCommand {
591 name: Some("Test Project".to_string()),
592 preset: None,
593 strategies: None,
594 initiatives: None,
595 prefix: None,
596 };
597 init_cmd.execute().await.unwrap();
598
599 let vision_path = temp_dir.path().join(".metis").join("vision.md");
601 let vision = Vision::from_file(&vision_path).await.unwrap();
602 let short_code = vision.metadata().short_code.clone();
603
604 let cmd = TransitionCommand {
606 short_code: short_code.clone(),
607 phase: Some("published".to_string()),
608 document_type: Some("vision".to_string()),
609 };
610 let result = cmd.execute().await;
611 assert!(result.is_err());
612 assert!(result
613 .unwrap_err()
614 .to_string()
615 .contains("Invalid phase transition"));
616
617 let cmd = TransitionCommand {
619 short_code: short_code.clone(),
620 phase: Some("invalid-phase".to_string()),
621 document_type: Some("vision".to_string()),
622 };
623 let result = cmd.execute().await;
624 assert!(result.is_err());
625 assert!(result.unwrap_err().to_string().contains("Unknown phase"));
626
627 if let Some(original) = original_dir {
629 let _ = std::env::set_current_dir(&original);
630 }
631 }
632
633 #[tokio::test]
634 async fn test_auto_transitions() {
635 let temp_dir = tempdir().unwrap();
636 let original_dir = std::env::current_dir().ok();
637
638 std::env::set_current_dir(temp_dir.path()).unwrap();
640
641 let init_cmd = InitCommand {
643 name: Some("Test Project".to_string()),
644 preset: None,
645 strategies: None,
646 initiatives: None,
647 prefix: None,
648 };
649 init_cmd.execute().await.unwrap();
650
651 let vision_path = temp_dir.path().join(".metis").join("vision.md");
652
653 let vision = Vision::from_file(&vision_path).await.unwrap();
655 let short_code = vision.metadata().short_code.clone();
656
657 let cmd = TransitionCommand {
659 short_code: short_code.clone(),
660 phase: None, document_type: Some("vision".to_string()),
662 };
663 cmd.execute().await.unwrap();
664
665 let vision = Vision::from_file(&vision_path).await.unwrap();
666 assert_eq!(vision.phase().unwrap(), Phase::Review);
667
668 let cmd = TransitionCommand {
670 short_code: short_code.clone(),
671 phase: None, document_type: Some("vision".to_string()),
673 };
674 cmd.execute().await.unwrap();
675
676 let vision = Vision::from_file(&vision_path).await.unwrap();
677 assert_eq!(vision.phase().unwrap(), Phase::Published);
678
679 if let Some(original) = original_dir {
681 let _ = std::env::set_current_dir(&original);
682 }
683 }
684}