diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062b23f..798aa58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,19 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + # The `v8` crate's build script downloads a large prebuilt static lib from + # GitHub releases into ~/.cargo/.rusty_v8 (NOT covered by the cargo cache + # above). That download intermittently 504s and fails the job. Cache it + # separately with a prefix restore-key so the binary survives Cargo.lock + # changes (the v8 version rarely moves) and is fetched at most once. + - name: Cache rusty_v8 prebuilt + uses: actions/cache@v4 + with: + path: ~/.cargo/.rusty_v8/ + key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rusty_v8- + - name: Run cargo check run: cargo check --workspace @@ -63,6 +76,15 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + # See the check job: cache the v8 prebuilt to dodge GitHub-releases 504s. + - name: Cache rusty_v8 prebuilt + uses: actions/cache@v4 + with: + path: ~/.cargo/.rusty_v8/ + key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rusty_v8- + - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | @@ -113,5 +135,14 @@ jobs: target/ key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + # See the check job: cache the v8 prebuilt to dodge GitHub-releases 504s. + - name: Cache rusty_v8 prebuilt + uses: actions/cache@v4 + with: + path: ~/.cargo/.rusty_v8/ + key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rusty_v8- + - name: Run clippy run: cargo clippy --workspace -- -D warnings diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 8d97f77..89f073b 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -85,6 +85,11 @@ jobs: with: shared-key: xplat cache-all-crates: "true" + # The `v8` crate's build script downloads a large prebuilt static lib + # from GitHub releases into ~/.cargo/.rusty_v8; that download + # intermittently 504s. rust-cache doesn't cover that dir by default, + # so add it explicitly — fetched at most once, then reused per OS. + cache-directories: ~/.cargo/.rusty_v8 # Debug profile keeps debug_assertions on, which compiles the # `#[cfg(any(debug_assertions, feature = "devtools"))]` DevTools paths. diff --git a/Cargo.lock b/Cargo.lock index fa7fc3a..11a049d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -962,6 +1012,46 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1015,6 +1105,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -3050,6 +3146,7 @@ version = "0.1.0-alpha.1" dependencies = [ "anyhow", "chrono", + "clap", "dirs 5.0.1", "forge-etch", "forge-smelt", @@ -4293,6 +4390,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -5572,6 +5675,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.3" @@ -8703,6 +8812,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" diff --git a/crates/forge-docs-check/src/checks/cli_commands.rs b/crates/forge-docs-check/src/checks/cli_commands.rs index b601969..e96d155 100644 --- a/crates/forge-docs-check/src/checks/cli_commands.rs +++ b/crates/forge-docs-check/src/checks/cli_commands.rs @@ -1,14 +1,19 @@ //! Rule `cli-command`: every `forge` subcommand is documented in `crates/forge.md`. //! //! Caught the missing `forge smelt` entry. The command list is sourced from the -//! CLI's own usage banner in `forge_cli/src/main.rs` (a single in-repo source), -//! so adding a subcommand there surfaces the requirement automatically. After the -//! Phase 5 clap migration this source switches to the clap command model. +//! CLI's own clap command model in `forge_cli/src/main.rs` (a single in-repo +//! source of truth), so adding a subcommand there surfaces the documentation +//! requirement automatically. +//! +//! Before the Phase 5 clap migration this was sourced from a hand-written +//! `forge ` usage banner; clap derive replaced that banner with the +//! `#[derive(Subcommand)] enum Commands { .. }` block, so this rule now parses +//! the enum variants and applies clap's default PascalCase -> kebab-case rename +//! to recover the exact subcommand names the CLI exposes. use crate::checks::read_optional; use crate::discovery::Workspace; use crate::Finding; -use regex::Regex; pub fn check(ws: &Workspace) -> Vec { let main_rs = ws.root.join("crates/forge_cli/src/main.rs"); @@ -30,7 +35,7 @@ pub fn check(ws: &Workspace) -> Vec { _ => { return vec![Finding::new( "cli-command", - "could not find the `forge ` usage banner in forge_cli/src/main.rs to source the command list", + "could not find the `#[derive(Subcommand)] enum Commands` block in forge_cli/src/main.rs to source the command list", )] } }; @@ -61,17 +66,110 @@ pub fn check(ws: &Workspace) -> Vec { findings } -/// Extract the pipe-separated command list from the first `forge ` usage -/// banner found in the CLI source. +/// Extract the top-level variant names from the clap `enum Commands { .. }` +/// block and convert each to the kebab-case name clap exposes on the CLI. fn parse_subcommands(src: &str) -> Option> { - let re = Regex::new(r"forge <([a-z][a-z|]+)>").expect("valid usage regex"); - let caps = re.captures(src)?; - let list = caps - .get(1)? - .as_str() - .split('|') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect::>(); - Some(list) + let body = enum_body(src, "Commands")?; + + let mut variants = Vec::new(); + let mut depth = 0i32; + for line in body.lines() { + let trimmed = line.trim(); + // A variant name is a PascalCase identifier sitting at the enum's top + // level (depth 0). Struct-variant fields live at depth >= 1, and + // attributes/doc-comments (`#[..]`, `///`) don't start with an + // uppercase identifier, so both are skipped naturally. + if depth == 0 { + if let Some(name) = variant_name(trimmed) { + variants.push(to_kebab(&name)); + } + } + depth += line.matches('{').count() as i32; + depth -= line.matches('}').count() as i32; + if depth < 0 { + depth = 0; + } + } + + if variants.is_empty() { + None + } else { + Some(variants) + } +} + +/// Return the brace-delimited body of `enum `, matching braces so nested +/// struct-variant `{ .. }` blocks don't terminate the scan early. +fn enum_body<'a>(src: &'a str, name: &str) -> Option<&'a str> { + let needle = format!("enum {name}"); + let start = src.find(&needle)?; + let open = src[start..].find('{')? + start; + + let bytes = src.as_bytes(); + let mut depth = 0i32; + let mut body_start = open + 1; + let mut i = open; + while i < bytes.len() { + match bytes[i] { + b'{' => { + depth += 1; + if depth == 1 { + body_start = i + 1; + } + } + b'}' => { + depth -= 1; + if depth == 0 { + return Some(&src[body_start..i]); + } + } + _ => {} + } + i += 1; + } + None +} + +/// If `line` begins a variant declaration, return its identifier. Accepts the +/// PascalCase name when it is followed by variant syntax — `{` (struct +/// variant), `(` (tuple variant), `,`, or end of line (unit variant). +fn variant_name(line: &str) -> Option { + let first = line.chars().next()?; + if !first.is_ascii_uppercase() { + return None; + } + let end = line + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_')) + .map(|(idx, _)| idx) + .unwrap_or(line.len()); + let ident = &line[..end]; + let rest = line[end..].trim_start(); + if rest.is_empty() || rest.starts_with('{') || rest.starts_with('(') || rest.starts_with(',') { + Some(ident.to_string()) + } else { + None + } +} + +/// Replicate clap's default subcommand rename (PascalCase -> kebab-case). +/// +/// This is a simple per-uppercase-boundary split, which matches clap's +/// `heck`-based kebab-casing for single-word variants (every current forge +/// subcommand). It diverges only on multi-word acronyms — `HTTPServer` here +/// becomes `h-t-t-p-server` where clap/heck emits `http-server`. No forge +/// subcommand triggers that, so the simpler form is kept intentionally. +fn to_kebab(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_ascii_uppercase() { + if i != 0 { + out.push('-'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out } diff --git a/crates/forge-docs-check/tests/rules.rs b/crates/forge-docs-check/tests/rules.rs index add70a1..a0124ec 100644 --- a/crates/forge-docs-check/tests/rules.rs +++ b/crates/forge-docs-check/tests/rules.rs @@ -250,9 +250,29 @@ fn counts_marker_is_authoritative() { fn cli_command_flags_missing_subcommand() { let mut fx = Fixture::new(); fx.add_crate("forge_cli"); + // The clap command model is the source of truth (post-P5.2 migration). + // Includes doc comments, attributes, and a struct variant with nested + // braces to exercise the brace-matching parser. fx.write( "crates/forge_cli/src/main.rs", - "fn usage() { eprintln!(\"forge [options]\"); }\n", + r#"#[derive(Subcommand)] +enum Commands { + /// Run an app + Dev { + app_dir: PathBuf, + }, + /// Build assets + Build { + app_dir: PathBuf, + }, + /// AOT compile + Smelt { + app_dir: PathBuf, + #[arg(long, short)] + out: Option, + }, +} +"#, ); // forge.md documents dev and build but not smelt. fx.write( diff --git a/crates/forge_cli/Cargo.toml b/crates/forge_cli/Cargo.toml index 6bc9c5c..ccbf3e8 100644 --- a/crates/forge_cli/Cargo.toml +++ b/crates/forge_cli/Cargo.toml @@ -14,6 +14,7 @@ rustdoc-args = ["--document-private-items"] [dependencies] anyhow = "1" +clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" diff --git a/crates/forge_cli/src/main.rs b/crates/forge_cli/src/main.rs index 90bdabe..89a8b73 100644 --- a/crates/forge_cli/src/main.rs +++ b/crates/forge_cli/src/main.rs @@ -95,6 +95,7 @@ //! - Main module - Command dispatch and build orchestration use anyhow::{anyhow, bail, Context, Result}; +use clap::{Parser, Subcommand}; use std::{ env, fs, path::{Path, PathBuf}, @@ -104,41 +105,102 @@ use std::{ mod bundler; mod docs; -fn usage() { - eprintln!("forge [options] "); - eprintln!(); - eprintln!("Commands:"); - eprintln!(" dev Run in development mode"); - eprintln!(" build Build for production"); - eprintln!(" bundle Package into distributable"); - eprintln!(" smelt [--out ] Compile app TypeScript -> JavaScript"); - eprintln!(" sign Sign a package artifact"); - eprintln!(" icon Manage app icons"); - eprintln!(" docs [options] [target] Generate API documentation"); - eprintln!(); - eprintln!("Icon subcommands:"); - eprintln!(" icon create Create a placeholder icon"); - eprintln!(" icon validate Validate app icon requirements"); - eprintln!(); - eprintln!("Docs options:"); - eprintln!(" --all-extensions Generate docs for all extensions"); - eprintln!(" --extension, -e Generate docs for specific extension"); - eprintln!(" --output, -o Output directory (default: docs)"); - eprintln!(" --format, -f Output format (default: astro)"); - eprintln!(); - eprintln!("Getting Started:"); - eprintln!(" Copy an example from the examples/ folder to start a new app:"); - eprintln!(" - examples/example-deno-app Minimal TypeScript app"); - eprintln!(" - examples/react-app React with TypeScript"); - eprintln!(" - examples/nextjs-app Next.js-style patterns"); - eprintln!(" - examples/svelte-app Svelte with TypeScript"); - eprintln!(" - examples/todo-app Todo app with persistence"); - eprintln!(" - examples/text-editor File operations example"); - eprintln!(); - eprintln!("Bundle output formats:"); - eprintln!(" Windows: .msix package"); - eprintln!(" macOS: .app bundle + .dmg disk image"); - eprintln!(" Linux: .AppImage or .tar.gz"); +/// Extra help shown under the generated command list (kept from the original +/// hand-written usage banner so `forge --help` still points at the examples). +const AFTER_HELP: &str = "\ +Getting Started: + Copy an example from the examples/ folder to start a new app: + - examples/example-deno-app Minimal TypeScript app + - examples/react-app React with TypeScript + - examples/nextjs-app Next.js-style patterns + - examples/svelte-app Svelte with TypeScript + - examples/todo-app Todo app with persistence + - examples/text-editor File operations example + +Bundle output formats: + Windows: .msix package + macOS: .app bundle + .dmg disk image + Linux: .AppImage or .tar.gz"; + +/// Forge — build cross-platform desktop apps with TypeScript and Deno. +#[derive(Parser)] +#[command( + name = "forge", + version, + about = "Forge — build cross-platform desktop apps with TypeScript and Deno", + after_help = AFTER_HELP, + subcommand_required = true, + arg_required_else_help = true +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run an app in development mode (hot reload, full debugging) + Dev { + /// App directory (contains manifest.app.toml) + app_dir: PathBuf, + }, + /// Build an app's web assets for production + Build { + /// App directory + app_dir: PathBuf, + }, + /// Package an app into a platform distributable (.app/.dmg, .msix, AppImage) + Bundle { + /// App directory + app_dir: PathBuf, + }, + /// Ahead-of-time compile an app's TypeScript to JavaScript + Smelt { + /// App directory + app_dir: PathBuf, + /// Output directory for the compiled JavaScript tree + #[arg(long, short)] + out: Option, + /// Also write the standalone-binary bootstrap shim + #[arg(long)] + embed: bool, + }, + /// Code-sign a bundled artifact for distribution + Sign { + /// Signing identity (e.g. "Developer ID Application: Name (TEAM)") + #[arg(long, short)] + identity: Option, + /// The bundled artifact to sign + artifact: PathBuf, + }, + /// Manage app icons + Icon { + #[command(subcommand)] + command: IconCommand, + }, + /// Generate API documentation from extension TypeScript/Rust source + #[command(disable_help_flag = true)] + Docs { + /// Options/target forwarded to the docs generator: --all-extensions, + /// --extension , --output , --format + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +#[derive(Subcommand)] +enum IconCommand { + /// Create the default Forge-branded icon at + Create { + /// Output path for the icon (PNG) + path: PathBuf, + }, + /// Validate an app's icon meets platform requirements + Validate { + /// App directory (defaults to the current directory) + #[arg(default_value = ".")] + app_dir: PathBuf, + }, } /// Find the forge-runtime binary in standard locations @@ -1077,23 +1139,6 @@ fn cmd_sign(artifact_path: &Path, identity: Option<&str>) -> Result<()> { Ok(()) } -fn icon_usage() { - eprintln!("forge icon [options]"); - eprintln!(); - eprintln!("Subcommands:"); - eprintln!(" create Create a placeholder icon at the specified path"); - eprintln!(" validate Validate icon for the specified app directory"); - eprintln!(); - eprintln!("Icon Requirements:"); - eprintln!(" • Format: PNG with transparency (RGBA)"); - eprintln!(" • Size: 1024x1024 pixels (minimum 512x512)"); - eprintln!(" • Shape: Square (1:1 aspect ratio)"); - eprintln!(); - eprintln!("Examples:"); - eprintln!(" forge icon create my-app/assets/icon.png"); - eprintln!(" forge icon validate my-app"); -} - fn cmd_icon_create(output_path: &Path) -> Result<()> { use bundler::{IconProcessor, RECOMMENDED_ICON_SIZE}; @@ -1254,141 +1299,30 @@ fn cmd_icon_validate(app_dir: &Path) -> Result<()> { } } -fn cmd_icon(args: &[String]) -> Result<()> { - if args.is_empty() { - icon_usage(); - return Ok(()); - } - - let subcommand = &args[0]; - let rest = &args[1..]; - - match subcommand.as_str() { - "create" => { - if rest.is_empty() { - return Err(anyhow!("Usage: forge icon create \n\nExample: forge icon create my-app/assets/icon.png")); - } - let output_path = PathBuf::from(&rest[0]); - cmd_icon_create(&output_path) - } - "validate" => { - let app_dir = if rest.is_empty() { - PathBuf::from(".") - } else { - PathBuf::from(&rest[0]) - }; - cmd_icon_validate(&app_dir) - } - _ => { - icon_usage(); - Err(anyhow!("Unknown icon subcommand: {}", subcommand)) - } - } -} - fn main() -> Result<()> { - let mut args: Vec = env::args().skip(1).collect(); - - if args.is_empty() { - usage(); - return Ok(()); - } - - let cmd = args.remove(0); - - match cmd.as_str() { - "dev" => { - let app_dir = args - .first() - .map(PathBuf::from) - .ok_or_else(|| anyhow!("Usage: forge dev "))?; - cmd_dev(&app_dir)?; - } - "build" => { - let app_dir = args - .first() - .map(PathBuf::from) - .ok_or_else(|| anyhow!("Usage: forge build "))?; - cmd_build(&app_dir)?; - } - "bundle" => { - let app_dir = args - .first() - .map(PathBuf::from) - .ok_or_else(|| anyhow!("Usage: forge bundle "))?; - cmd_bundle(&app_dir)?; - } - "smelt" => { - // forge smelt [--out ] [--embed] - let mut app_dir: Option = None; - let mut out_dir: Option = None; - let mut embed = false; - - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "--out" | "-o" => { - let value = args - .get(i + 1) - .ok_or_else(|| anyhow!("--out requires a value"))?; - out_dir = Some(PathBuf::from(value)); - i += 2; - } - "--embed" => { - embed = true; - i += 1; - } - flag if flag.starts_with('-') => { - return Err(anyhow!("Unknown flag: {}", flag)); - } - value => { - app_dir = Some(PathBuf::from(value)); - i += 1; - } - } - } - - let app_dir = app_dir - .ok_or_else(|| anyhow!("Usage: forge smelt [--out ] [--embed]"))?; - cmd_smelt(&app_dir, out_dir.as_deref(), embed)?; - } - "sign" => { - // Parse --identity flag - let mut identity: Option = None; - let mut artifact_path = None; - - let mut i = 0; - while i < args.len() { - if args[i] == "--identity" || args[i] == "-i" { - if i + 1 < args.len() { - identity = Some(args[i + 1].clone()); - i += 2; - } else { - return Err(anyhow!("--identity requires a value")); - } - } else if !args[i].starts_with('-') { - artifact_path = Some(PathBuf::from(&args[i])); - i += 1; - } else { - return Err(anyhow!("Unknown flag: {}", args[i])); - } - } - - let artifact_path = artifact_path - .ok_or_else(|| anyhow!("Usage: forge sign [--identity ] "))?; - cmd_sign(&artifact_path, identity.as_deref())?; - } - "icon" => { - cmd_icon(&args)?; - } - "docs" => { + match Cli::parse().command { + Commands::Dev { app_dir } => cmd_dev(&app_dir), + Commands::Build { app_dir } => cmd_build(&app_dir), + Commands::Bundle { app_dir } => cmd_bundle(&app_dir), + Commands::Smelt { + app_dir, + out, + embed, + } => cmd_smelt(&app_dir, out.as_deref(), embed), + Commands::Sign { identity, artifact } => cmd_sign(&artifact, identity.as_deref()), + Commands::Icon { command } => match command { + IconCommand::Create { path } => cmd_icon_create(&path), + IconCommand::Validate { app_dir } => cmd_icon_validate(&app_dir), + }, + // `docs` keeps its own argument parser (docs::run); clap forwards the raw + // tail to it. `forge docs` with no args or `--help` prints the docs usage. + Commands::Docs { args } => { if args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") { docs::usage(); + Ok(()) } else { - docs::run(&args)?; + docs::run(&args) } } - _ => usage(), } - Ok(()) } diff --git a/crates/forge_cli/tests/cli_characterization.rs b/crates/forge_cli/tests/cli_characterization.rs index 378e81d..9417677 100644 --- a/crates/forge_cli/tests/cli_characterization.rs +++ b/crates/forge_cli/tests/cli_characterization.rs @@ -1,18 +1,22 @@ -//! Characterization snapshot of the `forge` CLI's argument-parsing contract. +//! Characterization of the `forge` CLI's argument-parsing contract. //! -//! This is the safety net for the planned clap-derive migration (Phase 5 of -//! `docs/plans/2026-06-06-19-docs-autogeneration-pipeline.md`): it records the -//! *current* hand-parser's observable behavior — exit status, usage banner, and -//! error messages for the parse paths that don't require a real app — so the -//! migration can be verified byte-compatible. These tests touch no production -//! code; they only run the built binary and assert its output. +//! Originally (P5.1) these snapshots captured the hand-rolled `env::args()` +//! parser. P5.2 migrated the CLI to **clap derive**, which deliberately changes +//! the *edge-case* behavior to clap's conventions (better help + errors): //! -//! Behaviors are chosen to be deterministic (no real app dir, no environment -//! dependence): no-args usage, unknown subcommand, and missing/invalid flags. +//! | case | old hand-parser | clap (now) | +//! |------|-----------------|------------| +//! | no args | usage to stderr, **exit 0** | help to stderr, **exit != 0** | +//! | unknown subcommand | usage, **exit 0** | error, **exit != 0** | +//! | unknown flag | `Unknown flag: X` | `unexpected argument '--X'` | +//! | missing flag value | `--identity requires a value` | `a value is required for ...` | +//! +//! The **real contract** — the set of subcommands, their flags, the `-o`/`-i` +//! short aliases, and the positionals — is preserved exactly, and is asserted +//! below so a future change can't silently drop a command or flag. use std::process::{Command, Output}; -/// Run the `forge` binary with `args` and capture its output. fn forge(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_forge")) .args(args) @@ -20,63 +24,122 @@ fn forge(args: &[&str]) -> Output { .expect("run the forge binary") } +fn combined(out: &Output) -> String { + // clap renders its `Usage:` line from the real binary file name, which is + // `forge.exe` on Windows and `forge` elsewhere. Normalize it so assertions + // like `contains("forge dev")` hold cross-platform (otherwise the infix + // `.exe` splits `forge` from the subcommand and the match fails on Windows). + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + .replace("forge.exe", "forge") +} + +// --- Edge-case behavior (clap conventions) --------------------------------- + #[test] -fn no_args_prints_usage_to_stderr_and_succeeds() { +fn no_args_shows_help_and_exits_nonzero() { + // clap `arg_required_else_help`: print help, exit non-zero. let out = forge(&[]); - assert!(out.status.success(), "no-args invocation must exit 0"); - assert!( - out.stdout.is_empty(), - "the usage banner is written to stderr, not stdout" - ); - let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "no-args now exits non-zero (clap)"); assert!( - stderr.contains("forge [options] "), - "missing the usage banner: {stderr}" + combined(&out).contains("Usage: forge"), + "no-args must show usage: {}", + combined(&out) ); } #[test] -fn usage_documents_every_subcommand() { - let out = forge(&[]); - let stderr = String::from_utf8_lossy(&out.stderr); +fn help_documents_every_subcommand() { + let out = forge(&["--help"]); + assert!(out.status.success(), "--help exits 0"); + let text = combined(&out); for cmd in ["dev", "build", "bundle", "smelt", "sign", "icon", "docs"] { assert!( - stderr.contains(&format!(" {cmd} ")), - "usage banner must document `{cmd}`: {stderr}" + text.contains(&format!(" {cmd}")), + "help must list `{cmd}`: {text}" ); } + // The hand-written extras were preserved via clap `after_help`. + assert!(text.contains("Getting Started:")); + assert!(text.contains("Bundle output formats:")); } #[test] -fn unknown_subcommand_falls_through_to_usage() { - // Current contract: an unrecognized subcommand prints usage and exits 0 - // (the `_ => usage()` arm). The migration must preserve this, not error. +fn unknown_subcommand_is_an_error() { let out = forge(&["definitely-not-a-subcommand"]); assert!( - out.status.success(), - "unknown subcommand currently exits 0 (prints usage)" + !out.status.success(), + "unknown subcommand now errors (clap)" ); - assert!(String::from_utf8_lossy(&out.stderr).contains("forge