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 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}