Skip to main content

blotter/commands/
digest.rs

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                // accepted_cuts is judged by disposition_ts alone (r48/r49),
96                // never by the cut's ts or the resolution's ts, so a
97                // note-only amend cannot move a cut into or out of the
98                // window.
99                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}