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 guide markdown into a terminal-friendly string.
127///
128/// Each source line is individually wrapped to `width` columns, breaking
129/// between words rather than mid-word. (A single token longer than `width` —
130/// a long URL, say — can still overflow, since it has no interior break
131/// point.) The
132/// underlying parser is line-oriented and **preserves every source newline**:
133/// it does not join soft-wrapped lines into flowing paragraphs. Author guide
134/// markdown with each paragraph on a single physical line so it reflows to the
135/// terminal width; a paragraph that is hard-wrapped in the source stays wrapped
136/// at its authored breaks. See `docs/concepts.md` ("Guides") for authoring
137/// guidance.
138///
139/// `color` selects a styled skin (`true`, for an interactive terminal) or a
140/// plain, unstyled skin (`false`) whose output contains no ANSI escapes and is
141/// therefore deterministic for pipes and tests. Fenced code blocks and tables
142/// are laid out by the renderer rather than reflowed as prose, so their
143/// structure is preserved.
144#[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        // no_style emits no ANSI escapes — deterministic for pipes and tests.
150        termimad::MadSkin::no_style()
151    };
152    skin.text(content, Some(width)).to_string()
153}
154
155/// Renders the guide topic list.
156#[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
166/// Returns either the guide topic list or one guide's content.
167pub 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        // A single physical line, as produced by soft-wrapped guide sources.
193        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        // no_style output carries no ANSI escapes, so char count is the visible
199        // width; ignore any trailing padding the renderer may add.
200        for line in rendered.lines() {
201            assert!(
202                line.trim_end().chars().count() <= 20,
203                "line exceeds wrap width: {line:?}",
204            );
205        }
206
207        // One source line must reflow into several visible lines...
208        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        // ...without splitting any word across a line boundary.
218        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}