Skip to main content

blotter/commands/
list.rs

1use crate::cli::{ListArgs, ListKind, OutputFormat, StatusFilter};
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{ItemStatus, ListItem, Severity, parse_since};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::io::Write;
9use std::path::PathBuf;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct ListData {
13    pub items: Vec<ListItem>,
14    pub count: usize,
15    pub total: usize,
16    pub truncated: bool,
17}
18
19pub fn run(args: ListArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
20    let resolved = store::discover(file)?;
21    if args.kind != ListKind::Cut && args.severity.is_some() {
22        return Err(AppError::invalid_argument(
23            "--severity is only available with --kind cut",
24            "Remove --severity or use `blotter list --kind cut --severity minor|major|blocker`.",
25        ));
26    }
27    let store::LoadedFold {
28        items,
29        mut warnings,
30    } = store::load_folded(&resolved)?;
31    let include_auto = args.include_auto || args.tag.as_deref() == Some("auto");
32    let (items, auto_captures) = crate::partition_auto_captures(items, include_auto);
33    let since = args
34        .since
35        .as_deref()
36        .map(|value| parse_since(value, now))
37        .transpose()?;
38    let hidden = auto_captures
39        .iter()
40        .filter(|item| matches_filters(item, &args, since.as_ref()))
41        .count();
42    if hidden > 0 {
43        warnings.push(crate::auto_capture_warning(hidden));
44    }
45    let mut items: Vec<_> = items
46        .into_iter()
47        .filter(|item| matches_filters(item, &args, since.as_ref()))
48        .collect();
49    let total = items.len();
50    items.truncate(args.limit);
51    let data = ListData {
52        count: items.len(),
53        total,
54        truncated: total > items.len(),
55        items,
56    };
57    if total == 0 {
58        warnings.push(
59            match args.kind {
60                ListKind::Cut => "no cuts matched; try --status all or broader filters",
61                ListKind::Dogear => "no dogears matched; try --status all or broader filters",
62                ListKind::All => "no records matched; try --status all or broader filters",
63            }
64            .into(),
65        );
66    }
67    if args.format == OutputFormat::Md {
68        write_markdown(&data.items, &warnings)?;
69    } else {
70        let mut meta = Meta::new();
71        meta.file = Some(resolved.path.to_string_lossy().into_owned());
72        meta.warnings = warnings;
73        output::write_success(data, pretty, meta)
74            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
75    }
76    Ok(0)
77}
78
79fn matches_filters(item: &ListItem, args: &ListArgs, since: Option<&Timestamp>) -> bool {
80    let kind_matches = match args.kind {
81        ListKind::Cut => item.kind == "cut",
82        ListKind::Dogear => item.kind == "dogear",
83        ListKind::All => true,
84    };
85    let status_matches = match args.status {
86        StatusFilter::Open => item.status == ItemStatus::Open,
87        StatusFilter::Resolved => item.status == ItemStatus::Resolved,
88        StatusFilter::All => true,
89    };
90    kind_matches
91        && status_matches
92        && args.agent.as_ref().is_none_or(|agent| &item.agent == agent)
93        && args.tag.as_ref().is_none_or(|tag| item.tags.contains(tag))
94        && args
95            .severity
96            .is_none_or(|severity| item.severity == Some(severity))
97        && since.is_none_or(|threshold| {
98            item.ts
99                .parse::<Timestamp>()
100                .is_ok_and(|timestamp| timestamp >= *threshold)
101        })
102}
103
104fn write_markdown(items: &[ListItem], warnings: &[String]) -> AppResult<()> {
105    let mut output = output::stdout_writer()
106        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
107    for severity in [Severity::Blocker, Severity::Major, Severity::Minor] {
108        let matching: Vec<_> = items
109            .iter()
110            .filter(|item| item.severity == Some(severity))
111            .collect();
112        if matching.is_empty() {
113            continue;
114        }
115        writeln!(
116            output,
117            "## {}",
118            match severity {
119                Severity::Blocker => "Blocker",
120                Severity::Major => "Major",
121                Severity::Minor => "Minor",
122            }
123        )
124        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
125        for item in matching {
126            write_markdown_item(&mut output, item)?;
127        }
128    }
129    let dogears: Vec<_> = items.iter().filter(|item| item.kind == "dogear").collect();
130    if !dogears.is_empty() {
131        writeln!(output, "## Dogears")
132            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
133        for item in dogears {
134            write_markdown_item(&mut output, item)?;
135        }
136    }
137    for warning in warnings {
138        writeln!(output, "> note: {warning}")
139            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
140    }
141    output
142        .flush()
143        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
144    Ok(())
145}
146
147fn write_markdown_item(output: &mut impl Write, item: &ListItem) -> AppResult<()> {
148    let id = if item.status == ItemStatus::Resolved {
149        format!("~~{}~~", item.id)
150    } else {
151        item.id.clone()
152    };
153    let tags = if item.tags.is_empty() {
154        String::new()
155    } else {
156        format!(" ({})", item.tags.join(","))
157    };
158    // Every interpolated field can carry embedded newlines (resolve only rejects
159    // whitespace-only values), so each rendered line is collapsed as a whole.
160    let line = format!("- [{id}] {} — {}, {}{tags}", item.text, item.agent, item.ts);
161    writeln!(output, "{}", crate::output::collapse_markdown_text(&line))
162        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
163    if let Some(resolution) = &item.resolution {
164        let mut line = format!("resolved {} by {}", resolution.ts, resolution.agent);
165        if let Some(commit) = &resolution.commit {
166            line.push_str(&format!(" ({commit})"));
167        }
168        if let Some(pr) = &resolution.pr {
169            line.push_str(&format!(" pr {pr}"));
170        }
171        if let Some(task) = &resolution.task {
172            line.push_str(&format!(" task {task}"));
173        }
174        if let Some(note) = resolution
175            .note
176            .as_deref()
177            .map(crate::output::collapse_markdown_text)
178            .filter(|note| !note.is_empty())
179        {
180            line.push_str(&format!(": {note}"));
181        }
182        writeln!(
183            output,
184            "  - {}",
185            crate::output::collapse_markdown_text(&line)
186        )
187        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
188    }
189    Ok(())
190}