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]
|
[alias]
|
||||||
dev = "run --package fabro-dev --"
|
dev = "run --package fabro-dev --features dev --"
|
||||||
t = "test -- --format terse"
|
t = "test -- --format terse"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1810,7 +1810,6 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"csv",
|
"csv",
|
||||||
"fabro-cli",
|
|
||||||
"fabro-config",
|
"fabro-config",
|
||||||
"fabro-options-metadata",
|
"fabro-options-metadata",
|
||||||
"quick-xml 0.36.2",
|
"quick-xml 0.36.2",
|
||||||
|
|
|
||||||
|
|
@ -1146,6 +1146,9 @@ pub(crate) enum Commands {
|
||||||
/// Path to the JSON event file
|
/// Path to the JSON event file
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Print generated CLI reference Markdown (internal)
|
||||||
|
#[command(name = "__cli-reference", hide = true)]
|
||||||
|
CliReference,
|
||||||
/// Render a DOT graph to SVG (internal)
|
/// Render a DOT graph to SVG (internal)
|
||||||
#[command(name = "__render-graph", hide = true)]
|
#[command(name = "__render-graph", hide = true)]
|
||||||
RenderGraph,
|
RenderGraph,
|
||||||
|
|
@ -1236,6 +1239,7 @@ impl Commands {
|
||||||
},
|
},
|
||||||
Self::SendAnalytics { .. } => "__send_analytics",
|
Self::SendAnalytics { .. } => "__send_analytics",
|
||||||
Self::SendPanic { .. } => "__send_panic",
|
Self::SendPanic { .. } => "__send_panic",
|
||||||
|
Self::CliReference => "__cli-reference",
|
||||||
Self::RenderGraph => "__render-graph",
|
Self::RenderGraph => "__render-graph",
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
Self::TestPanic { .. } => "__test_panic",
|
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 artifact;
|
||||||
pub(crate) mod auth;
|
pub(crate) mod auth;
|
||||||
|
pub(crate) mod cli_reference;
|
||||||
pub(crate) mod config;
|
pub(crate) mod config;
|
||||||
pub(crate) mod doctor;
|
pub(crate) mod doctor;
|
||||||
pub(crate) mod dump;
|
pub(crate) mod dump;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
#![expect(
|
#![expect(
|
||||||
dead_code,
|
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 args;
|
||||||
mod manifest_builder;
|
mod manifest_builder;
|
||||||
|
|
||||||
use clap::{Command, CommandFactory};
|
|
||||||
pub use manifest_builder::{BuiltManifest, ManifestBuildInput, build_run_manifest};
|
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 raw_args: Vec<String> = std::env::args().collect();
|
||||||
let subcommand = raw_args.get(1).map(String::as_str);
|
let subcommand = raw_args.get(1).map(String::as_str);
|
||||||
let subcommand_arg = raw_args.get(2).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")) {
|
if subcommand == Some("__render-graph") && !matches!(subcommand_arg, Some("--help" | "-h")) {
|
||||||
std::process::exit(commands::render_graph::execute());
|
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);
|
let _ = std::fs::remove_file(&path);
|
||||||
result?;
|
result?;
|
||||||
}
|
}
|
||||||
|
Commands::CliReference => {
|
||||||
|
unreachable!("__cli-reference handled before CLI bootstrap")
|
||||||
|
}
|
||||||
Commands::RenderGraph => unreachable!("__render-graph handled before CLI bootstrap"),
|
Commands::RenderGraph => unreachable!("__render-graph handled before CLI bootstrap"),
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
Commands::TestPanic { message } => {
|
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]
|
#[test]
|
||||||
fn parse_settings_command() {
|
fn parse_settings_command() {
|
||||||
let cli = Cli::try_parse_from(["fabro", "settings"]).expect("should parse");
|
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 artifact_list;
|
||||||
mod attach;
|
mod attach;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod cli_reference;
|
||||||
mod config;
|
mod config;
|
||||||
mod create;
|
mod create;
|
||||||
mod diff;
|
mod diff;
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ impl RealAuthHarness {
|
||||||
))),
|
))),
|
||||||
github_webhook_ip_allowlist: None,
|
github_webhook_ip_allowlist: None,
|
||||||
static_asset_root: None,
|
static_asset_root: None,
|
||||||
|
watch_web: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,22 @@ publish = false
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Internal development tooling for Fabro"
|
description = "Internal development tooling for Fabro"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "fabro_dev"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "fabro-dev"
|
name = "fabro-dev"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
required-features = ["dev"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "it"
|
||||||
|
path = "tests/it/main.rs"
|
||||||
|
required-features = ["dev"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
dev = []
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
@ -18,7 +31,6 @@ anyhow.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
csv = "1"
|
csv = "1"
|
||||||
fabro-cli = { path = "../fabro-cli" }
|
|
||||||
fabro-config = { path = "../fabro-config" }
|
fabro-config = { path = "../fabro-config" }
|
||||||
fabro-options-metadata.workspace = true
|
fabro-options-metadata.workspace = true
|
||||||
quick-xml = "0.36"
|
quick-xml = "0.36"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
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 CLI_REFERENCE_PATH: &str = "docs/public/reference/cli.mdx";
|
||||||
const FENCE_START: &str = "{/* generated:cli */}";
|
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 path = root.join(CLI_REFERENCE_PATH);
|
||||||
let current =
|
let current =
|
||||||
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
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(
|
let updated = replace_generated_region(
|
||||||
¤t,
|
¤t,
|
||||||
&generated,
|
&generated,
|
||||||
|
|
@ -43,220 +41,24 @@ pub(crate) fn docs_cli_reference_root(root: &Path, check: bool) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_cli_reference(mut command: Command) -> Result<String> {
|
fn render_cli_reference() -> Result<String> {
|
||||||
command.build();
|
let command = PlannedCommand::new("cargo")
|
||||||
|
.arg("run")
|
||||||
let mut output = String::new();
|
.arg("-p")
|
||||||
render_command(&mut output, &command, &[], 2)?;
|
.arg("fabro-cli")
|
||||||
Ok(output.trim_end().to_string())
|
.arg("--")
|
||||||
}
|
.arg("__cli-reference");
|
||||||
|
let output = capture_command(&workspace_root(), &command)?;
|
||||||
fn render_command(
|
if !output.status.success() {
|
||||||
output: &mut String,
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
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!(
|
bail!(
|
||||||
"{command_path} argument `{}` is missing help text",
|
"`fabro __cli-reference` failed with {}:\n{}",
|
||||||
arg.get_id()
|
output.status,
|
||||||
)
|
stderr.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(parts.join("<br />"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_boolean_switch(arg: &Arg) -> bool {
|
String::from_utf8(output.stdout)
|
||||||
matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
|
.context("fabro __cli-reference emitted invalid UTF-8")
|
||||||
}
|
.map(|output| output.trim_end().to_string())
|
||||||
|
|
||||||
fn os_str_to_markdown_code(value: &OsStr) -> Option<String> {
|
|
||||||
let value = value.to_str()?;
|
|
||||||
(!value.is_empty()).then(|| format!("`{}`", markdown_cell(value)))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 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 {
|
fn main() -> ExitCode {
|
||||||
install_tracing();
|
fabro_dev::run()
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,11 +136,24 @@ fn build_help_lists_forwarded_cargo_args() {
|
||||||
fn cargo_dev_alias_points_at_fabro_dev() {
|
fn cargo_dev_alias_points_at_fabro_dev() {
|
||||||
let config = read_file(&workspace_root(), ".cargo/config.toml");
|
let config = read_file(&workspace_root(), ".cargo/config.toml");
|
||||||
assert!(
|
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}"
|
"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]
|
#[test]
|
||||||
fn unknown_subcommand_exits_with_clap_usage_error() {
|
fn unknown_subcommand_exits_with_clap_usage_error() {
|
||||||
let output = fabro_dev()
|
let output = fabro_dev()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue