Skip to main content

metis_docs_cli/commands/
status.rs

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