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 #[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 fn get_document_types() -> &'static [&'static str] {
33 &["vision", "strategy", "initiative", "task", "adr"]
34 }
35
36 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 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 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 if !self.include_archived {
66 all_docs.retain(|doc| !doc.archived);
67 }
68
69 Ok(all_docs)
70 }
71
72 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 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 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 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 let mut repo = Self::connect_to_database().await?;
117
118 let mut documents = self.fetch_documents(&mut repo).await?;
120 self.sort_documents_by_priority(&mut documents);
121
122 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 match doc.phase.as_str() {
135 "blocked" => 0, "todo" => 1, "discussion" => 2, "active" => 3, "discovery" | "shaping" | "design" => 4, "ready" | "decompose" => 5, "review" => 6, "decided" | "published" | "completed" => 7, _ => 8, }
145 }
146
147 fn display_status(&self, documents: &[metis_core::dal::database::models::Document]) {
148 println!("\nWORKSPACE STATUS\n");
149
150 let rows: Vec<StatusRow> = documents
152 .iter()
153 .map(|doc| self.create_status_row(doc))
154 .collect();
155
156 let table = Table::new(rows);
158 println!("{}", table);
159
160 println!("\nTotal: {} documents", documents.len());
161
162 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 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 if std::env::set_current_dir(temp_dir.path()).is_err() {
261 return; }
263
264 let cmd = StatusCommand {
265 include_archived: false,
266 };
267
268 let result = cmd.execute().await;
269
270 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 std::env::set_current_dir(temp_dir.path()).unwrap();
289
290 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 if let Some(original) = original_dir {
308 let _ = std::env::set_current_dir(&original);
309 }
310
311 assert!(result.is_ok());
313 }
314
315 #[test]
316 fn test_action_priority() {
317 let cmd = StatusCommand {
318 include_archived: false,
319 };
320
321 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 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}