1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5use mana_core::ops::list as ops_list;
6
7use crate::blocking::check_blocked;
8use crate::config::resolve_identity;
9use crate::index::{Index, IndexEntry};
10use crate::unit::Status;
11use crate::util::natural_cmp;
12
13#[allow(clippy::too_many_arguments)]
26pub fn cmd_list(
27 status_filter: Option<&str>,
28 priority_filter: Option<u8>,
29 parent_filter: Option<&str>,
30 label_filter: Option<&str>,
31 assignee_filter: Option<&str>,
32 mine: bool,
33 all: bool,
34 json: bool,
35 ids: bool,
36 format_str: Option<&str>,
37 mana_dir: &Path,
38) -> Result<()> {
39 let current_user = if mine {
40 let user = resolve_identity(mana_dir);
41 if user.is_none() {
42 anyhow::bail!(
43 "Cannot use --mine: no identity configured.\n\
44 Set one with: mana config set user <name>"
45 );
46 }
47 user
48 } else {
49 None
50 };
51
52 let filtered = ops_list::list(
53 mana_dir,
54 &ops_list::ListParams {
55 status: status_filter.map(str::to_string),
56 priority: priority_filter,
57 parent: parent_filter.map(str::to_string),
58 label: label_filter.map(str::to_string),
59 assignee: assignee_filter.map(str::to_string),
60 current_user: current_user.clone(),
61 include_closed: all,
62 },
63 )?;
64
65 if json {
66 let json_str = serde_json::to_string_pretty(&filtered)?;
67 println!("{}", json_str);
68 } else if ids {
69 for entry in &filtered {
70 println!("{}", entry.id);
71 }
72 } else if let Some(fmt) = format_str {
73 for entry in &filtered {
74 let line = fmt
75 .replace("{id}", &entry.id)
76 .replace("{title}", &entry.title)
77 .replace("{status}", &format!("{}", entry.status))
78 .replace("{priority}", &format!("P{}", entry.priority))
79 .replace("{parent}", entry.parent.as_deref().unwrap_or(""))
80 .replace("{assignee}", entry.assignee.as_deref().unwrap_or(""))
81 .replace("{labels}", &entry.labels.join(","))
82 .replace("\\t", "\t")
83 .replace("\\n", "\n");
84 println!("{}", line);
85 }
86 } else {
87 let index = Index::load_or_rebuild(mana_dir)?;
88 let include_archived = status_filter == Some("closed") || all;
89 let combined_index = if include_archived {
90 let mut all_units = index.units.clone();
91 if let Ok(archived) = Index::collect_archived(mana_dir) {
92 all_units.extend(archived);
93 }
94 Index { units: all_units }
95 } else {
96 index.clone()
97 };
98
99 let tree = render_tree(&filtered, &combined_index);
100 println!("{}", tree);
101 println!("Legend: [ ] open [-] in_progress [x] closed [!] blocked [?] has decisions");
102 }
103
104 Ok(())
105}
106
107fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
112 let mut output = String::new();
113
114 let mut children_map: HashMap<Option<String>, Vec<&IndexEntry>> = HashMap::new();
116 for entry in entries {
117 children_map
118 .entry(entry.parent.clone())
119 .or_default()
120 .push(entry);
121 }
122
123 for children in children_map.values_mut() {
125 children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
126 }
127
128 if let Some(roots) = children_map.get(&None) {
130 for root in roots {
131 render_entry(&mut output, root, 0, &children_map, index);
132 }
133 }
134
135 output
136}
137
138fn render_entry(
140 output: &mut String,
141 entry: &IndexEntry,
142 depth: u32,
143 children_map: &HashMap<Option<String>, Vec<&IndexEntry>>,
144 index: &Index,
145) {
146 let indent = " ".repeat(depth as usize);
147 let (status_indicator, reason_suffix) = get_status_indicator(entry, index);
148 output.push_str(&format!(
149 "{}{} {}. {}{}\n",
150 indent, status_indicator, entry.id, entry.title, reason_suffix
151 ));
152
153 if let Some(children) = children_map.get(&Some(entry.id.clone())) {
155 for child in children {
156 render_entry(output, child, depth + 1, children_map, index);
157 }
158 }
159}
160
161fn get_status_indicator(entry: &IndexEntry, index: &Index) -> (String, String) {
164 if let Some(reason) = check_blocked(entry, index) {
165 ("[!]".to_string(), format!(" ({})", reason))
166 } else {
167 let indicator = match entry.status {
168 Status::Open => "[ ]",
169 Status::InProgress | Status::AwaitingVerify => "[-]",
170 Status::Closed => "[x]",
171 };
172 let mut suffix = crate::blocking::check_scope_warning(entry)
174 .map(|w| format!(" (⚠ {})", w))
175 .unwrap_or_default();
176 if entry.has_decisions {
178 suffix.push_str(" [?]");
179 }
180 (indicator.to_string(), suffix)
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::fs;
188
189 use crate::index::{Index, IndexEntry};
190 use crate::unit::Status;
191 use crate::util::{parse_status, title_to_slug};
192 use tempfile::TempDir;
193
194 fn setup_test_units() -> (TempDir, std::path::PathBuf) {
195 let dir = TempDir::new().unwrap();
196 let mana_dir = dir.path().join(".mana");
197 fs::create_dir(&mana_dir).unwrap();
198
199 let unit1 = crate::unit::Unit::new("1", "First task");
201 let mut unit2 = crate::unit::Unit::new("2", "Second task");
202 unit2.status = Status::InProgress;
203 let mut unit3 = crate::unit::Unit::new("3", "Parent task");
204 unit3.dependencies = vec!["1".to_string()];
205
206 let mut unit3_1 = crate::unit::Unit::new("3.1", "Subtask");
207 unit3_1.parent = Some("3".to_string());
208
209 let slug1 = title_to_slug(&unit1.title);
210 let slug2 = title_to_slug(&unit2.title);
211 let slug3 = title_to_slug(&unit3.title);
212 let slug3_1 = title_to_slug(&unit3_1.title);
213
214 unit1
215 .to_file(mana_dir.join(format!("1-{}.md", slug1)))
216 .unwrap();
217 unit2
218 .to_file(mana_dir.join(format!("2-{}.md", slug2)))
219 .unwrap();
220 unit3
221 .to_file(mana_dir.join(format!("3-{}.md", slug3)))
222 .unwrap();
223 unit3_1
224 .to_file(mana_dir.join(format!("3.1-{}.md", slug3_1)))
225 .unwrap();
226
227 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 4\n").unwrap();
229
230 (dir, mana_dir)
231 }
232
233 #[test]
234 fn parse_status_valid() {
235 assert_eq!(parse_status("open"), Some(Status::Open));
236 assert_eq!(parse_status("in_progress"), Some(Status::InProgress));
237 assert_eq!(parse_status("closed"), Some(Status::Closed));
238 }
239
240 #[test]
241 fn parse_status_invalid() {
242 assert_eq!(parse_status("invalid"), None);
243 assert_eq!(parse_status(""), None);
244 }
245
246 #[test]
247 fn blocked_by_open_dependency() {
248 let index = Index::build(&setup_test_units().1).unwrap();
249 let entry = index.units.iter().find(|e| e.id == "3").unwrap();
250 assert!(check_blocked(entry, &index).is_some());
252 }
253
254 #[test]
255 fn not_blocked_when_no_dependencies() {
256 let index = Index::build(&setup_test_units().1).unwrap();
257 let entry = index.units.iter().find(|e| e.id == "1").unwrap();
258 let reason = check_blocked(entry, &index);
260 assert!(reason.is_none(), "should not be blocked: {:?}", reason);
261 }
262
263 fn make_scoped_entry(id: &str, status: Status) -> IndexEntry {
264 IndexEntry {
265 id: id.to_string(),
266 title: "Test".to_string(),
267 status,
268 priority: 2,
269 parent: None,
270 dependencies: Vec::new(),
271 labels: Vec::new(),
272 assignee: None,
273 updated_at: chrono::Utc::now(),
274 produces: vec!["Artifact".to_string()],
275 requires: Vec::new(),
276 has_verify: true,
277 verify: None,
278 created_at: chrono::Utc::now(),
279 claimed_by: None,
280 attempts: 0,
281 paths: vec!["src/test.rs".to_string()],
282 feature: false,
283 has_decisions: false,
284 }
285 }
286
287 #[test]
288 fn status_indicator_open() {
289 let entry = make_scoped_entry("1", Status::Open);
290 let index = Index {
291 units: vec![entry.clone()],
292 };
293 assert_eq!(
294 get_status_indicator(&entry, &index),
295 ("[ ]".to_string(), String::new())
296 );
297 }
298
299 #[test]
300 fn status_indicator_in_progress() {
301 let entry = make_scoped_entry("1", Status::InProgress);
302 let index = Index {
303 units: vec![entry.clone()],
304 };
305 assert_eq!(
306 get_status_indicator(&entry, &index),
307 ("[-]".to_string(), String::new())
308 );
309 }
310
311 #[test]
312 fn status_indicator_closed() {
313 let entry = make_scoped_entry("1", Status::Closed);
314 let index = Index {
315 units: vec![entry.clone()],
316 };
317 assert_eq!(
318 get_status_indicator(&entry, &index),
319 ("[x]".to_string(), String::new())
320 );
321 }
322
323 #[test]
324 fn status_indicator_oversized_shows_warning() {
325 let mut entry = make_scoped_entry("1", Status::Open);
326 entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()];
327 let index = Index {
328 units: vec![entry.clone()],
329 };
330 let (indicator, suffix) = get_status_indicator(&entry, &index);
331 assert_eq!(indicator, "[ ]");
333 assert!(suffix.contains("oversized"));
334 }
335
336 #[test]
337 fn status_indicator_unscoped_no_warning() {
338 let mut entry = make_scoped_entry("1", Status::Open);
339 entry.produces = Vec::new();
340 entry.paths = Vec::new();
341 let index = Index {
342 units: vec![entry.clone()],
343 };
344 let (indicator, suffix) = get_status_indicator(&entry, &index);
345 assert_eq!(indicator, "[ ]");
347 assert!(suffix.is_empty());
348 }
349
350 #[test]
351 fn render_tree_hierarchy() {
352 let (_dir, mana_dir) = setup_test_units();
353 let index = Index::build(&mana_dir).unwrap();
354 let tree = render_tree(&index.units, &index);
355
356 assert!(tree.contains("1. First task"));
358 assert!(tree.contains("2. Second task"));
359 assert!(tree.contains("3. Parent task"));
360 assert!(tree.contains("3.1. Subtask"));
361
362 let lines: Vec<&str> = tree.lines().collect();
364 let line_3 = lines.iter().find(|l| l.contains("3. Parent task")).unwrap();
365 let line_3_1 = lines.iter().find(|l| l.contains("3.1. Subtask")).unwrap();
366
367 let indent_3 = line_3.len() - line_3.trim_start().len();
369 let indent_3_1 = line_3_1.len() - line_3_1.trim_start().len();
370 assert!(indent_3_1 > indent_3);
371 }
372}