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]
128pub fn list_guides(entries: &[GuideEntry]) -> String {
129 let mut out = String::from("Available guide topics:\n\n");
130 for entry in entries {
131 out.push_str(&format!(" {:<16} {}\n", entry.name, entry.summary));
132 }
133 out.push_str("\nUsage: <cli> guide <topic>");
134 out
135}
136
137pub fn guide_content(entries: &[GuideEntry], topic: Option<&str>) -> Result<String, String> {
139 let Some(topic) = topic else {
140 return Ok(list_guides(entries));
141 };
142 entries
143 .iter()
144 .rev()
145 .find(|entry| entry.name == topic)
146 .map(|entry| entry.content.clone())
147 .ok_or_else(|| {
148 let names = entries
149 .iter()
150 .map(|entry| entry.name.as_str())
151 .collect::<Vec<_>>()
152 .join(", ");
153 format!("unknown guide topic {topic:?} — valid topics: {names}")
154 })
155}