Skip to main content

zad_cli/cli/
commands.rs

1//! Implementation of the `zad commands [name] [--examples]` subcommand
2//! mandated by `OSS_SPEC.md` §12.4.
3//!
4//! The command enumerates every CLI surface by walking the clap
5//! `Command` tree — the same source of truth that `--help`, `--help-agent`,
6//! and the manpage-parity test consume — so this output cannot drift
7//! from the parser.
8
9use std::fmt::Write as _;
10
11use clap::{Arg, Args, Command as ClapCommand, CommandFactory};
12
13use super::Cli;
14
15#[derive(Debug, Args)]
16pub struct CommandsArgs {
17    /// Narrow the listing to a single command path, e.g. `discord send` or
18    /// `service list`.
19    #[arg(num_args = 0.., value_name = "NAME")]
20    pub name: Vec<String>,
21
22    /// Print a realistic example invocation for each matching command.
23    #[arg(long)]
24    pub examples: bool,
25
26    /// Emit a machine-readable JSON dump of every command (path,
27    /// description, flags, positionals, example). Consumed by the
28    /// website extractor.
29    #[arg(long)]
30    pub json: bool,
31}
32
33pub fn run(args: CommandsArgs) -> zad::error::Result<()> {
34    let root = Cli::command();
35
36    if args.json {
37        let dump = json_dump(&root);
38        println!("{dump}");
39        return Ok(());
40    }
41
42    let mut out = String::new();
43    if args.name.is_empty() {
44        if args.examples {
45            render_all_examples(&root, &mut out);
46        } else {
47            render_index(&root, &mut out);
48        }
49    } else {
50        let path: Vec<&str> = args.name.iter().map(String::as_str).collect();
51        match find(&root, &path) {
52            Some(cmd) => {
53                if args.examples {
54                    render_command_examples(cmd, &path, &mut out);
55                } else {
56                    render_command_detail(cmd, &path, &mut out);
57                }
58            }
59            None => {
60                return Err(zad::error::ZadError::Invalid(format!(
61                    "no such command: `{}`. Run `zad commands` to list available commands.",
62                    args.name.join(" ")
63                )));
64            }
65        }
66    }
67
68    print!("{out}");
69    Ok(())
70}
71
72fn render_index(root: &ClapCommand, out: &mut String) {
73    let binary = root.get_name().to_string();
74    let _ = writeln!(out, "{binary} — available commands");
75    out.push('\n');
76    walk(root, &mut Vec::new(), &mut |path, cmd| {
77        if path.is_empty() {
78            return;
79        }
80        let desc = cmd.get_about().map(|s| s.to_string()).unwrap_or_default();
81        let joined = path.join(" ");
82        let _ = writeln!(out, "  {binary} {joined:<28} {desc}");
83    });
84}
85
86fn render_command_detail(cmd: &ClapCommand, path: &[&str], out: &mut String) {
87    let _ = writeln!(out, "zad {}", path.join(" "));
88    if let Some(about) = cmd.get_about() {
89        let _ = writeln!(out, "  {about}");
90    }
91    out.push('\n');
92
93    let flags: Vec<&Arg> = cmd
94        .get_arguments()
95        .filter(|a| !a.is_positional() && !a.is_hide_set())
96        .collect();
97    if !flags.is_empty() {
98        out.push_str("Flags:\n");
99        for arg in flags {
100            let render = format_flag(arg);
101            let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
102            let _ = writeln!(out, "  {render:<32} {help}");
103        }
104        out.push('\n');
105    }
106
107    let positionals: Vec<&Arg> = cmd.get_arguments().filter(|a| a.is_positional()).collect();
108    if !positionals.is_empty() {
109        out.push_str("Arguments:\n");
110        for arg in positionals {
111            let name = arg.get_id().as_str();
112            let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
113            let _ = writeln!(out, "  {name:<32} {help}");
114        }
115        out.push('\n');
116    }
117
118    let subs: Vec<&ClapCommand> = cmd.get_subcommands().filter(|s| !s.is_hide_set()).collect();
119    if !subs.is_empty() {
120        out.push_str("Subcommands:\n");
121        for sub in subs {
122            let name = sub.get_name();
123            let desc = sub.get_about().map(|s| s.to_string()).unwrap_or_default();
124            let _ = writeln!(out, "  {name:<20} {desc}");
125        }
126        out.push('\n');
127    }
128
129    out.push_str("Exit codes: 0 success; 1 on any error.\n");
130    out.push('\n');
131    out.push_str("See `zad man ");
132    out.push_str(path.first().copied().unwrap_or("main"));
133    out.push_str("` for the full reference.\n");
134}
135
136fn render_all_examples(root: &ClapCommand, out: &mut String) {
137    let binary = root.get_name();
138    let _ = writeln!(out, "{binary} — realistic example invocations");
139    out.push('\n');
140    walk(root, &mut Vec::new(), &mut |path, _cmd| {
141        if path.is_empty() {
142            return;
143        }
144        if let Some(ex) = example_for(path) {
145            let _ = writeln!(out, "# {}", path.join(" "));
146            let _ = writeln!(out, "{ex}");
147            out.push('\n');
148        }
149    });
150}
151
152fn render_command_examples(_cmd: &ClapCommand, path: &[&str], out: &mut String) {
153    match example_for(path) {
154        Some(ex) => {
155            let _ = writeln!(out, "# {}", path.join(" "));
156            let _ = writeln!(out, "{ex}");
157        }
158        None => {
159            let _ = writeln!(
160                out,
161                "no example registered for `{}` — see `zad man {}`.",
162                path.join(" "),
163                path.first().copied().unwrap_or("main")
164            );
165        }
166    }
167}
168
169fn format_flag(arg: &Arg) -> String {
170    let mut s = String::new();
171    if let Some(short) = arg.get_short() {
172        let _ = write!(s, "-{short}");
173    }
174    if let Some(long) = arg.get_long() {
175        if !s.is_empty() {
176            s.push_str(", ");
177        }
178        let _ = write!(s, "--{long}");
179    }
180    if arg.get_action().takes_values() {
181        let _ = write!(s, " <{}>", arg.get_id().as_str().to_uppercase());
182    }
183    s
184}
185
186fn walk<'a>(
187    cmd: &'a ClapCommand,
188    path: &mut Vec<&'a str>,
189    f: &mut dyn FnMut(&[&str], &ClapCommand),
190) {
191    f(path, cmd);
192    for sub in cmd.get_subcommands() {
193        if sub.is_hide_set() {
194            continue;
195        }
196        path.push(sub.get_name());
197        walk(sub, path, f);
198        path.pop();
199    }
200}
201
202fn find<'a>(root: &'a ClapCommand, path: &[&str]) -> Option<&'a ClapCommand> {
203    let mut cur = root;
204    for seg in path {
205        cur = cur.get_subcommands().find(|s| s.get_name() == *seg)?;
206    }
207    Some(cur)
208}
209
210fn json_dump(root: &ClapCommand) -> String {
211    let binary = root.get_name().to_string();
212    let version = zad::version();
213    let mut commands = Vec::new();
214    walk(root, &mut Vec::new(), &mut |path, cmd| {
215        if path.is_empty() {
216            return;
217        }
218        let flags: Vec<serde_json::Value> = cmd
219            .get_arguments()
220            .filter(|a| !a.is_positional() && !a.is_hide_set())
221            .map(|a| {
222                serde_json::json!({
223                    "name": a.get_id().as_str(),
224                    "long": a.get_long(),
225                    "short": a.get_short().map(|c| c.to_string()),
226                    "help": a.get_help().map(|s| s.to_string()),
227                    "takes_value": a.get_action().takes_values(),
228                })
229            })
230            .collect();
231        let positionals: Vec<serde_json::Value> = cmd
232            .get_arguments()
233            .filter(|a| a.is_positional())
234            .map(|a| {
235                serde_json::json!({
236                    "name": a.get_id().as_str(),
237                    "help": a.get_help().map(|s| s.to_string()),
238                })
239            })
240            .collect();
241        commands.push(serde_json::json!({
242            "path": path,
243            "description": cmd.get_about().map(|s| s.to_string()),
244            "flags": flags,
245            "positionals": positionals,
246            "example": example_for(path),
247        }));
248    });
249    let dump = serde_json::json!({
250        "binary": binary,
251        "version": version,
252        "commands": commands,
253    });
254    serde_json::to_string_pretty(&dump).unwrap()
255}
256
257/// Hand-curated example table keyed by full command path. Add entries
258/// next to the clap definitions when new commands land.
259fn example_for(path: &[&str]) -> Option<&'static str> {
260    match path {
261        ["service"] => Some("zad service list"),
262        ["service", "list"] => Some("zad service list"),
263        ["service", "discord"] => Some("zad service discord add"),
264        ["discord"] => Some("zad discord channels"),
265        ["discord", "send"] => Some("zad discord send --channel general --body 'deploy complete'"),
266        ["discord", "read"] => Some("zad discord read --channel general --limit 20"),
267        ["discord", "channels"] => Some("zad discord channels"),
268        ["discord", "join"] => Some("zad discord join --channel release-notes"),
269        ["discord", "leave"] => Some("zad discord leave --channel release-notes"),
270        ["discord", "permissions"] => Some("zad discord permissions show"),
271        ["telegram"] => Some("zad telegram chats"),
272        ["telegram", "send"] => Some("zad telegram send --chat team-room 'deploy complete'"),
273        ["telegram", "read"] => Some("zad telegram read --chat team-room --limit 20"),
274        ["telegram", "chats"] => Some("zad telegram chats"),
275        ["telegram", "discover"] => Some("zad telegram discover"),
276        ["telegram", "directory"] => Some("zad telegram directory"),
277        ["telegram", "permissions"] => Some("zad telegram permissions show"),
278        ["commands"] => Some("zad commands discord"),
279        ["docs"] => Some("zad docs architecture"),
280        ["man"] => Some("zad man discord"),
281        _ => None,
282    }
283}