Skip to main content

wyvern/
examples_cmd.rs

1//! `wyvern examples list` — discover bundled examples from README frontmatter.
2
3use crate::error::{BuiltinDomain, EmitError, UsageErrorKind};
4use crate::examples::{
5    discover_examples, format_examples_list, ExampleRecord, ExamplesDiscoverError,
6};
7use crate::extensions::resolve_wyvern_share;
8use wyvern_schema::{ErrorCode, SerializeError, StderrError};
9
10/// Usage text for `wyvern examples --help` / `-h`.
11#[must_use]
12pub fn examples_usage_message() -> String {
13    concat!(
14        "Usage: wyvern examples [list] [--json]\n",
15        "       wyvern examples --help\n",
16        "\n",
17        "Commands:\n",
18        "  list         List bundled examples discovered from README frontmatter\n",
19        "\n",
20        "Options:\n",
21        "  --json       Print ExampleRecord JSON array\n",
22        "\n",
23        "Each example README under {wyvern_share}/examples/ must begin with:\n",
24        "  ---\n",
25        "  name: Example title\n",
26        "  description: One-line summary\n",
27        "  ---\n",
28        "\n",
29        "See also: wyvern guide, wyvern --help\n",
30    )
31    .to_string()
32}
33
34/// Run `wyvern examples …`; returns stdout text on success.
35///
36/// # Errors
37///
38/// Returns usage text or structured stderr for invalid argv / discovery I/O.
39pub fn run_examples_command(args: &[String]) -> Result<String, ExamplesCmdError> {
40    if args
41        .first()
42        .is_some_and(|token| token == "--help" || token == "-h")
43    {
44        return Ok(examples_usage_message());
45    }
46    match args.first().map(String::as_str) {
47        None => run_list(args),
48        Some("list") => run_list(&args[1..]),
49        Some("--json") => run_list(args),
50        Some(other) if other.starts_with('-') => Err(unknown_flag(other)),
51        Some(other) => Err(ExamplesCmdError::Usage {
52            kind: UsageErrorKind::UnknownSubcommand {
53                domain: BuiltinDomain::Examples,
54                token: other.to_string(),
55            },
56            message: format!(
57                "unknown examples subcommand '{other}'\n{}",
58                examples_usage_message()
59            ),
60        }),
61    }
62}
63
64fn run_list(args: &[String]) -> Result<String, ExamplesCmdError> {
65    if wants_help(args) {
66        return Ok(examples_usage_message());
67    }
68    let json = parse_list_flags(args)?;
69    let share_root = resolve_wyvern_share();
70    let records = discover_examples(&share_root).map_err(map_discover)?;
71    if json {
72        serialize_records_json(&records)
73    } else {
74        Ok(format_examples_list(&records))
75    }
76}
77
78fn wants_help(args: &[String]) -> bool {
79    args.iter().any(|arg| arg == "--help" || arg == "-h")
80}
81
82fn parse_list_flags(args: &[String]) -> Result<bool, ExamplesCmdError> {
83    let mut json = false;
84    for arg in args {
85        match arg.as_str() {
86            "--json" => json = true,
87            other => return Err(unknown_flag(other)),
88        }
89    }
90    Ok(json)
91}
92
93fn serialize_records_json(records: &[ExampleRecord]) -> Result<String, ExamplesCmdError> {
94    match serde_json::to_string_pretty(records) {
95        Ok(mut text) => {
96            if !text.ends_with('\n') {
97                text.push('\n');
98            }
99            Ok(text)
100        }
101        Err(err) => Err(ExamplesCmdError::Emit(EmitError::Serialize(
102            SerializeError {
103                message: err.to_string(),
104            },
105        ))),
106    }
107}
108
109fn unknown_flag(flag: &str) -> ExamplesCmdError {
110    match StderrError::new(ErrorCode::ValidationError, format!("unknown flag '{flag}'"))
111        .cause("examples list accepts only --json")
112        .recovery("Run wyvern examples list")
113        .recovery("Run wyvern examples list --json")
114        .recovery("Run wyvern examples --help")
115        .docs("docs/wyvern/requirements.md")
116        .to_json_string()
117    {
118        Ok(stderr) => ExamplesCmdError::Stage {
119            stderr,
120            exit_code: ErrorCode::ValidationError.exit_code(),
121        },
122        Err(err) => ExamplesCmdError::Emit(EmitError::Serialize(err)),
123    }
124}
125
126fn map_discover(err: ExamplesDiscoverError) -> ExamplesCmdError {
127    match StderrError::new(ErrorCode::ValidationError, err.to_string())
128        .cause("Could not scan bundled example README files")
129        .recovery("Run wyvern examples list")
130        .recovery("Ensure {wyvern_share}/examples exists and README files are readable")
131        .docs("docs/wyvern/requirements.md")
132        .to_json_string()
133    {
134        Ok(stderr) => ExamplesCmdError::Stage {
135            stderr,
136            exit_code: ErrorCode::ValidationError.exit_code(),
137        },
138        Err(e) => ExamplesCmdError::Emit(EmitError::Serialize(e)),
139    }
140}
141
142/// CLI `examples` subcommand failure.
143#[derive(Debug)]
144pub enum ExamplesCmdError {
145    /// Bad argv.
146    Usage {
147        /// Discriminated usage class for structured stderr recovery.
148        kind: UsageErrorKind,
149        /// Plain-text usage.
150        message: String,
151    },
152    /// Discovery or emit failure with stderr JSON.
153    Stage {
154        /// Stderr JSON.
155        stderr: String,
156        /// Process exit code.
157        exit_code: i32,
158    },
159    /// Emit-boundary serialize failure.
160    Emit(EmitError),
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn examples_help_mentions_list_and_frontmatter() {
169        let text = examples_usage_message();
170        assert!(text.contains("list"), "{text}");
171        assert!(text.contains("name:"), "{text}");
172        assert!(text.contains("description:"), "{text}");
173    }
174
175    #[test]
176    fn bare_examples_defaults_to_list() {
177        let text = run_examples_command(&[]).expect("list");
178        assert!(text.contains("README:") || text.is_empty(), "{text}");
179    }
180}