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!(¬e[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}