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#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(untagged)]
16pub enum ListEntry {
17 Record(Box<ListItem>),
18 Promotion(Box<PromotionItem>),
19}
20
21impl ListEntry {
22 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 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 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 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
157fn 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 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}