Skip to main content

yuki_cli/
schema.rs

1use serde_json::{Value, json};
2
3fn arg_to_json(arg: &clap::Arg) -> Value {
4    let mut obj = serde_json::Map::new();
5
6    let id = arg.get_id().as_str();
7    let name = if arg.is_positional() {
8        id.to_string()
9    } else {
10        arg.get_long()
11            .map(|l| format!("--{l}"))
12            .unwrap_or_else(|| id.to_string())
13    };
14    obj.insert("name".into(), json!(name));
15
16    if let Some(help) = arg.get_help().map(|h| h.to_string()) {
17        obj.insert("description".into(), json!(help));
18    }
19
20    let is_bool = !arg.get_action().takes_values();
21    if is_bool {
22        obj.insert("type".into(), json!("bool"));
23    } else {
24        let possible: Vec<String> = arg
25            .get_possible_values()
26            .iter()
27            .map(|v| v.get_name().to_string())
28            .collect();
29        if !possible.is_empty() {
30            obj.insert("type".into(), json!("string"));
31            obj.insert("enum".into(), json!(possible));
32        } else {
33            obj.insert("type".into(), json!("string"));
34        }
35    }
36
37    if arg.is_positional() {
38        obj.insert("required".into(), json!(arg.is_required_set()));
39    }
40
41    if let Some(default) = arg.get_default_values().first() {
42        obj.insert("default".into(), json!(default.to_string_lossy()));
43    }
44
45    if let Some(short) = arg.get_short() {
46        obj.insert("short".into(), json!(format!("-{short}")));
47    }
48
49    Value::Object(obj)
50}
51
52fn walk_commands(cmd: &clap::Command, prefix: &str, out: &mut serde_json::Map<String, Value>) {
53    let global_ids = ["help", "version", "admin", "format", "quiet"];
54
55    for sub in cmd.get_subcommands() {
56        let name = sub.get_name();
57        if name == "help" {
58            continue;
59        }
60
61        let path = if prefix.is_empty() {
62            name.to_string()
63        } else {
64            format!("{prefix} {name}")
65        };
66
67        let has_subcommands = sub.get_subcommands().any(|s| s.get_name() != "help");
68        if has_subcommands {
69            walk_commands(sub, &path, out);
70        } else {
71            let mut entry = serde_json::Map::new();
72
73            if let Some(about) = sub.get_about().map(|a| a.to_string()) {
74                entry.insert("description".into(), json!(about));
75            }
76
77            let mut args = Vec::new();
78            let mut flags = Vec::new();
79            for arg in sub.get_arguments() {
80                if global_ids.contains(&arg.get_id().as_str()) {
81                    continue;
82                }
83                if arg.is_positional() {
84                    args.push(arg_to_json(arg));
85                } else {
86                    flags.push(arg_to_json(arg));
87                }
88            }
89
90            if !args.is_empty() {
91                entry.insert("args".into(), json!(args));
92            }
93            if !flags.is_empty() {
94                entry.insert("flags".into(), json!(flags));
95            }
96
97            out.insert(path, Value::Object(entry));
98        }
99    }
100}
101
102pub fn generate(cmd: &clap::Command) -> Value {
103    let mut commands = serde_json::Map::new();
104    walk_commands(cmd, "", &mut commands);
105
106    json!({
107        "name": "yuki",
108        "version": env!("CARGO_PKG_VERSION"),
109        "description": "CLI client for the Yuki bookkeeping API",
110        "usage": "yuki [OPTIONS] <COMMAND> [SUBCOMMAND] [ARGS]",
111        "global_flags": {
112            "--admin": {"type": "string", "description": "Override the active administration by name"},
113            "--format": {"type": "string", "description": "Output format: table or json"},
114            "--quiet": {"type": "bool", "short": "-q", "description": "Suppress all output except errors"}
115        },
116        "exit_codes": {
117            "0": "success",
118            "1": "general error",
119            "2": "authentication error",
120            "3": "not found",
121            "4": "rate limited"
122        },
123        "commands": commands
124    })
125}
126
127pub fn print_schema() {
128    use clap::CommandFactory;
129    let cmd = crate::cli::Cli::command();
130    let schema = generate(&cmd);
131    println!(
132        "{}",
133        serde_json::to_string_pretty(&schema).expect("serialize schema")
134    );
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use clap::CommandFactory;
141
142    fn test_cmd() -> clap::Command {
143        crate::cli::Cli::command()
144    }
145
146    #[test]
147    fn schema_has_required_top_level_keys() {
148        let schema = generate(&test_cmd());
149        assert!(schema.get("name").is_some());
150        assert!(schema.get("version").is_some());
151        assert!(schema.get("global_flags").is_some());
152        assert!(schema.get("exit_codes").is_some());
153        assert!(schema.get("commands").is_some());
154    }
155
156    #[test]
157    fn schema_is_valid_json() {
158        let schema = generate(&test_cmd());
159        let serialized = serde_json::to_string_pretty(&schema).unwrap();
160        let _: Value = serde_json::from_str(&serialized).unwrap();
161    }
162
163    #[test]
164    fn schema_includes_leaf_commands() {
165        let schema = generate(&test_cmd());
166        let commands = schema["commands"].as_object().unwrap();
167        assert!(commands.contains_key("admin list"));
168        assert!(commands.contains_key("vat returns"));
169        assert!(commands.contains_key("invoices list"));
170    }
171}