Skip to main content

kimun_notes/ask/
locate.rs

1//! Pure resolution of a retrieved chunk's location within its source note's
2//! full text, for the Ask workspace's Source reader (CONTEXT.md: **Ask
3//! workspace**, `SourcesPanel`). Three-step resolution, most confident
4//! first: an exact substring match of the retrieved chunk text (first
5//! occurrence wins on a duplicate); the `ContentChunk` core's own chunker
6//! computes for the note (matched by innermost heading), located by
7//! substring; and, only when that chunk's text can't be found verbatim in
8//! the note (e.g. it was normalized server-side), core's
9//! `note::scan::heading_section_range` — content analysis over raw note
10//! text belongs in core, not the TUI.
11
12use std::ops::Range;
13
14use kimun_core::nfs::VaultPath;
15use kimun_core::note::{NoteDetails, scan};
16
17/// Locates the byte range of the section identified by `heading`/
18/// `chunk_text` within `note_text`. `None` when nothing resolves — the
19/// reader then shows the note from the top, unhighlighted. Pure function: no
20/// I/O, no vault access.
21pub fn section_range(note_text: &str, heading: &str, chunk_text: &str) -> Option<Range<usize>> {
22    if !chunk_text.is_empty()
23        && let Some(start) = note_text.find(chunk_text)
24    {
25        return Some(start..start + chunk_text.len());
26    }
27
28    // Recompute the note's own chunks (core's chunker, not the server's) and
29    // find the one whose innermost heading matches.
30    let (chunks, _links) = NoteDetails::chunks_and_links_of(&VaultPath::root(), note_text);
31    let chunk = chunks.iter().find(|c| {
32        c.breadcrumb_last()
33            .is_some_and(|h| h.eq_ignore_ascii_case(heading))
34    })?;
35
36    if let Some(start) = note_text.find(&chunk.text) {
37        return Some(start..start + chunk.text.len());
38    }
39
40    // Last resort: the chunk's own text isn't a verbatim substring either
41    // (normalization — core's chunker strips diacritics, reformats lists,
42    // etc.). Fall back to the heading line itself; this is core content
43    // analysis, so it lives in `note::scan`, not here.
44    scan::heading_section_range(note_text, heading)
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn section_range_prefers_exact_chunk_text() {
53        let note = "# a\nalpha body\n# b\nbeta body\n";
54        let r = section_range(note, "b", "beta body").unwrap();
55        assert_eq!(&note[r], "beta body");
56    }
57
58    #[test]
59    fn section_range_falls_back_to_heading_match() {
60        let note = "# intro\nreal text here\n";
61        // chunk text was normalized server-side and no longer matches verbatim
62        let r = section_range(note, "INTRO", "normalized text").unwrap();
63        assert!(note[r].contains("real text here"));
64    }
65
66    #[test]
67    fn section_range_gives_up_gracefully() {
68        assert!(section_range("# x\nbody\n", "missing", "nope").is_none());
69    }
70
71    #[test]
72    fn section_range_matches_via_chunk_when_exact_text_absent_but_chunk_matches() {
73        // Two headings; chunk_text doesn't match verbatim anywhere, but the
74        // heading resolves via core's own chunker (whose text, being derived
75        // straight from the note, is itself found verbatim).
76        let note = "# one\nfirst body\n# two\nsecond body\n";
77        let r = section_range(note, "two", "does not appear literally").unwrap();
78        assert!(note[r].contains("second body"));
79    }
80
81    #[test]
82    fn section_range_prefers_the_first_occurrence_on_a_duplicate_chunk_text() {
83        // `str::find` returns the first match; document that a repeated
84        // chunk body resolves to its earlier occurrence, not a later one.
85        let note = "# a\nshared body\n# b\nshared body\n";
86        let r = section_range(note, "b", "shared body").unwrap();
87        assert_eq!(r, 4..15, "first occurrence, under heading a, wins");
88    }
89}