1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum Origin {
14 Yours,
16 Added,
18 Dropped,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25pub struct Span {
26 pub text: String,
28 pub origin: Origin,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35pub struct Diff {
36 pub proposal: Vec<Span>,
38 pub original: Vec<Span>,
40}
41
42const MAX_WORDS: usize = 3000;
44
45fn tokens(text: &str) -> Vec<(&str, bool)> {
47 let mut out = Vec::new();
48 let mut start = 0;
49 let mut word: Option<bool> = None;
50 for (i, c) in text.char_indices() {
51 let w = c.is_alphanumeric();
52 match word {
53 Some(prev) if prev != w => {
54 out.push((&text[start..i], prev));
55 start = i;
56 }
57 _ => {}
58 }
59 word = Some(w);
60 }
61 if start < text.len() {
62 out.push((&text[start..], word.unwrap_or(false)));
63 }
64 out
65}
66
67fn stem(word: &str) -> String {
70 let w = word.to_lowercase();
71 match w.strip_suffix('s') {
72 Some(base) if base.chars().count() > 2 && !base.ends_with('s') => base.to_owned(),
73 _ => w,
74 }
75}
76
77fn push(spans: &mut Vec<Span>, text: &str, origin: Origin) {
78 match spans.last_mut() {
79 Some(last) if last.origin == origin => last.text.push_str(text),
80 _ if text.is_empty() => {}
81 _ => spans.push(Span {
82 text: text.to_owned(),
83 origin,
84 }),
85 }
86}
87
88#[must_use]
93pub fn diff(original: &str, proposal: &str) -> Diff {
94 let a = tokens(original);
95 let b = tokens(proposal);
96 let aw: Vec<(usize, String)> = a
97 .iter()
98 .enumerate()
99 .filter(|(_, (_, w))| *w)
100 .map(|(i, (t, _))| (i, stem(t)))
101 .collect();
102 let bw: Vec<(usize, String)> = b
103 .iter()
104 .enumerate()
105 .filter(|(_, (_, w))| *w)
106 .map(|(i, (t, _))| (i, stem(t)))
107 .collect();
108 let mut kept_a = vec![false; a.len()];
109 let mut kept_b = vec![false; b.len()];
110 if aw.len() <= MAX_WORDS && bw.len() <= MAX_WORDS {
111 let (n, m) = (aw.len(), bw.len());
112 let mut lcs = vec![0u16; (n + 1) * (m + 1)];
113 let at = |i: usize, j: usize| i * (m + 1) + j;
114 for i in (0..n).rev() {
115 for j in (0..m).rev() {
116 lcs[at(i, j)] = if aw[i].1 == bw[j].1 {
117 lcs[at(i + 1, j + 1)] + 1
118 } else {
119 lcs[at(i + 1, j)].max(lcs[at(i, j + 1)])
120 };
121 }
122 }
123 let (mut i, mut j) = (0, 0);
124 while i < n && j < m {
125 if aw[i].1 == bw[j].1 {
126 kept_a[aw[i].0] = true;
127 kept_b[bw[j].0] = true;
128 i += 1;
129 j += 1;
130 } else if lcs[at(i + 1, j)] >= lcs[at(i, j + 1)] {
131 i += 1;
132 } else {
133 j += 1;
134 }
135 }
136 }
137 let spans = |toks: &[(&str, bool)], kept: &[bool], lost: Origin| {
138 let mut out = Vec::new();
139 let mut last = Origin::Yours;
140 for (k, (t, w)) in toks.iter().enumerate() {
141 let origin = if *w {
142 last = if kept[k] { Origin::Yours } else { lost };
143 last
144 } else {
145 last
146 };
147 push(&mut out, t, origin);
148 }
149 out
150 };
151 Diff {
152 proposal: spans(&b, &kept_b, Origin::Added),
153 original: spans(&a, &kept_a, Origin::Dropped),
154 }
155}
156
157const STOP: &[&str] = &[
159 "the", "and", "for", "that", "this", "with", "from", "are", "was", "but", "not", "can",
160 "should", "must", "will", "shall", "have", "has", "they", "them", "its", "into", "when",
161 "then", "also", "just", "some", "any", "all", "our", "your", "you", "who", "what", "which",
162 "there", "their", "would", "could", "maybe", "probably", "like", "stuff", "thing", "things",
163 "user", "users", "system", "dont", "don", "doesnt", "doesn", "isnt", "isn", "cant", "wont",
164 "won", "shouldnt", "make", "sure", "able",
165];
166
167fn content(text: &str) -> Vec<String> {
168 tokens(text)
169 .into_iter()
170 .filter(|(t, w)| *w && t.chars().count() > 2)
171 .map(|(t, _)| t.to_lowercase())
172 .filter(|t| !STOP.contains(&t.as_str()))
173 .collect()
174}
175
176#[must_use]
179pub fn sentences(text: &str) -> Vec<String> {
180 let mut out = Vec::new();
181 let mut cur = String::new();
182 let mut flush = |cur: &mut String| {
183 let s = cur
184 .trim()
185 .trim_start_matches(['-', '*', '•'])
186 .trim()
187 .to_owned();
188 if s.chars().any(char::is_alphanumeric) {
189 out.push(s);
190 }
191 cur.clear();
192 };
193 let chars: Vec<char> = text.chars().collect();
194 for (i, c) in chars.iter().enumerate() {
195 if *c == '\n' {
196 flush(&mut cur);
197 continue;
198 }
199 cur.push(*c);
200 let ends =
201 matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
202 if ends {
203 flush(&mut cur);
204 }
205 }
206 flush(&mut cur);
207 out
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
212pub struct Coverage {
213 pub sentence: String,
215 pub lines: Vec<usize>,
217 pub dropped: bool,
219}
220
221#[must_use]
225pub fn coverage(original: &str, proposal: &str) -> Vec<Coverage> {
226 let lines: Vec<Vec<String>> = proposal.lines().map(content).collect();
227 sentences(original)
228 .into_iter()
229 .map(|sentence| {
230 let words = content(&sentence);
231 let need = words.len().div_ceil(2).max(1);
232 let hits: Vec<usize> = lines
233 .iter()
234 .enumerate()
235 .filter(|(_, l)| words.iter().filter(|w| l.contains(w)).count() >= need)
236 .map(|(i, _)| i)
237 .collect();
238 Coverage {
239 dropped: !words.is_empty() && hits.is_empty(),
240 lines: hits,
241 sentence,
242 }
243 })
244 .collect()
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
249pub struct Assumption {
250 pub words: String,
252 #[serde(default)]
254 pub from: String,
255 pub question: String,
257}
258
259#[must_use]
262pub fn unexplained(diff: &Diff, assumptions: &[Assumption]) -> Vec<String> {
263 let bare = |t: &str| -> String {
266 t.chars()
267 .filter(|c| c.is_alphanumeric())
268 .flat_map(char::to_lowercase)
269 .collect()
270 };
271 let covered = |term: &str| {
272 let t = bare(term);
273 assumptions.iter().any(|a| bare(&a.words).contains(&t))
274 };
275 let mut out: Vec<String> = Vec::new();
276 for s in diff.proposal.iter().filter(|s| s.origin == Origin::Added) {
277 let text = s.text.as_str();
278 let words: Vec<(usize, usize)> = tokens(text)
279 .into_iter()
280 .filter(|(_, w)| *w)
281 .map(|(t, _)| {
282 let at = t.as_ptr() as usize - text.as_ptr() as usize;
283 (at, at + t.len())
284 })
285 .collect();
286 let gap = |a: (usize, usize), b: (usize, usize)| &text[a.1..b.0];
287 let mut terms: Vec<String> = Vec::new();
288 let mut k = 0;
291 while k < words.len() {
292 let w = &text[words[k].0..words[k].1];
293 if w.chars().any(|c| c.is_ascii_digit()) {
294 let start = words[k].0;
295 let mut end = words[k].1;
296 while k + 1 < words.len()
297 && text[words[k + 1].0..words[k + 1].1]
298 .chars()
299 .all(|c| c.is_ascii_digit())
300 && matches!(
301 gap(words[k], words[k + 1]),
302 " " | "," | "." | "\u{a0}" | "\u{202f}"
303 )
304 {
305 k += 1;
306 end = words[k].1;
307 }
308 if k + 1 < words.len()
309 && text[words[k + 1].0..words[k + 1].1]
310 .chars()
311 .all(char::is_alphabetic)
312 && matches!(gap(words[k], words[k + 1]), " " | "-" | "\u{a0}")
313 {
314 k += 1;
315 end = words[k].1;
316 }
317 terms.push(text[start..end].to_owned());
318 }
319 k += 1;
320 }
321 for q in ['"', '`', '“'] {
323 let close = if q == '“' { '”' } else { q };
324 let mut rest = text;
325 while let Some(i) = rest.find(q) {
326 let after = &rest[i + q.len_utf8()..];
327 let Some(j) = after.find(close) else { break };
328 if j > 0 {
329 terms.push(after[..j].to_owned());
330 }
331 rest = &after[j + close.len_utf8()..];
332 }
333 }
334 let whole = terms.clone();
336 terms.retain(|t| !whole.iter().any(|o| o != t && o.contains(t.as_str())));
337 for t in terms {
338 if !covered(&t) && !out.contains(&t) {
339 out.push(t);
340 }
341 }
342 }
343 out
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
348pub struct Finding {
349 pub rule: String,
352 pub words: String,
354 pub reason: String,
356 pub fix: String,
358}
359
360const VAGUE: &[(&str, &str)] = &[
362 ("fast", "how fast? a number and a unit (\"within 2 s\")"),
363 ("quick", "how quick? a number and a unit"),
364 ("quickly", "how quickly? a number and a unit"),
365 ("slow", "how slow is too slow? a number and a unit"),
366 ("large", "how large? a size or a count"),
367 ("big", "how big? a size or a count"),
368 ("small", "how small? a size or a count"),
369 ("many", "how many? a number"),
370 ("few", "how few? a number"),
371 ("several", "how many? a number"),
372 ("some", "which ones, or how many?"),
373 ("easy", "easy for whom, measured how? name the steps"),
374 ("simple", "simple how? name what it does and does not do"),
375 ("intuitive", "measured how? name the behaviour"),
376 ("user-friendly", "name the behaviour a person sees"),
377 ("friendly", "name the behaviour a person sees"),
378 (
379 "robust",
380 "robust against what? name the failure and the response",
381 ),
382 (
383 "secure",
384 "secure against what? name the threat and the behaviour",
385 ),
386 ("efficient", "how efficient? a number and a unit"),
387 ("flexible", "flexible how? name the variations"),
388 ("appropriate", "which, exactly? name it"),
389 ("reasonable", "what is reasonable? a number or a rule"),
390 ("normally", "and otherwise? name both cases"),
391 ("usually", "and otherwise? name both cases"),
392 ("probably", "decide it, or ask it as an open question"),
393 ("maybe", "decide it, or ask it as an open question"),
394 ("stuff", "name the things"),
395 ("things", "name the things"),
396 ("better", "better than what, by how much?"),
397];
398
399const ESCAPE: &[&str] = &[
400 "as appropriate",
401 "where possible",
402 "if possible",
403 "if practicable",
404 "as needed",
405 "as required",
406 "if necessary",
407];
408
409const OPEN_ENDED: &[&str] = &[
410 "etc",
411 "and so on",
412 "and so forth",
413 "including but not limited to",
414 "or similar",
415];
416
417const FAILURE: &[&str] = &[
419 "fail",
420 "fails",
421 "failed",
422 "failure",
423 "error",
424 "errors",
425 "invalid",
426 "reject",
427 "rejects",
428 "rejected",
429 "refuse",
430 "refuses",
431 "refused",
432 "missing",
433 "empty",
434 "cannot",
435 "not",
436 "denied",
437 "unavailable",
438 "timeout",
439 "malformed",
440 "exceeds",
441 "without",
442];
443
444fn find_phrase(lower: &str, phrase: &str) -> bool {
445 lower.match_indices(phrase).any(|(i, _)| {
446 let before = lower[..i].chars().next_back();
447 let after = lower[i + phrase.len()..].chars().next();
448 before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
449 })
450}
451
452#[must_use]
458pub fn check(text: &str, requirement: bool) -> Vec<Finding> {
459 let lower = text.to_lowercase();
460 let mut out = Vec::new();
461 for (word, fix) in VAGUE {
462 if find_phrase(&lower, word) {
463 out.push(Finding {
464 rule: "vague".to_owned(),
465 words: (*word).to_owned(),
466 reason: "cannot be tested as written".to_owned(),
467 fix: (*fix).to_owned(),
468 });
469 }
470 }
471 for p in ESCAPE {
472 if find_phrase(&lower, p) {
473 out.push(Finding {
474 rule: "escape_clause".to_owned(),
475 words: (*p).to_owned(),
476 reason: "lets any behaviour pass".to_owned(),
477 fix: "say when it applies, or drop it".to_owned(),
478 });
479 }
480 }
481 for p in OPEN_ENDED {
482 if find_phrase(&lower, p) {
483 out.push(Finding {
484 rule: "open_ended".to_owned(),
485 words: (*p).to_owned(),
486 reason: "leaves the list open: the rest cannot be tested".to_owned(),
487 fix: "name every item".to_owned(),
488 });
489 }
490 }
491 if !requirement {
492 return out;
493 }
494 let obliges = ["must", "shall"].iter().any(|w| find_phrase(&lower, w));
495 if !obliges {
496 out.push(Finding {
497 rule: "no_obligation".to_owned(),
498 words: String::new(),
499 reason: "no MUST or SHALL: it reads as a wish, not a rule".to_owned(),
500 fix: "say who MUST do what (\"The export MUST …\")".to_owned(),
501 });
502 }
503 let scenario_lines: Vec<&str> = lower
504 .lines()
505 .filter(|l| find_phrase(l, "when") || find_phrase(l, "then") || l.contains("scenario"))
506 .collect();
507 let whens = lower.lines().filter(|l| find_phrase(l, "when")).count();
508 let thens = lower.lines().filter(|l| find_phrase(l, "then")).count();
509 if whens > thens {
510 out.push(Finding {
511 rule: "when_without_then".to_owned(),
512 words: "When".to_owned(),
513 reason: "a When without a Then: nothing to check".to_owned(),
514 fix: "add what must happen: \"Then …\"".to_owned(),
515 });
516 }
517 let failure = scenario_lines
518 .iter()
519 .any(|l| FAILURE.iter().any(|w| find_phrase(l, w)));
520 if thens > 0 && !failure {
521 out.push(Finding {
522 rule: "no_failure_path".to_owned(),
523 words: String::new(),
524 reason: "every scenario is a success path".to_owned(),
525 fix: "add one: what if it fails, or the input is wrong?".to_owned(),
526 });
527 }
528 out
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 fn text(spans: &[Span], origin: Origin) -> String {
536 spans
537 .iter()
538 .filter(|s| s.origin == origin)
539 .map(|s| s.text.trim())
540 .collect::<Vec<_>>()
541 .join("|")
542 }
543
544 #[test]
545 fn the_diff_marks_added_and_dropped_words_and_keeps_every_byte() {
546 let original = "users should be able to export stuff fast, csv probably";
547 let proposal = "Users MUST be able to export the table as CSV within 2 seconds.";
548 let d = diff(original, proposal);
549 assert_eq!(
550 d.proposal
551 .iter()
552 .map(|s| s.text.as_str())
553 .collect::<String>(),
554 proposal
555 );
556 assert_eq!(
557 d.original
558 .iter()
559 .map(|s| s.text.as_str())
560 .collect::<String>(),
561 original
562 );
563 assert_eq!(
564 text(&d.proposal, Origin::Added),
565 "MUST|the table as|within 2 seconds."
566 );
567 assert_eq!(
568 text(&d.original, Origin::Dropped),
569 "should|stuff fast,|probably"
570 );
571 }
572
573 #[test]
574 fn every_sentence_lands_somewhere_or_is_dropped() {
575 let original = "Export the table to CSV. Keep the header row!\nAlso it must never delete the source file.";
576 let proposal =
577 "The export MUST write the table as CSV.\nThe header row MUST be the first line.";
578 let c = coverage(original, proposal);
579 assert_eq!(c.len(), 3);
580 assert_eq!(c[0].lines, [0]);
581 assert_eq!(c[1].lines, [1]);
582 assert!(c[2].dropped, "{c:?}");
583 let c = coverage(
585 "dont break the header thing",
586 "The tool MUST preserve the header row.",
587 );
588 assert!(!c[0].dropped, "{c:?}");
589 }
590
591 #[test]
592 fn an_added_number_or_quoted_term_needs_an_assumption() {
593 let d = diff(
594 "export it fast",
595 "The export MUST finish within 2 seconds for up to 10 000 rows as \"utf-8\".",
596 );
597 let declared = [Assumption {
598 words: "up to 10 000 rows".to_owned(),
599 from: String::new(),
600 question: "How many rows?".to_owned(),
601 }];
602 assert_eq!(unexplained(&d, &declared), ["2 seconds", "utf-8"]);
603 assert!(unexplained(&diff("finish in 2 seconds", "finish in 2 seconds"), &[]).is_empty());
604 let d = diff(
607 "export",
608 "export up to 10,000 rows in 1.5 s with a 2-second timeout",
609 );
610 assert_eq!(unexplained(&d, &[]), ["10,000 rows", "1.5 s", "2-second"]);
611 let declared = [Assumption {
612 words: "up to 10 000 rows".to_owned(),
613 from: String::new(),
614 question: String::new(),
615 }];
616 assert_eq!(unexplained(&d, &declared), ["1.5 s", "2-second"]);
617 }
618
619 #[test]
620 fn the_check_names_vague_words_and_what_a_requirement_lacks() {
621 let f = check(
622 "The export should be fast and handle large files etc.",
623 true,
624 );
625 let rules: Vec<(&str, &str)> = f
626 .iter()
627 .map(|f| (f.rule.as_str(), f.words.as_str()))
628 .collect();
629 assert_eq!(
630 rules,
631 [
632 ("vague", "fast"),
633 ("vague", "large"),
634 ("open_ended", "etc"),
635 ("no_obligation", "")
636 ]
637 );
638 assert!(check("A breakfast menu MUST be shown.", true).is_empty());
640 let happy = "The export MUST write CSV.\nWhen the person exports\nThen a file is written";
641 assert_eq!(
642 check(happy, true)
643 .iter()
644 .map(|f| f.rule.as_str())
645 .collect::<Vec<_>>(),
646 ["no_failure_path"]
647 );
648 let both = "The export MUST write CSV.\nWhen the person exports\nThen a file is written\nWhen the table is empty\nThen the export fails with a message";
649 assert!(check(both, true).is_empty());
650 assert_eq!(
651 check("When it runs", true)
652 .iter()
653 .filter(|f| f.rule == "when_without_then")
654 .count(),
655 1
656 );
657 assert!(check("try the faster path first", false).is_empty());
659 }
660}