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::{Impact, ItemStatus, ListItem, PromotionItem, parse_since};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::io::Write;
9use std::path::PathBuf;
10
11/// The `items` union (r48), discriminated by the existing `kind` field. Serde
12/// tells the two arms apart structurally: only a lifecycle record carries
13/// `status`, and only a promotion carries `sources`.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(untagged)]
16pub enum ListEntry {
17    Record(Box<ListItem>),
18    Promotion(Box<PromotionItem>),
19}
20
21impl ListEntry {
22    /// The lifecycle arm. Panics on a promotion, so callers that may see either
23    /// use `as_record` / `as_promotion`.
24    pub fn record(&self) -> &ListItem {
25        self.as_record().expect("list entry is a cut or dogear")
26    }
27
28    pub fn as_record(&self) -> Option<&ListItem> {
29        match self {
30            Self::Record(item) => Some(item),
31            Self::Promotion(_) => None,
32        }
33    }
34
35    pub fn as_promotion(&self) -> Option<&PromotionItem> {
36        match self {
37            Self::Promotion(promotion) => Some(promotion),
38            Self::Record(_) => None,
39        }
40    }
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct ListData {
45    pub items: Vec<ListEntry>,
46    pub count: usize,
47    pub total: usize,
48    pub truncated: bool,
49}
50
51pub fn run(args: ListArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
52    if args.kind != ListKind::Cut && args.impact.is_some() {
53        return Err(AppError::invalid_argument(
54            "--impact is only available with --kind cut",
55            "Remove --impact or use `blotter list --kind cut --impact low|material|blocking`.",
56        ));
57    }
58    if args.kind == ListKind::Promotion {
59        // A promotion has no status and no tags, so neither filter can select
60        // one; `--status all` alone is accepted and is a no-op (r48).
61        if matches!(
62            args.status,
63            Some(StatusFilter::Open | StatusFilter::Resolved)
64        ) {
65            return Err(AppError::invalid_argument(
66                "--status open|resolved is not available with --kind promotion",
67                "Promotions have no lifecycle; drop --status or pass --status all.",
68            ));
69        }
70        if args.tag.is_some() {
71            return Err(AppError::invalid_argument(
72                "--tag is not available with --kind promotion",
73                "Promotions carry no tags; drop --tag or list cuts or dogears.",
74            ));
75        }
76    }
77    let since = args
78        .since
79        .as_deref()
80        .map(|value| parse_since(value, now))
81        .transpose()?;
82    let resolved = store::discover(file)?;
83    let store::LoadedFold {
84        items,
85        promotions,
86        mut warnings,
87    } = store::load_folded(&resolved)?;
88    // Cuts, then dogears, then promotions: the r5 block ordering with a third
89    // block appended rather than interleaved (r48).
90    let mut items: Vec<_> = items
91        .into_iter()
92        .filter(|item| matches_filters(item, &args, since.as_ref()))
93        .map(|item| ListEntry::Record(Box::new(item)))
94        .chain(
95            promotions
96                .into_iter()
97                .filter(|promotion| matches_promotion_filters(promotion, &args, since.as_ref()))
98                .map(|promotion| ListEntry::Promotion(Box::new(promotion))),
99        )
100        .collect();
101    let total = items.len();
102    items.truncate(args.limit);
103    let data = ListData {
104        count: items.len(),
105        total,
106        truncated: total > items.len(),
107        items,
108    };
109    if total == 0 {
110        warnings.push(
111            match args.kind {
112                ListKind::Cut => "no cuts matched; try --status all or broader filters",
113                ListKind::Dogear => "no dogears matched; try --status all or broader filters",
114                // No `--status` in the promotion hint: promotions have none.
115                ListKind::Promotion => "no promotions matched; try broader filters",
116                ListKind::All => "no records matched; try --status all or broader filters",
117            }
118            .into(),
119        );
120    }
121    if args.format == OutputFormat::Md {
122        write_markdown(&data.items, &warnings)?;
123    } else {
124        let mut meta = Meta::new();
125        meta.file = Some(resolved.path.to_string_lossy().into_owned());
126        meta.warnings = warnings;
127        output::write_success(data, pretty, meta)
128            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
129    }
130    Ok(0)
131}
132
133fn matches_filters(item: &ListItem, args: &ListArgs, since: Option<&Timestamp>) -> bool {
134    let kind_matches = match args.kind {
135        ListKind::Cut => item.kind == "cut",
136        ListKind::Dogear => item.kind == "dogear",
137        ListKind::Promotion => false,
138        ListKind::All => true,
139    };
140    let status_matches = match args.status.unwrap_or(StatusFilter::Open) {
141        StatusFilter::Open => item.status == ItemStatus::Open,
142        StatusFilter::Resolved => item.status == ItemStatus::Resolved,
143        StatusFilter::All => true,
144    };
145    kind_matches
146        && status_matches
147        && args.agent.as_ref().is_none_or(|agent| &item.agent == agent)
148        && args.tag.as_ref().is_none_or(|tag| item.tags.contains(tag))
149        && args.impact.is_none_or(|impact| item.impact == Some(impact))
150        && since.is_none_or(|threshold| {
151            item.ts
152                .parse::<Timestamp>()
153                .is_ok_and(|timestamp| timestamp >= *threshold)
154        })
155}
156
157/// `--agent` and `--since` apply to a promotion; `--status` never selects one,
158/// and an explicitly passed `open`/`resolved` is a request for lifecycle
159/// records, so it excludes promotions. `--tag` and `--impact` exclude them
160/// under `--kind all` and are rejected outright under `--kind promotion` (r48).
161fn matches_promotion_filters(
162    promotion: &PromotionItem,
163    args: &ListArgs,
164    since: Option<&Timestamp>,
165) -> bool {
166    let kind_matches = matches!(args.kind, ListKind::Promotion | ListKind::All);
167    let status_matches = !matches!(
168        args.status,
169        Some(StatusFilter::Open | StatusFilter::Resolved)
170    );
171    kind_matches
172        && status_matches
173        && args.tag.is_none()
174        && args.impact.is_none()
175        && args
176            .agent
177            .as_ref()
178            .is_none_or(|agent| &promotion.agent == agent)
179        && since.is_none_or(|threshold| {
180            promotion
181                .ts
182                .parse::<Timestamp>()
183                .is_ok_and(|timestamp| timestamp >= *threshold)
184        })
185}
186
187fn write_markdown(items: &[ListEntry], warnings: &[String]) -> AppResult<()> {
188    let records: Vec<&ListItem> = items
189        .iter()
190        .filter_map(|entry| match entry {
191            ListEntry::Record(item) => Some(item.as_ref()),
192            ListEntry::Promotion(_) => None,
193        })
194        .collect();
195    let promotions: Vec<&PromotionItem> = items
196        .iter()
197        .filter_map(|entry| match entry {
198            ListEntry::Promotion(promotion) => Some(promotion.as_ref()),
199            ListEntry::Record(_) => None,
200        })
201        .collect();
202    write_markdown_blocks(&records, &promotions, warnings)
203}
204
205fn write_markdown_blocks(
206    items: &[&ListItem],
207    promotions: &[&PromotionItem],
208    warnings: &[String],
209) -> AppResult<()> {
210    let mut output = output::stdout_writer()
211        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
212    for impact in [Impact::Blocking, Impact::Material, Impact::Low] {
213        let matching: Vec<_> = items
214            .iter()
215            .copied()
216            .filter(|item| item.impact == Some(impact))
217            .collect();
218        if matching.is_empty() {
219            continue;
220        }
221        writeln!(
222            output,
223            "## {}",
224            match impact {
225                Impact::Blocking => "Blocking",
226                Impact::Material => "Material",
227                Impact::Low => "Low",
228            }
229        )
230        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
231        for item in matching {
232            write_markdown_item(&mut output, item)?;
233        }
234    }
235    let dogears: Vec<_> = items
236        .iter()
237        .copied()
238        .filter(|item| item.kind == "dogear")
239        .collect();
240    if !dogears.is_empty() {
241        writeln!(output, "## Dogears")
242            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
243        for item in dogears {
244            write_markdown_item(&mut output, item)?;
245        }
246    }
247    if !promotions.is_empty() {
248        writeln!(output, "## Promotions")
249            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
250        for promotion in promotions {
251            write_markdown_promotion(&mut output, promotion)?;
252        }
253    }
254    for warning in warnings {
255        writeln!(output, "> note: {warning}")
256            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
257    }
258    output
259        .flush()
260        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
261    Ok(())
262}
263
264fn write_markdown_promotion(output: &mut impl Write, promotion: &PromotionItem) -> AppResult<()> {
265    let line = format!(
266        "- [{}] {}: {} — {}, {}",
267        promotion.id,
268        promotion.artifact.kind.as_str(),
269        promotion.artifact.r#ref,
270        promotion.agent,
271        promotion.ts
272    );
273    writeln!(output, "{}", crate::output::collapse_markdown_text(&line))
274        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
275    if let Some(note) = promotion
276        .note
277        .as_deref()
278        .map(crate::output::collapse_markdown_text)
279        .filter(|note| !note.is_empty())
280    {
281        writeln!(output, "  - {note}")
282            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
283    }
284    Ok(())
285}
286
287fn write_markdown_item(output: &mut impl Write, item: &ListItem) -> AppResult<()> {
288    let id = if item.status == ItemStatus::Resolved {
289        format!("~~{}~~", item.id)
290    } else {
291        item.id.clone()
292    };
293    let tags = if item.tags.is_empty() {
294        String::new()
295    } else {
296        format!(" ({})", item.tags.join(","))
297    };
298    // Every interpolated field can carry embedded newlines (resolve only rejects
299    // whitespace-only values), so each rendered line is collapsed as a whole.
300    let line = format!("- [{id}] {} — {}, {}{tags}", item.text, item.agent, item.ts);
301    writeln!(output, "{}", crate::output::collapse_markdown_text(&line))
302        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
303    if let Some(resolution) = &item.resolution {
304        let mut line = format!("resolved {} by {}", resolution.ts, resolution.agent);
305        if let Some(commit) = &resolution.commit {
306            line.push_str(&format!(" ({commit})"));
307        }
308        if let Some(pr) = &resolution.pr {
309            line.push_str(&format!(" pr {pr}"));
310        }
311        if let Some(task) = &resolution.task {
312            line.push_str(&format!(" task {task}"));
313        }
314        if let Some(note) = resolution
315            .note
316            .as_deref()
317            .map(crate::output::collapse_markdown_text)
318            .filter(|note| !note.is_empty())
319        {
320            line.push_str(&format!(": {note}"));
321        }
322        writeln!(
323            output,
324            "  - {}",
325            crate::output::collapse_markdown_text(&line)
326        )
327        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
328    }
329    Ok(())
330}