escouade_plugin_kit/
text.rs1pub const MAX_CRITERIA: usize = 20;
5pub const CRITERION_CHARS: usize = 300;
6
7pub 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 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
43pub 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
72fn 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
92fn 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
102pub 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
125fn 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 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}