1use std::{
2 fs, io,
3 path::{Path, PathBuf},
4};
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct GuideEntry {
9 pub name: String,
11 pub summary: String,
13 pub content: String,
15}
16
17impl GuideEntry {
18 #[must_use]
20 pub fn new(
21 name: impl Into<String>,
22 summary: impl Into<String>,
23 content: impl Into<String>,
24 ) -> Self {
25 Self {
26 name: name.into(),
27 summary: summary.into(),
28 content: content.into(),
29 }
30 }
31
32 #[must_use]
34 pub fn from_markdown_path(path: &str, content: &str) -> Self {
35 let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
36 let name = file_name
37 .strip_suffix(".md")
38 .unwrap_or(file_name)
39 .to_owned();
40 let (summary, body) = parse_front_matter(content);
41 Self {
42 name,
43 summary,
44 content: body,
45 }
46 }
47}
48
49pub fn parse_guides(root: impl AsRef<Path>) -> io::Result<Vec<GuideEntry>> {
51 let mut markdown_paths = Vec::new();
52 collect_markdown_paths(root.as_ref(), &mut markdown_paths)?;
53 markdown_paths.sort();
54
55 Ok(parse_guides_from_markdown(
56 markdown_paths
57 .into_iter()
58 .filter_map(|path| fs::read(&path).ok().map(|content| (path, content))),
59 ))
60}
61
62#[must_use]
64pub fn parse_guides_from_markdown(
65 files: impl IntoIterator<Item = (impl AsRef<Path>, impl AsRef<[u8]>)>,
66) -> Vec<GuideEntry> {
67 let mut files = files
68 .into_iter()
69 .filter_map(|(path, content)| {
70 let path = path.as_ref().to_string_lossy().into_owned();
71 path.ends_with(".md")
72 .then(|| (path, content.as_ref().to_owned()))
73 })
74 .collect::<Vec<_>>();
75 files.sort_by(|(left, _), (right, _)| left.cmp(right));
76 files
77 .into_iter()
78 .map(|(path, content)| {
79 let content = String::from_utf8_lossy(&content);
80 GuideEntry::from_markdown_path(&path, content.as_ref())
81 })
82 .collect()
83}
84
85fn collect_markdown_paths(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
86 let mut entries = match fs::read_dir(dir) {
87 Ok(entries) => entries.collect::<io::Result<Vec<_>>>()?,
88 Err(_) => return Ok(()),
89 };
90 entries.sort_by_key(|entry| entry.path());
91
92 for entry in entries {
93 let path = entry.path();
94 let Ok(file_type) = entry.file_type() else {
95 continue;
96 };
97 if file_type.is_dir() {
98 collect_markdown_paths(&path, paths)?;
99 } else if path.extension().is_some_and(|extension| extension == "md") {
100 paths.push(path);
101 }
102 }
103 Ok(())
104}
105
106#[must_use]
108pub fn parse_front_matter(content: &str) -> (String, String) {
109 let Some(rest) = content.strip_prefix("---\n") else {
110 return (String::new(), content.to_owned());
111 };
112 let Some(end) = rest.find("\n---\n") else {
113 return (String::new(), content.to_owned());
114 };
115 let block = &rest[..end];
116 let body = &rest[end + "\n---\n".len()..];
117 let summary = block
118 .lines()
119 .filter_map(|line| line.strip_prefix("summary:").map(str::trim))
120 .next_back()
121 .unwrap_or_default()
122 .to_owned();
123 (summary, body.to_owned())
124}
125
126#[must_use]
145pub fn render_guide_human(content: &str, width: usize, color: bool) -> String {
146 let skin = if color {
147 termimad::MadSkin::default()
148 } else {
149 termimad::MadSkin::no_style()
151 };
152 skin.text(content, Some(width)).to_string()
153}
154
155#[must_use]
157pub fn list_guides(entries: &[GuideEntry]) -> String {
158 let mut out = String::from("Available guide topics:\n\n");
159 for entry in entries {
160 out.push_str(&format!(" {:<16} {}\n", entry.name, entry.summary));
161 }
162 out.push_str("\nUsage: <cli> guide <topic>");
163 out
164}
165
166pub fn guide_content(entries: &[GuideEntry], topic: Option<&str>) -> Result<String, String> {
168 let Some(topic) = topic else {
169 return Ok(list_guides(entries));
170 };
171 entries
172 .iter()
173 .rev()
174 .find(|entry| entry.name == topic)
175 .map(|entry| entry.content.clone())
176 .ok_or_else(|| {
177 let names = entries
178 .iter()
179 .map(|entry| entry.name.as_str())
180 .collect::<Vec<_>>()
181 .join(", ");
182 format!("unknown guide topic {topic:?} — valid topics: {names}")
183 })
184}
185
186#[cfg(test)]
187mod tests {
188 use super::render_guide_human;
189
190 #[test]
191 fn render_guide_human_wraps_long_prose_at_word_boundaries() {
192 let source =
194 "The quick brown fox jumps over the lazy dog and then keeps running along the fence.";
195
196 let rendered = render_guide_human(source, 20, false);
197
198 for line in rendered.lines() {
201 assert!(
202 line.trim_end().chars().count() <= 20,
203 "line exceeds wrap width: {line:?}",
204 );
205 }
206
207 assert!(
209 rendered
210 .lines()
211 .filter(|line| !line.trim().is_empty())
212 .count()
213 > 1,
214 "expected long line to wrap into multiple lines: {rendered:?}",
215 );
216
217 for word in source.split_whitespace() {
219 assert!(
220 rendered.lines().any(|line| line.contains(word)),
221 "word was split across lines: {word:?}",
222 );
223 }
224 }
225}