Skip to main content

nexus_core/
citations.rs

1//! Pure parsing for report citations: the trailing `Sources:` (or
2//! `## Sources`) list research/web-mode replies end with (see
3//! `WRITER_PROMPT`/`SEARCHER_PROMPT` in `app/research.rs` and
4//! `web_mode_clause` in `app/chat.rs`), and the `[n]` inline markers that
5//! reference it. Parsing is data — it lives here in core. Styling the
6//! markers into terminal colors is rendering — it lives in the TUI crate
7//! (`ui/citations_style.rs`).
8
9/// Parse a message's trailing citation list into `(n, url)` pairs, in the
10/// order they're listed. Recognizes a `Sources:` line or a `Sources` heading
11/// (any level), then reads `N. url` / `N) url` lines until a non-matching
12/// line ends the section.
13pub fn parse_citations(content: &str) -> Vec<(usize, String)> {
14    let mut out = Vec::new();
15    let mut in_section = false;
16    for line in content.lines() {
17        let t = line.trim();
18        if !in_section {
19            let heading = t.trim_start_matches('#').trim();
20            if t.eq_ignore_ascii_case("Sources:") || heading.eq_ignore_ascii_case("Sources") {
21                in_section = true;
22            }
23            continue;
24        }
25        if t.is_empty() {
26            continue;
27        }
28        let Some((num, rest)) = t.split_once(['.', ')']) else {
29            break;
30        };
31        let Ok(n) = num.trim().parse::<usize>() else {
32            break;
33        };
34        let url = rest.trim().to_string();
35        if url.is_empty() {
36            break;
37        }
38        out.push((n, url));
39    }
40    out
41}
42
43/// The first `[n]` (n = 1+ ascii digits) substring in `text`, if any.
44pub fn citation_number_in(text: &str) -> Option<usize> {
45    let mut rest = text;
46    while let Some(start) = rest.find('[') {
47        rest = &rest[start + 1..];
48        let end = rest.find(']')?;
49        let inner = &rest[..end];
50        if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
51            return inner.parse().ok();
52        }
53        rest = &rest[end + 1..];
54    }
55    None
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn parse_citations_reads_a_sources_heading_section() {
64        let content = "# Report\n\nBody text [1] and more [2].\n\n## Sources\n1. https://a.example\n2. https://b.example/page\n";
65        assert_eq!(
66            parse_citations(content),
67            vec![
68                (1, "https://a.example".into()),
69                (2, "https://b.example/page".into())
70            ]
71        );
72    }
73
74    #[test]
75    fn parse_citations_reads_a_plain_sources_colon_line() {
76        let content = "findings text [1]\nSources:\n1. https://a.example\n";
77        assert_eq!(
78            parse_citations(content),
79            vec![(1, "https://a.example".into())]
80        );
81    }
82
83    #[test]
84    fn parse_citations_returns_empty_when_no_sources_section() {
85        assert!(parse_citations("just prose, no citations").is_empty());
86    }
87
88    #[test]
89    fn citation_number_in_finds_first_bracketed_number() {
90        assert_eq!(
91            citation_number_in("supported by research [3] and also [4]"),
92            Some(3)
93        );
94        assert_eq!(citation_number_in("no citation here"), None);
95        assert_eq!(citation_number_in("[not a number] but [5] later"), Some(5));
96        assert_eq!(citation_number_in("[not a number]"), None);
97    }
98}