Skip to main content

rtb_docs/
loader.rs

1//! Load docs from an [`rtb_assets::Assets`] tree into the
2//! `(Index, HashMap<path, body>)` shape that [`crate::DocsBrowser`] and
3//! [`crate::DocsServer`] consume.
4//!
5//! The convention is:
6//!
7//! - `<root>/_index.yaml` — optional, parsed via [`crate::index::parse_index`].
8//! - Every `.md` file under `<root>/` — body is loaded into the
9//!   returned `HashMap` keyed on its `<root>`-relative path. When no
10//!   `_index.yaml` is present, [`crate::index::scan_index`] derives
11//!   one from the discovered pages.
12//!
13//! Asset-source layering is preserved: an embedded `_index.yaml` is
14//! deep-merged with on-disk overrides exactly as
15//! [`rtb_assets::Assets::load_merged_yaml`] does for any other YAML.
16
17use std::collections::HashMap;
18
19use rtb_assets::Assets;
20
21use crate::error::{DocsError, Result};
22use crate::index::{parse_index, scan_index, Index};
23
24/// Walk `<root>/` for `.md` files + an optional `_index.yaml`.
25/// Returns the parsed [`Index`] and a `path -> body` map.
26///
27/// `root` is the asset-relative directory holding the doc tree
28/// (commonly `"docs"`). A trailing `/` is tolerated.
29///
30/// # Errors
31///
32/// - [`DocsError::RootMissing`] when no `.md` pages and no
33///   `_index.yaml` are reachable under `root`.
34/// - [`DocsError::IndexMalformed`] from [`parse_index`].
35/// - [`DocsError::Assets`] when an asset read fails.
36pub fn load_docs(assets: &Assets, root: &str) -> Result<(Index, HashMap<String, String>)> {
37    let root = root.trim_end_matches('/');
38
39    let mut pages: HashMap<String, String> = HashMap::new();
40    walk(assets, root, "", &mut pages)?;
41
42    let yaml_key = format!("{root}/_index.yaml");
43    let index = if assets.exists(&yaml_key) {
44        let body = assets.open_text(&yaml_key).map_err(|e| DocsError::Assets(e.to_string()))?;
45        parse_index(&body)?
46    } else if pages.is_empty() {
47        return Err(DocsError::RootMissing(root.to_string()));
48    } else {
49        let scan_input: Vec<(String, String)> =
50            pages.iter().map(|(p, b)| (p.clone(), b.clone())).collect();
51        scan_index(&scan_input)
52    };
53
54    Ok((index, pages))
55}
56
57/// Recursive directory walk. `prefix` is the doc-tree-relative path
58/// (no leading slash); `under` is the asset key prefix (`<root>/<prefix>`).
59fn walk(
60    assets: &Assets,
61    root: &str,
62    prefix: &str,
63    out: &mut HashMap<String, String>,
64) -> Result<()> {
65    let dir_key = if prefix.is_empty() { root.to_string() } else { format!("{root}/{prefix}") };
66    for entry in assets.list_dir(&dir_key) {
67        // `list_dir` returns names relative to `dir_key`. We don't
68        // get a "is_dir" flag — distinguish by whether `open` returns
69        // bytes (file) or `None` (no such file → assume dir).
70        let rel = if prefix.is_empty() { entry.clone() } else { format!("{prefix}/{entry}") };
71        let key = format!("{root}/{rel}");
72        if std::path::Path::new(&entry)
73            .extension()
74            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
75        {
76            let body = assets.open_text(&key).map_err(|e| DocsError::Assets(e.to_string()))?;
77            out.insert(rel, body);
78        } else if assets.exists(&key) {
79            // Non-`.md` regular file (image, css, …) — skip, still
80            // shipped via the `/assets/` route at serve time.
81        } else {
82            // No bytes at the exact key → treat as a directory and
83            // recurse. This is consistent with how `Assets::list_dir`
84            // surfaces nested entries.
85            walk(assets, root, &rel, out)?;
86        }
87    }
88    Ok(())
89}
90
91// ---------------------------------------------------------------------
92// Tests
93// ---------------------------------------------------------------------
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use std::collections::HashMap;
99
100    fn assets_with(files: &[(&str, &str)]) -> Assets {
101        let map: HashMap<String, Vec<u8>> =
102            files.iter().map(|(p, b)| ((*p).to_string(), b.as_bytes().to_vec())).collect();
103        Assets::builder().memory("test", map).build()
104    }
105
106    #[test]
107    fn loads_with_index_yaml() {
108        let assets = assets_with(&[
109            (
110                "docs/_index.yaml",
111                "title: T\nsections:\n  - title: S\n    pages:\n      - { path: a.md, title: A }\n",
112            ),
113            ("docs/a.md", "# A\n\nbody"),
114        ]);
115        let (idx, pages) = load_docs(&assets, "docs").expect("load");
116        assert_eq!(idx.title, "T");
117        assert_eq!(pages.get("a.md").map(String::as_str), Some("# A\n\nbody"));
118    }
119
120    #[test]
121    fn falls_back_to_scan_when_no_index_yaml() {
122        let assets = assets_with(&[
123            ("docs/intro.md", "# Intro\n\nhi"),
124            ("docs/guide/setup.md", "# Setup\n\nbody"),
125        ]);
126        let (idx, pages) = load_docs(&assets, "docs").expect("load");
127        assert_eq!(idx.page_count(), 2);
128        assert!(pages.contains_key("intro.md"));
129        assert!(pages.contains_key("guide/setup.md"));
130    }
131
132    #[test]
133    fn empty_tree_is_root_missing() {
134        let assets = assets_with(&[]);
135        let err = load_docs(&assets, "docs").expect_err("empty tree");
136        assert!(matches!(err, DocsError::RootMissing(_)), "got {err:?}");
137    }
138}