Skip to main content

cli_engine/
guide.rs

1use std::{
2    fs, io,
3    path::{Path, PathBuf},
4};
5
6/// Parsed guide document.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct GuideEntry {
9    /// Topic name, usually the markdown filename without `.md`.
10    pub name: String,
11    /// One-line summary from front matter.
12    pub summary: String,
13    /// Markdown body without front matter.
14    pub content: String,
15}
16
17impl GuideEntry {
18    /// Creates a guide entry from explicit topic metadata and markdown content.
19    #[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    /// Parses a guide entry from a markdown path and content.
33    #[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
49/// Parses all markdown guide files under a directory.
50pub 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/// Parses guide entries from embedded `(path, bytes)` markdown pairs.
63#[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/// Parses optional YAML front matter and returns `(summary, body)`.
107#[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/// Renders the guide topic list.
127#[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
137/// Returns either the guide topic list or one guide's content.
138pub 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}