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
18impl TransitionCommand {
19 pub async fn execute(&self) -> Result<()> {
20 let (workspace_exists, metis_dir) = workspace::has_metis_vault();
22 if !workspace_exists {
23 anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
24 }
25 let metis_dir = metis_dir.unwrap();
26
27 let transition_service = PhaseTransitionService::new(&metis_dir);
29
30 let result = if let Some(phase_str) = &self.phase {
32 let target_phase = self.parse_phase(phase_str)?;
33 transition_service
34 .transition_document(&self.short_code, target_phase)
35 .await?
36 } else {
37 transition_service
38 .transition_to_next_phase(&self.short_code)
39 .await?
40 };
41
42 println!(
44 "[+] Transitioned {} '{}' from {} to {}",
45 result.document_type, result.document_id, result.from_phase, result.to_phase
46 );
47
48 let db_path = metis_dir.join("metis.db");
50 let database = Database::new(db_path.to_str().unwrap())
51 .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
52 let app = Application::new(database);
53 app.sync_directory(&metis_dir)
54 .await
55 .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
56
57 Ok(())
58 }
59
60 fn parse_phase(&self, phase_str: &str) -> Result<Phase> {
61 match phase_str.to_lowercase().as_str() {
62 "draft" => Ok(Phase::Draft),
63 "review" => Ok(Phase::Review),
64 "published" => Ok(Phase::Published),
65 "discussion" => Ok(Phase::Discussion),
66 "decided" => Ok(Phase::Decided),
67 "superseded" => Ok(Phase::Superseded),
68 "backlog" => Ok(Phase::Backlog),
69 "todo" => Ok(Phase::Todo),
70 "active" => Ok(Phase::Active),
71 "blocked" => Ok(Phase::Blocked),
72 "completed" => Ok(Phase::Completed),
73 "shaping" => Ok(Phase::Shaping),
74 "design" => Ok(Phase::Design),
75 "ready" => Ok(Phase::Ready),
76 "decompose" => Ok(Phase::Decompose),
77 "discovery" => Ok(Phase::Discovery),
78 _ => anyhow::bail!("Unknown phase: {}", phase_str),
79 }
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::commands::InitCommand;
87 use metis_core::{Adr, Document, Initiative, Strategy, Task, Vision};
88 use tempfile::tempdir;
89
90 #[test]
91 fn test_parse_phase() {
92 let cmd = TransitionCommand {
93 short_code: "test".to_string(),
94 phase: Some("active".to_string()),
95 };
96
97 assert_eq!(cmd.parse_phase("draft").unwrap(), Phase::Draft);
98 assert_eq!(cmd.parse_phase("ACTIVE").unwrap(), Phase::Active);
99 assert_eq!(cmd.parse_phase("completed").unwrap(), Phase::Completed);
100 assert!(cmd.parse_phase("invalid").is_err());
101 }
102
103 #[tokio::test]
104 async fn test_transition_command_no_workspace() {
105 let temp_dir = tempdir().unwrap();
106 let original_dir = std::env::current_dir().ok();
107
108 if std::env::set_current_dir(temp_dir.path()).is_err() {
110 return; }
112
113 let cmd = TransitionCommand {
114 short_code: "test-doc".to_string(),
115 phase: Some("active".to_string()),
116 };
117
118 let result = cmd.execute().await;
119
120 if let Some(original) = original_dir {
122 let _ = std::env::set_current_dir(&original);
123 }
124
125 assert!(result.is_err());
126 assert!(result
127 .unwrap_err()
128 .to_string()
129 .contains("Not in a Metis workspace"));
130 }
131
132 #[tokio::test]
133 async fn test_find_document_not_found() {
134 let temp_dir = tempdir().unwrap();
135 let original_dir = std::env::current_dir().ok();
136
137 std::env::set_current_dir(temp_dir.path()).unwrap();
139
140 let init_cmd = InitCommand {
142 name: Some("Test Project".to_string()),
143 preset: None,
144 strategies: None,
145 initiatives: None,
146 prefix: None,
147 };
148 init_cmd.execute().await.unwrap();
149
150 let cmd = TransitionCommand {
151 short_code: "TEST-T-9999".to_string(),
152 phase: Some("active".to_string()),
153 };
154
155 let result = cmd.execute().await;
156
157 if let Some(original) = original_dir {
159 let _ = std::env::set_current_dir(&original);
160 }
161
162 assert!(result.is_err());
163 assert!(result.unwrap_err().to_string().contains("not found"));
164 }
165
166 #[tokio::test]
167 async fn test_vision_full_transition_sequence() {
168 let temp_dir = tempdir().unwrap();
169 let original_dir = std::env::current_dir().ok();
170
171 std::env::set_current_dir(temp_dir.path()).unwrap();
173
174 let init_cmd = InitCommand {
176 name: Some("Test Project".to_string()),
177 preset: None,
178 strategies: None,
179 initiatives: None,
180 prefix: None,
181 };
182 init_cmd.execute().await.unwrap();
183
184 let vision_path = temp_dir.path().join(".metis").join("vision.md");
185
186 let vision = Vision::from_file(&vision_path).await.unwrap();
188 assert_eq!(vision.phase().unwrap(), Phase::Draft);
189 let short_code = vision.metadata().short_code.clone();
190
191 let cmd = TransitionCommand {
193 short_code: short_code.clone(),
194 phase: None, };
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, };
206 cmd.execute().await.unwrap();
207
208 let vision = Vision::from_file(&vision_path).await.unwrap();
209 assert_eq!(vision.phase().unwrap(), Phase::Published);
210
211 let cmd = TransitionCommand {
213 short_code: short_code.clone(),
214 phase: None, };
216 let result = cmd.execute().await;
217 assert!(result.is_err()); if let Some(original) = original_dir {
221 let _ = std::env::set_current_dir(&original);
222 }
223 }
224
225 #[tokio::test]
226 async fn test_strategy_full_transition_sequence() {
227 let temp_dir = tempdir().unwrap();
228 let original_dir = std::env::current_dir().ok();
229
230 std::env::set_current_dir(temp_dir.path()).unwrap();
232
233 let init_cmd = InitCommand {
235 name: Some("Test Project".to_string()),
236 preset: None,
237 strategies: None,
238 initiatives: None,
239 prefix: None,
240 };
241 init_cmd.execute().await.unwrap();
242
243 let create_cmd = crate::commands::CreateCommand {
245 document_type: crate::commands::create::CreateCommands::Strategy {
246 title: "Test Strategy".to_string(),
247 vision: None,
248 },
249 };
250 create_cmd.execute().await.unwrap();
251
252 let strategies_dir = temp_dir.path().join(".metis").join("strategies");
254 let strategy_dir = std::fs::read_dir(&strategies_dir)
255 .unwrap()
256 .find(|entry| entry.as_ref().unwrap().path().is_dir())
257 .unwrap()
258 .unwrap()
259 .path();
260 let strategy_path = strategy_dir.join("strategy.md");
261
262 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
264 assert_eq!(strategy.phase().unwrap(), Phase::Shaping);
265 let short_code = strategy.metadata().short_code.clone();
266
267 let cmd = TransitionCommand {
269 short_code: short_code.clone(),
270 phase: None, };
272 cmd.execute().await.unwrap();
273
274 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
275 assert_eq!(strategy.phase().unwrap(), Phase::Design);
276
277 let cmd = TransitionCommand {
279 short_code: short_code.clone(),
280 phase: None, };
282 cmd.execute().await.unwrap();
283
284 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
285 assert_eq!(strategy.phase().unwrap(), Phase::Ready);
286
287 let cmd = TransitionCommand {
289 short_code: short_code.clone(),
290 phase: None, };
292 cmd.execute().await.unwrap();
293
294 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
295 assert_eq!(strategy.phase().unwrap(), Phase::Active);
296
297 let cmd = TransitionCommand {
299 short_code: short_code.clone(),
300 phase: None, };
302 cmd.execute().await.unwrap();
303
304 let strategy = Strategy::from_file(&strategy_path).await.unwrap();
305 assert_eq!(strategy.phase().unwrap(), Phase::Completed);
306
307 if let Some(original) = original_dir {
309 let _ = std::env::set_current_dir(&original);
310 }
311 }
312
313 #[tokio::test]
314 async fn test_initiative_full_transition_sequence() {
315 let temp_dir = tempdir().unwrap();
316 let original_dir = std::env::current_dir().ok();
317
318 std::env::set_current_dir(temp_dir.path()).unwrap();
320
321 let init_cmd = InitCommand {
323 name: Some("Test Project".to_string()),
324 preset: None,
325 strategies: None,
326 initiatives: None,
327 prefix: None,
328 };
329 init_cmd.execute().await.unwrap();
330
331 let create_strategy_cmd = crate::commands::CreateCommand {
333 document_type: crate::commands::create::CreateCommands::Strategy {
334 title: "Parent Strategy".to_string(),
335 vision: None,
336 },
337 };
338 create_strategy_cmd.execute().await.unwrap();
339
340 let create_initiative_cmd = crate::commands::CreateCommand {
342 document_type: crate::commands::create::CreateCommands::Initiative {
343 title: "Test Initiative".to_string(),
344 strategy: "TEST-S-0001".to_string(),
345 },
346 };
347 create_initiative_cmd.execute().await.unwrap();
348
349 let initiative_path = temp_dir
351 .path()
352 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/initiative.md");
353 let initiative = Initiative::from_file(&initiative_path).await.unwrap();
354 println!("Initiative starts with phase: {:?}", initiative.phase());
355 let short_code = initiative.metadata().short_code.clone();
356
357 let cmd = TransitionCommand {
359 short_code: short_code.clone(),
360 phase: None, };
362 cmd.execute().await.unwrap();
363
364 let cmd = TransitionCommand {
366 short_code: short_code.clone(),
367 phase: None, };
369 cmd.execute().await.unwrap();
370
371 let cmd = TransitionCommand {
373 short_code: short_code.clone(),
374 phase: None, };
376 cmd.execute().await.unwrap();
377
378 let cmd = TransitionCommand {
380 short_code: short_code.clone(),
381 phase: None, };
383 cmd.execute().await.unwrap();
384
385 if let Some(original) = original_dir {
387 let _ = std::env::set_current_dir(&original);
388 }
389 }
390
391 #[tokio::test]
392 async fn test_task_full_transition_sequence() {
393 let temp_dir = tempdir().unwrap();
394 let original_dir = std::env::current_dir().ok();
395
396 std::env::set_current_dir(temp_dir.path()).unwrap();
398
399 let init_cmd = InitCommand {
401 name: Some("Test Project".to_string()),
402 preset: None,
403 strategies: None,
404 initiatives: None,
405 prefix: None,
406 };
407 init_cmd.execute().await.unwrap();
408
409 let create_strategy_cmd = crate::commands::CreateCommand {
411 document_type: crate::commands::create::CreateCommands::Strategy {
412 title: "Parent Strategy".to_string(),
413 vision: None,
414 },
415 };
416 create_strategy_cmd.execute().await.unwrap();
417
418 let create_initiative_cmd = crate::commands::CreateCommand {
420 document_type: crate::commands::create::CreateCommands::Initiative {
421 title: "Parent Initiative".to_string(),
422 strategy: "TEST-S-0001".to_string(),
423 },
424 };
425 create_initiative_cmd.execute().await.unwrap();
426
427 let create_task_cmd = crate::commands::CreateCommand {
429 document_type: crate::commands::create::CreateCommands::Task {
430 title: "Test Task".to_string(),
431 initiative: "TEST-I-0001".to_string(),
432 },
433 };
434 create_task_cmd.execute().await.unwrap();
435
436 let task_path = temp_dir
438 .path()
439 .join(".metis/strategies/TEST-S-0001/initiatives/TEST-I-0001/tasks/TEST-T-0001.md");
440 let task = Task::from_file(&task_path).await.unwrap();
441 let short_code = task.metadata().short_code.clone();
442
443 let cmd = TransitionCommand {
445 short_code: short_code.clone(),
446 phase: Some("active".to_string()),
447 };
448 cmd.execute().await.unwrap();
449
450 let cmd = TransitionCommand {
452 short_code: short_code.clone(),
453 phase: Some("completed".to_string()),
454 };
455 cmd.execute().await.unwrap();
456
457 let create_task_cmd2 = crate::commands::CreateCommand {
460 document_type: crate::commands::create::CreateCommands::Task {
461 title: "Blocked Task".to_string(),
462 initiative: "TEST-I-0001".to_string(),
463 },
464 };
465 create_task_cmd2.execute().await.unwrap();
466
467 let blocked_doc_id = "TEST-T-0002";
468
469 let cmd = TransitionCommand {
471 short_code: blocked_doc_id.to_string(),
472 phase: Some("blocked".to_string()),
473 };
474 cmd.execute().await.unwrap();
475
476 let cmd = TransitionCommand {
478 short_code: blocked_doc_id.to_string(),
479 phase: Some("active".to_string()),
480 };
481 cmd.execute().await.unwrap();
482
483 if let Some(original) = original_dir {
485 let _ = std::env::set_current_dir(&original);
486 }
487 }
488
489 #[tokio::test]
490 async fn test_adr_full_transition_sequence() {
491 let temp_dir = tempdir().unwrap();
492 let original_dir = std::env::current_dir().ok();
493
494 std::env::set_current_dir(temp_dir.path()).unwrap();
496
497 let init_cmd = InitCommand {
499 name: Some("Test Project".to_string()),
500 preset: None,
501 strategies: None,
502 initiatives: None,
503 prefix: None,
504 };
505 init_cmd.execute().await.unwrap();
506
507 let create_adr_cmd = crate::commands::CreateCommand {
509 document_type: crate::commands::create::CreateCommands::Adr {
510 title: "Test ADR".to_string(),
511 },
512 };
513 create_adr_cmd.execute().await.unwrap();
514
515 let adr_path = temp_dir.path().join(".metis/adrs/TEST-A-0001.md");
517 let adr = Adr::from_file(&adr_path).await.unwrap();
518 let short_code = adr.metadata().short_code.clone();
519
520 let cmd = TransitionCommand {
522 short_code: short_code.clone(),
523 phase: None, };
525 cmd.execute().await.unwrap();
526
527 let cmd = TransitionCommand {
529 short_code: short_code.clone(),
530 phase: None, };
532 cmd.execute().await.unwrap();
533
534 let cmd = TransitionCommand {
536 short_code: short_code.clone(),
537 phase: Some("superseded".to_string()),
538 };
539 cmd.execute().await.unwrap(); let cmd = TransitionCommand {
543 short_code: short_code.clone(),
544 phase: None, };
546 let result = cmd.execute().await;
547 assert!(result.is_err()); if let Some(original) = original_dir {
551 let _ = std::env::set_current_dir(&original);
552 }
553 }
554
555 #[tokio::test]
556 async fn test_invalid_transitions() {
557 let temp_dir = tempdir().unwrap();
558 let original_dir = std::env::current_dir().ok();
559
560 std::env::set_current_dir(temp_dir.path()).unwrap();
562
563 let init_cmd = InitCommand {
565 name: Some("Test Project".to_string()),
566 preset: None,
567 strategies: None,
568 initiatives: None,
569 prefix: None,
570 };
571 init_cmd.execute().await.unwrap();
572
573 let vision_path = temp_dir.path().join(".metis").join("vision.md");
575 let vision = Vision::from_file(&vision_path).await.unwrap();
576 let short_code = vision.metadata().short_code.clone();
577
578 let cmd = TransitionCommand {
580 short_code: short_code.clone(),
581 phase: Some("published".to_string()),
582 };
583 let result = cmd.execute().await;
584 assert!(result.is_err());
585 assert!(result
586 .unwrap_err()
587 .to_string()
588 .contains("Invalid phase transition"));
589
590 let cmd = TransitionCommand {
592 short_code: short_code.clone(),
593 phase: Some("invalid-phase".to_string()),
594 };
595 let result = cmd.execute().await;
596 assert!(result.is_err());
597 assert!(result.unwrap_err().to_string().contains("Unknown phase"));
598
599 if let Some(original) = original_dir {
601 let _ = std::env::set_current_dir(&original);
602 }
603 }
604
605 #[tokio::test]
606 async fn test_auto_transitions() {
607 let temp_dir = tempdir().unwrap();
608 let original_dir = std::env::current_dir().ok();
609
610 std::env::set_current_dir(temp_dir.path()).unwrap();
612
613 let init_cmd = InitCommand {
615 name: Some("Test Project".to_string()),
616 preset: None,
617 strategies: None,
618 initiatives: None,
619 prefix: None,
620 };
621 init_cmd.execute().await.unwrap();
622
623 let vision_path = temp_dir.path().join(".metis").join("vision.md");
624
625 let vision = Vision::from_file(&vision_path).await.unwrap();
627 let short_code = vision.metadata().short_code.clone();
628
629 let cmd = TransitionCommand {
631 short_code: short_code.clone(),
632 phase: None, };
634 cmd.execute().await.unwrap();
635
636 let vision = Vision::from_file(&vision_path).await.unwrap();
637 assert_eq!(vision.phase().unwrap(), Phase::Review);
638
639 let cmd = TransitionCommand {
641 short_code: short_code.clone(),
642 phase: None, };
644 cmd.execute().await.unwrap();
645
646 let vision = Vision::from_file(&vision_path).await.unwrap();
647 assert_eq!(vision.phase().unwrap(), Phase::Published);
648
649 if let Some(original) = original_dir {
651 let _ = std::env::set_current_dir(&original);
652 }
653 }
654}