Skip to main content

bn/commands/
list.rs

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/// List beans with optional filtering.
13/// - Default: tree-format with status indicators
14/// - --status: filter by status (open, in_progress, closed)
15/// - --priority: filter by priority (0-4)
16/// - --parent: show only children of this parent
17/// - --label: filter by label
18/// - --assignee: filter by assignee
19/// - --all: include closed beans (default excludes closed)
20/// - --json: JSON array output
21/// - Shows [!] for blocked beans
22///
23/// When --status closed is specified, also searches archived beans.
24#[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    // Parse status filter
41    let status_filter = status_filter.and_then(parse_status);
42
43    // Resolve current user for --mine filter
44    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    // Start with beans from the main index
58    let mut filtered = index.beans.clone();
59
60    // Include archived beans when querying for closed status or using --all
61    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    // Apply filters
69    filtered.retain(|entry| {
70        // Status filter
71        // By default, exclude closed beans (unless --all or --status closed)
72        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        // Priority filter
82        if let Some(priority) = priority_filter {
83            if entry.priority != priority {
84                return false;
85            }
86        }
87
88        // Parent filter
89        if let Some(parent) = parent_filter {
90            if entry.parent.as_deref() != Some(parent) {
91                return false;
92            }
93        }
94
95        // Label filter
96        if let Some(label) = label_filter {
97            if !entry.labels.contains(&label.to_string()) {
98                return false;
99            }
100        }
101
102        // Assignee filter
103        if let Some(_assignee) = assignee_filter {
104            // We need to load the full bean to check assignee (not in index)
105            // For now, skip this optimization and check during rendering
106            return true;
107        }
108
109        // --mine filter: show beans claimed by or assigned to the current user
110        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        // Just IDs, one per line — ideal for piping
129        for entry in &filtered {
130            println!("{}", entry.id);
131        }
132    } else if let Some(fmt) = format_str {
133        // Custom format string: {id}, {title}, {status}, {priority}, {parent}
134        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        // Build combined index for tree rendering (includes archived if needed)
149        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        // Tree format with status indicators
160        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
168/// Render beans as a hierarchical tree.
169/// - Root beans have no parent
170/// - Children indented 2 spaces per level
171/// - Status: [ ] open, [-] in_progress, [x] closed, [!] blocked
172fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
173    let mut output = String::new();
174
175    // Build parent -> children map
176    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    // Sort children by id within each parent
185    for children in children_map.values_mut() {
186        children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
187    }
188
189    // Render root entries
190    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
199/// Recursively render an entry and its children
200fn 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    // Render children
215    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
222/// Get status indicator and optional suffix for an entry.
223/// Returns (indicator, suffix) where suffix is e.g. " (waiting on 3.1)" or " (⚠ oversized)".
224fn 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        // Scope warnings are non-blocking annotations
234        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        // Create some test beans
254        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        // Create config
282        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        // bean 3 depends on bean 1 which is open, so bean 3 is blocked
305        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        // bean 1 has no deps — unscoped beans are no longer blocked
313        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        // Not blocked — still shows [ ] with a warning suffix
382        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        // Unscoped is totally fine — no warning, no blocking
396        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        // Should contain entries
407        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        // 3.1 should be indented (child of 3)
413        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        // 3.1 should have more indentation than 3
418        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}