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 =
93 serde_yaml::from_str(yaml).map_err(|e| DocsError::IndexMalformed(e.to_string()))?;
94 for section in &index.sections {
95 for page in §ion.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 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#[must_use]
126pub fn scan_index(pages: &[(String, String)]) -> Index {
127 use std::collections::BTreeMap;
128 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#[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 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}