Skip to main content

escouade_plugin_kit/
text.rs

1//! The acceptance criteria of a ticket's description, as every source reads them.
2
3/// At most this many criteria come from a description, each cut to `CRITERION_CHARS`.
4pub const MAX_CRITERIA: usize = 20;
5pub const CRITERION_CHARS: usize = 300;
6
7/// The acceptance criteria a description lists: the items under a heading or a line that names
8/// them (« Critères d'acceptation », « Acceptance criteria », « Definition of done »), else its
9/// task list items (`- [ ]`); none when it has neither.
10pub fn criteria_of(markdown: &str) -> Vec<String> {
11    let lines: Vec<&str> = markdown.lines().collect();
12    let mut found = Vec::new();
13    if let Some(start) = lines.iter().position(|l| names_criteria(l)) {
14        for line in &lines[start + 1..] {
15            let t = line.trim();
16            if t.starts_with('#') {
17                break;
18            }
19            match list_item(t) {
20                Some((text, _)) => found.push(text),
21                // A sentence before the list is part of the section; one after it ends it.
22                None if !t.is_empty() && !found.is_empty() => break,
23                None => {}
24            }
25        }
26    }
27    if found.is_empty() {
28        found = lines
29            .iter()
30            .filter_map(|l| list_item(l.trim()))
31            .filter(|(_, task)| *task)
32            .map(|(text, _)| text)
33            .collect();
34    }
35    found
36        .into_iter()
37        .filter(|c| !c.is_empty())
38        .take(MAX_CRITERIA)
39        .map(|c| cut(&c, CRITERION_CHARS))
40        .collect()
41}
42
43/// The criteria of a field that holds nothing else (Azure Boards' « Acceptance Criteria »), as
44/// markdown: its list items, else its lines; a line that names them is left out.
45pub fn criteria_in(markdown: &str) -> Vec<String> {
46    let lines: Vec<&str> = markdown
47        .lines()
48        .map(str::trim)
49        .filter(|l| !l.is_empty() && !names_criteria(l))
50        .collect();
51    let items: Vec<String> = lines
52        .iter()
53        .filter_map(|l| list_item(l))
54        .map(|(text, _)| text)
55        .collect();
56    let found = if items.is_empty() {
57        lines
58            .iter()
59            .map(|l| l.trim_start_matches('#').trim().to_string())
60            .collect()
61    } else {
62        items
63    };
64    found
65        .into_iter()
66        .filter(|c| !c.is_empty())
67        .take(MAX_CRITERIA)
68        .map(|c| cut(&c, CRITERION_CHARS))
69        .collect()
70}
71
72/// A list item's text, and whether it is a task (`- [ ]`, `- [x]`).
73fn list_item(line: &str) -> Option<(String, bool)> {
74    let rest = ["- ", "* ", "+ "]
75        .iter()
76        .find_map(|b| line.strip_prefix(b))
77        .or_else(|| {
78            let digits = line.chars().take_while(char::is_ascii_digit).count();
79            (digits > 0)
80                .then(|| &line[digits..])
81                .and_then(|r| r.strip_prefix(". ").or_else(|| r.strip_prefix(") ")))
82        })?;
83    let rest = rest.trim();
84    for box_ in ["[ ]", "[x]", "[X]"] {
85        if let Some(text) = rest.strip_prefix(box_) {
86            return Some((text.trim().to_string(), true));
87        }
88    }
89    Some((rest.to_string(), false))
90}
91
92/// At most `max` characters, the last one an ellipsis when cut.
93fn cut(s: &str, max: usize) -> String {
94    if s.chars().count() <= max {
95        return s.to_string();
96    }
97    let mut out: String = s.chars().take(max - 1).collect();
98    out.push('…');
99    out
100}
101
102/// A line that names the acceptance criteria (a heading, a bold line, a line ending with ":").
103pub fn names_criteria(line: &str) -> bool {
104    let t = line.trim();
105    if list_item(t).is_some() {
106        return false;
107    }
108    let name = t
109        .trim_start_matches('#')
110        .trim_matches(|c: char| matches!(c, '*' | '_' | ':' | ' '))
111        .replace('’', "'");
112    let slug = slug_within(&name, 40);
113    [
114        "criteres-d-accept",
115        "critere-d-accept",
116        "acceptance-criteri",
117        "definition-of-done",
118    ]
119    .iter()
120    .any(|p| slug.starts_with(p))
121        && slug.split('-').count() <= 4
122        || matches!(slug.as_str(), "ac" | "dod" | "criteres" | "criteria")
123}
124
125/// Kebab-case ASCII slug from free text (accents folded), of at most `max` characters: whole
126/// words while they fit, the first one cut when it alone is longer.
127fn slug_within(s: &str, max: usize) -> String {
128    let folded: String = s
129        .trim()
130        .to_lowercase()
131        .chars()
132        .map(|c| match c {
133            'à' | 'á' | 'â' | 'ä' | 'ã' => 'a',
134            'ç' => 'c',
135            'è' | 'é' | 'ê' | 'ë' => 'e',
136            'ì' | 'í' | 'î' | 'ï' => 'i',
137            'ò' | 'ó' | 'ô' | 'ö' | 'õ' => 'o',
138            'ù' | 'ú' | 'û' | 'ü' => 'u',
139            'ÿ' => 'y',
140            'ñ' => 'n',
141            c if c.is_ascii_alphanumeric() => c,
142            _ => '-',
143        })
144        .collect();
145    let mut out = String::new();
146    for part in folded.split('-').filter(|p| !p.is_empty()) {
147        if out.len() + part.len() + 1 > max {
148            if out.is_empty() {
149                // ASCII only by now: any byte ends a character.
150                out.push_str(&part[..max.min(part.len())]);
151            }
152            break;
153        }
154        if !out.is_empty() {
155            out.push('-');
156        }
157        out.push_str(part);
158    }
159    out
160}