Skip to main content

mana/commands/
status.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::blocking::{check_blocked, check_scope_warning, BlockReason};
8use crate::index::{ArchiveIndex, Index, IndexEntry};
9use crate::unit::Status;
10use crate::util::natural_cmp;
11
12/// Agent status parsed from claimed_by field
13#[derive(Debug, Clone, Serialize)]
14pub struct AgentStatus {
15    pub pid: u32,
16    pub alive: bool,
17}
18
19/// Parse claimed_by field for agent info (e.g., "spro:12345" -> Some(AgentStatus))
20fn parse_agent_claim(claimed_by: &Option<String>) -> Option<AgentStatus> {
21    let claim = claimed_by.as_ref()?;
22    if !claim.starts_with("spro:") {
23        return None;
24    }
25    let pid_str = claim.strip_prefix("spro:")?;
26    let pid: u32 = pid_str.parse().ok()?;
27    let alive = is_pid_alive(pid);
28    Some(AgentStatus { pid, alive })
29}
30
31/// Check if a process with the given PID is alive
32fn is_pid_alive(pid: u32) -> bool {
33    // Use kill -0 to check if process exists (doesn't send a signal)
34    Command::new("kill")
35        .args(["-0", &pid.to_string()])
36        .output()
37        .map(|output| output.status.success())
38        .unwrap_or(false)
39}
40
41/// Format agent status for display
42fn format_agent_status(entry: &IndexEntry) -> String {
43    match parse_agent_claim(&entry.claimed_by) {
44        Some(agent) if agent.alive => format!("spro:{} ●", agent.pid),
45        Some(agent) => format!("spro:{} ✗", agent.pid),
46        None => entry.claimed_by.clone().unwrap_or_else(|| "-".to_string()),
47    }
48}
49
50/// Entry with agent status for JSON output
51#[derive(Serialize)]
52struct StatusEntry {
53    #[serde(flatten)]
54    entry: IndexEntry,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    agent: Option<AgentStatus>,
57}
58
59impl StatusEntry {
60    fn from_entry(entry: IndexEntry) -> Self {
61        let agent = parse_agent_claim(&entry.claimed_by);
62        Self { entry, agent }
63    }
64}
65
66/// Blocked entry with reason for JSON output
67#[derive(Serialize)]
68struct BlockedEntry {
69    #[serde(flatten)]
70    entry: IndexEntry,
71    block_reason: String,
72}
73
74/// JSON output structure for status command
75#[derive(Serialize)]
76struct StatusOutput {
77    claimed: Vec<StatusEntry>,
78    ready: Vec<IndexEntry>,
79    goals: Vec<IndexEntry>,
80    blocked: Vec<BlockedEntry>,
81}
82
83/// Show complete work picture: claimed, ready, goals (need decomposition), and blocked units
84pub fn cmd_status(json: bool, mana_dir: &Path) -> Result<()> {
85    let index = Index::load_or_rebuild(mana_dir)?;
86
87    // Separate units into categories
88    let mut features: Vec<&IndexEntry> = Vec::new();
89    let mut claimed: Vec<&IndexEntry> = Vec::new();
90    let mut ready: Vec<&IndexEntry> = Vec::new();
91    let mut goals: Vec<&IndexEntry> = Vec::new();
92    let mut blocked: Vec<(&IndexEntry, BlockReason)> = Vec::new();
93
94    for entry in &index.units {
95        if entry.feature {
96            features.push(entry);
97            continue;
98        }
99        match entry.status {
100            Status::InProgress | Status::AwaitingVerify => {
101                claimed.push(entry);
102            }
103            Status::Open => {
104                if let Some(reason) = check_blocked(entry, &index) {
105                    blocked.push((entry, reason));
106                } else if entry.has_verify {
107                    ready.push(entry);
108                } else {
109                    goals.push(entry);
110                }
111            }
112            Status::Closed => {}
113        }
114    }
115
116    sort_units(&mut features);
117    sort_units(&mut claimed);
118    sort_units(&mut ready);
119    sort_units(&mut goals);
120    blocked.sort_by(|(a, _), (b, _)| match a.priority.cmp(&b.priority) {
121        std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
122        other => other,
123    });
124
125    if json {
126        let output = StatusOutput {
127            claimed: claimed
128                .into_iter()
129                .cloned()
130                .map(StatusEntry::from_entry)
131                .collect(),
132            ready: ready.into_iter().cloned().collect(),
133            goals: goals.into_iter().cloned().collect(),
134            blocked: blocked
135                .iter()
136                .map(|(e, reason)| BlockedEntry {
137                    entry: (*e).clone(),
138                    block_reason: reason.to_string(),
139                })
140                .collect(),
141        };
142        let json_str = serde_json::to_string_pretty(&output)?;
143        println!("{}", json_str);
144    } else {
145        // Features section (only shown if features exist)
146        if !features.is_empty() {
147            let archive = ArchiveIndex::load(mana_dir).unwrap_or(ArchiveIndex { units: vec![] });
148            let closed_features = features
149                .iter()
150                .filter(|f| f.status == Status::Closed)
151                .count();
152            println!("## Features ({}/{})", closed_features, features.len());
153            for feature in &features {
154                // Count children from both active index and archive
155                let active_children: Vec<&IndexEntry> = index
156                    .units
157                    .iter()
158                    .filter(|b| b.parent.as_deref() == Some(&feature.id))
159                    .collect();
160                let archived_children: Vec<&IndexEntry> = archive
161                    .units
162                    .iter()
163                    .filter(|b| b.parent.as_deref() == Some(&feature.id))
164                    .collect();
165                let active_count = active_children.len();
166                let archived_count = archived_children.len();
167                let total = active_count + archived_count;
168                let closed = active_children
169                    .iter()
170                    .filter(|b| b.status == Status::Closed)
171                    .count()
172                    + archived_count; // All archived units are closed
173
174                let progress = if total == 0 {
175                    "not decomposed".to_string()
176                } else {
177                    format!("{}/{}", closed, total)
178                };
179
180                let indicator = if feature.status == Status::Closed {
181                    "✓"
182                } else if total > 0 && closed == total {
183                    "★"
184                } else {
185                    "○"
186                };
187
188                let suffix = if total > 0 && closed == total && feature.status != Status::Closed {
189                    " — ready for review"
190                } else {
191                    ""
192                };
193
194                println!(
195                    "  {}  {} {} ({}{})",
196                    indicator, feature.id, feature.title, progress, suffix
197                );
198            }
199            println!();
200        }
201
202        println!("## Claimed ({})", claimed.len());
203        if claimed.is_empty() {
204            println!("  (none)");
205        } else {
206            for entry in claimed {
207                let agent_str = format_agent_status(entry);
208                println!("  {} [-] {} ({})", entry.id, entry.title, agent_str);
209            }
210        }
211        println!();
212
213        println!("## Ready ({})", ready.len());
214        if ready.is_empty() {
215            println!("  (none)");
216        } else {
217            for entry in ready {
218                let warning = check_scope_warning(entry)
219                    .map(|w| format!("  (⚠ {})", w))
220                    .unwrap_or_default();
221                println!("  {} [ ] {}{}", entry.id, entry.title, warning);
222            }
223        }
224        println!();
225
226        println!("## Goals (need decomposition) ({})", goals.len());
227        if goals.is_empty() {
228            println!("  (none)");
229        } else {
230            for entry in goals {
231                println!("  {} [?] {}", entry.id, entry.title);
232            }
233        }
234        println!();
235
236        println!("## Blocked ({})", blocked.len());
237        if blocked.is_empty() {
238            println!("  (none)");
239        } else {
240            for (entry, reason) in &blocked {
241                println!("  {} [!] {}  ({})", entry.id, entry.title, reason);
242            }
243        }
244    }
245
246    Ok(())
247}
248
249fn sort_units(units: &mut Vec<&IndexEntry>) {
250    units.sort_by(|a, b| match a.priority.cmp(&b.priority) {
251        std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
252        other => other,
253    });
254}