1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5
6use crate::bean::Status;
7use crate::index::{Index, IndexEntry};
8use crate::util::{natural_cmp, parse_status};
9
10#[allow(clippy::too_many_arguments)]
23pub fn cmd_list(
24 status_filter: Option<&str>,
25 priority_filter: Option<u8>,
26 parent_filter: Option<&str>,
27 label_filter: Option<&str>,
28 assignee_filter: Option<&str>,
29 all: bool,
30 json: bool,
31 ids: bool,
32 format_str: Option<&str>,
33 beans_dir: &Path,
34) -> Result<()> {
35 let index = Index::load_or_rebuild(beans_dir)?;
36
37 let status_filter = status_filter.and_then(parse_status);
39
40 let mut filtered = index.beans.clone();
42
43 let include_archived = status_filter == Some(Status::Closed) || all;
45 if include_archived {
46 if let Ok(archived) = Index::collect_archived(beans_dir) {
47 filtered.extend(archived);
48 }
49 }
50
51 filtered.retain(|entry| {
53 if !all && status_filter != Some(Status::Closed) && entry.status == Status::Closed {
56 return false;
57 }
58 if let Some(status) = status_filter {
59 if entry.status != status {
60 return false;
61 }
62 }
63
64 if let Some(priority) = priority_filter {
66 if entry.priority != priority {
67 return false;
68 }
69 }
70
71 if let Some(parent) = parent_filter {
73 if entry.parent.as_deref() != Some(parent) {
74 return false;
75 }
76 }
77
78 if let Some(label) = label_filter {
80 if !entry.labels.contains(&label.to_string()) {
81 return false;
82 }
83 }
84
85 if let Some(_assignee) = assignee_filter {
87 return true;
90 }
91
92 true
93 });
94
95 if json {
96 let json_str = serde_json::to_string_pretty(&filtered)?;
97 println!("{}", json_str);
98 } else if ids {
99 for entry in &filtered {
101 println!("{}", entry.id);
102 }
103 } else if let Some(fmt) = format_str {
104 for entry in &filtered {
106 let line = fmt
107 .replace("{id}", &entry.id)
108 .replace("{title}", &entry.title)
109 .replace("{status}", &format!("{}", entry.status))
110 .replace("{priority}", &format!("P{}", entry.priority))
111 .replace("{parent}", entry.parent.as_deref().unwrap_or(""))
112 .replace("{assignee}", entry.assignee.as_deref().unwrap_or(""))
113 .replace("{labels}", &entry.labels.join(","))
114 .replace("\\t", "\t")
115 .replace("\\n", "\n");
116 println!("{}", line);
117 }
118 } else {
119 let combined_index = if include_archived {
121 let mut all_beans = index.beans.clone();
122 if let Ok(archived) = Index::collect_archived(beans_dir) {
123 all_beans.extend(archived);
124 }
125 Index { beans: all_beans }
126 } else {
127 index.clone()
128 };
129
130 let tree = render_tree(&filtered, &combined_index);
132 println!("{}", tree);
133 println!("Legend: [ ] open [-] in_progress [x] closed [!] blocked");
134 }
135
136 Ok(())
137}
138
139fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
144 let mut output = String::new();
145
146 let mut children_map: HashMap<Option<String>, Vec<&IndexEntry>> = HashMap::new();
148 for entry in entries {
149 children_map
150 .entry(entry.parent.clone())
151 .or_default()
152 .push(entry);
153 }
154
155 for children in children_map.values_mut() {
157 children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
158 }
159
160 if let Some(roots) = children_map.get(&None) {
162 for root in roots {
163 render_entry(&mut output, root, 0, &children_map, index);
164 }
165 }
166
167 output
168}
169
170fn render_entry(
172 output: &mut String,
173 entry: &IndexEntry,
174 depth: u32,
175 children_map: &HashMap<Option<String>, Vec<&IndexEntry>>,
176 index: &Index,
177) {
178 let indent = " ".repeat(depth as usize);
179 let status_indicator = get_status_indicator(entry, index);
180 output.push_str(&format!(
181 "{}{} {}. {}\n",
182 indent, status_indicator, entry.id, entry.title
183 ));
184
185 if let Some(children) = children_map.get(&Some(entry.id.clone())) {
187 for child in children {
188 render_entry(output, child, depth + 1, children_map, index);
189 }
190 }
191}
192
193fn get_status_indicator(entry: &IndexEntry, index: &Index) -> String {
195 if is_blocked(entry, index) {
196 "[!]".to_string()
197 } else {
198 match entry.status {
199 Status::Open => "[ ]".to_string(),
200 Status::InProgress => "[-]".to_string(),
201 Status::Closed => "[x]".to_string(),
202 }
203 }
204}
205
206fn is_blocked(entry: &IndexEntry, index: &Index) -> bool {
208 for dep_id in &entry.dependencies {
209 if let Some(dep_entry) = index.beans.iter().find(|e| &e.id == dep_id) {
210 if dep_entry.status != Status::Closed {
211 return true;
212 }
213 }
214 }
215 false
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::util::title_to_slug;
222 use std::fs;
223 use tempfile::TempDir;
224
225 fn setup_test_beans() -> (TempDir, std::path::PathBuf) {
226 let dir = TempDir::new().unwrap();
227 let beans_dir = dir.path().join(".beans");
228 fs::create_dir(&beans_dir).unwrap();
229
230 let bean1 = crate::bean::Bean::new("1", "First task");
232 let mut bean2 = crate::bean::Bean::new("2", "Second task");
233 bean2.status = Status::InProgress;
234 let mut bean3 = crate::bean::Bean::new("3", "Parent task");
235 bean3.dependencies = vec!["1".to_string()];
236
237 let mut bean3_1 = crate::bean::Bean::new("3.1", "Subtask");
238 bean3_1.parent = Some("3".to_string());
239
240 let slug1 = title_to_slug(&bean1.title);
241 let slug2 = title_to_slug(&bean2.title);
242 let slug3 = title_to_slug(&bean3.title);
243 let slug3_1 = title_to_slug(&bean3_1.title);
244
245 bean1
246 .to_file(beans_dir.join(format!("1-{}.md", slug1)))
247 .unwrap();
248 bean2
249 .to_file(beans_dir.join(format!("2-{}.md", slug2)))
250 .unwrap();
251 bean3
252 .to_file(beans_dir.join(format!("3-{}.md", slug3)))
253 .unwrap();
254 bean3_1
255 .to_file(beans_dir.join(format!("3.1-{}.md", slug3_1)))
256 .unwrap();
257
258 fs::write(beans_dir.join("config.yaml"), "project: test\nnext_id: 4\n").unwrap();
260
261 (dir, beans_dir)
262 }
263
264 #[test]
265 fn parse_status_valid() {
266 assert_eq!(parse_status("open"), Some(Status::Open));
267 assert_eq!(parse_status("in_progress"), Some(Status::InProgress));
268 assert_eq!(parse_status("closed"), Some(Status::Closed));
269 }
270
271 #[test]
272 fn parse_status_invalid() {
273 assert_eq!(parse_status("invalid"), None);
274 assert_eq!(parse_status(""), None);
275 }
276
277 #[test]
278 fn is_blocked_by_open_dependency() {
279 let index = Index::build(&setup_test_beans().1).unwrap();
280 let entry = index.beans.iter().find(|e| e.id == "3").unwrap();
281 assert!(is_blocked(entry, &index));
283 }
284
285 #[test]
286 fn is_not_blocked_when_no_dependencies() {
287 let index = Index::build(&setup_test_beans().1).unwrap();
288 let entry = index.beans.iter().find(|e| e.id == "1").unwrap();
289 assert!(!is_blocked(entry, &index));
290 }
291
292 #[test]
293 fn status_indicator_open() {
294 let entry = IndexEntry {
295 id: "1".to_string(),
296 title: "Test".to_string(),
297 status: Status::Open,
298 priority: 2,
299 parent: None,
300 dependencies: Vec::new(),
301 labels: Vec::new(),
302 assignee: None,
303 updated_at: chrono::Utc::now(),
304 produces: Vec::new(),
305 requires: Vec::new(),
306 has_verify: true,
307 claimed_by: None,
308 attempts: 0,
309 };
310 let index = Index {
311 beans: vec![entry.clone()],
312 };
313 assert_eq!(get_status_indicator(&entry, &index), "[ ]");
314 }
315
316 #[test]
317 fn status_indicator_in_progress() {
318 let entry = IndexEntry {
319 id: "1".to_string(),
320 title: "Test".to_string(),
321 status: Status::InProgress,
322 priority: 2,
323 parent: None,
324 dependencies: Vec::new(),
325 labels: Vec::new(),
326 assignee: None,
327 updated_at: chrono::Utc::now(),
328 produces: Vec::new(),
329 requires: Vec::new(),
330 has_verify: true,
331 claimed_by: None,
332 attempts: 0,
333 };
334 let index = Index {
335 beans: vec![entry.clone()],
336 };
337 assert_eq!(get_status_indicator(&entry, &index), "[-]");
338 }
339
340 #[test]
341 fn status_indicator_closed() {
342 let entry = IndexEntry {
343 id: "1".to_string(),
344 title: "Test".to_string(),
345 status: Status::Closed,
346 priority: 2,
347 parent: None,
348 dependencies: Vec::new(),
349 labels: Vec::new(),
350 assignee: None,
351 updated_at: chrono::Utc::now(),
352 produces: Vec::new(),
353 requires: Vec::new(),
354 has_verify: true,
355 claimed_by: None,
356 attempts: 0,
357 };
358 let index = Index {
359 beans: vec![entry.clone()],
360 };
361 assert_eq!(get_status_indicator(&entry, &index), "[x]");
362 }
363
364 #[test]
365 fn render_tree_hierarchy() {
366 let (_dir, beans_dir) = setup_test_beans();
367 let index = Index::build(&beans_dir).unwrap();
368 let tree = render_tree(&index.beans, &index);
369
370 assert!(tree.contains("1. First task"));
372 assert!(tree.contains("2. Second task"));
373 assert!(tree.contains("3. Parent task"));
374 assert!(tree.contains("3.1. Subtask"));
375
376 let lines: Vec<&str> = tree.lines().collect();
378 let line_3 = lines.iter().find(|l| l.contains("3. Parent task")).unwrap();
379 let line_3_1 = lines.iter().find(|l| l.contains("3.1. Subtask")).unwrap();
380
381 let indent_3 = line_3.len() - line_3.trim_start().len();
383 let indent_3_1 = line_3_1.len() - line_3_1.trim_start().len();
384 assert!(indent_3_1 > indent_3);
385 }
386}