pub const MAX_CRITERIA: usize = 20;
pub const CRITERION_CHARS: usize = 300;
pub fn criteria_of(markdown: &str) -> Vec<String> {
let lines: Vec<&str> = markdown.lines().collect();
let mut found = Vec::new();
if let Some(start) = lines.iter().position(|l| names_criteria(l)) {
for line in &lines[start + 1..] {
let t = line.trim();
if t.starts_with('#') {
break;
}
match list_item(t) {
Some((text, _)) => found.push(text),
None if !t.is_empty() && !found.is_empty() => break,
None => {}
}
}
}
if found.is_empty() {
found = lines
.iter()
.filter_map(|l| list_item(l.trim()))
.filter(|(_, task)| *task)
.map(|(text, _)| text)
.collect();
}
found
.into_iter()
.filter(|c| !c.is_empty())
.take(MAX_CRITERIA)
.map(|c| cut(&c, CRITERION_CHARS))
.collect()
}
pub fn criteria_in(markdown: &str) -> Vec<String> {
let lines: Vec<&str> = markdown
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !names_criteria(l))
.collect();
let items: Vec<String> = lines
.iter()
.filter_map(|l| list_item(l))
.map(|(text, _)| text)
.collect();
let found = if items.is_empty() {
lines
.iter()
.map(|l| l.trim_start_matches('#').trim().to_string())
.collect()
} else {
items
};
found
.into_iter()
.filter(|c| !c.is_empty())
.take(MAX_CRITERIA)
.map(|c| cut(&c, CRITERION_CHARS))
.collect()
}
fn list_item(line: &str) -> Option<(String, bool)> {
let rest = ["- ", "* ", "+ "]
.iter()
.find_map(|b| line.strip_prefix(b))
.or_else(|| {
let digits = line.chars().take_while(char::is_ascii_digit).count();
(digits > 0)
.then(|| &line[digits..])
.and_then(|r| r.strip_prefix(". ").or_else(|| r.strip_prefix(") ")))
})?;
let rest = rest.trim();
for box_ in ["[ ]", "[x]", "[X]"] {
if let Some(text) = rest.strip_prefix(box_) {
return Some((text.trim().to_string(), true));
}
}
Some((rest.to_string(), false))
}
fn cut(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
pub fn names_criteria(line: &str) -> bool {
let t = line.trim();
if list_item(t).is_some() {
return false;
}
let name = t
.trim_start_matches('#')
.trim_matches(|c: char| matches!(c, '*' | '_' | ':' | ' '))
.replace('’', "'");
let slug = slug_within(&name, 40);
[
"criteres-d-accept",
"critere-d-accept",
"acceptance-criteri",
"definition-of-done",
]
.iter()
.any(|p| slug.starts_with(p))
&& slug.split('-').count() <= 4
|| matches!(slug.as_str(), "ac" | "dod" | "criteres" | "criteria")
}
fn slug_within(s: &str, max: usize) -> String {
let folded: String = s
.trim()
.to_lowercase()
.chars()
.map(|c| match c {
'à' | 'á' | 'â' | 'ä' | 'ã' => 'a',
'ç' => 'c',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ò' | 'ó' | 'ô' | 'ö' | 'õ' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
'ÿ' => 'y',
'ñ' => 'n',
c if c.is_ascii_alphanumeric() => c,
_ => '-',
})
.collect();
let mut out = String::new();
for part in folded.split('-').filter(|p| !p.is_empty()) {
if out.len() + part.len() + 1 > max {
if out.is_empty() {
out.push_str(&part[..max.min(part.len())]);
}
break;
}
if !out.is_empty() {
out.push('-');
}
out.push_str(part);
}
out
}