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 #[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 (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 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 let mut repo = Self::connect_to_database().await?;
133
134 let mut documents = self.fetch_documents(&mut repo).await?;
136 self.sort_documents_by_priority(&mut documents);
137
138 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 match doc.phase.as_str() {
151 "blocked" => 0, "todo" => 1, "discussion" => 2, "active" => 3, "discovery" | "shaping" | "design" => 4, "ready" | "decompose" => 5, "review" => 6, "decided" | "published" | "completed" => 7, _ => 8, }
161 }
162
163 fn display_status(&self, documents: &[metis_core::dal::database::models::Document]) {
164 println!("\nWORKSPACE STATUS\n");
165
166 let rows: Vec<StatusRow> = documents
168 .iter()
169 .map(|doc| self.create_status_row(doc))
170 .collect();
171
172 let table = Table::new(rows);
174 println!("{}", table);
175
176 println!("\nTotal: {} documents", documents.len());
177
178 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 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 if std::env::set_current_dir(temp_dir.path()).is_err() {
277 return; }
279
280 let cmd = StatusCommand {
281 include_archived: false,
282 };
283
284 let result = cmd.execute().await;
285
286 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 std::env::set_current_dir(temp_dir.path()).unwrap();
305
306 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 if let Some(original) = original_dir {
324 let _ = std::env::set_current_dir(&original);
325 }
326
327 assert!(result.is_ok());
329 }
330
331 #[test]
332 fn test_action_priority() {
333 let cmd = StatusCommand {
334 include_archived: false,
335 };
336
337 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 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}