sift-queue 0.8.0

Queue CLI and queue-native task/review substrate
Documentation
use crate::cli::formatters;
use crate::cli::help::{HelpDoc, HelpSection};
use crate::queue::{parse_priority_value, Item, Queue, VALID_DISPLAY_STATUSES};
use crate::ListArgs;
use anyhow::Result;
use clap::builder::{StyledStr, Styles};
use std::collections::HashSet;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};

pub fn after_help(styles: &Styles) -> StyledStr {
    HelpDoc::new()
        .section(
            HelpSection::new("Views:")
                .item(
                    "sq list --ready",
                    "Show only actionable work: pending items with no open blockers",
                )
                .item(
                    "sq list",
                    "Default view: show all non-closed items so blocked dependencies and in_progress work remain visible",
                )
                .item("sq list --all", "Include closed items for history"),
        )
        .section(
            HelpSection::new("Filters:")
                .item(
                    "--status <STATUS>",
                    "Restrict to visible states; repeat to include multiple (pending|blocked|in_progress|closed)",
                )
                .item(
                    "--priority <PRIORITY>",
                    "Repeat to include multiple priorities",
                )
                .item(
                    "--filter <EXPR>",
                    "Apply a jq select expression after built-in filtering",
                )
                .item("--sort <PATH>", "Sort by a jq path expression")
                .item("--reverse", "Reverse the selected sort order"),
        )
        .section(
            HelpSection::new("Dependencies:")
                .text("Use --blocked-by <id1,id2> on sq add or sq collect to declare blockers.")
                .text("Use sq edit <id> --set-blocked-by ... to update blockers later."),
        )
        .section(
            HelpSection::new("Examples:")
                .item("sq list --ready", "Focus on the next actionable task")
                .item(
                    "sq list --priority 0 --priority 1",
                    "Review the highest-priority work first",
                )
                .item(
                    "sq list --status in_progress --json",
                    "Inspect active work in machine-readable form",
                ),
        )
        .render(styles)
}

/// Execute the `sq list` command.
pub fn execute(args: &ListArgs, queue_path: PathBuf) -> Result<i32> {
    let queue = Queue::new(queue_path);

    for status in &args.status {
        if !VALID_DISPLAY_STATUSES.contains(&status.as_str()) {
            eprintln!(
                "Error: Invalid status: {}. Valid: {}",
                status,
                VALID_DISPLAY_STATUSES.join(", ")
            );
            return Ok(1);
        }
    }

    let mut items: Vec<Item> = if args.ready {
        queue.items_with_computed_status(queue.ready())
    } else if args.all || !args.status.is_empty() {
        queue.all_with_computed_status()
    } else {
        queue
            .all_with_computed_status()
            .into_iter()
            .filter(|item| item.status != "closed")
            .collect()
    };

    if !args.status.is_empty() {
        let requested_statuses: HashSet<&str> =
            args.status.iter().map(|status| status.as_str()).collect();
        items.retain(|item| requested_statuses.contains(item.status.as_str()));
    }

    if !args.priority.is_empty() {
        let requested_priorities: HashSet<u8> = args
            .priority
            .iter()
            .map(|value| parse_priority_value(value))
            .collect::<Result<_>>()?;

        items.retain(|item| {
            item.priority
                .is_some_and(|priority| requested_priorities.contains(&priority))
        });
    }

    // Apply jq filter
    if let Some(ref filter_expr) = args.filter {
        let expr = format!("[.[] | {}]", filter_expr);
        match jq_filter(&items, &expr) {
            Some(filtered) => items = filtered,
            None => return Ok(1),
        }
    }

    // Apply jq sort
    if let Some(ref sort_path) = args.sort {
        let expr = format!("sort_by({} // infinite)", sort_path);
        match jq_filter(&items, &expr) {
            Some(sorted) => items = sorted,
            None => return Ok(1),
        }
    } else {
        items.sort_by_key(|item| {
            (
                item.priority.unwrap_or(5),
                item.created_at.clone(),
                item.id.clone(),
            )
        });
    }

    // Apply reverse
    if args.reverse {
        items.reverse();
    }

    if args.json {
        let values: Vec<serde_json::Value> =
            items.iter().map(|i: &Item| i.to_json_value()).collect();
        let json = serde_json::to_string_pretty(&values)?;
        println!("{}", json);
    } else if items.is_empty() {
        eprintln!("No items found");
    } else {
        for item in &items {
            formatters::print_item_summary(item);
        }
        eprintln!("{} item(s)", items.len());
    }

    Ok(0)
}

/// Run a jq expression on items, returning parsed results or None on error.
fn jq_filter(items: &[Item], expr: &str) -> Option<Vec<Item>> {
    let json_values: Vec<serde_json::Value> =
        items.iter().map(|i: &Item| i.to_json_value()).collect();
    let json = serde_json::to_string(&json_values).ok()?;

    let mut child = Command::new("jq")
        .arg("-e")
        .arg(expr)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| {
            eprintln!("Error: Failed to run jq: {}", e);
        })
        .ok()?;

    if let Some(ref mut stdin) = child.stdin {
        stdin.write_all(json.as_bytes()).ok()?;
    }
    // Close stdin
    drop(child.stdin.take());

    let output = child.wait_with_output().ok()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        eprintln!("Error: Filter failed: {}", stderr.trim());
        return None;
    }

    let parsed: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
    Some(
        parsed
            .into_iter()
            .filter_map(|v| serde_json::from_value::<Item>(v).ok())
            .collect(),
    )
}