From be084c1944e920a90d748600f60ca1572e1da046 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 6 May 2026 12:31:02 -0400 Subject: [PATCH] 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. --- .cargo/config.toml | 2 +- Cargo.lock | 1 - lib/crates/fabro-cli/src/args.rs | 4 + .../fabro-cli/src/commands/cli_reference.rs | 260 ++++++++++++++++++ lib/crates/fabro-cli/src/commands/mod.rs | 1 + lib/crates/fabro-cli/src/lib.rs | 7 +- lib/crates/fabro-cli/src/main.rs | 15 + .../fabro-cli/tests/it/cmd/cli_reference.rs | 32 +++ lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 + .../tests/it/support/auth_harness.rs | 1 + lib/crates/fabro-dev/Cargo.toml | 14 +- .../src/commands/docs_cli_reference.rs | 236 ++-------------- lib/crates/fabro-dev/src/lib.rs | 80 ++++++ lib/crates/fabro-dev/src/main.rs | 77 +----- lib/crates/fabro-dev/tests/it/main.rs | 15 +- 15 files changed, 443 insertions(+), 303 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/cli_reference.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs create mode 100644 lib/crates/fabro-dev/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 3a6f66c2b..dc7058400 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ [alias] -dev = "run --package fabro-dev --" +dev = "run --package fabro-dev --features dev --" t = "test -- --format terse" [env] diff --git a/Cargo.lock b/Cargo.lock index 4af3cd7b8..2c14ddc8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1810,7 +1810,6 @@ dependencies = [ "chrono", "clap", "csv", - "fabro-cli", "fabro-config", "fabro-options-metadata", "quick-xml 0.36.2", diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 474cdb398..f605b166c 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -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", diff --git a/lib/crates/fabro-cli/src/commands/cli_reference.rs b/lib/crates/fabro-cli/src/commands/cli_reference.rs new file mode 100644 index 000000000..a0376b2bb --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/cli_reference.rs @@ -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 { + render_cli_reference(Cli::command()) +} + +fn render_cli_reference(mut command: Command) -> Result { + 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::>(); + 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::>() + .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 { + 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::>(); + 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 { + 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::>(); + 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::>(); + 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("
")) +} + +fn is_boolean_switch(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse) +} + +fn os_str_to_markdown_code(value: &OsStr) -> Option { + let value = value.to_str()?; + (!value.is_empty()).then(|| format!("`{}`", markdown_cell(value))) +} + +fn markdown_cell(value: &str) -> String { + value + .replace('|', "\\|") + .replace('\n', "
") + .trim() + .to_string() +} diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 1b1fa6f1b..5f2b24b16 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -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; diff --git a/lib/crates/fabro-cli/src/lib.rs b/lib/crates/fabro-cli/src/lib.rs index 568dceb3c..34a4cfc3f 100644 --- a/lib/crates/fabro-cli/src/lib.rs +++ b/lib/crates/fabro-cli/src/lib.rs @@ -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() -} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index fb2713103..ca413f2db 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -48,6 +48,9 @@ async fn main() { let raw_args: Vec = 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, 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"); diff --git a/lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs b/lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs new file mode 100644 index 000000000..325eeb660 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/cli_reference.rs @@ -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) + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 906637a6b..fed4a406a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -3,6 +3,7 @@ mod artifact_cp; mod artifact_list; mod attach; mod auth; +mod cli_reference; mod config; mod create; mod diff; diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs index 83ca6e64e..e8ec21ac6 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs @@ -110,6 +110,7 @@ impl RealAuthHarness { ))), github_webhook_ip_allowlist: None, static_asset_root: None, + watch_web: false, }, ); diff --git a/lib/crates/fabro-dev/Cargo.toml b/lib/crates/fabro-dev/Cargo.toml index 46d26fd9d..2c41028eb 100644 --- a/lib/crates/fabro-dev/Cargo.toml +++ b/lib/crates/fabro-dev/Cargo.toml @@ -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" diff --git a/lib/crates/fabro-dev/src/commands/docs_cli_reference.rs b/lib/crates/fabro-dev/src/commands/docs_cli_reference.rs index 433fdb55b..fda0a3e69 100644 --- a/lib/crates/fabro-dev/src/commands/docs_cli_reference.rs +++ b/lib/crates/fabro-dev/src/commands/docs_cli_reference.rs @@ -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 { - 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::>(); - 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::>() - .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 { - 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::>(); - 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 { - 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::>(); - 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::>(); - if !defaults.is_empty() { - parts.push(format!("Default: {}", defaults.join(", "))); - } - } - - if parts.is_empty() { +fn render_cli_reference() -> Result { + 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("
")) -} -fn is_boolean_switch(arg: &Arg) -> bool { - matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse) -} - -fn os_str_to_markdown_code(value: &OsStr) -> Option { - 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()) } diff --git a/lib/crates/fabro-dev/src/lib.rs b/lib/crates/fabro-dev/src/lib.rs new file mode 100644 index 000000000..60c8716a3 --- /dev/null +++ b/lib/crates/fabro-dev/src/lib.rs @@ -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}"); + } +} diff --git a/lib/crates/fabro-dev/src/main.rs b/lib/crates/fabro-dev/src/main.rs index 5d13a945c..9880a60e0 100644 --- a/lib/crates/fabro-dev/src/main.rs +++ b/lib/crates/fabro-dev/src/main.rs @@ -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() } diff --git a/lib/crates/fabro-dev/tests/it/main.rs b/lib/crates/fabro-dev/tests/it/main.rs index fd6afc5f5..265d96635 100644 --- a/lib/crates/fabro-dev/tests/it/main.rs +++ b/lib/crates/fabro-dev/tests/it/main.rs @@ -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()