1use serde::Deserialize;
29
30use crate::error::{DocsError, Result};
31
32#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
34pub struct Index {
35 #[serde(default = "default_title")]
39 pub title: String,
40
41 #[serde(default)]
43 pub sections: Vec<IndexSection>,
44}
45
46fn default_title() -> String {
47 "Documentation".into()
48}
49
50#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
53pub struct IndexSection {
54 pub title: String,
56 #[serde(default)]
58 pub pages: Vec<IndexEntry>,
59}
60
61#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
63pub struct IndexEntry {
64 pub path: String,
67 pub title: String,
69}
70
71impl Index {
72 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 #[must_use]
80 pub fn page_count(&self) -> usize {
81 self.sections.iter().map(|s| s.pages.len()).sum()
82 }
83}
84
85pub 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 §ion.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 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#[must_use]
129pub fn scan_index(pages: &[(String, String)]) -> Index {
130 use std::collections::BTreeMap;
131 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#[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 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}