1use std::fmt;
27
28pub fn normalize(s: &str) -> String {
31 let mut out = String::with_capacity(s.len());
32 let mut space = false;
33 for c in s.chars() {
34 if c == '*' || c == '`' {
35 continue;
36 }
37 if c.is_whitespace() {
38 space = true;
39 continue;
40 }
41 if space && !out.is_empty() {
42 out.push(' ');
43 }
44 space = false;
45 out.push(c);
46 }
47 out
48}
49
50pub fn string_leaves<'a>(
57 prefix: &str,
58 value: &'a serde_json::Value,
59) -> Vec<(String, &'a str)> {
60 fn walk<'a>(
61 at: String,
62 v: &'a serde_json::Value,
63 top: bool,
64 out: &mut Vec<(String, &'a str)>,
65 ) {
66 match v {
67 serde_json::Value::String(s) => out.push((at, s)),
68 serde_json::Value::Array(items) => {
69 for (i, item) in items.iter().enumerate() {
70 walk(format!("{at}/{i}"), item, false, out);
71 }
72 }
73 serde_json::Value::Object(map) => {
74 let mut entries: Vec<_> = map.iter().collect();
75 entries
76 .sort_by(|(a, _), (b, _)| a.as_bytes().cmp(b.as_bytes()));
77 for (k, item) in entries {
78 if top && k == crate::govlog::BLIND_KEY {
79 continue;
80 }
81 let key = k.replace('~', "~0").replace('/', "~1");
82 walk(format!("{at}/{key}"), item, false, out);
83 }
84 }
85 _ => {}
86 }
87 }
88 let mut out = Vec::new();
89 walk(prefix.to_string(), value, true, &mut out);
90 out
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum QuoteCheck {
96 Exact { at: Vec<String> },
98 Normalized { at: Vec<String> },
100 Missing { closest: Option<Closest> },
102}
103
104impl QuoteCheck {
105 pub fn found(&self) -> bool {
107 !matches!(self, Self::Missing { .. })
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum QuoteEnd {
114 Start,
115 End,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Closest {
123 pub at: String,
124 pub matched: usize,
125 pub of: usize,
126 pub from: QuoteEnd,
127 pub departs: String,
130}
131
132impl fmt::Display for Closest {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 let end = match self.from {
135 QuoteEnd::Start => "first",
136 QuoteEnd::End => "last",
137 };
138 write!(
139 f,
140 "closest is {}: its {end} {} of {} characters (normalised) match, then it departs at {:?}",
141 self.at, self.matched, self.of, self.departs
142 )
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
148pub enum QuoteError {
149 #[error("the quote is empty once markdown and whitespace are removed")]
150 Empty,
151}
152
153pub fn check<'a>(
157 quote: &str,
158 sources: impl IntoIterator<Item = (String, &'a str)>,
159) -> Result<QuoteCheck, QuoteError> {
160 let wanted = normalize(quote);
161 if wanted.is_empty() {
162 return Err(QuoteError::Empty);
163 }
164 let sources: Vec<(String, &str)> = sources.into_iter().collect();
165 let exact: Vec<String> = sources
166 .iter()
167 .filter(|(_, text)| text.contains(quote))
168 .map(|(at, _)| at.clone())
169 .collect();
170 if !exact.is_empty() {
171 return Ok(QuoteCheck::Exact { at: exact });
172 }
173 let normalized: Vec<(String, String)> = sources
174 .into_iter()
175 .map(|(at, text)| (at, normalize(text)))
176 .collect();
177 let found: Vec<String> = normalized
178 .iter()
179 .filter(|(_, text)| text.contains(&wanted))
180 .map(|(at, _)| at.clone())
181 .collect();
182 if !found.is_empty() {
183 return Ok(QuoteCheck::Normalized { at: found });
184 }
185 Ok(QuoteCheck::Missing {
186 closest: closest(&wanted, &normalized),
187 })
188}
189
190fn closest(wanted: &str, texts: &[(String, String)]) -> Option<Closest> {
194 let bounds: Vec<usize> = wanted
195 .char_indices()
196 .map(|(i, _)| i)
197 .chain([wanted.len()])
198 .collect();
199 let chars = bounds.len() - 1;
200 let longest = |holds: &dyn Fn(usize) -> bool| {
201 let (mut lo, mut hi) = (0, chars);
202 while lo < hi {
203 let mid = (lo + hi).div_ceil(2);
204 if holds(mid) { lo = mid } else { hi = mid - 1 }
205 }
206 lo
207 };
208 let mut best: Option<Closest> = None;
209 for (at, text) in texts {
210 let prefix = longest(&|n| text.contains(&wanted[..bounds[n]]));
211 let suffix = longest(&|n| text.contains(&wanted[bounds[chars - n]..]));
212 let (matched, from) = if suffix > prefix {
213 (suffix, QuoteEnd::End)
214 } else {
215 (prefix, QuoteEnd::Start)
216 };
217 if matched == 0 || best.as_ref().is_some_and(|b| b.matched >= matched) {
218 continue;
219 }
220 let departs = match from {
221 QuoteEnd::Start => {
222 wanted[bounds[matched]..].chars().take(40).collect()
223 }
224 QuoteEnd::End => {
225 let before: Vec<char> =
226 wanted[..bounds[chars - matched]].chars().collect();
227 before[before.len().saturating_sub(40)..].iter().collect()
228 }
229 };
230 best = Some(Closest {
231 at: at.clone(),
232 matched,
233 of: chars,
234 from,
235 departs,
236 });
237 }
238 best
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum QuoteReport {
244 Checked { source: String, found: QuoteCheck },
246 Operator { source: String, by: Option<String> },
248}
249
250impl std::fmt::Display for QuoteReport {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 Self::Checked {
254 source,
255 found: QuoteCheck::Exact { at },
256 } => write!(f, "verbatim in {source} at {}", at.join(", ")),
257 Self::Checked {
258 source,
259 found: QuoteCheck::Normalized { at },
260 } => write!(
261 f,
262 "in {source} once markdown and line wraps are normalised, at {}",
263 at.join(", ")
264 ),
265 Self::Checked { source, .. } => write!(f, "NOT in {source}"),
266 Self::Operator {
267 source,
268 by: Some(by),
269 } => write!(f, "{source}: checked by the operator, {by}"),
270 Self::Operator { source, by: None } => write!(
271 f,
272 "{source}: NOT CHECKED. The operator checks this source; --yes refuses \
273 without --quote-checked-by <who>"
274 ),
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn one(at: &str, text: &str) -> Vec<(String, &'static str)> {
284 vec![(at.to_string(), Box::leak(text.to_string().into_boxed_str()))]
285 }
286
287 #[test]
288 fn normalising_removes_layout_and_emphasis_only() {
289 assert_eq!(
290 normalize(" a⏎\n **pending your\tapproval** of `x` "),
291 "a⏎ pending your approval of x"
292 );
293 assert_eq!(normalize("It’s — “so”"), "It’s — “so”");
295 assert_eq!(normalize(" \n* `` **"), "");
296 }
297
298 #[test]
299 fn an_exact_quote_is_found_everywhere_it_is() {
300 let doc = serde_json::json!({
301 "_blind": "abc",
302 "rounds": [{"position": "P100 first. Then P47."}, {"rationale": "**P100 first.**"}],
303 "a/b": "P100 first."
304 });
305 let leaves = string_leaves("", &doc);
306 assert!(leaves.iter().all(|(at, _)| at != "/_blind"));
307 assert_eq!(
308 check("P100 first.", leaves).unwrap(),
309 QuoteCheck::Exact {
310 at: vec![
311 "/a~1b".into(),
312 "/rounds/0/position".into(),
313 "/rounds/1/rationale".into(),
314 ]
315 }
316 );
317 }
318
319 #[test]
320 fn a_wrapped_or_bold_source_matches_only_normalised() {
321 let source = "a\nsentence for the Protocol will be put to you at the next review of\n\
322 accumulated Protocol revisions.";
323 let quote = "a sentence for the Protocol will be put to you at the next review of \
324 accumulated Protocol revisions.";
325 assert_eq!(
326 check(quote, one("ops", source)).unwrap(),
327 QuoteCheck::Normalized {
328 at: vec!["ops".into()]
329 }
330 );
331 assert!(
332 check(
333 "pending your approval",
334 one("ops", "**pending your approval** of")
335 )
336 .unwrap()
337 .found()
338 );
339 }
340
341 #[test]
342 fn a_missing_quote_names_the_closest_location() {
343 let sources = vec![
344 ("/a".to_string(), "nothing like it"),
345 (
346 "/b".to_string(),
347 "The slot will be built after the sitting.",
348 ),
349 ];
350 let QuoteCheck::Missing { closest: Some(c) } =
351 check("The slot will be built before the sitting.", sources)
352 .unwrap()
353 else {
354 panic!("not missing")
355 };
356 assert_eq!(c.at, "/b");
357 assert_eq!(c.from, QuoteEnd::Start);
358 assert_eq!(c.matched, "The slot will be built ".chars().count());
359 assert!(c.departs.starts_with("before"));
360
361 let QuoteCheck::Missing { closest: Some(c) } = check(
362 "Totally different start, the sitting.",
363 one("/b", "after the sitting."),
364 )
365 .unwrap() else {
366 panic!("not missing")
367 };
368 assert_eq!(c.from, QuoteEnd::End);
369 assert!(c.departs.ends_with("start,"));
370
371 assert_eq!(
372 check("zzz", one("/a", "abc")).unwrap(),
373 QuoteCheck::Missing { closest: None }
374 );
375 }
376
377 #[test]
378 fn an_empty_quote_is_refused() {
379 assert_eq!(check(" ** ` ", one("/a", "x")), Err(QuoteError::Empty));
380 }
381}