1use crate::cli::{DigestArgs, OutputFormat};
2use crate::commands::triage::{self, TriageCluster};
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{Disposition, ItemStatus, ListItem, format_timestamp, parse_since};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Serialize, Deserialize)]
14pub struct DigestData {
15 pub chronic: Vec<TriageCluster>,
16 pub new_cuts: NewCuts,
17 pub open_dogears: OpenDogears,
18 pub accepted_cuts: AcceptedCuts,
19 pub window: DigestWindow,
20}
21
22#[derive(Debug, Serialize, Deserialize)]
23pub struct AcceptedCuts {
24 pub count: usize,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct NewCuts {
29 pub count: usize,
30 pub by_tag: Vec<TagGroup>,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct TagGroup {
35 pub tag: String,
36 pub count: usize,
37 pub ids: Vec<String>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct OpenDogears {
42 pub count: usize,
43 pub items: Vec<OpenDogear>,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47pub struct OpenDogear {
48 pub id: String,
49 pub ts: String,
50 pub text: String,
51 pub tags: Vec<String>,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55pub struct DigestWindow {
56 pub since: String,
57 pub until: String,
58}
59
60pub fn run(
61 args: DigestArgs,
62 file: Option<PathBuf>,
63 pretty: bool,
64 now: Timestamp,
65) -> AppResult<i32> {
66 let since = parse_since(&args.since, now)?;
67 let resolved = store::discover(file)?;
68 let store::LoadedFold {
69 items, warnings, ..
70 } = store::load_folded(&resolved)?;
71
72 let data = digest(items, since, now);
73 if args.format == OutputFormat::Md {
74 write_markdown(&data, &warnings)?;
75 } else {
76 let mut meta = Meta::new();
77 meta.file = Some(resolved.path.to_string_lossy().into_owned());
78 meta.warnings = warnings;
79 output::write_success(data, pretty, meta)
80 .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
81 }
82 Ok(0)
83}
84
85fn digest(items: Vec<ListItem>, since: Timestamp, until: Timestamp) -> DigestData {
86 let chronic = triage::triage(items.clone(), 2).clusters;
87 let mut tags = BTreeMap::<String, Vec<String>>::new();
88 let mut new_cut_count = 0;
89 let mut open_dogears = Vec::new();
90 let mut accepted_cut_count = 0;
91
92 for item in items {
93 if item.status != ItemStatus::Open {
94 if item.kind == "cut" {
95 let is_accepted = item.resolution.as_ref().is_some_and(|resolution| {
100 resolution.disposition == Some(Disposition::Accepted)
101 });
102 if is_accepted {
103 let disposition_timestamp = item
104 .resolution
105 .as_ref()
106 .and_then(|resolution| resolution.disposition_ts.as_deref())
107 .expect("accepted resolutions carry disposition_ts")
108 .parse::<Timestamp>()
109 .expect("folded resolutions have valid RFC3339 disposition_ts");
110 if disposition_timestamp >= since && disposition_timestamp <= until {
111 accepted_cut_count += 1;
112 }
113 }
114 }
115 continue;
116 }
117 match item.kind.as_str() {
118 "cut" => {
119 let timestamp = item
120 .ts
121 .parse::<Timestamp>()
122 .expect("folded items have valid RFC3339 timestamps");
123 if timestamp >= since && timestamp <= until {
124 new_cut_count += 1;
125 let item_tags = if item.tags.is_empty() {
126 vec![String::new()]
127 } else {
128 item.tags
129 };
130 for tag in item_tags {
131 tags.entry(tag).or_default().push(item.id.clone());
132 }
133 }
134 }
135 "dogear" => open_dogears.push(OpenDogear {
136 id: item.id,
137 ts: item.ts,
138 text: item.text,
139 tags: item.tags,
140 }),
141 _ => unreachable!("folded items are cut or dogear"),
142 }
143 }
144
145 let mut by_tag: Vec<_> = tags
146 .into_iter()
147 .map(|(tag, mut ids)| {
148 ids.sort();
149 TagGroup {
150 tag,
151 count: ids.len(),
152 ids,
153 }
154 })
155 .collect();
156 by_tag.sort_by(|left, right| {
157 right
158 .count
159 .cmp(&left.count)
160 .then_with(|| left.tag.cmp(&right.tag))
161 });
162 open_dogears.sort_by(|left, right| {
163 right
164 .ts
165 .parse::<Timestamp>()
166 .expect("folded dogears have valid RFC3339 timestamps")
167 .cmp(
168 &left
169 .ts
170 .parse::<Timestamp>()
171 .expect("folded dogears have valid RFC3339 timestamps"),
172 )
173 .then_with(|| left.id.cmp(&right.id))
174 });
175
176 DigestData {
177 chronic,
178 new_cuts: NewCuts {
179 count: new_cut_count,
180 by_tag,
181 },
182 open_dogears: OpenDogears {
183 count: open_dogears.len(),
184 items: open_dogears,
185 },
186 accepted_cuts: AcceptedCuts {
187 count: accepted_cut_count,
188 },
189 window: DigestWindow {
190 since: format_timestamp(since),
191 until: format_timestamp(until),
192 },
193 }
194}
195
196fn write_markdown(data: &DigestData, warnings: &[String]) -> AppResult<()> {
197 let mut output =
198 output::stdout_writer().map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
199 let result: std::io::Result<()> = (|| {
200 let mut wrote_section = false;
201 if !data.chronic.is_empty() {
202 writeln!(output, "## Chronic")?;
203 for cluster in &data.chronic {
204 writeln!(
205 output,
206 "- {} ({}): {}",
207 crate::output::collapse_markdown_text(&cluster.text),
208 cluster.count,
209 cluster.ids.join(", ")
210 )?;
211 }
212 wrote_section = true;
213 }
214 if !data.new_cuts.by_tag.is_empty() {
215 if wrote_section {
216 writeln!(output)?;
217 }
218 writeln!(output, "## New cuts")?;
219 for group in &data.new_cuts.by_tag {
220 let tag = if group.tag.is_empty() {
221 "untagged".into()
222 } else {
223 crate::output::collapse_markdown_text(&group.tag)
224 };
225 writeln!(output, "### {tag} ({})", group.count)?;
226 for id in &group.ids {
227 writeln!(output, "- {id}")?;
228 }
229 }
230 wrote_section = true;
231 }
232 if !data.open_dogears.items.is_empty() {
233 if wrote_section {
234 writeln!(output)?;
235 }
236 writeln!(output, "## Open dogears")?;
237 for dogear in &data.open_dogears.items {
238 let tags = if dogear.tags.is_empty() {
239 String::new()
240 } else {
241 format!(" ({})", dogear.tags.join(","))
242 };
243 let line = format!("- [{}] {} — {}{tags}", dogear.id, dogear.text, dogear.ts);
244 writeln!(output, "{}", crate::output::collapse_markdown_text(&line))?;
245 }
246 wrote_section = true;
247 }
248 if !wrote_section {
249 writeln!(output, "No friction in window.")?;
250 }
251 for warning in warnings {
252 writeln!(output, "> note: {warning}")?;
253 }
254 output.flush()?;
255 Ok(())
256 })();
257 result.map_err(|error| AppError::from_io(error, Path::new("stdout")))
258}