Skip to main content

oxios_markdown/
parser.rs

1//! Markdown text processing utilities.
2//!
3//! Ported from files.md (`server/txt/mod.rs`) by Artem Zakirullin.
4//! Provides string similarity, link extraction, and text normalization.
5
6use regex::Regex;
7
8/// Normalize CRLF and CR to LF.
9pub fn norm_new_lines(s: &str) -> String {
10    s.replace("\r\n", "\n").replace('\r', "\n")
11}
12
13/// Get the first word from a string.
14pub fn first_word(s: &str) -> &str {
15    s.split_whitespace().next().unwrap_or(s)
16}
17
18/// Calculate similarity between two strings (0.0 – 100.0) using Levenshtein distance.
19pub fn similar(a: &str, b: &str) -> f64 {
20    if a.is_empty() || b.is_empty() {
21        return 0.0;
22    }
23    let a_lower = a.to_lowercase();
24    let b_lower = b.to_lowercase();
25    if a_lower == b_lower {
26        return 100.0;
27    }
28    let max_len = a_lower.len().max(b_lower.len());
29    if max_len == 0 {
30        return 100.0;
31    }
32    let distance = levenshtein(&a_lower, &b_lower);
33    ((max_len - distance) as f64 / max_len as f64) * 100.0
34}
35
36/// Compute Levenshtein edit distance between two strings.
37#[allow(clippy::needless_range_loop)]
38pub fn levenshtein(a: &str, b: &str) -> usize {
39    let len_a = a.len();
40    let len_b = b.len();
41    if len_a == 0 {
42        return len_b;
43    }
44    if len_b == 0 {
45        return len_a;
46    }
47
48    let mut matrix = vec![vec![0usize; len_b + 1]; len_a + 1];
49    for i in 0..=len_a {
50        matrix[i][0] = i;
51    }
52    for j in 0..=len_b {
53        matrix[0][j] = j;
54    }
55
56    for i in 1..=len_a {
57        for j in 1..=len_b {
58            let cost = if a.as_bytes()[i - 1] == b.as_bytes()[j - 1] {
59                0
60            } else {
61                1
62            };
63            matrix[i][j] = (matrix[i - 1][j] + 1)
64                .min(matrix[i][j - 1] + 1)
65                .min(matrix[i - 1][j - 1] + cost);
66        }
67    }
68    matrix[len_a][len_b]
69}
70
71/// Truncate a string to `max_len`, appending "..." if truncated.
72pub fn truncate(s: &str, max_len: usize) -> String {
73    if s.len() <= max_len {
74        s.to_string()
75    } else {
76        format!("{}...", &s[..max_len.saturating_sub(3)])
77    }
78}
79
80/// First character uppercase (Unicode-aware).
81///
82/// ```
83/// use oxios_markdown::parser::ucfirst;
84/// assert_eq!(ucfirst("hello"), "Hello");
85/// assert_eq!(ucfirst(""), "");
86/// assert_eq!(ucfirst("über"), "Über");
87/// ```
88pub fn ucfirst(s: &str) -> String {
89    let mut chars = s.chars();
90    match chars.next() {
91        Some(first) => first.to_uppercase().chain(chars).collect(),
92        None => String::new(),
93    }
94}
95
96/// First character lowercase (Unicode-aware).
97///
98/// ```
99/// use oxios_markdown::parser::lcfirst;
100/// assert_eq!(lcfirst("Hello"), "hello");
101/// assert_eq!(lcfirst(""), "");
102/// ```
103pub fn lcfirst(s: &str) -> String {
104    let mut chars = s.chars();
105    match chars.next() {
106        Some(first) => first.to_lowercase().chain(chars).collect(),
107        None => String::new(),
108    }
109}
110
111/// Unicode-safe substring.
112///
113/// Respects Unicode codepoints but is not grapheme-cluster aware
114/// (combining characters like skin-tone modifiers count as separate codepoints).
115///
116/// ```
117/// use oxios_markdown::parser::substr;
118/// assert_eq!(substr("Hello", 0, 3), "Hel");
119/// assert_eq!(substr("Hello", 3, 10), "lo");
120/// assert_eq!(substr("Hello", 10, 2), "");
121/// ```
122pub fn substr(input: &str, start: usize, length: usize) -> String {
123    let runes: Vec<char> = input.chars().collect();
124    if start >= runes.len() {
125        return String::new();
126    }
127    let end = (start + length).min(runes.len());
128    runes[start..end].iter().collect()
129}
130
131/// Check if text has multiple lines.
132///
133/// ```
134/// use oxios_markdown::parser::is_multiline;
135/// assert!(is_multiline("line one\nline two"));
136/// assert!(!is_multiline("single line"));
137/// ```
138pub fn is_multiline(text: &str) -> bool {
139    let text = norm_new_lines(text);
140    text.lines().count() > 1
141}
142
143/// Split text into chunks of at most `max_len` characters.
144///
145/// Tries to break at the last newline, then the last space within the window.
146/// Trims leading/trailing whitespace from each chunk.
147///
148/// ```
149/// use oxios_markdown::parser::split_text_into_chunks;
150/// let chunks = split_text_into_chunks("Hello world how are you", 11);
151/// assert!(chunks.len() > 1);
152/// for chunk in &chunks {
153///     assert!(chunk.len() <= 11);
154/// }
155/// ```
156pub fn split_text_into_chunks(text: &str, max_len: usize) -> Vec<String> {
157    let text = text.trim();
158
159    if max_len == 0 {
160        return vec![text.to_string()];
161    }
162
163    let mut chunks = Vec::new();
164    let mut runes: Vec<char> = text.chars().collect();
165
166    while runes.len() > max_len {
167        let window = &runes[..max_len];
168
169        // Find the last newline in the window
170        let mut split_index = None;
171        for i in (0..window.len()).rev() {
172            if window[i] == '\n' {
173                split_index = Some(i);
174                break;
175            }
176        }
177
178        // No newline — find the last space
179        if split_index.is_none() {
180            for i in (0..window.len()).rev() {
181                if window[i] == ' ' {
182                    split_index = Some(i);
183                    break;
184                }
185            }
186        }
187
188        // No space either — split at max_len
189        let split_index = split_index.unwrap_or(max_len);
190
191        let chunk: String = runes[..split_index].iter().collect();
192        let chunk = chunk.trim();
193        if !chunk.is_empty() {
194            chunks.push(chunk.to_string());
195        }
196
197        let remainder: String = runes[split_index..].iter().collect();
198        runes = remainder.trim().chars().collect();
199    }
200
201    // Add the remaining runes as the final chunk
202    let remainder: String = runes.iter().collect();
203    let remainder = remainder.trim();
204    if !remainder.is_empty() {
205        chunks.push(remainder.to_string());
206    }
207
208    chunks
209}
210
211/// Known emoji prefixes to strip before re-adding.
212const EMOJI_STRIP_PREFIXES: &[&str] = &["WRK ", "UA ", "US ", "CY ", "HOB ", "SRB ", "PL "];
213
214/// Add emoji prefix to string, stripping known prefixes first.
215///
216/// If `emoji` is empty the string is returned with prefixes stripped only.
217///
218/// ```
219/// use oxios_markdown::parser::emoji_prefix;
220/// assert_eq!(emoji_prefix("📝", "WRK Task"), "📝 Task");
221/// assert_eq!(emoji_prefix("", "Hello"), "Hello");
222/// ```
223pub fn emoji_prefix(emoji: &str, s: &str) -> String {
224    let mut s = s.to_string();
225    for prefix in EMOJI_STRIP_PREFIXES {
226        s = s.trim_start_matches(prefix).to_string();
227    }
228    if emoji.is_empty() {
229        return s;
230    }
231    format!("{emoji} {s}")
232}
233
234/// Check if text contains a markdown image.
235pub fn has_image(msg: &str) -> bool {
236    Regex::new(r"!\[.*?\]\(.*?\)")
237        .expect("valid regex literal")
238        .is_match(msg)
239}
240
241/// Strip a leading `` `HH:MM` `` timestamp from chat entries.
242pub fn strip_chat_timestamp(s: &str) -> String {
243    Regex::new(r"^`\d{2}:\d{2}` ")
244        .expect("valid regex literal")
245        .replace(s, "")
246        .to_string()
247}
248
249/// Extract all markdown links `[text](path)` from content.
250///
251/// Returns a list of (link_text, target_path) pairs.
252pub fn extract_markdown_links(content: &str) -> Vec<(String, String)> {
253    let re = Regex::new(r"\[([^\]]*)\]\(([^)]+)\)").expect("valid regex literal");
254    re.captures_iter(content)
255        .filter_map(|cap| {
256            let text = cap.get(1)?.as_str().to_string();
257            let path = cap.get(2)?.as_str().to_string();
258            // Skip external links and images
259            if path.starts_with("http://") || path.starts_with("https://") {
260                return None;
261            }
262            Some((text, path))
263        })
264        .collect()
265}
266
267/// Rewrite markdown-link targets matching `old_target` to `new_target`.
268///
269/// Matches the `[text](target)` form produced/consumed by
270/// [`extract_markdown_links`]. The target is matched literally (regex-escaped)
271/// inside the trailing `(...)` of a link, so it won't touch the same string
272/// appearing in prose or code. Anchors/extensions are preserved only when
273/// they were part of the captured target — i.e. an exact-target match.
274///
275/// Returns the number of replacements made.
276pub fn rewrite_link_targets(content: &str, old_target: &str, new_target: &str) -> (String, usize) {
277    if old_target == new_target || old_target.is_empty() {
278        return (content.to_string(), 0);
279    }
280    // Match `](<old_target>)` — the `]` guards against replacing the target
281    // text when it shows up in link labels or body prose.
282    let pattern = format!(r"\]\({}\)", regex::escape(old_target));
283    let re = match Regex::new(&pattern) {
284        Ok(r) => r,
285        Err(_) => return (content.to_string(), 0),
286    };
287    let replacement = format!("]({new_target})");
288    let count = re.find_iter(content).count();
289    (
290        re.replace_all(content, replacement.as_str()).to_string(),
291        count,
292    )
293}
294
295/// Extract all wikilinks `[[target]]` / `[[target|alias]]` from content.
296///
297/// Returns `(target, alias?)` pairs. The alias is the LAST group when
298/// multiple `|`-separated aliases are present (mirrors the frontend
299/// widget's backreference semantics). Frontmatter is stripped first so
300/// links inside metadata blocks are ignored.
301pub fn extract_wikilinks(content: &str) -> Vec<(String, Option<String>)> {
302    let body = crate::backlinks::strip_frontmatter(content);
303    let re = match Regex::new(r"\[\[([^\[\]\n|]+)(?:\|([^\[\]\n]+))*\]\]") {
304        Ok(r) => r,
305        Err(_) => return Vec::new(),
306    };
307    re.captures_iter(body)
308        .filter_map(|cap| {
309            let target = cap.get(1)?.as_str().trim().to_string();
310            if target.is_empty() {
311                return None;
312            }
313            let alias = cap.get(2).map(|m| m.as_str().trim().to_string());
314            Some((target, alias))
315        })
316        .collect()
317}
318
319/// Map of lowercase filename stem → candidate full paths. Built by the
320/// knowledge base from the filesystem; consumed by [`resolve_wikilink`].
321pub type StemIndex = std::collections::HashMap<String, Vec<String>>;
322
323/// Resolve a wikilink target to a canonical note path.
324///
325/// Mirrors the frontend resolver in `web/src/lib/wikilink-resolve.ts`:
326///   - `brain/Rust.md` → exact match
327///   - `brain/Rust`    → `brain/Rust.md`
328///   - `Rust`          → unique stem match; on collision prefer the same
329///     directory as `source_path`; still ambiguous → None.
330///
331/// Returning `None` for ambiguous bare stems is load-bearing: it is what
332/// prevents a bare-stem wikilink from being indexed (and later rewritten
333/// on rename) when we can't prove which file it meant. See the design
334/// doc §6.
335pub fn resolve_wikilink(
336    target: &str,
337    source_path: Option<&str>,
338    stem_index: &StemIndex,
339) -> Option<String> {
340    let t = target.trim();
341    if t.is_empty() {
342        return None;
343    }
344    let lower = t.to_lowercase();
345    // Form 1: full path with extension — exact membership.
346    if lower.ends_with(".md") {
347        return path_exists(t, stem_index).then_some(t.to_string());
348    }
349    // Form 2: path with a directory separator — append `.md`, exact.
350    if t.contains('/') {
351        let with_ext = format!("{t}.md");
352        return path_exists(&with_ext, stem_index).then_some(with_ext);
353    }
354    // Form 3: bare stem — basename lookup with same-dir preference.
355    let candidates = stem_index.get(&lower)?;
356    if candidates.len() == 1 {
357        return Some(candidates[0].clone());
358    }
359    if let Some(src) = source_path {
360        let src_dir = dir_of(src);
361        let same_dir: Vec<&String> = candidates.iter().filter(|p| dir_of(p) == src_dir).collect();
362        if same_dir.len() == 1 {
363            return Some(same_dir[0].clone());
364        }
365    }
366    None
367}
368
369/// Rewrite `[[target]]` / `[[target|alias]]` wikilinks whose target points
370/// at `old_path` so they point at `new_path`, preserving the user's
371/// original form (bare stem stays bare, path-without-ext stays that way,
372/// aliases are kept verbatim).
373///
374/// Matches three target forms derived from `old_path`:
375///   - the full path (`brain/Rust.md`)
376///   - the path without extension (`brain/Rust`)
377///   - the bare stem (`Rust`)
378///
379/// Per the design doc §6, bare-stem rewrites are safe here because the
380/// caller (`note_move`) only feeds in source files already known (via the
381/// backward index) to have linked to `old_path` — meaning their bare-stem
382/// wikilinks were proven unique at index time. We do NOT re-resolve at
383/// rewrite time (old_path is already gone from disk by then).
384///
385/// Returns the rewritten content and the number of substitutions made.
386pub fn rewrite_wikilink_targets(
387    content: &str,
388    old_path: &str,
389    new_path: &str,
390    // Pre-rename stem index. Required to safely rewrite BARE-STEM wikilinks
391    // (`[[Rust]]`): we only rewrite when the stem is globally unique, so an
392    // ambiguous `[[Dup]]` (two files share the stem) is left untouched even
393    // if its source also contained an explicit-path link to old_path that
394    // put it in `sources_for`. Explicit-path forms are exact matches and
395    // need no ambiguity check. Pass None to disable bare-stem rewrites.
396    stem_index: Option<&StemIndex>,
397) -> (String, usize) {
398    if old_path == new_path || old_path.is_empty() {
399        return (content.to_string(), 0);
400    }
401    let old_no_ext = old_path.strip_suffix(".md").unwrap_or(old_path);
402    let new_no_ext = new_path.strip_suffix(".md").unwrap_or(new_path);
403    let old_stem = old_no_ext.rsplit('/').next().unwrap_or(old_no_ext);
404    let new_stem = new_no_ext.rsplit('/').next().unwrap_or(new_no_ext);
405
406    let re = match Regex::new(r"\[\[([^\[\]\n|]+)((?:\|[^\[\]\n]+)*)\]\]") {
407        Ok(r) => r,
408        Err(_) => return (content.to_string(), 0),
409    };
410    let mut count = 0usize;
411    let result = re
412        .replace_all(content, |caps: &regex::Captures| {
413            let full = caps
414                .get(0)
415                .expect("capture group present after successful match")
416                .as_str();
417            let target = caps
418                .get(1)
419                .expect("capture group present after successful match")
420                .as_str();
421            let alias_part = caps
422                .get(2)
423                .expect("capture group present after successful match")
424                .as_str();
425            // Explicit-path forms are exact matches — always safe.
426            // The bare-stem form is only safe when the stem is globally
427            // unique (design doc §6): a stem shared by several files
428            // could have pointed at any of them, so rewriting would
429            // risk retargeting a link the system can't disambiguate.
430            let new_target = if target == old_path {
431                Some(new_path)
432            } else if target == old_no_ext {
433                Some(new_no_ext)
434            } else if target == old_stem
435                && old_stem != new_stem
436                && stem_index
437                    .and_then(|idx| idx.get(&target.to_lowercase()))
438                    .is_some_and(|candidates| candidates.len() == 1)
439            {
440                Some(new_stem)
441            } else {
442                None
443            };
444            match new_target {
445                Some(nt) => {
446                    count += 1;
447                    format!("[[{nt}{alias_part}]]")
448                }
449                None => full.to_string(),
450            }
451        })
452        .to_string();
453    (result, count)
454}
455
456/// Whether a full path exists in the stem index (stem lookup + bucket membership).
457fn path_exists(path: &str, stem_index: &StemIndex) -> bool {
458    stem_index
459        .get(&stem_of(path))
460        .is_some_and(|bucket| bucket.iter().any(|p| p == path))
461}
462
463fn stem_of(path: &str) -> String {
464    let basename = path.rsplit('/').next().unwrap_or(path);
465    basename
466        .strip_suffix(".md")
467        .or_else(|| basename.strip_suffix(".MD"))
468        .unwrap_or(basename)
469        .to_lowercase()
470}
471
472fn dir_of(path: &str) -> &str {
473    match path.rfind('/') {
474        Some(i) => &path[..i],
475        None => "",
476    }
477}
478
479/// Extract all headings (`## Title`) from content.
480///
481/// Returns heading texts (without the `#` prefix).
482pub fn extract_headings(content: &str) -> Vec<String> {
483    let re = Regex::new(r"(?m)^(#{1,6})\s+(.+)$").expect("valid regex literal");
484    re.captures_iter(content)
485        .filter_map(|cap| cap.get(2).map(|m| m.as_str().trim().to_string()))
486        .collect()
487}
488
489/// Minimum similarity score (0-100) for fuzzy name search.
490pub const MIN_SEARCH_SIMILARITY: i32 = 70;
491
492/// 오늘 날짜의 Chat.md 헤더 문자열 (예: "#### 20 May, Tuesday").
493pub fn today_chat_header() -> String {
494    use chrono::Local;
495    let now = Local::now();
496    format!("#### {} {}", now.format("%d %B,"), now.format("%A"))
497}
498
499/// 오늘 날짜의 저널 파일 경로 (예: "journal/2026.05 May.md").
500pub fn today_journal_path() -> String {
501    use chrono::Local;
502    let now = Local::now();
503    format!("journal/{}.{}.md", now.format("%Y.%m"), now.format("%B"))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_norm_newlines() {
512        assert_eq!(norm_new_lines("a\r\nb\r\nc"), "a\nb\nc");
513        assert_eq!(norm_new_lines("a\rb\rc"), "a\nb\nc");
514    }
515
516    #[test]
517    fn test_similar() {
518        assert!(similar("hello", "helo") > 70.0);
519        assert!(similar("test", "test") > 99.0);
520        assert_eq!(similar("", ""), 0.0);
521    }
522
523    #[test]
524    fn test_levenshtein() {
525        assert_eq!(levenshtein("kitten", "sitting"), 3);
526        assert_eq!(levenshtein("test", "test"), 0);
527    }
528
529    #[test]
530    fn test_truncate() {
531        assert_eq!(truncate("hello", 10), "hello");
532        assert_eq!(truncate("hello world", 8), "hello...");
533    }
534
535    #[test]
536    fn test_extract_links() {
537        let md =
538            "See [Rust](brain/Rust.md) and [Go](brain/Go.md) but not [ext](https://example.com)";
539        let links = extract_markdown_links(md);
540        assert_eq!(links.len(), 2);
541        assert_eq!(links[0].0, "Rust");
542        assert_eq!(links[0].1, "brain/Rust.md");
543    }
544
545    fn stem_index(entries: &[&str]) -> StemIndex {
546        let mut idx: StemIndex = StemIndex::new();
547        for path in entries {
548            let stem = path
549                .rsplit('/')
550                .next()
551                .unwrap_or(path)
552                .trim_end_matches(".md")
553                .to_lowercase();
554            idx.entry(stem).or_default().push((*path).to_string());
555        }
556        idx
557    }
558
559    #[test]
560    fn test_extract_wikilinks() {
561        let md = "See [[Rust]] and [[brain/Go|The Go Page]] but not [md](brain/Other.md)";
562        let links = extract_wikilinks(md);
563        assert_eq!(links.len(), 2);
564        assert_eq!(links[0].0, "Rust");
565        assert!(links[0].1.is_none());
566        assert_eq!(links[1].0, "brain/Go");
567        assert_eq!(links[1].1.as_deref(), Some("The Go Page"));
568    }
569
570    #[test]
571    fn test_resolve_wikilink() {
572        let idx = stem_index(&[
573            "brain/Rust.md",
574            "brain/Ownership.md",
575            "lang/Rust.md",
576            "Notes.md",
577        ]);
578        // Full path with extension — exact.
579        assert_eq!(
580            resolve_wikilink("brain/Rust.md", None, &idx),
581            Some("brain/Rust.md".into())
582        );
583        assert_eq!(resolve_wikilink("brain/Missing.md", None, &idx), None);
584        // Path without extension appends `.md`.
585        assert_eq!(
586            resolve_wikilink("brain/Ownership", None, &idx),
587            Some("brain/Ownership.md".into())
588        );
589        // Unique bare stem.
590        assert_eq!(
591            resolve_wikilink("Notes", None, &idx),
592            Some("Notes.md".into())
593        );
594        // Ambiguous bare stem resolves via same-dir hint.
595        assert_eq!(
596            resolve_wikilink("Rust", Some("brain/Ownership.md"), &idx),
597            Some("brain/Rust.md".into()),
598        );
599        assert_eq!(
600            resolve_wikilink("Rust", Some("lang/Other.md"), &idx),
601            Some("lang/Rust.md".into())
602        );
603        // No hint → unresolved.
604        assert_eq!(resolve_wikilink("Rust", None, &idx), None);
605        // Unknown / empty.
606        assert_eq!(resolve_wikilink("Nowhere", None, &idx), None);
607        assert_eq!(resolve_wikilink("", None, &idx), None);
608    }
609
610    #[test]
611    fn test_rewrite_link_targets() {
612        let md = "See [Rust](brain/Rust.md) and [also](brain/Rust.md); prose brain/Rust.md stays.";
613        let (out, n) = rewrite_link_targets(md, "brain/Rust.md", "brain/Rust Lang.md");
614        assert_eq!(n, 2);
615        assert!(out.contains("[Rust](brain/Rust Lang.md)"));
616        assert!(out.contains("[also](brain/Rust Lang.md)"));
617        // Untouched in prose / other links
618        assert!(out.contains("prose brain/Rust.md stays"));
619        // No-op when targets equal
620        let (same, zero) = rewrite_link_targets(md, "brain/Rust.md", "brain/Rust.md");
621        assert_eq!(zero, 0);
622        assert_eq!(same, md);
623    }
624
625    #[test]
626    fn test_rewrite_wikilink_targets() {
627        // Unique stem: bare, path, full, and alias forms all rewrite.
628        let unique = stem_index(&["brain/Rust.md"]);
629        let md = "Bare [[Rust]] path [[brain/Rust]] full [[brain/Rust.md]] alias [[Rust|Rusty]].";
630        let (out, n) =
631            rewrite_wikilink_targets(md, "brain/Rust.md", "brain/Rust Lang.md", Some(&unique));
632        assert_eq!(n, 4);
633        assert!(out.contains("[[Rust Lang]]"));
634        assert!(out.contains("[[brain/Rust Lang]]"));
635        assert!(out.contains("[[brain/Rust Lang.md]]"));
636        assert!(
637            out.contains("[[Rust Lang|Rusty]]"),
638            "alias preserved: {out}"
639        );
640
641        // Ambiguous stem: bare form is SKIPPED (can't prove which file it
642        // meant), explicit-path forms still rewrite.
643        let ambiguous = stem_index(&["a/Dup.md", "b/Dup.md"]);
644        let md2 = "ambig [[Dup]] explicit [[a/Dup]] full [[a/Dup.md]]";
645        let (out2, n2) = rewrite_wikilink_targets(md2, "a/Dup.md", "a/Moved.md", Some(&ambiguous));
646        assert!(
647            out2.contains("[[Dup]]"),
648            "ambiguous bare link preserved: {out2}"
649        );
650        assert!(
651            out2.contains("[[a/Moved]]"),
652            "explicit path rewritten: {out2}"
653        );
654        assert!(
655            out2.contains("[[a/Moved.md]]"),
656            "full path rewritten: {out2}"
657        );
658        assert_eq!(n2, 2);
659
660        // Bare stem NOT rewritten when only the directory changed (stem
661        // unchanged → no-op, skipped even though stem is unique).
662        let (out3, n3) =
663            rewrite_wikilink_targets("[[Rust]]", "brain/Rust.md", "lang/Rust.md", Some(&unique));
664        assert_eq!(n3, 0);
665        assert_eq!(out3, "[[Rust]]");
666
667        // No stem_index → bare-stem rewrites disabled (conservative).
668        // rewrite_wikilink_targets ONLY touches wikilinks; the markdown
669        // link in the same content is handled separately by rewrite_link_targets.
670        let (out4, n4) = rewrite_wikilink_targets(
671            "[[Rust]] [r](brain/Rust.md)",
672            "brain/Rust.md",
673            "brain/X.md",
674            None,
675        );
676        assert_eq!(n4, 0); // no wikilink rewrites (bare stem disabled, markdown links untouched here)
677        assert!(out4.contains("[[Rust]]"));
678        assert!(out4.contains("[r](brain/Rust.md)"));
679
680        // No-op when paths equal.
681        let (same, zero) =
682            rewrite_wikilink_targets(md, "brain/Rust.md", "brain/Rust.md", Some(&unique));
683        assert_eq!(zero, 0);
684        assert_eq!(same, md);
685    }
686
687    #[test]
688    fn test_extract_headings() {
689        let md = "# Title\n## Section\n### Sub\nsome text";
690        let headings = extract_headings(md);
691        assert_eq!(headings, vec!["Title", "Section", "Sub"]);
692    }
693
694    #[test]
695    fn test_ucfirst() {
696        assert_eq!(ucfirst("hello"), "Hello");
697        assert_eq!(ucfirst(""), "");
698        assert_eq!(ucfirst("Already"), "Already");
699        assert_eq!(ucfirst("über"), "Über");
700    }
701
702    #[test]
703    fn test_lcfirst() {
704        assert_eq!(lcfirst("Hello"), "hello");
705        assert_eq!(lcfirst(""), "");
706        assert_eq!(lcfirst("lower"), "lower");
707    }
708
709    #[test]
710    fn test_substr() {
711        assert_eq!(substr("Hello", 0, 3), "Hel");
712        assert_eq!(substr("Hello", 2, 3), "llo");
713        assert_eq!(substr("Hello", 3, 10), "lo");
714        assert_eq!(substr("Hello", 10, 2), "");
715        assert_eq!(substr("", 0, 5), "");
716        // Unicode
717        assert_eq!(substr("안녕하세요", 0, 2), "안녕");
718    }
719
720    #[test]
721    fn test_is_multiline() {
722        assert!(is_multiline("line one\nline two"));
723        assert!(!is_multiline("single line"));
724        assert!(is_multiline("a\r\nb"));
725        assert!(!is_multiline(""));
726    }
727
728    #[test]
729    fn test_split_text_into_chunks() {
730        // Exact fit
731        let chunks = split_text_into_chunks("Hello", 5);
732        assert_eq!(chunks, vec!["Hello"]);
733
734        // Split at space (Go test: basic split with spaces)
735        let chunks = split_text_into_chunks("This is a test to check the splitting of text", 10);
736        for chunk in &chunks {
737            assert!(
738                chunk.len() <= 10,
739                "chunk too long: '{}' ({})",
740                chunk,
741                chunk.len()
742            );
743        }
744
745        // Split at newline (Go test: max_len=15)
746        let chunks = split_text_into_chunks("Line one\nLine two\nLine three", 15);
747        assert_eq!(chunks, vec!["Line one", "Line two", "Line three"]);
748
749        // max_len == 0 returns everything as one chunk
750        let chunks = split_text_into_chunks("Hello world", 0);
751        assert_eq!(chunks, vec!["Hello world"]);
752    }
753
754    #[test]
755    fn test_emoji_prefix() {
756        assert_eq!(emoji_prefix("📝", "WRK Task"), "📝 Task");
757        assert_eq!(emoji_prefix("✅", "Task"), "✅ Task");
758        assert_eq!(emoji_prefix("", "Hello"), "Hello");
759        assert_eq!(emoji_prefix("🎉", "UA Celebration"), "🎉 Celebration");
760    }
761
762    #[test]
763    fn test_has_image() {
764        assert!(has_image("look: ![alt](img.png)"));
765        assert!(!has_image("just text"));
766    }
767}