metis_docs_cli/commands/
status.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{Database, Result as MetisResult};
5use tabled::{Table, Tabled};
6
7#[derive(Args)]
8pub struct StatusCommand {
9    /// Include archived documents in the status view
10    #[arg(long)]
11    pub include_archived: bool,
12}
13
14#[derive(Tabled)]
15struct StatusRow {
16    #[tabled(rename = "TITLE")]
17    title: String,
18    #[tabled(rename = "TYPE")]
19    doc_type: String,
20    #[tabled(rename = "PHASE")]
21    phase: String,
22    #[tabled(rename = "BLOCKED BY")]
23    blocked_by: String,
24    #[tabled(rename = "UPDATED")]
25    updated: String,
26}
27
28impl StatusCommand {
29    // Helper methods to reduce complexity
30
31    /// Get all document types to query
32    fn get_document_types() -> &'static [&'static str] {
33        &["vision", "strategy", "initiative", "task", "adr"]
34    }
35
36    /// Initialize database connection from workspace
37    async fn connect_to_database(
38    ) -> Result<metis_core::dal::database::repository::DocumentRepository> {
39        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
40        if !workspace_exists {
41            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
42        }
43        let metis_dir = metis_dir.unwrap();
44
45        let db_path = metis_dir.join("metis.db");
46        let db = Database::new(db_path.to_str().unwrap())
47            .map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
48        Ok(db.into_repository())
49    }
50
51    /// Fetch and filter documents from repository
52    async fn fetch_documents(
53        &self,
54        repo: &mut metis_core::dal::database::repository::DocumentRepository,
55    ) -> MetisResult<Vec<metis_core::dal::database::models::Document>> {
56        let mut all_docs = Vec::new();
57
58        // Collect all documents
59        for doc_type in Self::get_document_types() {
60            let mut docs = repo.find_by_type(doc_type)?;
61            all_docs.append(&mut docs);
62        }
63
64        // Filter archived if needed
65        if !self.include_archived {
66            all_docs.retain(|doc| !doc.archived);
67        }
68
69        Ok(all_docs)
70    }
71
72    /// Sort documents by actionability and recency
73    fn sort_documents_by_priority(&self, docs: &mut [metis_core::dal::database::models::Document]) {
74        docs.sort_by(|a, b| {
75            let a_priority = self.get_action_priority(a);
76            let b_priority = self.get_action_priority(b);
77
78            match a_priority.cmp(&b_priority) {
79                std::cmp::Ordering::Equal => {
80                    // If same priority, sort by most recently updated
81                    b.updated_at
82                        .partial_cmp(&a.updated_at)
83                        .unwrap_or(std::cmp::Ordering::Equal)
84                }
85                other => other,
86            }
87        });
88    }
89
90    /// Format a single document into a status row
91    fn create_status_row(&self, doc: &metis_core::dal::database::models::Document) -> StatusRow {
92        StatusRow {
93            title: self.truncate_string(&doc.title, 35),
94            doc_type: doc.document_type.clone(),
95            phase: doc.phase.clone(),
96            blocked_by: self.extract_blocked_by_info(doc),
97            updated: chrono::DateTime::from_timestamp(doc.updated_at as i64, 0)
98                .map(|dt| self.format_relative_time(dt))
99                .unwrap_or_else(|| "Unknown".to_string()),
100        }
101    }
102
103    /// Count documents by phase for insights
104    fn count_documents_by_phase(
105        &self,
106        documents: &[metis_core::dal::database::models::Document],
107    ) -> (usize, usize, usize) {
108        let blocked_count = documents.iter().filter(|d| d.phase == "blocked").count();
109        let todo_count = documents.iter().filter(|d| d.phase == "todo").count();
110        let active_count = documents.iter().filter(|d| d.phase == "active").count();
111        (blocked_count, todo_count, active_count)
112    }
113
114    pub async fn execute(&self) -> Result<()> {
115        // 1. Connect to database
116        let mut repo = Self::connect_to_database().await?;
117
118        // 2. Fetch and sort documents
119        let mut documents = self.fetch_documents(&mut repo).await?;
120        self.sort_documents_by_priority(&mut documents);
121
122        // 3. Display results
123        if documents.is_empty() {
124            println!("No documents found in workspace.");
125            return Ok(());
126        }
127
128        self.display_status(&documents);
129        Ok(())
130    }
131
132    fn get_action_priority(&self, doc: &metis_core::dal::database::models::Document) -> u8 {
133        // Lower numbers = higher priority (more actionable)
134        match doc.phase.as_str() {
135            "blocked" => 0,                          // Most urgent - things blocking other work
136            "todo" => 1,                             // Ready to start
137            "discussion" => 2,                       // Needs decision
138            "active" => 3,                           // Currently being worked on
139            "discovery" | "shaping" | "design" => 4, // Needs planning/refinement
140            "ready" | "decompose" => 5,              // Staged for work
141            "review" => 6,                           // Waiting for review
142            "decided" | "published" | "completed" => 7, // Done but recent
143            _ => 8,                                  // Other states
144        }
145    }
146
147    fn display_status(&self, documents: &[metis_core::dal::database::models::Document]) {
148        println!("\nWORKSPACE STATUS\n");
149
150        // Convert documents to table rows
151        let rows: Vec<StatusRow> = documents
152            .iter()
153            .map(|doc| self.create_status_row(doc))
154            .collect();
155
156        // Create and display table
157        let table = Table::new(rows);
158        println!("{}", table);
159
160        println!("\nTotal: {} documents", documents.len());
161
162        // Summary insights
163        self.display_insights(documents);
164    }
165
166    fn extract_blocked_by_info(&self, doc: &metis_core::dal::database::models::Document) -> String {
167        if doc.phase != "blocked" {
168            return String::new();
169        }
170
171        // Parse frontmatter JSON to get blocked_by information
172        if let Ok(frontmatter) = serde_json::from_str::<serde_json::Value>(&doc.frontmatter_json) {
173            if let Some(blocked_by) = frontmatter.get("blocked_by") {
174                if let Some(array) = blocked_by.as_array() {
175                    let blocking_docs: Vec<String> = array
176                        .iter()
177                        .filter_map(|v| v.as_str())
178                        .map(|s| s.to_string())
179                        .collect();
180
181                    if !blocking_docs.is_empty() {
182                        return self.truncate_string(&blocking_docs.join(", "), 18);
183                    }
184                }
185            }
186        }
187
188        "Unknown".to_string()
189    }
190
191    fn format_relative_time(&self, dt: chrono::DateTime<chrono::Utc>) -> String {
192        let now = chrono::Utc::now();
193        let diff = now.signed_duration_since(dt);
194
195        if diff.num_days() > 0 {
196            if diff.num_days() == 1 {
197                "1 day ago".to_string()
198            } else if diff.num_days() < 7 {
199                format!("{} days ago", diff.num_days())
200            } else if diff.num_days() < 30 {
201                format!("{} weeks ago", diff.num_days() / 7)
202            } else {
203                format!("{} months ago", diff.num_days() / 30)
204            }
205        } else if diff.num_hours() > 0 {
206            if diff.num_hours() == 1 {
207                "1 hour ago".to_string()
208            } else {
209                format!("{} hours ago", diff.num_hours())
210            }
211        } else if diff.num_minutes() > 0 {
212            if diff.num_minutes() == 1 {
213                "1 minute ago".to_string()
214            } else {
215                format!("{} minutes ago", diff.num_minutes())
216            }
217        } else {
218            "Just now".to_string()
219        }
220    }
221
222    fn display_insights(&self, documents: &[metis_core::dal::database::models::Document]) {
223        let (blocked_count, todo_count, active_count) = self.count_documents_by_phase(documents);
224
225        if blocked_count > 0 || todo_count > 0 {
226            println!("ACTIONABLE ITEMS:");
227            if blocked_count > 0 {
228                println!("  ⚠️  {} blocked documents need unblocking", blocked_count);
229            }
230            if todo_count > 0 {
231                println!("  📋 {} documents ready to start", todo_count);
232            }
233            if active_count > 0 {
234                println!("  🔄 {} documents in progress", active_count);
235            }
236        }
237    }
238
239    fn truncate_string(&self, s: &str, max_len: usize) -> String {
240        if s.len() <= max_len {
241            s.to_string()
242        } else {
243            format!("{}…", &s[..max_len.saturating_sub(1)])
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::commands::InitCommand;
252    use tempfile::tempdir;
253
254    #[tokio::test]
255    async fn test_status_command_no_workspace() {
256        let temp_dir = tempdir().unwrap();
257        let original_dir = std::env::current_dir().ok();
258
259        // Change to temp directory without workspace
260        if std::env::set_current_dir(temp_dir.path()).is_err() {
261            return; // Skip test if we can't change directory
262        }
263
264        let cmd = StatusCommand {
265            include_archived: false,
266        };
267
268        let result = cmd.execute().await;
269
270        // Always restore original directory first
271        if let Some(original) = original_dir {
272            let _ = std::env::set_current_dir(&original);
273        }
274
275        assert!(result.is_err());
276        assert!(result
277            .unwrap_err()
278            .to_string()
279            .contains("Not in a Metis workspace"));
280    }
281
282    #[tokio::test]
283    async fn test_status_command_empty_workspace() {
284        let temp_dir = tempdir().unwrap();
285        let original_dir = std::env::current_dir().ok();
286
287        // Change to temp directory
288        std::env::set_current_dir(temp_dir.path()).unwrap();
289
290        // Create workspace
291        let init_cmd = InitCommand {
292            name: Some("Test Project".to_string()),
293            preset: None,
294            strategies: None,
295            initiatives: None,
296            prefix: None,
297        };
298        init_cmd.execute().await.unwrap();
299
300        let cmd = StatusCommand {
301            include_archived: false,
302        };
303
304        let result = cmd.execute().await;
305
306        // Always restore original directory first
307        if let Some(original) = original_dir {
308            let _ = std::env::set_current_dir(&original);
309        }
310
311        // Should succeed and show at least the vision document created by init
312        assert!(result.is_ok());
313    }
314
315    #[test]
316    fn test_action_priority() {
317        let cmd = StatusCommand {
318            include_archived: false,
319        };
320
321        // Create mock documents with different phases
322        let blocked_doc = metis_core::dal::database::models::Document {
323            filepath: "/test.md".to_string(),
324            id: "test-1".to_string(),
325            title: "Test".to_string(),
326            document_type: "task".to_string(),
327            created_at: 0.0,
328            updated_at: 0.0,
329            archived: false,
330            exit_criteria_met: false,
331            file_hash: "hash".to_string(),
332            frontmatter_json: "{}".to_string(),
333            content: None,
334            phase: "blocked".to_string(),
335            strategy_id: Some("test-strategy".to_string()),
336            initiative_id: Some("test-initiative".to_string()),
337            short_code: "TEST-T-0001".to_string(),
338        };
339
340        let todo_doc = metis_core::dal::database::models::Document {
341            phase: "todo".to_string(),
342            ..blocked_doc.clone()
343        };
344
345        let completed_doc = metis_core::dal::database::models::Document {
346            phase: "completed".to_string(),
347            ..blocked_doc.clone()
348        };
349
350        // Blocked should have highest priority (lowest number)
351        assert!(cmd.get_action_priority(&blocked_doc) < cmd.get_action_priority(&todo_doc));
352        assert!(cmd.get_action_priority(&todo_doc) < cmd.get_action_priority(&completed_doc));
353    }
354}