Skip to main content

blotter/commands/
verify.rs

1use crate::cli::VerifyArgs;
2use crate::commands::triage::{self, Candidate};
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{ItemStatus, ListItem};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct VerifyData {
13    pub recurrences: Vec<Recurrence>,
14    pub count: usize,
15    pub scanned: usize,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct Recurrence {
20    pub resolved_id: String,
21    pub resolved_text: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub source: Option<String>,
24    pub resolution: VerifyResolution,
25    pub recurrence_ids: Vec<String>,
26    pub count: usize,
27    pub first_recurrence_ts: String,
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31pub struct VerifyResolution {
32    pub ts: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub task: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub pr: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub commit: Option<String>,
39}
40
41struct ResolvedAnchor {
42    candidate: Candidate,
43    resolution_timestamp: Timestamp,
44}
45
46pub(crate) struct RecurrenceGroup {
47    pub(crate) anchor: Candidate,
48    pub(crate) members: Vec<Candidate>,
49}
50
51pub(crate) struct RecurrenceAnalysis {
52    pub(crate) recurrences: Vec<RecurrenceGroup>,
53    pub(crate) scanned: usize,
54}
55
56struct OrderedRecurrence {
57    data: RecurrenceGroup,
58    first_recurrence_timestamp: Timestamp,
59}
60
61fn is_verify_eligible(item: &ListItem) -> bool {
62    if item.kind != "cut" {
63        return false;
64    }
65
66    match item.status {
67        ItemStatus::Open => true,
68        ItemStatus::Resolved => {
69            let resolution = item
70                .resolution
71                .as_ref()
72                .expect("resolved folded items have a resolution");
73            !resolution.dropped && !triage::normalized_title(&item.text).is_empty()
74        }
75    }
76}
77
78pub fn run(args: VerifyArgs, file: Option<PathBuf>, pretty: bool) -> AppResult<i32> {
79    let resolved = store::discover(file)?;
80    let store::LoadedFold {
81        items,
82        mut warnings,
83    } = store::load_folded(&resolved)?;
84    let (items, auto_captures) = crate::partition_auto_captures(items, args.include_auto);
85    let hidden = auto_captures
86        .iter()
87        .filter(|item| is_verify_eligible(item))
88        .count();
89    if hidden > 0 {
90        warnings.push(crate::auto_capture_warning(hidden));
91    }
92
93    let data = verify(items);
94    let exit = i32::from(!data.recurrences.is_empty());
95    let mut meta = Meta::new();
96    meta.file = Some(resolved.path.to_string_lossy().into_owned());
97    meta.warnings = warnings;
98    output::write_success(data, pretty, meta)
99        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
100    Ok(exit)
101}
102
103fn verify(items: Vec<ListItem>) -> VerifyData {
104    let analysis = recurrence_groups(items);
105    let recurrences: Vec<_> = analysis
106        .recurrences
107        .iter()
108        .map(materialize_recurrence)
109        .collect();
110
111    VerifyData {
112        count: recurrences.len(),
113        recurrences,
114        scanned: analysis.scanned,
115    }
116}
117
118pub(crate) fn recurrence_groups(items: Vec<ListItem>) -> RecurrenceAnalysis {
119    let mut open = Vec::new();
120    let mut anchors = Vec::new();
121
122    for item in items {
123        if !is_verify_eligible(&item) {
124            continue;
125        }
126
127        let normalized_title = triage::normalized_title(&item.text);
128        let candidate = Candidate {
129            timestamp: item
130                .ts
131                .parse()
132                .expect("folded items have valid RFC3339 timestamps"),
133            tags: item.tags.iter().cloned().collect(),
134            tokens: triage::scoring_tokens(&normalized_title),
135            normalized_title,
136            item,
137        };
138
139        match candidate.item.status {
140            ItemStatus::Open => open.push(candidate),
141            ItemStatus::Resolved => {
142                let resolution = candidate
143                    .item
144                    .resolution
145                    .as_ref()
146                    .expect("resolved folded items have a resolution");
147                anchors.push(ResolvedAnchor {
148                    resolution_timestamp: resolution
149                        .ts
150                        .parse()
151                        .expect("folded resolutions have valid RFC3339 timestamps"),
152                    candidate,
153                });
154            }
155        }
156    }
157
158    open.sort_by(triage::candidate_order);
159    let scanned = open.len();
160    let frequencies = triage::token_frequencies(
161        open.iter()
162            .chain(anchors.iter().map(|anchor| &anchor.candidate)),
163    );
164    let mut recurrences = Vec::new();
165    for anchor in anchors {
166        let recurring: Vec<_> = open
167            .iter()
168            .filter(|candidate| {
169                candidate.timestamp > anchor.resolution_timestamp
170                    && triage::linked(&anchor.candidate, candidate, &frequencies)
171            })
172            .collect();
173        let Some(first) = recurring.first() else {
174            continue;
175        };
176        recurrences.push(OrderedRecurrence {
177            first_recurrence_timestamp: first.timestamp,
178            data: RecurrenceGroup {
179                anchor: anchor.candidate,
180                members: recurring.into_iter().cloned().collect(),
181            },
182        });
183    }
184    recurrences.sort_by(|left, right| {
185        left.first_recurrence_timestamp
186            .cmp(&right.first_recurrence_timestamp)
187            .then_with(|| left.data.anchor.item.id.cmp(&right.data.anchor.item.id))
188    });
189
190    RecurrenceAnalysis {
191        recurrences: recurrences
192            .into_iter()
193            .map(|recurrence| recurrence.data)
194            .collect(),
195        scanned,
196    }
197}
198
199fn materialize_recurrence(group: &RecurrenceGroup) -> Recurrence {
200    let resolution = group
201        .anchor
202        .item
203        .resolution
204        .as_ref()
205        .expect("resolved anchors have a resolution");
206    let first = group
207        .members
208        .first()
209        .expect("recurrence groups have members");
210
211    Recurrence {
212        resolved_id: group.anchor.item.id.clone(),
213        resolved_text: group.anchor.item.text.clone(),
214        source: group.anchor.item.source.clone(),
215        resolution: VerifyResolution {
216            ts: resolution.ts.clone(),
217            task: resolution.task.clone(),
218            pr: resolution.pr.clone(),
219            commit: resolution.commit.clone(),
220        },
221        recurrence_ids: group
222            .members
223            .iter()
224            .map(|candidate| candidate.item.id.clone())
225            .collect(),
226        count: group.members.len(),
227        first_recurrence_ts: first.item.ts.clone(),
228    }
229}