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 #[arg(long)]
12 pub include_archived: bool,
13
14 #[arg(short = 'f', long, value_enum, default_value = "table")]
16 pub format: OutputFormat,
17}
18
19#[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 fn get_document_types() -> &'static [&'static str] {
37 &["vision", "strategy", "initiative", "task", "adr"]
38 }
39
40 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 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 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 if !self.include_archived {
70 all_docs.retain(|doc| !doc.archived);
71 }
72
73 Ok(all_docs)
74 }
75
76 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 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 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 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 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 let mut repo = Self::connect_to_database().await?;
124
125 let mut documents = self.fetch_documents(&mut repo).await?;
127 self.sort_documents_by_priority(&mut documents);
128
129 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 match doc.phase.as_str() {
149 "blocked" => 0, "todo" => 1, "discussion" => 2, "active" => 3, "discovery" | "shaping" | "design" => 4, "ready" | "decompose" => 5, "review" => 6, "decided" | "published" | "completed" => 7, _ => 8, }
159 }
160
161 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 self.display_insights(documents);
189 }
190
191 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 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 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 if std::env::set_current_dir(temp_dir.path()).is_err() {
323 return; }
325
326 let cmd = StatusCommand {
327 include_archived: false,
328 format: OutputFormat::Table,
329 };
330
331 let result = cmd.execute().await;
332
333 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 std::env::set_current_dir(temp_dir.path()).unwrap();
352
353 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 if let Some(original) = original_dir {
372 let _ = std::env::set_current_dir(&original);
373 }
374
375 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 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 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}