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 =
93        serde_yaml::from_str(yaml).map_err(|e| DocsError::IndexMalformed(e.to_string()))?;
94    for section in &index.sections {
95        for page in &section.pages {
96            if is_unsafe_path(&page.path) {
97                return Err(DocsError::IndexMalformed(format!(
98                    "page path escapes root: {}",
99                    page.path
100                )));
101            }
102        }
103    }
104    Ok(index)
105}
106
107fn is_unsafe_path(path: &str) -> bool {
108    // `Path::is_absolute()` is platform-aware — on Windows a leading `/`
109    // without a drive letter is classified as relative, so we also reject
110    // a leading `RootDir` component explicitly.
111    let p = std::path::Path::new(path);
112    p.is_absolute()
113        || p.components().any(|c| {
114            matches!(
115                c,
116                std::path::Component::ParentDir
117                    | std::path::Component::Prefix(_)
118                    | std::path::Component::RootDir
119            )
120        })
121}
122
123/// Synthesise an index from a list of `(relative_path, body)` pairs.
124/// Pairs should be paths under the doc-tree root.
125#[must_use]
126pub fn scan_index(pages: &[(String, String)]) -> Index {
127    use std::collections::BTreeMap;
128    // Bucket pages by top-level directory.
129    let mut sections: BTreeMap<String, Vec<IndexEntry>> = BTreeMap::new();
130    for (path, body) in pages {
131        if path == "_index.yaml" {
132            continue;
133        }
134        let title = extract_title(body).unwrap_or_else(|| fallback_title(path));
135        let entry = IndexEntry { path: path.clone(), title };
136        let section_key = first_component(path)
137            .filter(|c| {
138                !std::path::Path::new(c)
139                    .extension()
140                    .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
141            })
142            .map_or_else(|| "Overview".to_string(), str::to_string);
143        sections.entry(section_key).or_default().push(entry);
144    }
145    Index {
146        title: "Documentation".into(),
147        sections: sections
148            .into_iter()
149            .map(|(title, pages)| IndexSection { title, pages })
150            .collect(),
151    }
152}
153
154fn extract_title(body: &str) -> Option<String> {
155    body.lines().find_map(|l| l.strip_prefix("# ").map(|s| s.trim().to_string()))
156}
157
158fn fallback_title(path: &str) -> String {
159    std::path::Path::new(path)
160        .file_stem()
161        .and_then(|s| s.to_str())
162        .unwrap_or(path)
163        .replace(['-', '_'], " ")
164}
165
166fn first_component(path: &str) -> Option<&str> {
167    path.split('/').next()
168}
169
170// ---------------------------------------------------------------------
171// Tests — pure-function coverage
172// ---------------------------------------------------------------------
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn parses_minimal_yaml() {
180        let yaml = r"
181title: My Docs
182sections:
183  - title: Getting started
184    pages:
185      - { path: intro.md, title: Introduction }
186";
187        let idx = parse_index(yaml).expect("parse");
188        assert_eq!(idx.title, "My Docs");
189        assert_eq!(idx.sections.len(), 1);
190        assert_eq!(idx.sections[0].pages[0].path, "intro.md");
191        assert_eq!(idx.page_count(), 1);
192    }
193
194    #[test]
195    fn default_title_when_absent() {
196        let yaml = "sections: []";
197        let idx = parse_index(yaml).expect("parse");
198        assert_eq!(idx.title, "Documentation");
199    }
200
201    #[test]
202    fn rejects_parent_traversal() {
203        let yaml = r#"
204sections:
205  - title: X
206    pages:
207      - { path: "../secret.md", title: bad }
208"#;
209        let err = parse_index(yaml).expect_err("traversal");
210        match err {
211            DocsError::IndexMalformed(msg) => {
212                assert!(msg.contains("escapes root"), "got: {msg}");
213            }
214            other => panic!("expected IndexMalformed, got {other:?}"),
215        }
216    }
217
218    #[test]
219    fn rejects_absolute_paths() {
220        let yaml = r#"
221sections:
222  - title: X
223    pages:
224      - { path: "/etc/passwd", title: bad }
225"#;
226        let err = parse_index(yaml).expect_err("absolute");
227        assert!(matches!(err, DocsError::IndexMalformed(_)));
228    }
229
230    #[test]
231    fn fallback_scan_extracts_heading() {
232        let pages = vec![
233            ("intro.md".into(), "# Introduction\n\nBody".into()),
234            ("guide/getting.md".into(), "# Getting started\n\nBody".into()),
235        ];
236        let idx = scan_index(&pages);
237        assert_eq!(idx.page_count(), 2);
238        // Two sections: "Overview" (intro.md) and "guide" (guide/getting.md).
239        assert_eq!(idx.sections.len(), 2);
240        let overview = idx.sections.iter().find(|s| s.title == "Overview").expect("overview");
241        assert_eq!(overview.pages[0].title, "Introduction");
242        let guide = idx.sections.iter().find(|s| s.title == "guide").expect("guide");
243        assert_eq!(guide.pages[0].title, "Getting started");
244    }
245
246    #[test]
247    fn fallback_uses_filename_when_no_heading() {
248        let pages = vec![("no-title.md".into(), "Just a body paragraph".into())];
249        let idx = scan_index(&pages);
250        assert_eq!(idx.sections[0].pages[0].title, "no title");
251    }
252}