Skip to main content

wyvern/examples/
mod.rs

1//! Bundled example discovery from `{wyvern_share}/examples/**/README.md` frontmatter.
2
3mod frontmatter;
4
5use std::path::{Path, PathBuf};
6
7use serde::Serialize;
8
9pub use frontmatter::parse_readme_frontmatter;
10
11/// One catalog row from a README with YAML frontmatter.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct ExampleRecord {
14    /// Display name from frontmatter.
15    pub name: String,
16    /// Short description from frontmatter.
17    pub description: String,
18    /// Path to the README, relative to `{wyvern_share}` when possible.
19    pub readme: String,
20}
21
22/// Failure while scanning example README files.
23#[derive(Debug)]
24pub enum ExamplesDiscoverError {
25    /// The examples root directory could not be read.
26    Io {
27        /// Affected path.
28        path: PathBuf,
29        /// Error detail.
30        message: String,
31    },
32}
33
34/// One README that violates the bundled example discovery contract.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ExampleReadmeViolation {
37    /// Path to the README under audit (absolute or share-relative).
38    pub readme: PathBuf,
39    /// Human-readable violation detail.
40    pub message: String,
41}
42
43impl std::fmt::Display for ExampleReadmeViolation {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{}: {}", self.readme.display(), self.message)
46    }
47}
48
49impl std::fmt::Display for ExamplesDiscoverError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Io { path, message } => {
53                write!(f, "failed to read {}: {message}", path.display())
54            }
55        }
56    }
57}
58
59impl std::error::Error for ExamplesDiscoverError {}
60
61/// Discover bundled examples under `{share_root}/examples/`.
62///
63/// Each `README.md` with mandatory `name` and `description` frontmatter becomes
64/// one record. READMEs may live in an example folder or in the examples base
65/// folder when one README documents multiple related examples.
66///
67/// # Errors
68///
69/// Returns [`ExamplesDiscoverError`] when the examples directory cannot be read.
70pub fn discover_examples(share_root: &Path) -> Result<Vec<ExampleRecord>, ExamplesDiscoverError> {
71    let examples_root = share_root.join("examples");
72    if !examples_root.is_dir() {
73        return Ok(Vec::new());
74    }
75
76    let mut records = Vec::new();
77    let mut seen = std::collections::BTreeSet::new();
78
79    let base_readme = examples_root.join("README.md");
80    if let Some(record) = record_from_readme(&base_readme, share_root) {
81        seen.insert(record.readme.clone());
82        records.push(record);
83    }
84
85    let entries = std::fs::read_dir(&examples_root).map_err(|err| ExamplesDiscoverError::Io {
86        path: examples_root.clone(),
87        message: err.to_string(),
88    })?;
89    for entry in entries {
90        let entry = match entry {
91            Ok(entry) => entry,
92            Err(err) => {
93                return Err(ExamplesDiscoverError::Io {
94                    path: examples_root.clone(),
95                    message: err.to_string(),
96                });
97            }
98        };
99        let file_type = match entry.file_type() {
100            Ok(file_type) => file_type,
101            Err(err) => {
102                return Err(ExamplesDiscoverError::Io {
103                    path: entry.path(),
104                    message: err.to_string(),
105                });
106            }
107        };
108        if !file_type.is_dir() {
109            continue;
110        }
111        let readme = entry.path().join("README.md");
112        if let Some(record) = record_from_readme(&readme, share_root) {
113            if seen.insert(record.readme.clone()) {
114                records.push(record);
115            }
116        }
117    }
118
119    records.sort_by(|left, right| {
120        left.name
121            .to_ascii_lowercase()
122            .cmp(&right.name.to_ascii_lowercase())
123    });
124    Ok(records)
125}
126
127/// Audit every immediate child directory under `{share_root}/examples/` for a
128/// `README.md` whose YAML frontmatter satisfies the discovery contract
129/// (`name` + `description`).
130///
131/// Optional `examples/README.md` (base-folder doc) is validated when present.
132///
133/// # Errors
134///
135/// Returns [`ExamplesDiscoverError`] when the examples directory cannot be read.
136pub fn validate_example_folder_readmes(
137    share_root: &Path,
138) -> Result<Vec<ExampleReadmeViolation>, ExamplesDiscoverError> {
139    let examples_root = share_root.join("examples");
140    if !examples_root.is_dir() {
141        return Ok(Vec::new());
142    }
143
144    let mut violations = Vec::new();
145    let base_readme = examples_root.join("README.md");
146    if base_readme.is_file() {
147        violations.extend(audit_readme_contract(&base_readme));
148    }
149
150    let entries = std::fs::read_dir(&examples_root).map_err(|err| ExamplesDiscoverError::Io {
151        path: examples_root.clone(),
152        message: err.to_string(),
153    })?;
154    for entry in entries {
155        let entry = match entry {
156            Ok(entry) => entry,
157            Err(err) => {
158                return Err(ExamplesDiscoverError::Io {
159                    path: examples_root.clone(),
160                    message: err.to_string(),
161                });
162            }
163        };
164        let file_type = match entry.file_type() {
165            Ok(file_type) => file_type,
166            Err(err) => {
167                return Err(ExamplesDiscoverError::Io {
168                    path: entry.path(),
169                    message: err.to_string(),
170                });
171            }
172        };
173        if !file_type.is_dir() {
174            continue;
175        }
176        let readme = entry.path().join("README.md");
177        if !readme.is_file() {
178            violations.push(ExampleReadmeViolation {
179                readme: readme.clone(),
180                message: "missing README.md (each examples/<dir>/ must ship a README)".into(),
181            });
182            continue;
183        }
184        violations.extend(audit_readme_contract(&readme));
185    }
186
187    Ok(violations)
188}
189
190fn audit_readme_contract(readme: &Path) -> Vec<ExampleReadmeViolation> {
191    let content = match std::fs::read_to_string(readme) {
192        Ok(content) => content,
193        Err(err) => {
194            return vec![ExampleReadmeViolation {
195                readme: readme.to_path_buf(),
196                message: format!("could not read README: {err}"),
197            }];
198        }
199    };
200    match parse_readme_frontmatter(&content) {
201        Some(meta) => {
202            let mut violations = Vec::new();
203            if meta.name.trim().is_empty() {
204                violations.push(ExampleReadmeViolation {
205                    readme: readme.to_path_buf(),
206                    message: "frontmatter name must be non-empty".into(),
207                });
208            }
209            if meta.description.trim().is_empty() {
210                violations.push(ExampleReadmeViolation {
211                    readme: readme.to_path_buf(),
212                    message: "frontmatter description must be non-empty".into(),
213                });
214            }
215            violations
216        }
217        None => vec![ExampleReadmeViolation {
218            readme: readme.to_path_buf(),
219            message: "README must begin with YAML frontmatter containing name and description"
220                .into(),
221        }],
222    }
223}
224
225fn record_from_readme(readme: &Path, share_root: &Path) -> Option<ExampleRecord> {
226    let content = std::fs::read_to_string(readme).ok()?;
227    let meta = parse_readme_frontmatter(&content)?;
228    Some(ExampleRecord {
229        name: meta.name,
230        description: meta.description,
231        readme: relativize_share_path(readme, share_root),
232    })
233}
234
235fn relativize_share_path(path: &Path, share_root: &Path) -> String {
236    path.strip_prefix(share_root)
237        .map(|rel| rel.to_string_lossy().replace('\\', "/"))
238        .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
239}
240
241/// Format example records as human-readable text blocks.
242#[must_use]
243pub fn format_examples_list(records: &[ExampleRecord]) -> String {
244    if records.is_empty() {
245        return String::new();
246    }
247    records
248        .iter()
249        .map(format_example_record)
250        .collect::<Vec<_>>()
251        .join("\n")
252}
253
254fn format_example_record(record: &ExampleRecord) -> String {
255    format!(
256        "{}\n{}\nREADME: {}",
257        record.name, record.description, record.readme
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::fs;
265
266    #[test]
267    fn discover_examples_finds_readme_frontmatter_in_child_dirs() {
268        let tmp = tempfile::tempdir().expect("tempdir");
269        let share = tmp.path();
270        let example = share.join("examples/path-picker");
271        fs::create_dir_all(&example).expect("mkdir");
272        fs::write(
273            example.join("README.md"),
274            "---\nname: Path picker\ndescription: Native pickers.\n---\n",
275        )
276        .expect("write");
277        let records = discover_examples(share).expect("discover");
278        assert_eq!(records.len(), 1);
279        assert_eq!(records[0].name, "Path picker");
280        assert_eq!(records[0].description, "Native pickers.");
281        assert_eq!(records[0].readme, "examples/path-picker/README.md");
282    }
283
284    #[test]
285    fn discover_examples_includes_base_readme() {
286        let tmp = tempfile::tempdir().expect("tempdir");
287        let share = tmp.path();
288        fs::create_dir_all(share.join("examples/group-a")).expect("mkdir");
289        fs::write(
290            share.join("examples/README.md"),
291            "---\nname: Group\ndescription: Shared docs.\n---\n",
292        )
293        .expect("write");
294        let records = discover_examples(share).expect("discover");
295        assert_eq!(records.len(), 1);
296        assert_eq!(records[0].readme, "examples/README.md");
297    }
298
299    #[test]
300    fn validate_example_folder_readmes_requires_readme_per_dir() {
301        let tmp = tempfile::tempdir().expect("tempdir");
302        let share = tmp.path();
303        fs::create_dir_all(share.join("examples/ok")).expect("mkdir");
304        fs::create_dir_all(share.join("examples/missing")).expect("mkdir");
305        fs::write(
306            share.join("examples/ok/README.md"),
307            "---\nname: OK\ndescription: Valid.\n---\n",
308        )
309        .expect("write");
310
311        let violations = validate_example_folder_readmes(share).expect("audit");
312        assert_eq!(violations.len(), 1);
313        assert_eq!(
314            relativize_share_path(&violations[0].readme, share),
315            "examples/missing/README.md"
316        );
317    }
318
319    #[test]
320    fn validate_example_folder_readmes_rejects_invalid_frontmatter() {
321        let tmp = tempfile::tempdir().expect("tempdir");
322        let share = tmp.path();
323        fs::create_dir_all(share.join("examples/bad")).expect("mkdir");
324        fs::write(share.join("examples/bad/README.md"), "# no frontmatter\n").expect("write");
325
326        let violations = validate_example_folder_readmes(share).expect("audit");
327        assert_eq!(violations.len(), 1);
328        assert!(
329            violations[0].message.contains("frontmatter"),
330            "{violations:?}"
331        );
332    }
333}