diff --git a/Cargo.lock b/Cargo.lock index d5facd291f..2ba570ef39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2663,6 +2663,7 @@ dependencies = [ "reqwest 0.12.5", "rustyline", "serde", + "serde_json", "serde_with", "shell-words", "shellexpand", @@ -5081,11 +5082,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] diff --git a/iroh-cli/Cargo.toml b/iroh-cli/Cargo.toml index 3525bc4595..844c6cf814 100644 --- a/iroh-cli/Cargo.toml +++ b/iroh-cli/Cargo.toml @@ -53,6 +53,7 @@ ratatui = "0.26.2" reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls"] } rustyline = "12.0.0" serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.122" serde_with = "3.7.0" shell-words = "1.1.0" shellexpand = "3.1.0" diff --git a/iroh-cli/src/commands.rs b/iroh-cli/src/commands.rs index 43cf2f5e82..e2f8edc125 100644 --- a/iroh-cli/src/commands.rs +++ b/iroh-cli/src/commands.rs @@ -16,6 +16,7 @@ pub(crate) mod blobs; pub(crate) mod console; pub(crate) mod docs; pub(crate) mod doctor; +pub(crate) mod gen_api; pub(crate) mod gossip; pub(crate) mod net; pub(crate) mod rpc; @@ -94,6 +95,9 @@ pub(crate) enum Commands { #[clap(subcommand)] command: self::doctor::Commands, }, + /// Generate JSON definitions for the CLI. + #[clap(hide = true)] + GenApi { cmd: String }, } impl Cli { @@ -195,6 +199,7 @@ impl Cli { let config = Self::load_config(self.config, self.metrics_addr).await?; self::doctor::run(command, &config).await } + Commands::GenApi { cmd } => gen_api::gen_json_api(&cmd), } } diff --git a/iroh-cli/src/commands/gen_api.rs b/iroh-cli/src/commands/gen_api.rs new file mode 100644 index 0000000000..166fafe604 --- /dev/null +++ b/iroh-cli/src/commands/gen_api.rs @@ -0,0 +1,130 @@ +use anyhow::Context; +use clap::CommandFactory; + +#[derive(serde::Serialize)] +struct ApiDef { + name: String, + description: String, + // I have no idea what's this + slug: String, + arguments: Vec, + examples: Example, +} + +#[derive(serde::Serialize)] +struct Example { + console: String, +} + +#[derive(serde::Serialize)] +struct ApiArg { + name: String, + necessity: &'static str, + // required: bool, + description: String, +} + +const fn necessity(is_required: bool) -> &'static str { + if is_required { + "required" + } else { + "" + } +} + +// subcmd ex `blobs`, `tags`, etc +pub(crate) fn gen_json_api(subcmd: &str) -> anyhow::Result<()> { + let cli = super::Cli::command(); + let cmd = cli + .get_subcommands() + .find(|cmd| cmd.get_name() == subcmd) + .context("subcommand not found")?; + let definitions = describe_cmd(cmd)?; + let repr = serde_json::to_string_pretty(&definitions) + .context("failed to write api descriptions")? + .replace('\'', "\\'") // escape single quotes + .replace("\"name\"", "name") // remove quotes around keys + .replace("\"description\"", "description") + .replace("\"slug\"", "slug") + .replace("\"arguments\"", "arguments") + .replace("\"necessity\"", "necessity") + .replace("\"examples\"", "examples") + .replace("\"console\"", "console") + .replace('"', "'"); + println!("{repr}"); + Ok(()) +} + +/// Iterate the cmd subcommands and build an [`ApiDef`] for each. +/// +/// The list is generated using recursive flattening. For instance, calling this function with the +/// blobs command will indlude an item with name `blobs list incomplete-blobs` +fn describe_cmd(cmd: &clap::Command) -> anyhow::Result> { + let mut cmds = Vec::with_capacity(cmd.get_subcommands().count()); + get_api_def(cmd, "", &mut cmds)?; + Ok(cmds) +} + +fn get_api_def(cmd: &clap::Command, parent: &str, acc: &mut Vec) -> anyhow::Result<()> { + let me = format!("{parent} {}", cmd.get_name()).trim().to_owned(); + if cmd.get_subcommands().next().is_some() { + for subcmd in cmd.get_subcommands() { + get_api_def(subcmd, &me, acc)?; + } + } else { + let description = cmd + .get_about() + .map(|help_txt| help_txt.to_string()) + .unwrap_or_default(); + let mut arguments = Vec::default(); + for positional in cmd.get_positionals().filter_map(get_arg_def) { + arguments.push(positional?); + } + for flag_def in cmd.get_opts().filter_map(get_arg_def) { + arguments.push(flag_def?); + } + let console = format!("> {me}"); + acc.push(ApiDef { + name: me.clone(), + description, + slug: me.replace(' ', "-"), + arguments, + examples: Example { console }, + }) + } + Ok(()) +} + +/// Gets the [`ApiArg`] for this [`clap::Arg`]. +/// +/// Returns None if the arg is hidden. +fn get_arg_def(arg: &clap::Arg) -> Option> { + (!arg.is_hide_set()).then(|| get_arg_def_inner(arg)) +} + +/// Unconditionall gets the [`ApiArg`] for this [`clap::Arg`]. +fn get_arg_def_inner(arg: &clap::Arg) -> anyhow::Result { + let name = if let Some(long) = arg.get_long() { + long.to_owned() + } else if let Some(value_names) = arg.get_value_names() { + value_names + .first() + .expect("clap returned Some with an empty array") + .as_str() + .to_ascii_lowercase() + } else if let Some(short) = arg.get_short() { + short.to_string() + } else { + anyhow::bail!("arg without a name") + }; + let description = arg + .get_help() + .map(|help_txt| help_txt.to_string()) + .unwrap_or_default(); + let required = arg.is_required_set() || arg.is_positional(); + Ok(ApiArg { + name, + necessity: necessity(required), + description, + }) +}