mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
refactor(dev): decouple CLI reference generation
Expose the CLI reference renderer through a hidden fabro subcommand so fabro-dev can refresh docs without linking fabro-cli. Gate the fabro-dev binary behind the dev feature and update the cargo dev alias to opt into it explicitly.
This commit is contained in:
parent
45bebb1fd8
commit
be084c1944
15 changed files with 443 additions and 303 deletions
|
|
@ -1,5 +1,5 @@
|
|||
[alias]
|
||||
dev = "run --package fabro-dev --"
|
||||
dev = "run --package fabro-dev --features dev --"
|
||||
t = "test -- --format terse"
|
||||
|
||||
[env]
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1810,7 +1810,6 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"csv",
|
||||
"fabro-cli",
|
||||
"fabro-config",
|
||||
"fabro-options-metadata",
|
||||
"quick-xml 0.36.2",
|
||||
|
|
|
|||
|
|
@ -1146,6 +1146,9 @@ pub(crate) enum Commands {
|
|||
/// Path to the JSON event file
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Print generated CLI reference Markdown (internal)
|
||||
#[command(name = "__cli-reference", hide = true)]
|
||||
CliReference,
|
||||
/// Render a DOT graph to SVG (internal)
|
||||
#[command(name = "__render-graph", hide = true)]
|
||||
RenderGraph,
|
||||
|
|
@ -1236,6 +1239,7 @@ impl Commands {
|
|||
},
|
||||
Self::SendAnalytics { .. } => "__send_analytics",
|
||||
Self::SendPanic { .. } => "__send_panic",
|
||||
Self::CliReference => "__cli-reference",
|
||||
Self::RenderGraph => "__render-graph",
|
||||
#[cfg(debug_assertions)]
|
||||
Self::TestPanic { .. } => "__test_panic",
|
||||
|
|
|
|||
260
lib/crates/fabro-cli/src/commands/cli_reference.rs
Normal file
260
lib/crates/fabro-cli/src/commands/cli_reference.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use std::ffi::OsStr;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Arg, ArgAction, Command, CommandFactory};
|
||||
|
||||
use crate::args::Cli;
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
clippy::disallowed_types,
|
||||
clippy::print_stderr,
|
||||
reason = "internal CLI reference command writes generated Markdown directly to stdout"
|
||||
)]
|
||||
pub(crate) fn execute() -> i32 {
|
||||
match render() {
|
||||
Ok(markdown) => {
|
||||
use std::io::Write;
|
||||
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if writeln!(stdout, "{markdown}").is_err() {
|
||||
return 1;
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("fabro __cli-reference failed: {error:#}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render() -> Result<String> {
|
||||
render_cli_reference(Cli::command())
|
||||
}
|
||||
|
||||
fn render_cli_reference(mut command: Command) -> Result<String> {
|
||||
command.build();
|
||||
|
||||
let mut output = String::new();
|
||||
render_command(&mut output, &command, &[], 2)?;
|
||||
Ok(output.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn render_command(
|
||||
output: &mut String,
|
||||
command: &Command,
|
||||
parents: &[&str],
|
||||
level: usize,
|
||||
) -> Result<()> {
|
||||
if command.is_hide_set() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = command_path(command, parents);
|
||||
output.push_str(&"#".repeat(level));
|
||||
output.push_str(" `");
|
||||
output.push_str(&path);
|
||||
output.push_str("`\n\n");
|
||||
|
||||
if let Some(about) = command_about(command) {
|
||||
output.push_str(&about);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
|
||||
output.push_str("```bash\n");
|
||||
output.push_str(&usage(command));
|
||||
output.push_str("\n```\n\n");
|
||||
|
||||
let positionals = visible_positionals(command);
|
||||
if !positionals.is_empty() {
|
||||
output.push_str("#### Arguments\n\n");
|
||||
output.push_str("| Name | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for arg in positionals {
|
||||
output.push_str("| `");
|
||||
output.push_str(&argument_name(arg));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&arg_help(arg, &path)?);
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let options = visible_options(command);
|
||||
if !options.is_empty() {
|
||||
output.push_str("#### Options\n\n");
|
||||
output.push_str("| Option | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for arg in options {
|
||||
output.push_str("| `");
|
||||
output.push_str(&option_name(arg));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&arg_help(arg, &path)?);
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let mut visible_subcommands = command
|
||||
.get_subcommands()
|
||||
.filter(|subcommand| !subcommand.is_hide_set() && subcommand.get_name() != "help")
|
||||
.collect::<Vec<_>>();
|
||||
visible_subcommands.sort_by_key(|subcommand| subcommand.get_name());
|
||||
|
||||
if !visible_subcommands.is_empty() {
|
||||
output.push_str("#### Subcommands\n\n");
|
||||
output.push_str("| Command | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for subcommand in &visible_subcommands {
|
||||
output.push_str("| `");
|
||||
output.push_str(&command_path(subcommand, &[&path]));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&command_about(subcommand).unwrap_or_default());
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let mut next_parents = parents.to_vec();
|
||||
next_parents.push(command.get_name());
|
||||
for subcommand in visible_subcommands {
|
||||
render_command(output, subcommand, &next_parents, level + 1)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_path(command: &Command, parents: &[&str]) -> String {
|
||||
parents
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([command.get_name()])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn usage(command: &Command) -> String {
|
||||
let mut command = command.clone();
|
||||
let usage = command.render_usage().to_string();
|
||||
usage.trim_start_matches("Usage: ").trim().to_string()
|
||||
}
|
||||
|
||||
fn command_about(command: &Command) -> Option<String> {
|
||||
command
|
||||
.get_long_about()
|
||||
.or_else(|| command.get_about())
|
||||
.map(ToString::to_string)
|
||||
.map(|help| markdown_cell(help.trim()))
|
||||
.filter(|help| !help.is_empty())
|
||||
}
|
||||
|
||||
fn visible_positionals(command: &Command) -> Vec<&Arg> {
|
||||
command
|
||||
.get_positionals()
|
||||
.filter(|arg| !arg.is_hide_set())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible_options(command: &Command) -> Vec<&Arg> {
|
||||
let mut options = command
|
||||
.get_arguments()
|
||||
.filter(|arg| {
|
||||
!arg.is_positional()
|
||||
&& !arg.is_hide_set()
|
||||
&& !arg.is_global_set()
|
||||
&& !matches!(arg.get_id().as_str(), "help" | "version")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
options.sort_by_key(|arg| arg.get_id().to_string());
|
||||
options
|
||||
}
|
||||
|
||||
fn argument_name(arg: &Arg) -> String {
|
||||
arg.get_value_names()
|
||||
.and_then(|names| names.first())
|
||||
.map_or_else(|| arg.get_id().to_string(), ToString::to_string)
|
||||
}
|
||||
|
||||
fn option_name(arg: &Arg) -> String {
|
||||
let mut names = Vec::new();
|
||||
if let Some(short) = arg.get_short() {
|
||||
names.push(format!("-{short}"));
|
||||
}
|
||||
if let Some(long) = arg.get_long() {
|
||||
names.push(format!("--{long}"));
|
||||
}
|
||||
if names.is_empty() {
|
||||
names.push(arg.get_id().to_string());
|
||||
}
|
||||
|
||||
let mut name = names.join(", ");
|
||||
if option_takes_value(arg) {
|
||||
name.push(' ');
|
||||
name.push('<');
|
||||
name.push_str(&argument_name(arg).to_ascii_lowercase());
|
||||
name.push('>');
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn option_takes_value(arg: &Arg) -> bool {
|
||||
arg.get_num_args().is_some_and(|range| range.takes_values())
|
||||
}
|
||||
|
||||
fn arg_help(arg: &Arg, command_path: &str) -> Result<String> {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
|
||||
let help = help.to_string();
|
||||
let help = help.trim();
|
||||
if !help.is_empty() {
|
||||
parts.push(markdown_cell(help));
|
||||
}
|
||||
}
|
||||
|
||||
let possible_values = arg
|
||||
.get_possible_values()
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_hide_set())
|
||||
.map(|value| format!("`{}`", value.get_name()))
|
||||
.collect::<Vec<_>>();
|
||||
if !possible_values.is_empty() {
|
||||
parts.push(format!("Values: {}", possible_values.join(", ")));
|
||||
}
|
||||
|
||||
if !is_boolean_switch(arg) {
|
||||
let defaults = arg
|
||||
.get_default_values()
|
||||
.iter()
|
||||
.filter_map(|value| os_str_to_markdown_code(value.as_os_str()))
|
||||
.collect::<Vec<_>>();
|
||||
if !defaults.is_empty() {
|
||||
parts.push(format!("Default: {}", defaults.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
bail!(
|
||||
"{command_path} argument `{}` is missing help text",
|
||||
arg.get_id()
|
||||
)
|
||||
}
|
||||
Ok(parts.join("<br />"))
|
||||
}
|
||||
|
||||
fn is_boolean_switch(arg: &Arg) -> bool {
|
||||
matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
|
||||
}
|
||||
|
||||
fn os_str_to_markdown_code(value: &OsStr) -> Option<String> {
|
||||
let value = value.to_str()?;
|
||||
(!value.is_empty()).then(|| format!("`{}`", markdown_cell(value)))
|
||||
}
|
||||
|
||||
fn markdown_cell(value: &str) -> String {
|
||||
value
|
||||
.replace('|', "\\|")
|
||||
.replace('\n', "<br />")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub(crate) mod artifact;
|
||||
pub(crate) mod auth;
|
||||
pub(crate) mod cli_reference;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod doctor;
|
||||
pub(crate) mod dump;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
#![expect(
|
||||
dead_code,
|
||||
reason = "the reference-facing library compiles CLI args without the binary dispatch modules"
|
||||
reason = "the library exports manifest builder helpers while the binary owns most CLI dispatch"
|
||||
)]
|
||||
|
||||
mod args;
|
||||
mod manifest_builder;
|
||||
|
||||
use clap::{Command, CommandFactory};
|
||||
pub use manifest_builder::{BuiltManifest, ManifestBuildInput, build_run_manifest};
|
||||
|
||||
pub fn command_for_reference() -> Command {
|
||||
args::Cli::command()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ async fn main() {
|
|||
let raw_args: Vec<String> = std::env::args().collect();
|
||||
let subcommand = raw_args.get(1).map(String::as_str);
|
||||
let subcommand_arg = raw_args.get(2).map(String::as_str);
|
||||
if subcommand == Some("__cli-reference") && !matches!(subcommand_arg, Some("--help" | "-h")) {
|
||||
std::process::exit(commands::cli_reference::execute());
|
||||
}
|
||||
if subcommand == Some("__render-graph") && !matches!(subcommand_arg, Some("--help" | "-h")) {
|
||||
std::process::exit(commands::render_graph::execute());
|
||||
}
|
||||
|
|
@ -387,6 +390,9 @@ async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
|
|||
let _ = std::fs::remove_file(&path);
|
||||
result?;
|
||||
}
|
||||
Commands::CliReference => {
|
||||
unreachable!("__cli-reference handled before CLI bootstrap")
|
||||
}
|
||||
Commands::RenderGraph => unreachable!("__render-graph handled before CLI bootstrap"),
|
||||
#[cfg(debug_assertions)]
|
||||
Commands::TestPanic { message } => {
|
||||
|
|
@ -1275,6 +1281,15 @@ destination = "{destination}"
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_reference_command() {
|
||||
let cli = Cli::try_parse_from(["fabro", "__cli-reference"]).expect("should parse");
|
||||
match *cli.command.unwrap() {
|
||||
Commands::CliReference => {}
|
||||
_ => panic!("unexpected command variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_settings_command() {
|
||||
let cli = Cli::try_parse_from(["fabro", "settings"]).expect("should parse");
|
||||
|
|
|
|||
32
lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs
Normal file
32
lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use fabro_test::test_context;
|
||||
|
||||
#[test]
|
||||
fn emits_cli_reference_markdown() {
|
||||
let context = test_context!();
|
||||
let output = context
|
||||
.command()
|
||||
.arg("__cli-reference")
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.clone();
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8");
|
||||
|
||||
assert!(
|
||||
stdout.contains("## `fabro`"),
|
||||
"CLI reference should include the root command:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("### `fabro run`"),
|
||||
"CLI reference should include visible subcommands:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("__cli-reference"),
|
||||
"CLI reference should not include hidden commands:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"CLI reference command should not emit stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ mod artifact_cp;
|
|||
mod artifact_list;
|
||||
mod attach;
|
||||
mod auth;
|
||||
mod cli_reference;
|
||||
mod config;
|
||||
mod create;
|
||||
mod diff;
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ impl RealAuthHarness {
|
|||
))),
|
||||
github_webhook_ip_allowlist: None,
|
||||
static_asset_root: None,
|
||||
watch_web: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,22 @@ publish = false
|
|||
license.workspace = true
|
||||
description = "Internal development tooling for Fabro"
|
||||
|
||||
[lib]
|
||||
name = "fabro_dev"
|
||||
|
||||
[[bin]]
|
||||
name = "fabro-dev"
|
||||
path = "src/main.rs"
|
||||
required-features = ["dev"]
|
||||
|
||||
[[test]]
|
||||
name = "it"
|
||||
path = "tests/it/main.rs"
|
||||
required-features = ["dev"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
dev = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -18,7 +31,6 @@ anyhow.workspace = true
|
|||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
csv = "1"
|
||||
fabro-cli = { path = "../fabro-cli" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-options-metadata.workspace = true
|
||||
quick-xml = "0.36"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
|
||||
use super::{markdown_cell, replace_generated_region};
|
||||
use super::{PlannedCommand, capture_command, replace_generated_region, workspace_root};
|
||||
|
||||
const CLI_REFERENCE_PATH: &str = "docs/public/reference/cli.mdx";
|
||||
const FENCE_START: &str = "{/* generated:cli */}";
|
||||
|
|
@ -19,7 +17,7 @@ pub(crate) fn docs_cli_reference_root(root: &Path, check: bool) -> Result<()> {
|
|||
let path = root.join(CLI_REFERENCE_PATH);
|
||||
let current =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let generated = render_cli_reference(fabro_cli::command_for_reference())?;
|
||||
let generated = render_cli_reference()?;
|
||||
let updated = replace_generated_region(
|
||||
¤t,
|
||||
&generated,
|
||||
|
|
@ -43,220 +41,24 @@ pub(crate) fn docs_cli_reference_root(root: &Path, check: bool) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn render_cli_reference(mut command: Command) -> Result<String> {
|
||||
command.build();
|
||||
|
||||
let mut output = String::new();
|
||||
render_command(&mut output, &command, &[], 2)?;
|
||||
Ok(output.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn render_command(
|
||||
output: &mut String,
|
||||
command: &Command,
|
||||
parents: &[&str],
|
||||
level: usize,
|
||||
) -> Result<()> {
|
||||
if command.is_hide_set() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = command_path(command, parents);
|
||||
output.push_str(&"#".repeat(level));
|
||||
output.push_str(" `");
|
||||
output.push_str(&path);
|
||||
output.push_str("`\n\n");
|
||||
|
||||
if let Some(about) = command_about(command) {
|
||||
output.push_str(&about);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
|
||||
output.push_str("```bash\n");
|
||||
output.push_str(&usage(command));
|
||||
output.push_str("\n```\n\n");
|
||||
|
||||
let positionals = visible_positionals(command);
|
||||
if !positionals.is_empty() {
|
||||
output.push_str("#### Arguments\n\n");
|
||||
output.push_str("| Name | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for arg in positionals {
|
||||
output.push_str("| `");
|
||||
output.push_str(&argument_name(arg));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&arg_help(arg, &path)?);
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let options = visible_options(command);
|
||||
if !options.is_empty() {
|
||||
output.push_str("#### Options\n\n");
|
||||
output.push_str("| Option | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for arg in options {
|
||||
output.push_str("| `");
|
||||
output.push_str(&option_name(arg));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&arg_help(arg, &path)?);
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let mut visible_subcommands = command
|
||||
.get_subcommands()
|
||||
.filter(|subcommand| !subcommand.is_hide_set() && subcommand.get_name() != "help")
|
||||
.collect::<Vec<_>>();
|
||||
visible_subcommands.sort_by_key(|subcommand| subcommand.get_name());
|
||||
|
||||
if !visible_subcommands.is_empty() {
|
||||
output.push_str("#### Subcommands\n\n");
|
||||
output.push_str("| Command | Description |\n");
|
||||
output.push_str("| --- | --- |\n");
|
||||
for subcommand in &visible_subcommands {
|
||||
output.push_str("| `");
|
||||
output.push_str(&command_path(subcommand, &[&path]));
|
||||
output.push_str("` | ");
|
||||
output.push_str(&command_about(subcommand).unwrap_or_default());
|
||||
output.push_str(" |\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let mut next_parents = parents.to_vec();
|
||||
next_parents.push(command.get_name());
|
||||
for subcommand in visible_subcommands {
|
||||
render_command(output, subcommand, &next_parents, level + 1)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_path(command: &Command, parents: &[&str]) -> String {
|
||||
parents
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([command.get_name()])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn usage(command: &Command) -> String {
|
||||
let mut command = command.clone();
|
||||
let usage = command.render_usage().to_string();
|
||||
usage.trim_start_matches("Usage: ").trim().to_string()
|
||||
}
|
||||
|
||||
fn command_about(command: &Command) -> Option<String> {
|
||||
command
|
||||
.get_long_about()
|
||||
.or_else(|| command.get_about())
|
||||
.map(ToString::to_string)
|
||||
.map(|help| markdown_cell(help.trim()))
|
||||
.filter(|help| !help.is_empty())
|
||||
}
|
||||
|
||||
fn visible_positionals(command: &Command) -> Vec<&Arg> {
|
||||
command
|
||||
.get_positionals()
|
||||
.filter(|arg| !arg.is_hide_set())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible_options(command: &Command) -> Vec<&Arg> {
|
||||
let mut options = command
|
||||
.get_arguments()
|
||||
.filter(|arg| {
|
||||
!arg.is_positional()
|
||||
&& !arg.is_hide_set()
|
||||
&& !arg.is_global_set()
|
||||
&& !matches!(arg.get_id().as_str(), "help" | "version")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
options.sort_by_key(|arg| arg.get_id().to_string());
|
||||
options
|
||||
}
|
||||
|
||||
fn argument_name(arg: &Arg) -> String {
|
||||
arg.get_value_names()
|
||||
.and_then(|names| names.first())
|
||||
.map_or_else(|| arg.get_id().to_string(), ToString::to_string)
|
||||
}
|
||||
|
||||
fn option_name(arg: &Arg) -> String {
|
||||
let mut names = Vec::new();
|
||||
if let Some(short) = arg.get_short() {
|
||||
names.push(format!("-{short}"));
|
||||
}
|
||||
if let Some(long) = arg.get_long() {
|
||||
names.push(format!("--{long}"));
|
||||
}
|
||||
if names.is_empty() {
|
||||
names.push(arg.get_id().to_string());
|
||||
}
|
||||
|
||||
let mut name = names.join(", ");
|
||||
if option_takes_value(arg) {
|
||||
name.push(' ');
|
||||
name.push('<');
|
||||
name.push_str(&argument_name(arg).to_ascii_lowercase());
|
||||
name.push('>');
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn option_takes_value(arg: &Arg) -> bool {
|
||||
arg.get_num_args().is_some_and(|range| range.takes_values())
|
||||
}
|
||||
|
||||
fn arg_help(arg: &Arg, command_path: &str) -> Result<String> {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
|
||||
let help = help.to_string();
|
||||
let help = help.trim();
|
||||
if !help.is_empty() {
|
||||
parts.push(markdown_cell(help));
|
||||
}
|
||||
}
|
||||
|
||||
let possible_values = arg
|
||||
.get_possible_values()
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_hide_set())
|
||||
.map(|value| format!("`{}`", value.get_name()))
|
||||
.collect::<Vec<_>>();
|
||||
if !possible_values.is_empty() {
|
||||
parts.push(format!("Values: {}", possible_values.join(", ")));
|
||||
}
|
||||
|
||||
if !is_boolean_switch(arg) {
|
||||
let defaults = arg
|
||||
.get_default_values()
|
||||
.iter()
|
||||
.filter_map(|value| os_str_to_markdown_code(value.as_os_str()))
|
||||
.collect::<Vec<_>>();
|
||||
if !defaults.is_empty() {
|
||||
parts.push(format!("Default: {}", defaults.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
fn render_cli_reference() -> Result<String> {
|
||||
let command = PlannedCommand::new("cargo")
|
||||
.arg("run")
|
||||
.arg("-p")
|
||||
.arg("fabro-cli")
|
||||
.arg("--")
|
||||
.arg("__cli-reference");
|
||||
let output = capture_command(&workspace_root(), &command)?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!(
|
||||
"{command_path} argument `{}` is missing help text",
|
||||
arg.get_id()
|
||||
)
|
||||
"`fabro __cli-reference` failed with {}:\n{}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Ok(parts.join("<br />"))
|
||||
}
|
||||
|
||||
fn is_boolean_switch(arg: &Arg) -> bool {
|
||||
matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
|
||||
}
|
||||
|
||||
fn os_str_to_markdown_code(value: &OsStr) -> Option<String> {
|
||||
let value = value.to_str()?;
|
||||
(!value.is_empty()).then(|| format!("`{}`", markdown_cell(value)))
|
||||
String::from_utf8(output.stdout)
|
||||
.context("fabro __cli-reference emitted invalid UTF-8")
|
||||
.map(|output| output.trim_end().to_string())
|
||||
}
|
||||
|
|
|
|||
80
lib/crates/fabro-dev/src/lib.rs
Normal file
80
lib/crates/fabro-dev/src/lib.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod commands;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "fabro-dev",
|
||||
version,
|
||||
about = "Internal development tooling for Fabro"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Run the test suite N times and capture per-test timing to CSV.
|
||||
BenchTests(commands::BenchTestsArgs),
|
||||
/// Refresh embedded SPA assets and run cargo build.
|
||||
Build(commands::BuildArgs),
|
||||
/// Build Fabro Docker images with the release pipeline layout.
|
||||
DockerBuild(commands::DockerBuildArgs),
|
||||
/// Manage generated reference documentation.
|
||||
Docs(commands::DocsArgs),
|
||||
/// Run Fabro release automation.
|
||||
Release(commands::ReleaseArgs),
|
||||
/// Manage embedded Fabro web SPA assets.
|
||||
Spa(commands::SpaArgs),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn run(self) -> Result<()> {
|
||||
match self {
|
||||
Self::BenchTests(args) => commands::bench_tests(args),
|
||||
Self::Build(args) => commands::build(args),
|
||||
Self::DockerBuild(args) => commands::docker_build(args),
|
||||
Self::Docs(args) => commands::docs(args),
|
||||
Self::Release(args) => commands::release(args),
|
||||
Self::Spa(args) => commands::spa(args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> ExitCode {
|
||||
install_tracing();
|
||||
|
||||
match Cli::parse().command.run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
report_error(&error);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "dev CLI installs a process-global stderr tracing sink before command dispatch"
|
||||
)]
|
||||
fn install_tracing() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.try_init();
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "dev CLI reports final command errors to stderr"
|
||||
)]
|
||||
fn report_error(error: &anyhow::Error) {
|
||||
eprintln!("fabro-dev failed");
|
||||
for cause in error.chain() {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +1,5 @@
|
|||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod commands;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "fabro-dev",
|
||||
version,
|
||||
about = "Internal development tooling for Fabro"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Run the test suite N times and capture per-test timing to CSV.
|
||||
BenchTests(commands::BenchTestsArgs),
|
||||
/// Refresh embedded SPA assets and run cargo build.
|
||||
Build(commands::BuildArgs),
|
||||
/// Build Fabro Docker images with the release pipeline layout.
|
||||
DockerBuild(commands::DockerBuildArgs),
|
||||
/// Manage generated reference documentation.
|
||||
Docs(commands::DocsArgs),
|
||||
/// Run Fabro release automation.
|
||||
Release(commands::ReleaseArgs),
|
||||
/// Manage embedded Fabro web SPA assets.
|
||||
Spa(commands::SpaArgs),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn run(self) -> Result<()> {
|
||||
match self {
|
||||
Self::BenchTests(args) => commands::bench_tests(args),
|
||||
Self::Build(args) => commands::build(args),
|
||||
Self::DockerBuild(args) => commands::docker_build(args),
|
||||
Self::Docs(args) => commands::docs(args),
|
||||
Self::Release(args) => commands::release(args),
|
||||
Self::Spa(args) => commands::spa(args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
install_tracing();
|
||||
|
||||
match Cli::parse().command.run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
report_error(&error);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "dev CLI installs a process-global stderr tracing sink before command dispatch"
|
||||
)]
|
||||
fn install_tracing() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.try_init();
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "dev CLI reports final command errors to stderr"
|
||||
)]
|
||||
fn report_error(error: &anyhow::Error) {
|
||||
eprintln!("fabro-dev failed");
|
||||
for cause in error.chain() {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
fabro_dev::run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,11 +136,24 @@ fn build_help_lists_forwarded_cargo_args() {
|
|||
fn cargo_dev_alias_points_at_fabro_dev() {
|
||||
let config = read_file(&workspace_root(), ".cargo/config.toml");
|
||||
assert!(
|
||||
config.contains(r#"dev = "run --package fabro-dev --""#),
|
||||
config.contains(r#"dev = "run --package fabro-dev --features dev --""#),
|
||||
"cargo dev alias should invoke fabro-dev:\n{config}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fabro_dev_does_not_depend_on_fabro_cli() {
|
||||
let manifest = read_file(&workspace_root(), "lib/crates/fabro-dev/Cargo.toml");
|
||||
assert!(
|
||||
!manifest.contains("fabro-cli"),
|
||||
"fabro-dev should shell out to fabro-cli instead of depending on it:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("required-features = [\"dev\"]"),
|
||||
"fabro-dev binary should require the dev feature:\n{manifest}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subcommand_exits_with_clap_usage_error() {
|
||||
let output = fabro_dev()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue