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::collections::BTreeSet;
10use std::path::PathBuf;
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct VerifyData {
14 pub recurrences: Vec<Recurrence>,
15 pub count: usize,
16 pub distinct_recurring_cuts: usize,
17 pub scanned: usize,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct Recurrence {
22 pub resolved_id: String,
23 pub resolved_text: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub origin: Option<crate::Origin>,
26 pub resolution: VerifyResolution,
27 pub recurrence_ids: Vec<String>,
28 pub count: usize,
29 pub first_recurrence_ts: String,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct VerifyResolution {
34 pub ts: String,
35 pub disposition: crate::Disposition,
36 pub disposition_ts: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub task: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub pr: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub commit: Option<String>,
43}
44
45struct ResolvedAnchor {
46 candidate: Candidate,
47 disposition_timestamp: Timestamp,
48}
49
50pub(crate) struct RecurrenceGroup {
51 pub(crate) anchor: Candidate,
52 pub(crate) members: Vec<Candidate>,
53}
54
55pub(crate) struct RecurrenceAnalysis {
56 pub(crate) recurrences: Vec<RecurrenceGroup>,
57 pub(crate) scanned: usize,
58}
59
60struct OrderedRecurrence {
61 data: RecurrenceGroup,
62 first_recurrence_timestamp: Timestamp,
63}
64
65fn is_verify_eligible(item: &ListItem) -> bool {
66 if item.kind != "cut" {
67 return false;
68 }
69
70 match item.status {
71 ItemStatus::Open => true,
72 ItemStatus::Resolved => {
73 let resolution = item
74 .resolution
75 .as_ref()
76 .expect("resolved folded items have a resolution");
77 !resolution.dropped
82 && !triage::normalized_title(&item.text).is_empty()
83 && matches!(
84 resolution.disposition,
85 Some(crate::Disposition::Fixed) | Some(crate::Disposition::Promoted)
86 )
87 }
88 }
89}
90
91pub fn run(_args: VerifyArgs, file: Option<PathBuf>, pretty: bool) -> AppResult<i32> {
92 let resolved = store::discover(file)?;
93 let store::LoadedFold {
94 items, warnings, ..
95 } = store::load_folded(&resolved)?;
96
97 let data = verify(items);
98 let exit = i32::from(!data.recurrences.is_empty());
99 let mut meta = Meta::new();
100 meta.file = Some(resolved.path.to_string_lossy().into_owned());
101 meta.warnings = warnings;
102 output::write_success(data, pretty, meta)
103 .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
104 Ok(exit)
105}
106
107fn verify(items: Vec<ListItem>) -> VerifyData {
108 let analysis = recurrence_groups(items);
109 let recurrences: Vec<_> = analysis
110 .recurrences
111 .iter()
112 .map(materialize_recurrence)
113 .collect();
114
115 let distinct_recurring_cuts = recurrences
120 .iter()
121 .flat_map(|recurrence| recurrence.recurrence_ids.iter().map(String::as_str))
122 .collect::<BTreeSet<_>>()
123 .len();
124
125 VerifyData {
126 count: recurrences.len(),
127 distinct_recurring_cuts,
128 recurrences,
129 scanned: analysis.scanned,
130 }
131}
132
133pub(crate) fn recurrence_groups(items: Vec<ListItem>) -> RecurrenceAnalysis {
134 let mut open = Vec::new();
135 let mut anchors = Vec::new();
136
137 for item in items {
138 if !is_verify_eligible(&item) {
139 continue;
140 }
141
142 let normalized_title = triage::normalized_title(&item.text);
143 let candidate = Candidate {
144 timestamp: item
145 .ts
146 .parse()
147 .expect("folded items have valid RFC3339 timestamps"),
148 tags: item.tags.iter().cloned().collect(),
149 tokens: triage::scoring_tokens(&normalized_title),
150 normalized_title,
151 item,
152 };
153
154 match candidate.item.status {
155 ItemStatus::Open => open.push(candidate),
156 ItemStatus::Resolved => {
157 let resolution = candidate
158 .item
159 .resolution
160 .as_ref()
161 .expect("resolved folded items have a resolution");
162 anchors.push(ResolvedAnchor {
163 disposition_timestamp: resolution
164 .disposition_ts
165 .as_deref()
166 .expect("resolved cut anchors carry disposition_ts")
167 .parse()
168 .expect("folded resolutions have valid RFC3339 timestamps"),
169 candidate,
170 });
171 }
172 }
173 }
174
175 open.sort_by(triage::candidate_order);
176 let scanned = open.len();
177 let frequencies = triage::corpus_frequencies(
178 open.iter()
179 .chain(anchors.iter().map(|anchor| &anchor.candidate)),
180 );
181 let index = triage::CandidateIndex::new(&open, &frequencies);
187 let mut scratch = triage::CandidateScratch::new(scanned);
188 let mut recurrences = Vec::new();
189 for anchor in anchors {
190 let floor =
196 open.partition_point(|candidate| candidate.timestamp <= anchor.disposition_timestamp);
197 let recurring: Vec<_> = scratch
198 .matching_candidates(&anchor.candidate, &index, &frequencies, floor)
199 .indices_from(floor)
200 .filter(|&candidate| triage::linked(&anchor.candidate, &open[candidate], &frequencies))
201 .map(|candidate| open[candidate].clone())
202 .collect();
203 let Some(first) = recurring.first() else {
204 continue;
205 };
206 recurrences.push(OrderedRecurrence {
207 first_recurrence_timestamp: first.timestamp,
208 data: RecurrenceGroup {
209 anchor: anchor.candidate,
210 members: recurring,
211 },
212 });
213 }
214 recurrences.sort_by(|left, right| {
215 left.first_recurrence_timestamp
216 .cmp(&right.first_recurrence_timestamp)
217 .then_with(|| left.data.anchor.item.id.cmp(&right.data.anchor.item.id))
218 });
219
220 RecurrenceAnalysis {
221 recurrences: recurrences
222 .into_iter()
223 .map(|recurrence| recurrence.data)
224 .collect(),
225 scanned,
226 }
227}
228
229fn materialize_recurrence(group: &RecurrenceGroup) -> Recurrence {
230 let resolution = group
231 .anchor
232 .item
233 .resolution
234 .as_ref()
235 .expect("resolved anchors have a resolution");
236 let first = group
237 .members
238 .first()
239 .expect("recurrence groups have members");
240
241 Recurrence {
242 resolved_id: group.anchor.item.id.clone(),
243 resolved_text: group.anchor.item.text.clone(),
244 origin: group.anchor.item.origin.clone(),
245 resolution: VerifyResolution {
246 ts: resolution.ts.clone(),
247 disposition: resolution
248 .disposition
249 .expect("resolved anchors carry a disposition"),
250 disposition_ts: resolution
251 .disposition_ts
252 .clone()
253 .expect("resolved cut anchors carry disposition_ts"),
254 task: resolution.task.clone(),
255 pr: resolution.pr.clone(),
256 commit: resolution.commit.clone(),
257 },
258 recurrence_ids: group
259 .members
260 .iter()
261 .map(|candidate| candidate.item.id.clone())
262 .collect(),
263 count: group.members.len(),
264 first_recurrence_ts: first.item.ts.clone(),
265 }
266}