1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5
6use crate::bean::Status;
7use crate::blocking::check_blocked;
8use crate::config::resolve_identity;
9use crate::index::{Index, IndexEntry};
10use crate::util::{natural_cmp, parse_status};
11
12#[allow(clippy::too_many_arguments)]
25pub fn cmd_list(
26 status_filter: Option<&str>,
27 priority_filter: Option<u8>,
28 parent_filter: Option<&str>,
29 label_filter: Option<&str>,
30 assignee_filter: Option<&str>,
31 mine: bool,
32 all: bool,
33 json: bool,
34 ids: bool,
35 format_str: Option<&str>,
36 beans_dir: &Path,
37) -> Result<()> {
38 let index = Index::load_or_rebuild(beans_dir)?;
39
40 let status_filter = status_filter.and_then(parse_status);
42
43 let current_user = if mine {
45 let user = resolve_identity(beans_dir);
46 if user.is_none() {
47 anyhow::bail!(
48 "Cannot use --mine: no identity configured.\n\
49 Set one with: bn config set user <name>"
50 );
51 }
52 user
53 } else {
54 None
55 };
56
57 let mut filtered = index.beans.clone();
59
60 let include_archived = status_filter == Some(Status::Closed) || all;
62 if include_archived {
63 if let Ok(archived) = Index::collect_archived(beans_dir) {
64 filtered.extend(archived);
65 }
66 }
67
68 filtered.retain(|entry| {
70 if !all && status_filter != Some(Status::Closed) && entry.status == Status::Closed {
73 return false;
74 }
75 if let Some(status) = status_filter {
76 if entry.status != status {
77 return false;
78 }
79 }
80
81 if let Some(priority) = priority_filter {
83 if entry.priority != priority {
84 return false;
85 }
86 }
87
88 if let Some(parent) = parent_filter {
90 if entry.parent.as_deref() != Some(parent) {
91 return false;
92 }
93 }
94
95 if let Some(label) = label_filter {
97 if !entry.labels.contains(&label.to_string()) {
98 return false;
99 }
100 }
101
102 if let Some(_assignee) = assignee_filter {
104 return true;
107 }
108
109 if let Some(ref user) = current_user {
111 let claimed_match = entry
112 .claimed_by
113 .as_ref()
114 .is_some_and(|c| c == user || c.starts_with(&format!("{}/", user)));
115 let assignee_match = entry.assignee.as_deref() == Some(user.as_str());
116 if !claimed_match && !assignee_match {
117 return false;
118 }
119 }
120
121 true
122 });
123
124 if json {
125 let json_str = serde_json::to_string_pretty(&filtered)?;
126 println!("{}", json_str);
127 } else if ids {
128 for entry in &filtered {
130 println!("{}", entry.id);
131 }
132 } else if let Some(fmt) = format_str {
133 for entry in &filtered {
135 let line = fmt
136 .replace("{id}", &entry.id)
137 .replace("{title}", &entry.title)
138 .replace("{status}", &format!("{}", entry.status))
139 .replace("{priority}", &format!("P{}", entry.priority))
140 .replace("{parent}", entry.parent.as_deref().unwrap_or(""))
141 .replace("{assignee}", entry.assignee.as_deref().unwrap_or(""))
142 .replace("{labels}", &entry.labels.join(","))
143 .replace("\\t", "\t")
144 .replace("\\n", "\n");
145 println!("{}", line);
146 }
147 } else {
148 let combined_index = if include_archived {
150 let mut all_beans = index.beans.clone();
151 if let Ok(archived) = Index::collect_archived(beans_dir) {
152 all_beans.extend(archived);
153 }
154 Index { beans: all_beans }
155 } else {
156 index.clone()
157 };
158
159 let tree = render_tree(&filtered, &combined_index);
161 println!("{}", tree);
162 println!("Legend: [ ] open [-] in_progress [x] closed [!] blocked");
163 }
164
165 Ok(())
166}
167
168fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
173 let mut output = String::new();
174
175 let mut children_map: HashMap<Option<String>, Vec<&IndexEntry>> = HashMap::new();
177 for entry in entries {
178 children_map
179 .entry(entry.parent.clone())
180 .or_default()
181 .push(entry);
182 }
183
184 for children in children_map.values_mut() {
186 children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
187 }
188
189 if let Some(roots) = children_map.get(&None) {
191 for root in roots {
192 render_entry(&mut output, root, 0, &children_map, index);
193 }
194 }
195
196 output
197}
198
199fn render_entry(
201 output: &mut String,
202 entry: &IndexEntry,
203 depth: u32,
204 children_map: &HashMap<Option<String>, Vec<&IndexEntry>>,
205 index: &Index,
206) {
207 let indent = " ".repeat(depth as usize);
208 let (status_indicator, reason_suffix) = get_status_indicator(entry, index);
209 output.push_str(&format!(
210 "{}{} {}. {}{}\n",
211 indent, status_indicator, entry.id, entry.title, reason_suffix
212 ));
213
214 if let Some(children) = children_map.get(&Some(entry.id.clone())) {
216 for child in children {
217 render_entry(output, child, depth + 1, children_map, index);
218 }
219 }
220}
221
222fn get_status_indicator(entry: &IndexEntry, index: &Index) -> (String, String) {
225 if let Some(reason) = check_blocked(entry, index) {
226 ("[!]".to_string(), format!(" ({})", reason))
227 } else {
228 let indicator = match entry.status {
229 Status::Open => "[ ]",
230 Status::InProgress => "[-]",
231 Status::Closed => "[x]",
232 };
233 let suffix = crate::blocking::check_scope_warning(entry)
235 .map(|w| format!(" (⚠ {})", w))
236 .unwrap_or_default();
237 (indicator.to_string(), suffix)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::util::title_to_slug;
245 use std::fs;
246 use tempfile::TempDir;
247
248 fn setup_test_beans() -> (TempDir, std::path::PathBuf) {
249 let dir = TempDir::new().unwrap();
250 let beans_dir = dir.path().join(".beans");
251 fs::create_dir(&beans_dir).unwrap();
252
253 let bean1 = crate::bean::Bean::new("1", "First task");
255 let mut bean2 = crate::bean::Bean::new("2", "Second task");
256 bean2.status = Status::InProgress;
257 let mut bean3 = crate::bean::Bean::new("3", "Parent task");
258 bean3.dependencies = vec!["1".to_string()];
259
260 let mut bean3_1 = crate::bean::Bean::new("3.1", "Subtask");
261 bean3_1.parent = Some("3".to_string());
262
263 let slug1 = title_to_slug(&bean1.title);
264 let slug2 = title_to_slug(&bean2.title);
265 let slug3 = title_to_slug(&bean3.title);
266 let slug3_1 = title_to_slug(&bean3_1.title);
267
268 bean1
269 .to_file(beans_dir.join(format!("1-{}.md", slug1)))
270 .unwrap();
271 bean2
272 .to_file(beans_dir.join(format!("2-{}.md", slug2)))
273 .unwrap();
274 bean3
275 .to_file(beans_dir.join(format!("3-{}.md", slug3)))
276 .unwrap();
277 bean3_1
278 .to_file(beans_dir.join(format!("3.1-{}.md", slug3_1)))
279 .unwrap();
280
281 fs::write(beans_dir.join("config.yaml"), "project: test\nnext_id: 4\n").unwrap();
283
284 (dir, beans_dir)
285 }
286
287 #[test]
288 fn parse_status_valid() {
289 assert_eq!(parse_status("open"), Some(Status::Open));
290 assert_eq!(parse_status("in_progress"), Some(Status::InProgress));
291 assert_eq!(parse_status("closed"), Some(Status::Closed));
292 }
293
294 #[test]
295 fn parse_status_invalid() {
296 assert_eq!(parse_status("invalid"), None);
297 assert_eq!(parse_status(""), None);
298 }
299
300 #[test]
301 fn blocked_by_open_dependency() {
302 let index = Index::build(&setup_test_beans().1).unwrap();
303 let entry = index.beans.iter().find(|e| e.id == "3").unwrap();
304 assert!(check_blocked(entry, &index).is_some());
306 }
307
308 #[test]
309 fn not_blocked_when_no_dependencies() {
310 let index = Index::build(&setup_test_beans().1).unwrap();
311 let entry = index.beans.iter().find(|e| e.id == "1").unwrap();
312 let reason = check_blocked(entry, &index);
314 assert!(reason.is_none(), "should not be blocked: {:?}", reason);
315 }
316
317 fn make_scoped_entry(id: &str, status: Status) -> IndexEntry {
318 IndexEntry {
319 id: id.to_string(),
320 title: "Test".to_string(),
321 status,
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!["Artifact".to_string()],
329 requires: Vec::new(),
330 has_verify: true,
331 claimed_by: None,
332 attempts: 0,
333 paths: vec!["src/test.rs".to_string()],
334 }
335 }
336
337 #[test]
338 fn status_indicator_open() {
339 let entry = make_scoped_entry("1", Status::Open);
340 let index = Index {
341 beans: vec![entry.clone()],
342 };
343 assert_eq!(
344 get_status_indicator(&entry, &index),
345 ("[ ]".to_string(), String::new())
346 );
347 }
348
349 #[test]
350 fn status_indicator_in_progress() {
351 let entry = make_scoped_entry("1", Status::InProgress);
352 let index = Index {
353 beans: vec![entry.clone()],
354 };
355 assert_eq!(
356 get_status_indicator(&entry, &index),
357 ("[-]".to_string(), String::new())
358 );
359 }
360
361 #[test]
362 fn status_indicator_closed() {
363 let entry = make_scoped_entry("1", Status::Closed);
364 let index = Index {
365 beans: vec![entry.clone()],
366 };
367 assert_eq!(
368 get_status_indicator(&entry, &index),
369 ("[x]".to_string(), String::new())
370 );
371 }
372
373 #[test]
374 fn status_indicator_oversized_shows_warning() {
375 let mut entry = make_scoped_entry("1", Status::Open);
376 entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()];
377 let index = Index {
378 beans: vec![entry.clone()],
379 };
380 let (indicator, suffix) = get_status_indicator(&entry, &index);
381 assert_eq!(indicator, "[ ]");
383 assert!(suffix.contains("oversized"));
384 }
385
386 #[test]
387 fn status_indicator_unscoped_no_warning() {
388 let mut entry = make_scoped_entry("1", Status::Open);
389 entry.produces = Vec::new();
390 entry.paths = Vec::new();
391 let index = Index {
392 beans: vec![entry.clone()],
393 };
394 let (indicator, suffix) = get_status_indicator(&entry, &index);
395 assert_eq!(indicator, "[ ]");
397 assert!(suffix.is_empty());
398 }
399
400 #[test]
401 fn render_tree_hierarchy() {
402 let (_dir, beans_dir) = setup_test_beans();
403 let index = Index::build(&beans_dir).unwrap();
404 let tree = render_tree(&index.beans, &index);
405
406 assert!(tree.contains("1. First task"));
408 assert!(tree.contains("2. Second task"));
409 assert!(tree.contains("3. Parent task"));
410 assert!(tree.contains("3.1. Subtask"));
411
412 let lines: Vec<&str> = tree.lines().collect();
414 let line_3 = lines.iter().find(|l| l.contains("3. Parent task")).unwrap();
415 let line_3_1 = lines.iter().find(|l| l.contains("3.1. Subtask")).unwrap();
416
417 let indent_3 = line_3.len() - line_3.trim_start().len();
419 let indent_3_1 = line_3_1.len() - line_3_1.trim_start().len();
420 assert!(indent_3_1 > indent_3);
421 }
422}