Skip to main content

sift_queue/cli/commands/
list.rs

1use crate::cli::formatters;
2use crate::cli::help::{HelpDoc, HelpSection};
3use crate::queue::{parse_priority_value, Item, Queue, VALID_DISPLAY_STATUSES};
4use crate::ListArgs;
5use anyhow::Result;
6use clap::builder::{StyledStr, Styles};
7use std::collections::HashSet;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::{Command, Stdio};
11
12pub fn after_help(styles: &Styles) -> StyledStr {
13    HelpDoc::new()
14        .section(
15            HelpSection::new("Views:")
16                .item(
17                    "sq list --ready",
18                    "Show only actionable work: pending items with no open blockers",
19                )
20                .item(
21                    "sq list",
22                    "Default view: show all non-closed items so blocked dependencies and in_progress work remain visible",
23                )
24                .item("sq list --all", "Include closed items for history"),
25        )
26        .section(
27            HelpSection::new("Filters:")
28                .item(
29                    "--status <STATUS>",
30                    "Restrict to one visible state (pending|blocked|in_progress|closed)",
31                )
32                .item(
33                    "--priority <PRIORITY>",
34                    "Repeat to include multiple priorities",
35                )
36                .item(
37                    "--filter <EXPR>",
38                    "Apply a jq select expression after built-in filtering",
39                )
40                .item("--sort <PATH>", "Sort by a jq path expression")
41                .item("--reverse", "Reverse the selected sort order"),
42        )
43        .section(
44            HelpSection::new("Dependencies:")
45                .text("Use --blocked-by <id1,id2> on sq add or sq collect to declare blockers.")
46                .text("Use sq edit <id> --set-blocked-by ... to update blockers later."),
47        )
48        .section(
49            HelpSection::new("Examples:")
50                .item("sq list --ready", "Focus on the next actionable task")
51                .item(
52                    "sq list --priority 0 --priority 1",
53                    "Review the highest-priority work first",
54                )
55                .item(
56                    "sq list --status in_progress --json",
57                    "Inspect active work in machine-readable form",
58                ),
59        )
60        .render(styles)
61}
62
63/// Execute the `sq list` command.
64pub fn execute(args: &ListArgs, queue_path: PathBuf) -> Result<i32> {
65    let queue = Queue::new(queue_path);
66
67    if let Some(status) = args.status.as_deref() {
68        if !VALID_DISPLAY_STATUSES.contains(&status) {
69            eprintln!(
70                "Error: Invalid status: {}. Valid: {}",
71                status,
72                VALID_DISPLAY_STATUSES.join(", ")
73            );
74            return Ok(1);
75        }
76    }
77
78    let mut items: Vec<Item> = if args.ready {
79        queue.items_with_computed_status(queue.ready())
80    } else if args.all || args.status.is_some() {
81        queue.all_with_computed_status()
82    } else {
83        queue
84            .all_with_computed_status()
85            .into_iter()
86            .filter(|item| item.status != "closed")
87            .collect()
88    };
89
90    if let Some(status) = args.status.as_deref() {
91        items.retain(|item| item.status == status);
92    }
93
94    if !args.priority.is_empty() {
95        let requested_priorities: HashSet<u8> = args
96            .priority
97            .iter()
98            .map(|value| parse_priority_value(value))
99            .collect::<Result<_>>()?;
100
101        items.retain(|item| {
102            item.priority
103                .is_some_and(|priority| requested_priorities.contains(&priority))
104        });
105    }
106
107    // Apply jq filter
108    if let Some(ref filter_expr) = args.filter {
109        let expr = format!("[.[] | {}]", filter_expr);
110        match jq_filter(&items, &expr) {
111            Some(filtered) => items = filtered,
112            None => return Ok(1),
113        }
114    }
115
116    // Apply jq sort
117    if let Some(ref sort_path) = args.sort {
118        let expr = format!("sort_by({} // infinite)", sort_path);
119        match jq_filter(&items, &expr) {
120            Some(sorted) => items = sorted,
121            None => return Ok(1),
122        }
123    } else {
124        items.sort_by_key(|item| {
125            (
126                item.priority.unwrap_or(5),
127                item.created_at.clone(),
128                item.id.clone(),
129            )
130        });
131    }
132
133    // Apply reverse
134    if args.reverse {
135        items.reverse();
136    }
137
138    if args.json {
139        let values: Vec<serde_json::Value> =
140            items.iter().map(|i: &Item| i.to_json_value()).collect();
141        let json = serde_json::to_string_pretty(&values)?;
142        println!("{}", json);
143    } else if items.is_empty() {
144        eprintln!("No items found");
145    } else {
146        for item in &items {
147            formatters::print_item_summary(item);
148        }
149        eprintln!("{} item(s)", items.len());
150    }
151
152    Ok(0)
153}
154
155/// Run a jq expression on items, returning parsed results or None on error.
156fn jq_filter(items: &[Item], expr: &str) -> Option<Vec<Item>> {
157    let json_values: Vec<serde_json::Value> =
158        items.iter().map(|i: &Item| i.to_json_value()).collect();
159    let json = serde_json::to_string(&json_values).ok()?;
160
161    let mut child = Command::new("jq")
162        .arg("-e")
163        .arg(expr)
164        .stdin(Stdio::piped())
165        .stdout(Stdio::piped())
166        .stderr(Stdio::piped())
167        .spawn()
168        .map_err(|e| {
169            eprintln!("Error: Failed to run jq: {}", e);
170        })
171        .ok()?;
172
173    if let Some(ref mut stdin) = child.stdin {
174        stdin.write_all(json.as_bytes()).ok()?;
175    }
176    // Close stdin
177    drop(child.stdin.take());
178
179    let output = child.wait_with_output().ok()?;
180
181    if !output.status.success() {
182        let stderr = String::from_utf8_lossy(&output.stderr);
183        eprintln!("Error: Filter failed: {}", stderr.trim());
184        return None;
185    }
186
187    let parsed: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
188    Some(
189        parsed
190            .into_iter()
191            .filter_map(|v| serde_json::from_value::<Item>(v).ok())
192            .collect(),
193    )
194}