Skip to main content

rtb_docs/
index.rs

1//! Navigation index — either parsed from `_index.yaml` under the
2//! doc-tree root or synthesised from a recursive `.md` file scan.
3//!
4//! # YAML shape
5//!
6//! ```yaml
7//! title: My Tool Documentation
8//! sections:
9//!   - title: Getting started
10//!     pages:
11//!       - { path: intro.md, title: Introduction }
12//!       - { path: install.md, title: Install }
13//!   - title: Reference
14//!     pages:
15//!       - { path: config.md, title: Configuration reference }
16//! ```
17//!
18//! # Fallback scan
19//!
20//! When no `_index.yaml` exists under the root, the index is derived
21//! by:
22//! 1. Walking the tree for `.md` files (skipping `_index.yaml`).
23//! 2. For each page, parsing the first `# Heading` line as its title;
24//!    pages with no heading use their filename as a fallback.
25//! 3. Grouping by top-level directory into sections; pages directly
26//!    under the root go into an "Overview" section.
27
28use serde::Deserialize;
29
30use crate::error::{DocsError, Result};
31
32/// Parsed document index.
33#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
34pub struct Index {
35    /// Human-readable index title. Shown at the top of the browser
36    /// pane. Defaults to `"Documentation"` when neither `_index.yaml`
37    /// nor the first page supplies a title.
38    #[serde(default = "default_title")]
39    pub title: String,
40
41    /// Grouped list of pages.
42    #[serde(default)]
43    pub sections: Vec<IndexSection>,
44}
45
46fn default_title() -> String {
47    "Documentation".into()
48}
49
50/// A named group of pages, shown as an expandable heading in the
51/// browser's left pane.
52#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
53pub struct IndexSection {
54    /// Section title, shown as a heading.
55    pub title: String,
56    /// Pages in this section, in display order.
57    #[serde(default)]
58    pub pages: Vec<IndexEntry>,
59}
60
61/// One navigable page.
62#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
63pub struct IndexEntry {
64    /// Path relative to the doc-tree root. Must not escape the root
65    /// via `..` — rejected at load time with [`DocsError::IndexMalformed`].
66    pub path: String,
67    /// Display title.
68    pub title: String,
69}
70
71impl Index {
72    /// Iterate every entry (section + page) in display order. Used by
73    /// search ingestion.
74    pub fn entries(&self) -> impl Iterator<Item = (&str, &IndexEntry)> {
75        self.sections.iter().flat_map(|s| s.pages.iter().map(move |p| (s.title.as_str(), p)))
76    }
77
78    /// Count of pages across all sections.
79    #[must_use]
80    pub fn page_count(&self) -> usize {
81        self.sections.iter().map(|s| s.pages.len()).sum()
82    }
83}
84
85/// Parse an `_index.yaml` body. Paths containing `..` or absolute
86/// components are rejected.
87///
88/// # Errors
89///
90/// [`DocsError::IndexMalformed`] on parse failure or unsafe paths.
91pub fn parse_index(yaml: &str) -> Result<Index> {
92    let index: Index = serde_saphyr::from_str_with_options(
93        yaml,
94        serde_saphyr::options! { strict_booleans: true, with_snippet: false },
95    )
96    .map_err(|e| DocsError::IndexMalformed(e.to_string()))?;
97    for section in &index.sections {
98        for page in &section.pages {
99            if is_unsafe_path(&page.path) {
100                return Err(DocsError::IndexMalformed(format!(
101                    "page path escapes root: {}",
102                    page.path
103                )));
104            }
105        }
106    }
107    Ok(index)
108}
109
110fn is_unsafe_path(path: &str) -> bool {
111    // `Path::is_absolute()` is platform-aware — on Windows a leading `/`
112    // without a drive letter is classified as relative, so we also reject
113    // a leading `RootDir` component explicitly.
114    let p = std::path::Path::new(path);
115    p.is_absolute()
116        || p.components().any(|c| {
117            matches!(
118                c,
119                std::path::Component::ParentDir
120                    | std::path::Component::Prefix(_)
121                    | std::path::Component::RootDir
122            )
123        })
124}
125
126/// Synthesise an index from a list of `(relative_path, body)` pairs.
127/// Pairs should be paths under the doc-tree root.
128#[must_use]
129pub fn scan_index(pages: &[(String, String)]) -> Index {
130    use std::collections::BTreeMap;
131    // Bucket pages by top-level directory.
132    let mut sections: BTreeMap<String, Vec<IndexEntry>> = BTreeMap::new();
133    for (path, body) in pages {
134        if path == "_index.yaml" {
135            continue;
136        }
137        let title = extract_title(body).unwrap_or_else(|| fallback_title(path));
138        let entry = IndexEntry { path: path.clone(), title };
139        let section_key = first_component(path)
140            .filter(|c| {
141                !std::path::Path::new(c)
142                    .extension()
143                    .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
144            })
145            .map_or_else(|| "Overview".to_string(), str::to_string);
146        sections.entry(section_key).or_default().push(entry);
147    }
148    Index {
149        title: "Documentation".into(),
150        sections: sections
151            .into_iter()
152            .map(|(title, pages)| IndexSection { title, pages })
153            .collect(),
154    }
155}
156
157fn extract_title(body: &str) -> Option<String> {
158    body.lines().find_map(|l| l.strip_prefix("# ").map(|s| s.trim().to_string()))
159}
160
161fn fallback_title(path: &str) -> String {
162    std::path::Path::new(path)
163        .file_stem()
164        .and_then(|s| s.to_str())
165        .unwrap_or(path)
166        .replace(['-', '_'], " ")
167}
168
169fn first_component(path: &str) -> Option<&str> {
170    path.split('/').next()
171}
172
173// ---------------------------------------------------------------------
174// Tests — pure-function coverage
175// ---------------------------------------------------------------------
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parses_minimal_yaml() {
183        let yaml = r"
184title: My Docs
185sections:
186  - title: Getting started
187    pages:
188      - { path: intro.md, title: Introduction }
189";
190        let idx = parse_index(yaml).expect("parse");
191        assert_eq!(idx.title, "My Docs");
192        assert_eq!(idx.sections.len(), 1);
193        assert_eq!(idx.sections[0].pages[0].path, "intro.md");
194        assert_eq!(idx.page_count(), 1);
195    }
196
197    #[test]
198    fn default_title_when_absent() {
199        let yaml = "sections: []";
200        let idx = parse_index(yaml).expect("parse");
201        assert_eq!(idx.title, "Documentation");
202    }
203
204    #[test]
205    fn rejects_parent_traversal() {
206        let yaml = r#"
207sections:
208  - title: X
209    pages:
210      - { path: "../secret.md", title: bad }
211"#;
212        let err = parse_index(yaml).expect_err("traversal");
213        match err {
214            DocsError::IndexMalformed(msg) => {
215                assert!(msg.contains("escapes root"), "got: {msg}");
216            }
217            other => panic!("expected IndexMalformed, got {other:?}"),
218        }
219    }
220
221    #[test]
222    fn rejects_absolute_paths() {
223        let yaml = r#"
224sections:
225  - title: X
226    pages:
227      - { path: "/etc/passwd", title: bad }
228"#;
229        let err = parse_index(yaml).expect_err("absolute");
230        assert!(matches!(err, DocsError::IndexMalformed(_)));
231    }
232
233    #[test]
234    fn fallback_scan_extracts_heading() {
235        let pages = vec![
236            ("intro.md".into(), "# Introduction\n\nBody".into()),
237            ("guide/getting.md".into(), "# Getting started\n\nBody".into()),
238        ];
239        let idx = scan_index(&pages);
240        assert_eq!(idx.page_count(), 2);
241        // Two sections: "Overview" (intro.md) and "guide" (guide/getting.md).
242        assert_eq!(idx.sections.len(), 2);
243        let overview = idx.sections.iter().find(|s| s.title == "Overview").expect("overview");
244        assert_eq!(overview.pages[0].title, "Introduction");
245        let guide = idx.sections.iter().find(|s| s.title == "guide").expect("guide");
246        assert_eq!(guide.pages[0].title, "Getting started");
247    }
248
249    #[test]
250    fn fallback_uses_filename_when_no_heading() {
251        let pages = vec![("no-title.md".into(), "Just a body paragraph".into())];
252        let idx = scan_index(&pages);
253        assert_eq!(idx.sections[0].pages[0].title, "no title");
254    }
255}