blotter/commands/
verify.rs1use 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}