1mod frontmatter;
4
5use std::path::{Path, PathBuf};
6
7use serde::Serialize;
8
9pub use frontmatter::parse_readme_frontmatter;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct ExampleRecord {
14 pub name: String,
16 pub description: String,
18 pub readme: String,
20}
21
22#[derive(Debug)]
24pub enum ExamplesDiscoverError {
25 Io {
27 path: PathBuf,
29 message: String,
31 },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ExampleReadmeViolation {
37 pub readme: PathBuf,
39 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
61pub 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
127pub 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#[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}