const MAX_SLUG_LEN: usize = 40;
pub fn parse_tags(s: &str) -> Vec<String> {
let mut tags: Vec<String> = Vec::new();
for raw in s.split(|c: char| c == ',' || c.is_whitespace()) {
let tag = raw.trim_start_matches('#').to_lowercase();
if !tag.is_empty() && !tags.contains(&tag) {
tags.push(tag);
}
}
tags
}
pub fn counted(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
pub fn one_line(title: &str) -> String {
title.split(['\n', '\r']).map(str::trim).filter(|line| !line.is_empty()).collect::<Vec<_>>().join(" ")
}
pub fn slugify(title: &str) -> String {
let mut slug = String::new();
for c in title.chars() {
if slug.len() >= MAX_SLUG_LEN {
break;
}
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else if !slug.ends_with('-') {
slug.push('-');
}
}
let slug = slug.trim_matches('-');
if slug.is_empty() { "task".to_string() } else { slug.to_string() }
}
pub fn sanitize_description(text: &str) -> (String, bool) {
demote(text, &["## "], "### ")
}
pub fn sanitize_comment(text: &str) -> (String, bool) {
demote(text, &["## ", "### "], "#### ")
}
fn demote(text: &str, prefixes: &[&str], to: &str) -> (String, bool) {
let mut changed = false;
let lines: Vec<String> = text
.lines()
.map(|line| match prefixes.iter().find_map(|p| line.strip_prefix(p)) {
Some(rest) => {
changed = true;
format!("{to}{rest}")
}
None => line.to_string(),
})
.collect();
(lines.join("\n").trim().to_string(), changed)
}
#[cfg(test)]
#[path = "../../tests/model/text.rs"]
mod tests;