metis_docs_cli/commands/
status.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{Application, 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. Validate workspace and sync before reading
116        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
117        if !workspace_exists {
118            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
119        }
120        let metis_dir = metis_dir.unwrap();
121
122        // 2. Sync before reading to catch external edits
123        let db_path = metis_dir.join("metis.db");
124        let database = Database::new(db_path.to_str().unwrap())
125            .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
126        let app = Application::new(database);
127        app.sync_directory(&metis_dir)
128            .await
129            .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
130
131        // 3. Connect to database
132        let mut repo = Self::connect_to_database().await?;
133
134        // 4. Fetch and sort documents
135        let mut documents = self.fetch_documents(&mut repo).await?;
136        self.sort_documents_by_priority(&mut documents);
137
138        // 5. Display results
139        if documents.is_empty() {
140            println!("No documents found in workspace.");
141            return Ok(());
142        }
143
144        self.display_status(&documents);
145        Ok(())
146    }
147
148    fn get_action_priority(&self, doc: &metis_core::dal::database::models::Document) -> u8 {
149        // Lower numbers = higher priority (more actionable)
150        match doc.phase.as_str() {
151            "blocked" => 0,                          // Most urgent - things blocking other work
152            "todo" => 1,                             // Ready to start
153            "discussion" => 2,                       // Needs decision
154            "active" => 3,                           // Currently being worked on
155            "discovery" | "shaping" | "design" => 4, // Needs planning/refinement
156            "ready" | "decompose" => 5,              // Staged for work
157            "review" => 6,                           // Waiting for review
158            "decided" | "published" | "completed" => 7, // Done but recent
159            _ => 8,                                  // Other states
160        }
161    }
162
163    fn display_status(&self, documents: &[metis_core::dal::database::models::Document]) {
164        println!("\nWORKSPACE STATUS\n");
165
166        // Convert documents to table rows
167        let rows: Vec<StatusRow> = documents
168            .iter()
169            .map(|doc| self.create_status_row(doc))
170            .collect();
171
172        // Create and display table
173        let table = Table::new(rows);
174        println!("{}", table);
175
176        println!("\nTotal: {} documents", documents.len());
177
178        // Summary insights
179        self.display_insights(documents);
180    }
181
182    fn extract_blocked_by_info(&self, doc: &metis_core::dal::database::models::Document) -> String {
183        if doc.phase != "blocked" {
184            return String::new();
185        }
186
187        // Parse frontmatter JSON to get blocked_by information
188        if let Ok(frontmatter) = serde_json::from_str::<serde_json::Value>(&doc.frontmatter_json) {
189            if let Some(blocked_by) = frontmatter.get("blocked_by") {
190                if let Some(array) = blocked_by.as_array() {
191                    let blocking_docs: Vec<String> = array
192                        .iter()
193                        .filter_map(|v| v.as_str())
194                        .map(|s| s.to_string())
195                        .collect();
196
197                    if !blocking_docs.is_empty() {
198                        return self.truncate_string(&blocking_docs.join(", "), 18);
199                    }
200                }
201            }
202        }
203
204        "Unknown".to_string()
205    }
206
207    fn format_relative_time(&self, dt: chrono::DateTime<chrono::Utc>) -> String {
208        let now = chrono::Utc::now();
209        let diff = now.signed_duration_since(dt);
210
211        if diff.num_days() > 0 {
212            if diff.num_days() == 1 {
213                "1 day ago".to_string()
214            } else if diff.num_days() < 7 {
215                format!("{} days ago", diff.num_days())
216            } else if diff.num_days() < 30 {
217                format!("{} weeks ago", diff.num_days() / 7)
218            } else {
219                format!("{} months ago", diff.num_days() / 30)
220            }
221        } else if diff.num_hours() > 0 {
222            if diff.num_hours() == 1 {
223                "1 hour ago".to_string()
224            } else {
225                format!("{} hours ago", diff.num_hours())
226            }
227        } else if diff.num_minutes() > 0 {
228            if diff.num_minutes() == 1 {
229                "1 minute ago".to_string()
230            } else {
231                format!("{} minutes ago", diff.num_minutes())
232            }
233        } else {
234            "Just now".to_string()
235        }
236    }
237
238    fn display_insights(&self, documents: &[metis_core::dal::database::models::Document]) {
239        let (blocked_count, todo_count, active_count) = self.count_documents_by_phase(documents);
240
241        if blocked_count > 0 || todo_count > 0 {
242            println!("ACTIONABLE ITEMS:");
243            if blocked_count > 0 {
244                println!("  ⚠️  {} blocked documents need unblocking", blocked_count);
245            }
246            if todo_count > 0 {
247                println!("  📋 {} documents ready to start", todo_count);
248            }
249            if active_count > 0 {
250                println!("  🔄 {} documents in progress", active_count);
251            }
252        }
253    }
254
255    fn truncate_string(&self, s: &str, max_len: usize) -> String {
256        if s.len() <= max_len {
257            s.to_string()
258        } else {
259            format!("{}…", &s[..max_len.saturating_sub(1)])
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::commands::InitCommand;
268    use tempfile::tempdir;
269
270    #[tokio::test]
271    async fn test_status_command_no_workspace() {
272        let temp_dir = tempdir().unwrap();
273        let original_dir = std::env::current_dir().ok();
274
275        // Change to temp directory without workspace
276        if std::env::set_current_dir(temp_dir.path()).is_err() {
277            return; // Skip test if we can't change directory
278        }
279
280        let cmd = StatusCommand {
281            include_archived: false,
282        };
283
284        let result = cmd.execute().await;
285
286        // Always restore original directory first
287        if let Some(original) = original_dir {
288            let _ = std::env::set_current_dir(&original);
289        }
290
291        assert!(result.is_err());
292        assert!(result
293            .unwrap_err()
294            .to_string()
295            .contains("Not in a Metis workspace"));
296    }
297
298    #[tokio::test]
299    async fn test_status_command_empty_workspace() {
300        let temp_dir = tempdir().unwrap();
301        let original_dir = std::env::current_dir().ok();
302
303        // Change to temp directory
304        std::env::set_current_dir(temp_dir.path()).unwrap();
305
306        // Create workspace
307        let init_cmd = InitCommand {
308            name: Some("Test Project".to_string()),
309            preset: None,
310            strategies: None,
311            initiatives: None,
312            prefix: None,
313        };
314        init_cmd.execute().await.unwrap();
315
316        let cmd = StatusCommand {
317            include_archived: false,
318        };
319
320        let result = cmd.execute().await;
321
322        // Always restore original directory first
323        if let Some(original) = original_dir {
324            let _ = std::env::set_current_dir(&original);
325        }
326
327        // Should succeed and show at least the vision document created by init
328        assert!(result.is_ok());
329    }
330
331    #[test]
332    fn test_action_priority() {
333        let cmd = StatusCommand {
334            include_archived: false,
335        };
336
337        // Create mock documents with different phases
338        let blocked_doc = metis_core::dal::database::models::Document {
339            filepath: "/test.md".to_string(),
340            id: "test-1".to_string(),
341            title: "Test".to_string(),
342            document_type: "task".to_string(),
343            created_at: 0.0,
344            updated_at: 0.0,
345            archived: false,
346            exit_criteria_met: false,
347            file_hash: "hash".to_string(),
348            frontmatter_json: "{}".to_string(),
349            content: None,
350            phase: "blocked".to_string(),
351            strategy_id: Some("test-strategy".to_string()),
352            initiative_id: Some("test-initiative".to_string()),
353            short_code: "TEST-T-0001".to_string(),
354        };
355
356        let todo_doc = metis_core::dal::database::models::Document {
357            phase: "todo".to_string(),
358            ..blocked_doc.clone()
359        };
360
361        let completed_doc = metis_core::dal::database::models::Document {
362            phase: "completed".to_string(),
363            ..blocked_doc.clone()
364        };
365
366        // Blocked should have highest priority (lowest number)
367        assert!(cmd.get_action_priority(&blocked_doc) < cmd.get_action_priority(&todo_doc));
368        assert!(cmd.get_action_priority(&todo_doc) < cmd.get_action_priority(&completed_doc));
369    }
370}