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    Application, Database,
7};
8
9#[derive(Args)]
10pub struct TransitionCommand {
11    /// Document short code to transition (e.g., PROJ-V-0001)
12    pub short_code: String,
13
14    /// Target phase to transition to (optional - if not provided, transitions to next phase)
15    pub phase: Option<String>,
16}
17
18impl TransitionCommand {
19    pub async fn execute(&self) -> Result<()> {
20        // 1. Validate we're in a metis workspace
21        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        // 2. Create the phase transition service
28        let transition_service = PhaseTransitionService::new(&metis_dir);
29
30        // 3. Perform the transition
31        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        // 4. Report success
43        println!(
44            "[+] Transitioned {} '{}' from {} to {}",
45            result.document_type, result.document_id, result.from_phase, result.to_phase
46        );
47
48        // 5. Auto-sync workspace after transition
49        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        // Change to temp directory without workspace
109        if std::env::set_current_dir(temp_dir.path()).is_err() {
110            return; // Skip test if we can't change directory
111        }
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        // Always restore original directory first
121        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        // Change to temp directory
138        std::env::set_current_dir(temp_dir.path()).unwrap();
139
140        // Create workspace
141        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        // Always restore original directory first
158        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        // Change to temp directory
172        std::env::set_current_dir(temp_dir.path()).unwrap();
173
174        // Create workspace
175        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        // Verify initial state (Draft)
187        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        // 1. Auto-transition: Draft → Review
192        let cmd = TransitionCommand {
193            short_code: short_code.clone(),
194            phase: None, // Auto transition
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        };
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        // 3. Test auto-transition (should fail at Published - final phase)
212        let cmd = TransitionCommand {
213            short_code: short_code.clone(),
214            phase: None, // Auto transition
215        };
216        let result = cmd.execute().await;
217        assert!(result.is_err()); // Should fail as Published is final
218
219        // Always restore original directory
220        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        // Change to temp directory
231        std::env::set_current_dir(temp_dir.path()).unwrap();
232
233        // Create workspace
234        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        // Create strategy using create command
244        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        // Find strategy file path
253        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        // Verify initial state (Shaping)
263        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        // 1. Auto-transition: Shaping → Design
268        let cmd = TransitionCommand {
269            short_code: short_code.clone(),
270            phase: None, // Auto transition
271        };
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        // 2. Auto-transition: Design → Ready
278        let cmd = TransitionCommand {
279            short_code: short_code.clone(),
280            phase: None, // Auto transition
281        };
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        // 3. Auto-transition: Ready → Active
288        let cmd = TransitionCommand {
289            short_code: short_code.clone(),
290            phase: None, // Auto transition
291        };
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        // 4. Auto-transition: Active → Completed
298        let cmd = TransitionCommand {
299            short_code: short_code.clone(),
300            phase: None, // Auto transition
301        };
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        // Always restore original directory
308        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        // Change to temp directory
319        std::env::set_current_dir(temp_dir.path()).unwrap();
320
321        // Create workspace
322        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        // Create strategy first
332        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        // Create initiative
341        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        // Check what phase the initiative actually starts with
350        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        // 1. Auto-transition: Discovery → Shaping
358        let cmd = TransitionCommand {
359            short_code: short_code.clone(),
360            phase: None, // Auto transition
361        };
362        cmd.execute().await.unwrap();
363
364        // 2. Auto-transition: Shaping → Decompose
365        let cmd = TransitionCommand {
366            short_code: short_code.clone(),
367            phase: None, // Auto transition
368        };
369        cmd.execute().await.unwrap();
370
371        // 3. Auto-transition: Decompose → Active
372        let cmd = TransitionCommand {
373            short_code: short_code.clone(),
374            phase: None, // Auto transition
375        };
376        cmd.execute().await.unwrap();
377
378        // 4. Auto-transition: Active → Completed
379        let cmd = TransitionCommand {
380            short_code: short_code.clone(),
381            phase: None, // Auto transition
382        };
383        cmd.execute().await.unwrap();
384
385        // Always restore original directory
386        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        // Change to temp directory
397        std::env::set_current_dir(temp_dir.path()).unwrap();
398
399        // Create workspace
400        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        // Create strategy
410        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        // Create initiative
419        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        // Create task
428        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        // Load the task to get its short code
437        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        // 1. Todo → Active
444        let cmd = TransitionCommand {
445            short_code: short_code.clone(),
446            phase: Some("active".to_string()),
447        };
448        cmd.execute().await.unwrap();
449
450        // 2. Active → Completed
451        let cmd = TransitionCommand {
452            short_code: short_code.clone(),
453            phase: Some("completed".to_string()),
454        };
455        cmd.execute().await.unwrap();
456
457        // 3. Test blocking workflow: Todo → Blocked → Active
458        // Create another task
459        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        // Todo → Blocked
470        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        // Blocked → Active (this tests the blocked → unblocked workflow)
477        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        // Always restore original directory
484        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        // Change to temp directory
495        std::env::set_current_dir(temp_dir.path()).unwrap();
496
497        // Create workspace
498        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        // Create ADR
508        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        // Load the ADR to get its short code
516        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        // 1. Auto-transition: Draft → Discussion
521        let cmd = TransitionCommand {
522            short_code: short_code.clone(),
523            phase: None, // Auto transition
524        };
525        cmd.execute().await.unwrap();
526
527        // 2. Auto-transition: Discussion → Decided
528        let cmd = TransitionCommand {
529            short_code: short_code.clone(),
530            phase: None, // Auto transition
531        };
532        cmd.execute().await.unwrap();
533
534        // 3. Test transition from decided to superseded (should work)
535        let cmd = TransitionCommand {
536            short_code: short_code.clone(),
537            phase: Some("superseded".to_string()),
538        };
539        cmd.execute().await.unwrap(); // Should succeed as Decided → Superseded is valid
540
541        // 4. Test that superseded ADRs cannot be transitioned further
542        let cmd = TransitionCommand {
543            short_code: short_code.clone(),
544            phase: None, // Auto transition
545        };
546        let result = cmd.execute().await;
547        assert!(result.is_err()); // Should fail as Superseded has no valid transitions
548
549        // Always restore original directory
550        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        // Change to temp directory
561        std::env::set_current_dir(temp_dir.path()).unwrap();
562
563        // Create workspace
564        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        // Load the vision to get its short code
574        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        // Test invalid vision transition: Draft → Published (must go through Review)
579        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        // Test transition to invalid phase
591        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        // Always restore original directory
600        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        // Change to temp directory
611        std::env::set_current_dir(temp_dir.path()).unwrap();
612
613        // Create workspace
614        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        // Load the vision to get its short code
626        let vision = Vision::from_file(&vision_path).await.unwrap();
627        let short_code = vision.metadata().short_code.clone();
628
629        // Test auto-transition (no phase specified): Draft → Review
630        let cmd = TransitionCommand {
631            short_code: short_code.clone(),
632            phase: None, // Auto transition
633        };
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        // Test auto-transition: Review → Published
640        let cmd = TransitionCommand {
641            short_code: short_code.clone(),
642            phase: None, // Auto transition
643        };
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        // Always restore original directory
650        if let Some(original) = original_dir {
651            let _ = std::env::set_current_dir(&original);
652        }
653    }
654}