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"),
("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"),
("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"),
("workspace 2fa", "writes"),
("import", "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"),
("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"),
("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"),
("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"),
("webhook 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"),
];
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()
}
}