use clap::{Arg, ArgAction, Command};
use owo_colors::OwoColorize;
use serde::Serialize;
use crate::exit::Exit;
use crate::output::Format;
use crate::render::table::{Cell, Table};
use crate::render::theme;
#[derive(Serialize)]
struct Schema {
tool: &'static str,
version: &'static str,
about: String,
json: JsonContract,
effects: Vec<Effect>,
exits: Vec<ExitClass>,
globals: Vec<Argument>,
commands: Vec<CommandEntry>,
}
#[derive(Serialize)]
struct JsonContract {
flag: &'static str,
environment: &'static str,
answers: &'static str,
failures: &'static str,
}
#[derive(Serialize)]
struct Effect {
name: &'static str,
means: &'static str,
}
#[derive(Serialize)]
struct ExitClass {
code: i32,
name: &'static str,
means: &'static str,
retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<&'static str>,
}
#[derive(Serialize)]
struct CommandEntry {
path: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
aliases: Vec<String>,
about: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
effect: &'static str,
arguments: Vec<Argument>,
#[serde(skip_serializing_if = "Vec::is_empty")]
subcommands: Vec<String>,
}
#[derive(Serialize)]
struct Argument {
name: String,
kind: &'static str,
required: bool,
many: bool,
#[serde(skip_serializing_if = "Option::is_none")]
long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
short: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<String>,
}
const EFFECTS: &[(&str, &str)] = &[
(
"local",
"Touches this machine and nothing else. No instance, no token, no network.",
),
("reads", "Asks the instance something and changes nothing."),
(
"writes",
"Adds or replaces something. Calling it twice is the same as calling it once.",
),
(
"starts",
"Sets something running. Calling it twice is two of them — deploying twice is two deployments.",
),
(
"destroys",
"Removes or overwrites something. Takes --confirm <name>, and calling it again will not undo it.",
),
];
const EFFECT_OF: &[(&str, &str)] = &[
("login", "local"),
("logout", "local"),
("upgrade", "local"),
("changelog", "local"),
("completions", "local"),
("schema", "local"),
("guide", "local"),
("skill", "local"),
("skill show", "local"),
("skill install", "local"),
("skill path", "local"),
("whoami", "reads"),
("status", "reads"),
("projects", "reads"),
("envs", "reads"),
("env", "reads"),
("webhooks", "reads"),
("webhook", "reads"),
("hooks", "reads"),
("replicas", "reads"),
("hooks list", "reads"),
("hooks add", "writes"),
("bulk", "starts"),
("services", "reads"),
("service", "reads"),
("canvas", "reads"),
("canvas show", "reads"),
("templates", "reads"),
("uploads", "reads"),
("history", "reads"),
("commit", "reads"),
("activity", "reads"),
("sign-ins", "reads"),
("sessions", "reads"),
("watch", "reads"),
("logs", "reads"),
("requests", "reads"),
("incidents", "reads"),
("workflows", "reads"),
("workflows list", "reads"),
("workflows runs", "reads"),
("workflows show", "reads"),
("workflows logs", "reads"),
("workflows artifact", "reads"),
("workflows run", "starts"),
("workflows rerun", "starts"),
("workflows cancel", "writes"),
("approvals", "reads"),
("autofix", "reads"),
("metrics", "reads"),
("consumption", "reads"),
("agent-activity", "reads"),
("variables", "reads"),
("variables list", "reads"),
("shared", "reads"),
("shared list", "reads"),
("previews", "writes"),
("deployments", "reads"),
("status-page", "writes"),
("workspace 2fa", "writes"),
("import", "starts"),
("stack", "reads"),
("stack list", "reads"),
("stack add", "starts"),
("stack new", "starts"),
("duplicate", "starts"),
("pull", "starts"),
("tasks", "reads"),
("tasks list", "reads"),
("tasks add", "destroys"),
("tasks edit", "destroys"),
("tasks rm", "destroys"),
("tasks run", "destroys"),
("tasks runs", "reads"),
("files", "reads"),
("files ls", "reads"),
("files cat", "reads"),
("files get", "reads"),
("data", "reads"),
("data tables", "reads"),
("data query", "reads"),
("capacity", "reads"),
("capabilities", "reads"),
("connectable", "reads"),
("volumes", "reads"),
("volumes list", "reads"),
("domains", "reads"),
("domains list", "reads"),
("backups", "reads"),
("backups list", "reads"),
("access", "reads"),
("access status", "reads"),
("recipe", "reads"),
("recipe show", "reads"),
("config", "reads"),
("config show", "reads"),
("workspace", "reads"),
("workspace list", "reads"),
("workspace members", "reads"),
("workspace credits", "reads"),
("project", "reads"),
("attention", "reads"),
("health", "reads"),
("diagnose", "reads"),
("variables audit", "reads"),
("grep", "reads"),
("deps", "reads"),
("export", "reads"),
("compare", "reads"),
("probe", "reads"),
("workflows stats", "reads"),
("workflows wait", "reads"),
("security", "reads"),
("security show", "reads"),
("security list", "reads"),
("agent", "writes"),
("rename", "writes"),
("settings", "writes"),
("expose", "writes"),
("maintenance", "writes"),
("protect", "writes"),
("allow", "writes"),
("ratelimit", "writes"),
("encrypt", "writes"),
("connect", "writes"),
("disconnect", "writes"),
("incident", "writes"),
("reject", "writes"),
("canvas move", "writes"),
("canvas arrange", "writes"),
("variables set", "writes"),
("shared set", "writes"),
("variables import", "writes"),
("variables seal", "writes"),
("volumes add", "writes"),
("volumes resize", "writes"),
("domains add", "writes"),
("domains verify", "writes"),
("backups schedule", "writes"),
("backups keep", "writes"),
("backups unkeep", "writes"),
("access request", "writes"),
("recipe edit", "writes"),
("config edit", "writes"),
("wake", "writes"),
("issue", "writes"),
("project new", "writes"),
("project rename", "writes"),
("env rename", "writes"),
("env server", "reads"),
("servers", "reads"),
("servers list", "reads"),
("servers show", "reads"),
("servers add", "writes"),
("servers cordon", "writes"),
("servers uncordon", "writes"),
("servers failover", "writes"),
("env expire", "writes"),
("env diff", "reads"),
("webhook add", "writes"),
("project star", "writes"),
("project unstar", "writes"),
("webhook set", "writes"),
("project move", "writes"),
("workspace invite", "writes"),
("new", "starts"),
("env new", "starts"),
("env copy", "starts"),
("env move", "starts"),
("servers drain", "starts"),
("env promote", "starts"),
("webhook test", "starts"),
("up", "starts"),
("deploy", "starts"),
("redeploy", "starts"),
("rollback", "starts"),
("cancel", "starts"),
("stop", "starts"),
("approve", "starts"),
("fix", "starts"),
("fix deployment", "starts"),
("security scan", "starts"),
("backups new", "starts"),
("delete", "destroys"),
("exec", "destroys"),
("project delete", "destroys"),
("env rm", "destroys"),
("servers rm", "destroys"),
("env copy-data", "destroys"),
("webhook rm", "destroys"),
("hooks rm", "destroys"),
("unprotect", "destroys"),
("variables unset", "destroys"),
("shared unset", "destroys"),
("data run", "destroys"),
("volumes remove", "destroys"),
("domains remove", "destroys"),
("backups restore", "destroys"),
("backups remove", "destroys"),
("sessions revoke", "destroys"),
("workspace revoke", "destroys"),
("workspace remove", "destroys"),
("servers label", "writes"),
("env require", "writes"),
("servers metrics", "reads"),
("servers bootstrap", "reads"),
("servers test", "reads"),
("servers rebalance", "starts"),
("monitors", "reads"),
("monitors list", "reads"),
("monitors show", "reads"),
("monitors add", "writes"),
("monitors edit", "writes"),
("monitors pause", "writes"),
("monitors resume", "writes"),
("monitors rm", "destroys"),
("alerts", "reads"),
("alerts list", "reads"),
("alerts add", "writes"),
("alerts edit", "writes"),
("alerts pause", "writes"),
("alerts resume", "writes"),
("alerts rm", "destroys"),
("rules", "reads"),
("redirects", "reads"),
("redirects list", "reads"),
("redirects add", "writes"),
("redirects rm", "writes"),
("headers", "reads"),
("headers list", "reads"),
("headers set", "writes"),
("headers rm", "writes"),
("headers security", "writes"),
("cors", "writes"),
("routes", "reads"),
("routes list", "reads"),
("routes add", "writes"),
("routes rm", "writes"),
("error-page", "writes"),
("freeze", "reads"),
("freeze status", "reads"),
("freeze now", "writes"),
("freeze window", "writes"),
("freeze lift", "writes"),
("schedule", "reads"),
("schedule list", "reads"),
("schedule add", "writes"),
("schedule rm", "writes"),
("env auto-promote", "reads"),
("env auto-promote list", "reads"),
("env auto-promote add", "writes"),
("env auto-promote rm", "writes"),
("env protect", "writes"),
("env unprotect", "writes"),
];
pub fn run(root: &Command, format: Format) -> anyhow::Result<()> {
let schema = describe(root);
if format.answered_in_json(&schema) {
return Ok(());
}
draw(&schema);
Ok(())
}
fn describe(root: &Command) -> Schema {
let mut commands = Vec::new();
collect(root, "", &mut commands);
Schema {
tool: "fundaia",
version: env!("CARGO_PKG_VERSION"),
about: root
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
json: JsonContract {
flag: "--json",
environment: "FUNDAIA_JSON=1",
answers:
"One compact JSON value on stdout, on one line. On a stream (logs, watch) one object per line, as they arrive. A write confirms by its exit code and prints nothing.",
failures:
"One object on stderr: { error: { code, message, exit, retryable, causes?, hint? } }. `code` is the platform's own, `exit` is the number the process leaves.",
},
effects: EFFECTS
.iter()
.map(|(name, means)| Effect { name, means })
.collect(),
exits: Exit::all()
.into_iter()
.map(|exit| ExitClass {
code: exit.code(),
name: exit.name(),
means: exit.meaning(),
retryable: exit.retryable(),
hint: crate::exit::hint(exit),
})
.collect(),
globals: root
.get_arguments()
.filter(|argument| worth_publishing(argument))
.map(describe_argument)
.collect(),
commands,
}
}
fn collect(parent: &Command, prefix: &str, into: &mut Vec<CommandEntry>) {
for command in parent.get_subcommands() {
if command.get_name() == "help" {
continue;
}
let path = if prefix.is_empty() {
command.get_name().to_owned()
} else {
format!("{prefix} {}", command.get_name())
};
into.push(CommandEntry {
path: path.clone(),
aliases: command
.get_visible_aliases()
.map(ToOwned::to_owned)
.collect(),
about: command
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
detail: command.get_long_about().map(ToString::to_string),
effect: effect_of(&path),
arguments: command
.get_arguments()
.filter(|argument| worth_publishing(argument))
.map(describe_argument)
.collect(),
subcommands: command
.get_subcommands()
.map(|child| format!("{path} {}", child.get_name()))
.collect(),
});
collect(command, &path, into);
}
}
fn worth_publishing(argument: &Arg) -> bool {
!matches!(argument.get_id().as_str(), "help" | "version")
}
fn describe_argument(argument: &Arg) -> Argument {
let takes_value = !matches!(
argument.get_action(),
ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count
);
Argument {
name: argument.get_id().to_string(),
kind: if argument.is_positional() {
"positional"
} else if takes_value {
"option"
} else {
"flag"
},
required: argument.is_required_set(),
many: matches!(argument.get_action(), ArgAction::Append)
|| argument.get_value_delimiter().is_some(),
long: argument.get_long().map(ToOwned::to_owned),
short: argument.get_short().map(|short| short.to_string()),
value: takes_value
.then(|| argument.get_value_names().and_then(|names| names.first()))
.flatten()
.map(ToString::to_string),
default: takes_value
.then(|| argument.get_default_values().first())
.flatten()
.map(|value| value.to_string_lossy().into_owned()),
environment: argument
.get_env()
.map(|name| name.to_string_lossy().into_owned()),
help: argument.get_help().map(ToString::to_string),
}
}
pub fn reference(root: &Command) -> String {
let globals: Vec<String> = root
.get_arguments()
.map(|argument| argument.get_id().to_string())
.collect();
describe(root)
.commands
.iter()
.map(|entry| {
let arguments: Vec<String> = entry
.arguments
.iter()
.filter(|argument| !globals.contains(&argument.name))
.map(|argument| match (argument.kind, argument.long.as_deref()) {
("positional", _) if argument.required => format!("<{}>", argument.name),
("positional", _) => format!("[{}]", argument.name),
(_, Some(long)) => format!("[--{long}]"),
_ => format!("[{}]", argument.name),
})
.collect();
let usage = [entry.path.clone(), arguments.join(" ")].join(" ");
format!(
"- `fundaia {}` — {} *({})*",
usage.trim_end(),
entry.about,
entry.effect
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn effect_of(path: &str) -> &'static str {
EFFECT_OF
.iter()
.find(|(command, _)| *command == path)
.map_or("destroys", |(_, effect)| effect)
}
fn draw(schema: &Schema) {
println!();
println!(
" {} {}",
"fundaia".style(theme::strong()),
schema.version.style(theme::muted()),
);
println!(
" {}",
"`fundaia schema --json` trae esto mismo con cada argumento.".style(theme::muted())
);
println!();
let mut commands = Table::new(&["COMANDO", "EFECTO", "QUÉ HACE"]);
for command in &schema.commands {
commands.push(vec![
Cell::styled(command.path.as_str(), theme::strong()),
Cell::styled(command.effect, effect_style(command.effect)),
Cell::plain(command.about.as_str()),
]);
}
commands.print();
println!();
let mut exits = Table::new(&["SALIDA", "CLASE", "REPETIR", "QUÉ HACER"]);
for exit in &schema.exits {
exits.push(vec![
Cell::plain(exit.code.to_string()),
Cell::styled(exit.name, theme::strong()),
Cell::plain(if exit.retryable { "sÃ" } else { "no" }),
Cell::plain(exit.hint.unwrap_or("—")),
]);
}
exits.print();
println!();
}
fn effect_style(effect: &str) -> owo_colors::Style {
match effect {
"destroys" => theme::danger(),
"starts" => theme::warning(),
"reads" | "local" => theme::muted(),
_ => theme::strong(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_say_what_every_command_does() {
let unannotated: Vec<String> = paths()
.into_iter()
.filter(|path| !EFFECT_OF.iter().any(|(command, _)| command == path))
.collect();
assert_eq!(unannotated, Vec::<String>::new());
}
#[test]
fn it_should_annotate_nothing_that_cannot_be_typed() {
let known = paths();
let stale: Vec<&str> = EFFECT_OF
.iter()
.map(|(command, _)| *command)
.filter(|command| !known.iter().any(|path| path == command))
.collect();
assert_eq!(stale, Vec::<&str>::new());
}
#[test]
fn it_should_annotate_every_command_with_one_of_the_five_effects() {
let invented: Vec<&str> = EFFECT_OF
.iter()
.map(|(_, effect)| *effect)
.filter(|effect| !EFFECTS.iter().any(|(name, _)| name == effect))
.collect();
assert_eq!(invented, Vec::<&str>::new());
}
#[test]
fn it_should_name_every_command_once() {
let paths = paths();
assert_eq!(
paths.iter().collect::<std::collections::HashSet<_>>().len(),
paths.len()
);
}
#[test]
fn it_should_mark_deleting_a_service_as_destructive() {
assert_eq!(effect_of("delete"), "destroys");
}
#[test]
fn it_should_mark_deploying_as_something_that_starts() {
assert_eq!(effect_of("deploy"), "starts");
}
#[test]
fn it_should_treat_a_command_it_has_never_heard_of_as_the_worst_case() {
assert_eq!(effect_of("something-added-later"), "destroys");
}
#[test]
fn it_should_publish_the_confirmation_that_deleting_a_service_needs() {
let schema = describe(&crate::built());
let delete = schema
.commands
.iter()
.find(|command| command.path == "delete")
.expect("`delete` is a command");
assert!(delete
.arguments
.iter()
.any(|argument| argument.name == "confirm"));
}
#[test]
fn it_should_publish_the_environment_variable_that_carries_the_token() {
let schema = describe(&crate::built());
let token = schema
.globals
.iter()
.find(|argument| argument.name == "token")
.expect("`--token` is global");
assert_eq!(token.environment.as_deref(), Some("FUNDAIA_TOKEN"));
}
#[test]
fn it_should_publish_a_list_argument_as_one_that_takes_many() {
let schema = describe(&crate::built());
let new = schema
.commands
.iter()
.find(|command| command.path == "new")
.expect("`new` is a command");
let connect = new
.arguments
.iter()
.find(|argument| argument.name == "connect_to")
.expect("`--connect-to` is an argument of `new`");
assert!(connect.many);
}
#[test]
fn it_should_publish_no_value_for_a_flag_that_takes_none() {
let schema = describe(&crate::built());
let upload = schema
.commands
.iter()
.find(|command| command.path == "new")
.and_then(|command| {
command
.arguments
.iter()
.find(|argument| argument.name == "upload")
})
.expect("`--upload` is a flag of `new`");
assert_eq!(upload.value, None);
}
#[test]
fn it_should_publish_no_default_for_a_flag_that_takes_none() {
let schema = describe(&crate::built());
let upload = schema
.commands
.iter()
.find(|command| command.path == "new")
.and_then(|command| {
command
.arguments
.iter()
.find(|argument| argument.name == "upload")
})
.expect("`--upload` is a flag of `new`");
assert_eq!(upload.default, None);
}
#[test]
fn it_should_publish_the_default_of_an_option_that_has_one() {
let schema = describe(&crate::built());
let branch = schema
.commands
.iter()
.find(|command| command.path == "new")
.and_then(|command| {
command
.arguments
.iter()
.find(|argument| argument.name == "branch")
})
.expect("`--branch` is an option of `new`");
assert_eq!(branch.default.as_deref(), Some("main"));
}
#[test]
fn it_should_publish_clap_s_own_flags_nowhere() {
let schema = describe(&crate::built());
let clap_s_own: Vec<&Argument> = schema
.commands
.iter()
.flat_map(|command| command.arguments.iter())
.filter(|argument| argument.name == "help" || argument.name == "version")
.collect();
assert_eq!(clap_s_own.len(), 0);
}
#[test]
fn it_should_explain_every_effect_it_annotates_with() {
let unexplained: Vec<&str> = EFFECTS
.iter()
.filter(|(_, means)| means.is_empty())
.map(|(name, _)| *name)
.collect();
assert_eq!(unexplained, Vec::<&str>::new());
}
#[test]
fn it_should_publish_every_exit_class_the_binary_can_leave() {
assert_eq!(describe(&crate::built()).exits.len(), Exit::all().len());
}
#[test]
fn it_should_publish_an_exit_class_with_its_advice_as_well_as_its_meaning() {
let schema = describe(&crate::built());
let unauthorized = schema
.exits
.iter()
.find(|class| class.name == "unauthorized")
.expect("`unauthorized` is a class");
assert!(unauthorized
.hint
.is_some_and(|advice| advice.contains("fundaia login")));
}
fn paths() -> Vec<String> {
let mut found = Vec::new();
collect(&crate::built(), "", &mut found);
found.into_iter().map(|entry| entry.path).collect()
}
}