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}