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::index::{Index, IndexEntry};
8use crate::util::{natural_cmp, parse_status};
9
10/// List beans with optional filtering.
11/// - Default: tree-format with status indicators
12/// - --status: filter by status (open, in_progress, closed)
13/// - --priority: filter by priority (0-4)
14/// - --parent: show only children of this parent
15/// - --label: filter by label
16/// - --assignee: filter by assignee
17/// - --all: include closed beans (default excludes closed)
18/// - --json: JSON array output
19/// - Shows [!] for blocked beans
20///
21/// When --status closed is specified, also searches archived beans.
22#[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    // Parse status filter
38    let status_filter = status_filter.and_then(parse_status);
39
40    // Start with beans from the main index
41    let mut filtered = index.beans.clone();
42
43    // Include archived beans when querying for closed status or using --all
44    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    // Apply filters
52    filtered.retain(|entry| {
53        // Status filter
54        // By default, exclude closed beans (unless --all or --status closed)
55        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        // Priority filter
65        if let Some(priority) = priority_filter {
66            if entry.priority != priority {
67                return false;
68            }
69        }
70
71        // Parent filter
72        if let Some(parent) = parent_filter {
73            if entry.parent.as_deref() != Some(parent) {
74                return false;
75            }
76        }
77
78        // Label filter
79        if let Some(label) = label_filter {
80            if !entry.labels.contains(&label.to_string()) {
81                return false;
82            }
83        }
84
85        // Assignee filter
86        if let Some(_assignee) = assignee_filter {
87            // We need to load the full bean to check assignee (not in index)
88            // For now, skip this optimization and check during rendering
89            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        // Just IDs, one per line — ideal for piping
100        for entry in &filtered {
101            println!("{}", entry.id);
102        }
103    } else if let Some(fmt) = format_str {
104        // Custom format string: {id}, {title}, {status}, {priority}, {parent}
105        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        // Build combined index for tree rendering (includes archived if needed)
120        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        // Tree format with status indicators
131        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
139/// Render beans as a hierarchical tree.
140/// - Root beans have no parent
141/// - Children indented 2 spaces per level
142/// - Status: [ ] open, [-] in_progress, [x] closed, [!] blocked
143fn render_tree(entries: &[IndexEntry], index: &Index) -> String {
144    let mut output = String::new();
145
146    // Build parent -> children map
147    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    // Sort children by id within each parent
156    for children in children_map.values_mut() {
157        children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
158    }
159
160    // Render root entries
161    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
170/// Recursively render an entry and its children
171fn 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    // Render children
186    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
193/// Get status indicator for an entry
194fn 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
206/// Check if a bean is blocked by unresolved dependencies
207fn 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        // Create some test beans
231        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        // Create config
259        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        // bean 3 depends on bean 1 which is open, so bean 3 is blocked
282        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        // Should contain entries
371        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        // 3.1 should be indented (child of 3)
377        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        // 3.1 should have more indentation than 3
382        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}