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    /// Document type (vision, strategy, initiative, task, adr)
18    #[arg(short = 't', long)]
19    pub document_type: Option<String>,
20}
21
22impl TransitionCommand {
23    pub async fn execute(&self) -> Result<()> {
24        // 1. Validate we're in a metis workspace
25        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        // 2. Create the phase transition service
32        let transition_service = PhaseTransitionService::new(&metis_dir);
33
34        // 3. Perform the transition
35        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        // 4. Report success
47        println!(
48            "✓ Transitioned {} '{}' from {} to {}",
49            result.document_type, result.document_id, result.from_phase, result.to_phase
50        );
51
52        // 5. Auto-sync workspace after transition
53        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        // Change to temp directory without workspace
114        if std::env::set_current_dir(temp_dir.path()).is_err() {
115            return; // Skip test if we can't change directory
116        }
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        // Always restore original directory first
127        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        // Change to temp directory
144        std::env::set_current_dir(temp_dir.path()).unwrap();
145
146        // Create workspace
147        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        // Always restore original directory first
165        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        // Change to temp directory
179        std::env::set_current_dir(temp_dir.path()).unwrap();
180
181        // Create workspace
182        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        // Verify initial state (Draft)
194        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        // 1. Auto-transition: Draft → Review
199        let cmd = TransitionCommand {
200            short_code: short_code.clone(),
201            phase: None, // Auto transition
202            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        // 2. Auto-transition: Review → Published
210        let cmd = TransitionCommand {
211            short_code: short_code.clone(),
212            phase: None, // Auto transition
213            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        // 3. Test auto-transition (should fail at Published - final phase)
221        let cmd = TransitionCommand {
222            short_code: short_code.clone(),
223            phase: None, // Auto transition
224            document_type: Some("vision".to_string()),
225        };
226        let result = cmd.execute().await;
227        assert!(result.is_err()); // Should fail as Published is final
228
229        // Always restore original directory
230        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        // Change to temp directory
241        std::env::set_current_dir(temp_dir.path()).unwrap();
242
243        // Create workspace
244        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        // Create strategy using create command
254        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        // Find strategy file path
263        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        // Verify initial state (Shaping)
273        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        // 1. Auto-transition: Shaping → Design
278        let cmd = TransitionCommand {
279            short_code: short_code.clone(),
280            phase: None, // Auto transition
281            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        // 2. Auto-transition: Design → Ready
289        let cmd = TransitionCommand {
290            short_code: short_code.clone(),
291            phase: None, // Auto transition
292            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        // 3. Auto-transition: Ready → Active
300        let cmd = TransitionCommand {
301            short_code: short_code.clone(),
302            phase: None, // Auto transition
303            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        // 4. Auto-transition: Active → Completed
311        let cmd = TransitionCommand {
312            short_code: short_code.clone(),
313            phase: None, // Auto transition
314            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        // Always restore original directory
322        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        // Change to temp directory
333        std::env::set_current_dir(temp_dir.path()).unwrap();
334
335        // Create workspace
336        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        // Create strategy first
346        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        // Create initiative
355        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        // Check what phase the initiative actually starts with
364        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        // 1. Auto-transition: Discovery → Shaping
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        // 2. Auto-transition: Shaping → Decompose
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        // 3. Auto-transition: Decompose → Active
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        // 4. Auto-transition: Active → Completed
396        let cmd = TransitionCommand {
397            short_code: short_code.clone(),
398            phase: None, // Auto transition
399            document_type: Some("initiative".to_string()),
400        };
401        cmd.execute().await.unwrap();
402
403        // Always restore original directory
404        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        // Change to temp directory
415        std::env::set_current_dir(temp_dir.path()).unwrap();
416
417        // Create workspace
418        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        // Create strategy
428        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        // Create initiative
437        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        // Create task
446        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        // Load the task to get its short code
455        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        // 1. Todo → Active
462        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        // 2. Active → Completed
470        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        // 3. Test blocking workflow: Todo → Blocked → Active
478        // Create another task
479        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        // Todo → Blocked
490        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        // Blocked → Active (this tests the blocked → unblocked workflow)
498        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        // Always restore original directory
506        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        // Change to temp directory
517        std::env::set_current_dir(temp_dir.path()).unwrap();
518
519        // Create workspace
520        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        // Create ADR
530        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        // Load the ADR to get its short code
538        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        // 1. Auto-transition: Draft → Discussion
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        // 2. Auto-transition: Discussion → Decided
551        let cmd = TransitionCommand {
552            short_code: short_code.clone(),
553            phase: None, // Auto transition
554            document_type: Some("adr".to_string()),
555        };
556        cmd.execute().await.unwrap();
557
558        // 3. Test transition from decided to superseded (should work)
559        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(); // Should succeed as Decided → Superseded is valid
565
566        // 4. Test that superseded ADRs cannot be transitioned further
567        let cmd = TransitionCommand {
568            short_code: short_code.clone(),
569            phase: None, // Auto transition
570            document_type: Some("adr".to_string()),
571        };
572        let result = cmd.execute().await;
573        assert!(result.is_err()); // Should fail as Superseded has no valid transitions
574
575        // Always restore original directory
576        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        // Change to temp directory
587        std::env::set_current_dir(temp_dir.path()).unwrap();
588
589        // Create workspace
590        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        // Load the vision to get its short code
600        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        // Test invalid vision transition: Draft → Published (must go through Review)
605        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        // Test transition to invalid phase
618        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        // Always restore original directory
628        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        // Change to temp directory
639        std::env::set_current_dir(temp_dir.path()).unwrap();
640
641        // Create workspace
642        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        // Load the vision to get its short code
654        let vision = Vision::from_file(&vision_path).await.unwrap();
655        let short_code = vision.metadata().short_code.clone();
656
657        // Test auto-transition (no phase specified): Draft → Review
658        let cmd = TransitionCommand {
659            short_code: short_code.clone(),
660            phase: None, // Auto transition
661            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        // Test auto-transition: Review → Published
669        let cmd = TransitionCommand {
670            short_code: short_code.clone(),
671            phase: None, // Auto transition
672            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        // Always restore original directory
680        if let Some(original) = original_dir {
681            let _ = std::env::set_current_dir(&original);
682        }
683    }
684}