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/// `1 session`, `3 sessions`: `n` with its noun, `one` for one and `many` otherwise (`0 sessions`).
19pub fn counted(n: usize, one: &str, many: &str) -> String {
20    format!("{n} {}", if n == 1 { one } else { many })
21}
22
23/// A title on one line, as its file's `# ` line holds it: line breaks typed into it (`kb add $'Plan\nSteps'`) become
24/// spaces, rather than lines the file would read as something else, and the ends are trimmed.
25pub fn one_line(title: &str) -> String {
26    // A lone `\r` is a line break too, as markdown reads it, though `str::lines` keeps it.
27    title.split(['\n', '\r']).map(str::trim).filter(|line| !line.is_empty()).collect::<Vec<_>>().join(" ")
28}
29
30/// `Fix: prod login!` → `fix-prod-login`; ASCII only so the name works on every file system.
31pub fn slugify(title: &str) -> String {
32    let mut slug = String::new();
33    for c in title.chars() {
34        if slug.len() >= MAX_SLUG_LEN {
35            break;
36        }
37        if c.is_ascii_alphanumeric() {
38            slug.push(c.to_ascii_lowercase());
39        } else if !slug.ends_with('-') {
40            slug.push('-');
41        }
42    }
43    let slug = slug.trim_matches('-');
44    if slug.is_empty() { "task".to_string() } else { slug.to_string() }
45}
46
47/// In a task file `## ` starts a section and, inside Comments, `### ` starts a comment.
48/// Headings typed into a description or comment are pushed below those levels so they
49/// can't break the file. Returns the text and whether anything was changed.
50pub fn sanitize_description(text: &str) -> (String, bool) {
51    demote(text, &["## "], "### ")
52}
53
54/// See [`sanitize_description`].
55pub fn sanitize_comment(text: &str) -> (String, bool) {
56    demote(text, &["## ", "### "], "#### ")
57}
58
59/// Rewrites lines starting with any of `prefixes` to start with `to` instead.
60fn demote(text: &str, prefixes: &[&str], to: &str) -> (String, bool) {
61    let mut changed = false;
62    let lines: Vec<String> = text
63        .lines()
64        .map(|line| match prefixes.iter().find_map(|p| line.strip_prefix(p)) {
65            Some(rest) => {
66                changed = true;
67                format!("{to}{rest}")
68            }
69            None => line.to_string(),
70        })
71        .collect();
72    (lines.join("\n").trim().to_string(), changed)
73}
74
75#[cfg(test)]
76#[path = "../../tests/model/text.rs"]
77mod tests;