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:
Bryan Helmkamp 2026-05-06 12:31:02 -04:00
parent 45bebb1fd8
commit be084c1944
No known key found for this signature in database
15 changed files with 443 additions and 303 deletions

View file

@ -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
View file

@ -1810,7 +1810,6 @@ dependencies = [
"chrono",
"clap",
"csv",
"fabro-cli",
"fabro-config",
"fabro-options-metadata",
"quick-xml 0.36.2",

View file

@ -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",

View 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()
}

View file

@ -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;

View file

@ -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()
}

View file

@ -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");

View 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)
);
}

View file

@ -3,6 +3,7 @@ mod artifact_cp;
mod artifact_list;
mod attach;
mod auth;
mod cli_reference;
mod config;
mod create;
mod diff;

View file

@ -110,6 +110,7 @@ impl RealAuthHarness {
))),
github_webhook_ip_allowlist: None,
static_asset_root: None,
watch_web: false,
},
);

View file

@ -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"

View file

@ -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(
&current,
&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())
}

View 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}");
}
}

View file

@ -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()
}

View file

@ -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()