metis_docs_cli/commands/
transition.rs

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    /// Document short code to transition (e.g., PROJ-V-0001)
11    pub short_code: String,
12
13    /// Target phase to transition to (optional - if not provided, transitions to next phase)
14    pub phase: Option<String>,
15
16    /// Document type (vision, strategy, initiative, task, adr)
17    #[arg(short = 't', long)]
18    pub document_type: Option<String>,
19}
20
21impl TransitionCommand {
22    pub async fn execute(&self) -> Result<()> {
23        // 1. Validate we're in a metis workspace
24        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        // 2. Create the phase transition service
31        let transition_service = PhaseTransitionService::new(&metis_dir);
32
33        // 3. Perform the transition
34        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        // 4. Report success
46        println!(
47            "✓ Transitioned {} '{}' from {} to {}",
48            result.document_type, result.document_id, result.from_phase, result.to_phase
49        );
50
51        // 5. TODO: Auto-sync workspace after transition
52
53        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        // Change to temp directory without workspace
106        if std::env::set_current_dir(temp_dir.path()).is_err() {
107            return; // Skip test if we can't change directory
108        }
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        // Always restore original directory first
119        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        // Change to temp directory
136        std::env::set_current_dir(temp_dir.path()).unwrap();
137
138        // Create workspace
139        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        // Always restore original directory first
157        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        // Change to temp directory
171        std::env::set_current_dir(temp_dir.path()).unwrap();
172
173        // Create workspace
174        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        // Verify initial state (Draft)
186        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        // 1. Auto-transition: Draft → Review
191        let cmd = TransitionCommand {
192            short_code: short_code.clone(),
193            phase: None, // Auto transition
194            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        // 2. Auto-transition: Review → Published
202        let cmd = TransitionCommand {
203            short_code: short_code.clone(),
204            phase: None, // Auto transition
205            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        // 3. Test auto-transition (should fail at Published - final phase)
213        let cmd = TransitionCommand {
214            short_code: short_code.clone(),
215            phase: None, // Auto transition
216            document_type: Some("vision".to_string()),
217        };
218        let result = cmd.execute().await;
219        assert!(result.is_err()); // Should fail as Published is final
220
221        // Always restore original directory
222        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        // Change to temp directory
233        std::env::set_current_dir(temp_dir.path()).unwrap();
234
235        // Create workspace
236        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        // Create strategy using create command
246        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        // Find strategy file path
255        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        // Verify initial state (Shaping)
265        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        // 1. Auto-transition: Shaping → Design
270        let cmd = TransitionCommand {
271            short_code: short_code.clone(),
272            phase: None, // Auto transition
273            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        // 2. Auto-transition: Design → Ready
281        let cmd = TransitionCommand {
282            short_code: short_code.clone(),
283            phase: None, // Auto transition
284            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        // 3. Auto-transition: Ready → Active
292        let cmd = TransitionCommand {
293            short_code: short_code.clone(),
294            phase: None, // Auto transition
295            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        // 4. Auto-transition: Active → Completed
303        let cmd = TransitionCommand {
304            short_code: short_code.clone(),
305            phase: None, // Auto transition
306            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        // Always restore original directory
314        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        // Change to temp directory
325        std::env::set_current_dir(temp_dir.path()).unwrap();
326
327        // Create workspace
328        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        // Create strategy first
338        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        // Create initiative
347        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        // Check what phase the initiative actually starts with
356        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        // 1. Auto-transition: Discovery → Shaping
364        let cmd = TransitionCommand {
365            short_code: short_code.clone(),
366            phase: None, // Auto transition
367            document_type: Some("initiative".to_string()),
368        };
369        cmd.execute().await.unwrap();
370
371        // 2. Auto-transition: Shaping → Decompose
372        let cmd = TransitionCommand {
373            short_code: short_code.clone(),
374            phase: None, // Auto transition
375            document_type: Some("initiative".to_string()),
376        };
377        cmd.execute().await.unwrap();
378
379        // 3. Auto-transition: Decompose → Active
380        let cmd = TransitionCommand {
381            short_code: short_code.clone(),
382            phase: None, // Auto transition
383            document_type: Some("initiative".to_string()),
384        };
385        cmd.execute().await.unwrap();
386
387        // 4. Auto-transition: Active → Completed
388        let cmd = TransitionCommand {
389            short_code: short_code.clone(),
390            phase: None, // Auto transition
391            document_type: Some("initiative".to_string()),
392        };
393        cmd.execute().await.unwrap();
394
395        // Always restore original directory
396        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        // Change to temp directory
407        std::env::set_current_dir(temp_dir.path()).unwrap();
408
409        // Create workspace
410        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        // Create strategy
420        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        // Create initiative
429        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        // Create task
438        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        // Load the task to get its short code
447        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        // 1. Todo → Active
454        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        // 2. Active → Completed
462        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        // 3. Test blocking workflow: Todo → Blocked → Active
470        // Create another task
471        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        // Todo → Blocked
482        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        // Blocked → Active (this tests the blocked → unblocked workflow)
490        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        // Always restore original directory
498        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        // Change to temp directory
509        std::env::set_current_dir(temp_dir.path()).unwrap();
510
511        // Create workspace
512        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        // Create ADR
522        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        // Load the ADR to get its short code
530        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        // 1. Auto-transition: Draft → Discussion
535        let cmd = TransitionCommand {
536            short_code: short_code.clone(),
537            phase: None, // Auto transition
538            document_type: Some("adr".to_string()),
539        };
540        cmd.execute().await.unwrap();
541
542        // 2. Auto-transition: Discussion → Decided
543        let cmd = TransitionCommand {
544            short_code: short_code.clone(),
545            phase: None, // Auto transition
546            document_type: Some("adr".to_string()),
547        };
548        cmd.execute().await.unwrap();
549
550        // 3. Test transition from decided to superseded (should work)
551        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(); // Should succeed as Decided → Superseded is valid
557
558        // 4. Test that superseded ADRs cannot be transitioned further
559        let cmd = TransitionCommand {
560            short_code: short_code.clone(),
561            phase: None, // Auto transition
562            document_type: Some("adr".to_string()),
563        };
564        let result = cmd.execute().await;
565        assert!(result.is_err()); // Should fail as Superseded has no valid transitions
566
567        // Always restore original directory
568        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        // Change to temp directory
579        std::env::set_current_dir(temp_dir.path()).unwrap();
580
581        // Create workspace
582        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        // Load the vision to get its short code
592        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        // Test invalid vision transition: Draft → Published (must go through Review)
597        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        // Test transition to invalid phase
610        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        // Always restore original directory
620        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        // Change to temp directory
631        std::env::set_current_dir(temp_dir.path()).unwrap();
632
633        // Create workspace
634        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        // Load the vision to get its short code
646        let vision = Vision::from_file(&vision_path).await.unwrap();
647        let short_code = vision.metadata().short_code.clone();
648
649        // Test auto-transition (no phase specified): Draft → Review
650        let cmd = TransitionCommand {
651            short_code: short_code.clone(),
652            phase: None, // Auto transition
653            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        // Test auto-transition: Review → Published
661        let cmd = TransitionCommand {
662            short_code: short_code.clone(),
663            phase: None, // Auto transition
664            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        // Always restore original directory
672        if let Some(original) = original_dir {
673            let _ = std::env::set_current_dir(&original);
674        }
675    }
676}