Skip to main content

kanbrain_core/model/
text.rs

1//! Rules for user-typed text: tags, file-name slugs, and headings inside descriptions and comments.
2
3/// Slugs in file names are cut at this many characters.
4const MAX_SLUG_LEN: usize = 40;
5
6/// Splits on commas/whitespace, drops a leading `#`, lowercases and dedupes.
7pub fn parse_tags(s: &str) -> Vec<String> {
8    let mut tags: Vec<String> = Vec::new();
9    for raw in s.split(|c: char| c == ',' || c.is_whitespace()) {
10        let tag = raw.trim_start_matches('#').to_lowercase();
11        if !tag.is_empty() && !tags.contains(&tag) {
12            tags.push(tag);
13        }
14    }
15    tags
16}
17
18/// `Fix: prod login!` → `fix-prod-login`; ASCII only so the name works on every file system.
19pub fn slugify(title: &str) -> String {
20    let mut slug = String::new();
21    for c in title.chars() {
22        if slug.len() >= MAX_SLUG_LEN {
23            break;
24        }
25        if c.is_ascii_alphanumeric() {
26            slug.push(c.to_ascii_lowercase());
27        } else if !slug.ends_with('-') {
28            slug.push('-');
29        }
30    }
31    let slug = slug.trim_matches('-');
32    if slug.is_empty() { "task".to_string() } else { slug.to_string() }
33}
34
35/// In a task file `## ` starts a section and, inside Comments, `### ` starts a comment.
36/// Headings typed into a description or comment are pushed below those levels so they
37/// can't break the file. Returns the text and whether anything was changed.
38pub fn sanitize_description(text: &str) -> (String, bool) {
39    demote(text, &["## "], "### ")
40}
41
42/// See [`sanitize_description`].
43pub fn sanitize_comment(text: &str) -> (String, bool) {
44    demote(text, &["## ", "### "], "#### ")
45}
46
47/// Rewrites lines starting with any of `prefixes` to start with `to` instead.
48fn demote(text: &str, prefixes: &[&str], to: &str) -> (String, bool) {
49    let mut changed = false;
50    let lines: Vec<String> = text
51        .lines()
52        .map(|line| match prefixes.iter().find_map(|p| line.strip_prefix(p)) {
53            Some(rest) => {
54                changed = true;
55                format!("{to}{rest}")
56            }
57            None => line.to_string(),
58        })
59        .collect();
60    (lines.join("\n").trim().to_string(), changed)
61}
62
63#[cfg(test)]
64#[path = "../../tests/model/text.rs"]
65mod tests;