From 7b400bb08c9701a8f9f0be046a356b4b26175534 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:17:05 +0800 Subject: [PATCH 1/3] feat(cli): add structured presentation and completion Fixes #164 --- Cargo.lock | 137 ++++++++ crates/wright-cli/Cargo.toml | 2 + crates/wright-cli/src/cli.rs | 212 ++++++++++++ crates/wright-cli/src/main.rs | 522 +++++++++++------------------ crates/wright-cli/src/present.rs | 551 +++++++++++++++++++++++++------ crates/wright-cli/tests/cli.rs | 216 ++++++++++++ docs/cli.md | 30 ++ 7 files changed, 1245 insertions(+), 425 deletions(-) create mode 100644 crates/wright-cli/src/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 5f0b185..b17c3ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +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 = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "base64" version = "0.22.1" @@ -60,6 +110,61 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -184,6 +289,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "icu_collections" version = "2.1.1" @@ -286,6 +397,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -351,6 +468,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "os_pipe" version = "1.2.3" @@ -574,6 +697,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -686,6 +815,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 = "version_check" version = "0.9.5" @@ -842,6 +977,8 @@ dependencies = [ name = "wright-cli" version = "0.2.4" dependencies = [ + "clap", + "clap_complete", "flate2", "serde", "serde_json", diff --git a/crates/wright-cli/Cargo.toml b/crates/wright-cli/Cargo.toml index 2984c91..b346ce6 100644 --- a/crates/wright-cli/Cargo.toml +++ b/crates/wright-cli/Cargo.toml @@ -14,6 +14,8 @@ name = "wright" path = "src/main.rs" [dependencies] +clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" flate2 = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs new file mode 100644 index 0000000..1cdded5 --- /dev/null +++ b/crates/wright-cli/src/cli.rs @@ -0,0 +1,212 @@ +//! The authoritative structured command model for `wright`. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +/// The top-level command model used by parsing, help, and completion. +#[derive(Debug, Parser)] +#[command( + name = "wright", + disable_version_flag = true, + disable_help_subcommand = true, + subcommand_precedence_over_arg = true, + about = "Wright compiler and Workshop tooling CLI", + long_about = LONG_ABOUT +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Option, + /// Print the implementation and driver versions. + #[arg(long, action = clap::ArgAction::SetTrue)] + pub(crate) version: bool, +} + +pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI. + +Commands parse, validate, analyze, lint, inspect, compile, or reconstruct +source through the typed wright-driver result envelope. `compile` and `convert` +keep their source artifact stdout contracts; JSON mode prints only one +wright-result/v1 envelope to stdout. + +EXIT CODES: + 0 success + 1 source/user error + 2 usage error + 3 recognized but unsupported input or operation + 4 internal/environment failure + +WORKFLOW OPTIONS: + --kind Input frontend: auto|opy|ostw|workshop|protocol + --target Reconstruction target for convert: opy|ostw + --locale Workshop client locale override + --root Include/project root for source inputs + --profile WIR transformation policy: off|compat|aggressive + -o, --output Write compiled output to PATH (compile only) + -f, --format Output format: text|json + --renderer Presentation: auto|terminal|plain|github-actions + --color ANSI color: auto|always|never + +LINT OPTIONS: + --disable-rule Disable a lint rule (repeatable) + --rule-severity : Override a lint rule severity (repeatable) + +UPDATE OPTIONS: + --check Check for an update without modifying the installation + --version Install an exact version instead of the latest stable release"; + +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Parse, lower, validate, and emit Workshop text. + Compile(CompileArgs), + /// Reconstruct validated Workshop input as canonical OPY or OSTW source. + Convert(ConvertArgs), + /// Parse, validate, and analyze the input. + Check(CommonArgs), + /// Parse, lower, and report semantic findings. + Analyze(CommonArgs), + /// Parse, lower, and report lint findings. + Lint(LintArgs), + /// Parse, lower, and show the structural/semantic program model. + Inspect(CommonArgs), + /// Generate static shell completion from the command model. + Completion(CompletionArgs), + /// Update a standalone installation. + Update(UpdateArgs), + /// Show the top-level help. + Help, + /// Show version and result-contract metadata. + Version, +} + +#[derive(Debug, Args)] +pub(crate) struct CompileArgs { + #[command(flatten)] + pub(crate) common: CommonArgs, + /// Write compiled output to PATH instead of stdout. + #[arg(short = 'o', long, value_name = "PATH")] + pub(crate) output: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct ConvertArgs { + #[command(flatten)] + pub(crate) common: CommonArgs, + /// Reconstruction target. + #[arg(long, value_name = "TARGET")] + pub(crate) target: ConvertTargetArg, +} + +#[derive(Debug, Args)] +pub(crate) struct CommonArgs { + /// Input path, or `-`/omitted for standard input. + #[arg(value_name = "INPUT")] + pub(crate) input: Option, + /// Input frontend. + #[arg(long, value_enum, default_value_t = SourceKindArg::Auto)] + pub(crate) kind: SourceKindArg, + /// Workshop client locale override. + #[arg(long, value_name = "LOCALE")] + pub(crate) locale: Option, + /// Include/project root for source inputs. + #[arg(long, value_name = "DIR")] + pub(crate) root: Option, + /// WIR transformation policy. + #[arg(long, value_enum, default_value_t = ProfileArg::Off)] + pub(crate) profile: ProfileArg, + /// Output format. + #[arg(short = 'f', long, value_enum, default_value_t = OutputFormatArg::Text)] + pub(crate) format: OutputFormatArg, + /// Renderer environment; `auto` detects terminal, CI, and GitHub Actions. + #[arg(long, value_enum, default_value_t = RendererArg::Auto)] + pub(crate) renderer: RendererArg, + /// ANSI color policy. + #[arg(long, value_enum, default_value_t = ColorArg::Auto)] + pub(crate) color: ColorArg, +} + +#[derive(Debug, Args)] +pub(crate) struct LintArgs { + #[command(flatten)] + pub(crate) common: CommonArgs, + /// Disable a lint rule (repeatable). + #[arg(long = "disable-rule", value_name = "ID")] + pub(crate) disable_rule: Vec, + /// Override a lint rule severity as ID:warning or ID:info (repeatable). + #[arg(long = "rule-severity", value_name = "ID:SEVERITY")] + pub(crate) rule_severity: Vec, +} + +#[derive(Debug, Args)] +pub(crate) struct CompletionArgs { + /// Shell to generate completion for. + #[arg(value_enum, value_name = "SHELL")] + pub(crate) shell: ShellArg, +} + +#[derive(Debug, Args)] +pub(crate) struct UpdateArgs { + /// Check for an update without modifying the installation. + #[arg(long)] + pub(crate) check: bool, + /// Install an exact version instead of the latest stable release. + #[arg(long, value_name = "VERSION")] + pub(crate) version: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum SourceKindArg { + Auto, + Opy, + Ostw, + #[value(alias = "ws")] + Workshop, + #[value(alias = "hir", alias = "json")] + Protocol, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum OutputFormatArg { + #[value(alias = "human")] + Text, + #[value(alias = "machine")] + Json, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ProfileArg { + Off, + Compat, + Aggressive, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ConvertTargetArg { + Opy, + Ostw, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum RendererArg { + Auto, + Terminal, + Plain, + #[value(name = "github-actions", alias = "github")] + GithubActions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ColorArg { + Auto, + Always, + Never, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ShellArg { + Bash, + Zsh, + Fish, + #[value(name = "powershell", alias = "pwsh")] + PowerShell, +} diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index b662ec7..57fe573 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -1,376 +1,236 @@ -//! `wright` — the primary Wright command-line interface (issue #38). -//! -//! A thin presentation/argv layer over [`wright_driver::CompilerSession`]: -//! every subcommand builds a [`SessionConfig`] and renders the driver's typed -//! result envelope. Human-readable text and machine-readable JSON derive from -//! the same envelope, and exit codes follow the documented contract -//! (`0` success, `1` source error, `2` usage, `3` unsupported, `4` internal). +//! `wright` — the primary Wright command-line interface. +mod cli; mod present; mod update; -use std::path::PathBuf; use std::process::ExitCode; +use clap::{CommandFactory, Parser}; use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind}; use wright_driver::result::exit; +use crate::cli::{Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg}; + /// The CLI name and version banner. pub const CLI_NAME: &str = "wright"; pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// The command-line help text (also the `--help` output). -pub const HELP: &str = "\ -wright — the Wright compiler command-line interface - -USAGE: - wright [OPTIONS] [INPUT] - -COMMANDS: - compile Parse, lower, and emit Workshop text for the input - convert Reconstruct validated Workshop input as canonical OPY or OSTW source - check Parse, validate, and analyze the input; report diagnostics - analyze Parse, lower, and run semantic analysis; report findings - lint Parse, lower, and lint the input; report findings - inspect Parse, lower, and show the structural/semantic program model - update Update a standalone installation to the latest stable release - help Show this help - version Show version and result-contract metadata - -INPUT: - A file path, or `-`/omitted to read from standard input. - Input kind is detected from the extension (.opy, .ostw/.del, .json, - .txt/.ws) or stdin content; pass --kind to override. - -OPTIONS: - --kind Input frontend: auto|opy|ostw|workshop|protocol - --target Reconstruction target for `convert`: opy|ostw (required) - --locale Workshop client locale override (e.g. en-US) - --root Include/project root for .opy/.ostw input (default: input directory) - --profile WIR transformation policy: off|compat|aggressive (default: off) - -o, --output Write compiled output to PATH (compile only) - -f, --format Output format: text|json (default: text) - -h, --help Show this help - -LINT OPTIONS: - --disable-rule Disable a lint rule by stable ID (lint only; repeatable) - --rule-severity : Override a rule's severity: warning|info (lint only; repeatable) - -UPDATE OPTIONS: - --check Check for an update without modifying the installation - --version Install an exact version instead of the latest stable - -EXIT CODES: - 0 success - 1 source/user error (parse, validation, ambiguous input) - 2 usage error (unknown command, flag, or value) - 3 recognized but unsupported input or operation (convert rejection) - 4 internal/environment failure - -OUTPUT CONTRACT: - Text mode writes the command result to stdout and diagnostics to stderr. - JSON mode writes one `wright-result/v1` envelope to stdout and keeps - stderr empty on success. Exit codes and the envelope shape are stable; - human-readable wording is not part of the machine contract. `convert` - writes the reconstructed source for the selected `--target` (canonical - OPY or OSTW; semantic reconstruction, not original-source recovery) and - reports the reconstructed text plus its SHA-256 in the `result` envelope - in JSON mode. `lint` reports findings through the same envelope with rule - metadata and the effective configuration (#97/#98). `update` is - text-only and outside the `wright-result/v1` envelope: it updates - standalone `wright`/`wright-lsp` installs from the canonical GitHub - Release archives and refuses to overwrite package-manager-managed - binaries. -"; +fn version_banner() -> String { + format!( + "{CLI_NAME} {CLI_VERSION} (wright-driver {})", + wright_driver::result::DRIVER_VERSION + ) +} fn main() -> ExitCode { - match run() { - Ok(code) => ExitCode::from(code), - Err(usage) => { - eprintln!("{usage}"); - ExitCode::from(exit::USAGE) - } + let args: Vec<_> = std::env::args_os().collect(); + if args.len() == 2 && args[1] == "--version" { + println!("{}", version_banner()); + return ExitCode::SUCCESS; } -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Command { - Compile, - Convert, - Check, - Analyze, - Lint, - Inspect, - Update, -} - -impl Command { - fn as_str(self) -> &'static str { - match self { - Command::Compile => "compile", - Command::Convert => "convert", - Command::Check => "check", - Command::Analyze => "analyze", - Command::Lint => "lint", - Command::Inspect => "inspect", - Command::Update => "update", + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) => { + let code = error.exit_code(); + let _ = error.print(); + return ExitCode::from(code as u8); } - } - - fn parse(name: &str) -> Option { - Some(match name { - "compile" => Command::Compile, - "convert" => Command::Convert, - "check" => Command::Check, - "analyze" => Command::Analyze, - "lint" => Command::Lint, - "inspect" => Command::Inspect, - "update" => Command::Update, - _ => return None, - }) - } -} - -fn run() -> Result { - let mut args: Vec = std::env::args().skip(1).collect(); + }; - // Global help/version without a subcommand. - if args.is_empty() || args[0] == "help" { - print!("{HELP}"); - return Ok(0); - } - if args[0] == "--help" || args[0] == "-h" { - print!("{HELP}"); - return Ok(0); + if cli.version { + println!("{}", version_banner()); + return ExitCode::SUCCESS; } - if args[0] == "version" || args[0] == "--version" { - println!( - "{CLI_NAME} {CLI_VERSION} (wright-driver {})", - wright_driver::result::DRIVER_VERSION - ); - return Ok(0); - } - - let command = Command::parse(&args[0]) - .ok_or_else(|| format!("wright: unknown command '{}'\n\n{HELP}", args[0]))?; - args.remove(0); - // `update` is not a compiler workflow: it owns its argv entirely and - // never touches the driver session. - if command == Command::Update { - return run_update(args); + match cli.command { + None | Some(Command::Help) => { + let mut command = Cli::command(); + let _ = command.print_help(); + println!(); + ExitCode::SUCCESS + } + Some(Command::Version) => { + println!("{}", version_banner()); + ExitCode::SUCCESS + } + Some(Command::Completion(args)) => { + let mut command = Cli::command(); + let shell = match args.shell { + cli::ShellArg::Bash => clap_complete::Shell::Bash, + cli::ShellArg::Zsh => clap_complete::Shell::Zsh, + cli::ShellArg::Fish => clap_complete::Shell::Fish, + cli::ShellArg::PowerShell => clap_complete::Shell::PowerShell, + }; + clap_complete::generate(shell, &mut command, CLI_NAME, &mut std::io::stdout()); + ExitCode::SUCCESS + } + Some(Command::Update(args)) => match update::run(args.check, args.version.as_deref()) { + Ok(code) => ExitCode::from(code), + Err(error) => { + eprintln!("wright: {}", error.message()); + ExitCode::from(error.exit_code()) + } + }, + Some(command) => run_workflow(command), } +} - let mut config = SessionConfig::default(); - let mut help = false; - let mut positional: Option = None; - let mut convert_target: Option = None; - - while let Some(arg) = args.first().cloned() { - match arg.as_str() { - "-h" | "--help" => { - help = true; - args.remove(0); - } - "--target" => { - if command != Command::Convert { - return Err(format!( - "wright: --target is only valid for `convert` (not `{}`)", - command.as_str() - )); - } - let value = take_value(&mut args, &arg)?; - convert_target = - Some(wright_driver::ConvertTarget::parse(&value).ok_or_else(|| { - format!("wright: unknown conversion target '{value}' (expected opy|ostw)") - })?); - } - "-f" | "--format" => { - let value = take_value(&mut args, &arg)?; - config.format = OutputFormat::parse(&value).ok_or_else(|| { - format!("wright: unknown format '{value}' (expected text|json)") - })?; - } - "--kind" => { - let value = take_value(&mut args, &arg)?; - config.kind = SourceKind::parse(&value).ok_or_else(|| { - format!( - "wright: unknown input kind '{value}' (expected auto|opy|ostw|workshop|protocol)" - ) - })?; - } - "--locale" => { - let value = take_value(&mut args, &arg)?; - config.locale = Some(value); - } - "--root" => { - let value = take_value(&mut args, &arg)?; - config.root = Some(PathBuf::from(value)); - } - "--profile" => { - let value = take_value(&mut args, &arg)?; - config.profile = wright_transform::Profile::parse(&value).ok_or_else(|| { - format!("wright: unknown profile '{value}' (expected off|compat|aggressive)") - })?; - } - "-o" | "--output" => { - if command != Command::Compile { - return Err(format!( - "wright: --output is only valid for `compile` (not `{}`)", - command.as_str() - )); - } - let value = take_value(&mut args, &arg)?; - config.output = Some(PathBuf::from(value)); - } - "--disable-rule" => { - if command != Command::Lint { - return Err(format!( - "wright: --disable-rule is only valid for `lint` (not `{}`)", - command.as_str() - )); - } - let value = take_value(&mut args, &arg)?; - config.lint.disable(&value); - } - "--rule-severity" => { - if command != Command::Lint { - return Err(format!( - "wright: --rule-severity is only valid for `lint` (not `{}`)", - command.as_str() - )); - } - let value = take_value(&mut args, &arg)?; - let (rule_id, severity) = value.split_once(':').ok_or_else(|| { - format!("wright: --rule-severity expects : (got '{value}')") - })?; +fn run_workflow(command: Command) -> ExitCode { + let (name, config, presentation, convert_target) = match command { + Command::Compile(args) => { + let mut config = config_from_common(&args.common); + config.output = args.output; + ( + "compile", + config, + present::Presentation::from_common(&args.common), + None, + ) + } + Command::Convert(args) => ( + "convert", + config_from_common(&args.common), + present::Presentation::from_common(&args.common), + Some(args.target), + ), + Command::Check(args) => ( + "check", + config_from_common(&args), + present::Presentation::from_common(&args), + None, + ), + Command::Analyze(args) => ( + "analyze", + config_from_common(&args), + present::Presentation::from_common(&args), + None, + ), + Command::Lint(args) => { + let mut config = config_from_common(&args.common); + for rule in &args.disable_rule { + config.lint.disable(rule); + } + for value in &args.rule_severity { + let (rule_id, severity) = match value.split_once(':') { + Some(parts) => parts, + None => { + eprintln!( + "wright: --rule-severity expects : (got '{value}')" + ); + return ExitCode::from(exit::USAGE); + } + }; if !config.lint.set_severity_by_name(rule_id, severity) { - return Err(format!( - "wright: unknown severity '{severity}' (expected warning|info)" - )); + eprintln!("wright: unknown severity '{severity}' (expected warning|info)"); + return ExitCode::from(exit::USAGE); } } - "-" => { - config.input = InputSpec::Stdin; - args.remove(0); - } - other => { - if other.starts_with('-') { - return Err(format!( - "wright: unknown option '{other}' for `{}`\n\n{HELP}", - command.as_str() - )); - } - if positional.is_some() { - return Err(format!( - "wright: unexpected extra argument '{other}' for `{}`\n\n{HELP}", - command.as_str() - )); - } - positional = Some(PathBuf::from(other)); - args.remove(0); - } + ( + "lint", + config, + present::Presentation::from_common(&args.common), + None, + ) + } + Command::Inspect(args) => ( + "inspect", + config_from_common(&args), + present::Presentation::from_common(&args), + None, + ), + Command::Completion(_) | Command::Update(_) | Command::Help | Command::Version => { + unreachable!("non-workflow command handled before run_workflow") } - } - - if help { - print!("{HELP}"); - return Ok(0); - } - - if command == Command::Convert && convert_target.is_none() { - return Err(format!( - "wright: `convert` requires --target opy|ostw\n\n{HELP}" - )); - } - - config.input = match positional { - Some(path) => InputSpec::Path(path), - None => InputSpec::Stdin, }; - let mut session = wright_driver::CompilerSession::new(config) - .map_err(|diagnostic| format!("wright: {}", diagnostic.message))?; + let mut session = match wright_driver::CompilerSession::new(config) { + Ok(session) => session, + Err(diagnostic) => { + eprintln!("wright: {}", diagnostic.message); + return ExitCode::from(exit::USAGE); + } + }; - let code = match command { - Command::Compile => run_command(&mut session, wright_driver::CompilerSession::compile), - Command::Check => run_command(&mut session, wright_driver::CompilerSession::check), - Command::Analyze => run_command(&mut session, wright_driver::CompilerSession::analyze), - Command::Lint => run_command(&mut session, wright_driver::CompilerSession::lint), - Command::Inspect => run_command(&mut session, wright_driver::CompilerSession::inspect), - Command::Convert => { - let target = convert_target.expect("validated above"); + let code = match name { + "compile" => run_command( + &mut session, + wright_driver::CompilerSession::compile, + presentation, + ), + "check" => run_command( + &mut session, + wright_driver::CompilerSession::check, + presentation, + ), + "analyze" => run_command( + &mut session, + wright_driver::CompilerSession::analyze, + presentation, + ), + "lint" => run_command( + &mut session, + wright_driver::CompilerSession::lint, + presentation, + ), + "inspect" => run_command( + &mut session, + wright_driver::CompilerSession::inspect, + presentation, + ), + "convert" => { + let target = match convert_target.expect("convert target is required") { + ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy, + ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw, + }; let envelope = session.convert(target); let code = envelope.exit; - present::render(&envelope, session.config.format); + present::render(&envelope, presentation); code } - // `update` returns before a session is constructed (see above). - Command::Update => unreachable!("update is dispatched before session setup"), + _ => unreachable!("all workflow commands are mapped"), }; - Ok(code) + ExitCode::from(code) } -/// Run one driver workflow and render its envelope in the session's format. +fn config_from_common(common: &CommonArgs) -> SessionConfig { + let input = match &common.input { + Some(path) if path.as_os_str() != "-" => InputSpec::Path(path.clone()), + _ => InputSpec::Stdin, + }; + SessionConfig { + input, + kind: match common.kind { + cli::SourceKindArg::Auto => SourceKind::Auto, + cli::SourceKindArg::Opy => SourceKind::Opy, + cli::SourceKindArg::Ostw => SourceKind::Ostw, + cli::SourceKindArg::Workshop => SourceKind::Workshop, + cli::SourceKindArg::Protocol => SourceKind::Protocol, + }, + locale: common.locale.clone(), + root: common.root.clone(), + format: match common.format { + OutputFormatArg::Text => OutputFormat::Text, + OutputFormatArg::Json => OutputFormat::Json, + }, + profile: match common.profile { + cli::ProfileArg::Off => wright_transform::Profile::Off, + cli::ProfileArg::Compat => wright_transform::Profile::Compat, + cli::ProfileArg::Aggressive => wright_transform::Profile::Aggressive, + }, + ..SessionConfig::default() + } +} + +/// Run one driver workflow and render its envelope in the CLI presentation. fn run_command( session: &mut wright_driver::CompilerSession, run: fn(&mut wright_driver::CompilerSession) -> wright_driver::Envelope, + presentation: present::Presentation, ) -> u8 { let envelope = run(session); let code = envelope.exit; - present::render(&envelope, session.config.format); + present::render(&envelope, presentation); code } - -/// Run `wright update` (#116): parse the update-only flags, delegate to the -/// self-update workflow, and map its typed errors onto the shared exit-code -/// contract (`0` success, `1` user error, `2` usage, `3` unsupported, -/// `4` environment failure). -fn run_update(args: Vec) -> Result { - let mut check_only = false; - let mut requested: Option = None; - let mut help = false; - let mut rest = args; - while let Some(arg) = rest.first().cloned() { - match arg.as_str() { - "-h" | "--help" => { - help = true; - rest.remove(0); - } - "--check" => { - check_only = true; - rest.remove(0); - } - "--version" => { - let value = take_value(&mut rest, &arg)?; - requested = Some(value); - } - other => { - return Err(format!( - "wright: unknown option '{other}' for `update`\n\n{HELP}" - )); - } - } - } - if help { - print!("{HELP}"); - return Ok(0); - } - match update::run(check_only, requested.as_deref()) { - Ok(code) => Ok(code), - Err(error) => { - eprintln!("wright: {}", error.message()); - Ok(error.exit_code()) - } - } -} - -/// Take the value of an option that requires one, failing on a missing value. -fn take_value(args: &mut Vec, option: &str) -> Result { - if args.len() < 2 { - return Err(format!("wright: missing value for {option}")); - } - let value = args.remove(1); - args.remove(0); - Ok(value) -} diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index b55c0e8..ab8cca9 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -1,30 +1,135 @@ -//! Human and machine rendering of driver result envelopes. +//! CLI-only presentation policy and renderers. //! -//! Human-readable text and machine-readable JSON are two presentations of the -//! same typed result model: the CLI never formats results independently of the -//! driver's contract. JSON mode prints one pretty-printed `wright-result/v1` -//! envelope to stdout and keeps stderr empty; text mode writes the command -//! result to stdout and diagnostics to stderr. +//! The driver owns structured envelopes and diagnostics. This module owns how +//! those existing values are presented to a terminal, a pipe, or GitHub +//! Actions. JSON and source artifacts bypass every human/CI renderer. + +use std::io::{IsTerminal, Write}; use wright_driver::Severity; use wright_driver::config::OutputFormat; use wright_driver::result::Envelope; -/// Render one result envelope in the requested format. -pub(crate) fn render(envelope: &Envelope, format: OutputFormat) { - match format { - OutputFormat::Json => { - let value = serde_json::to_value(envelope).expect("envelope serializes"); - let text = serde_json::to_string_pretty(&value).expect("envelope serializes"); - println!("{text}"); +use crate::cli::{ColorArg, CommonArgs, OutputFormatArg, RendererArg}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Presentation { + format: OutputFormat, + renderer: Renderer, + color: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Renderer { + Terminal, + Plain, + GithubActions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RuntimeEnvironment { + github_actions: bool, + ci: bool, + stdout_terminal: bool, + no_color: bool, + force_color: bool, + term_dumb: bool, +} + +impl RuntimeEnvironment { + fn process() -> Self { + Self { + github_actions: env_truthy("GITHUB_ACTIONS"), + ci: env_truthy("CI"), + stdout_terminal: std::io::stdout().is_terminal(), + no_color: std::env::var_os("NO_COLOR").is_some(), + force_color: env_truthy("FORCE_COLOR"), + term_dumb: std::env::var("TERM").is_ok_and(|term| term == "dumb"), + } + } +} + +impl Presentation { + pub(crate) fn from_common(common: &CommonArgs) -> Self { + let format = match common.format { + OutputFormatArg::Text => OutputFormat::Text, + OutputFormatArg::Json => OutputFormat::Json, + }; + Self::resolve( + format, + common.renderer, + common.color, + RuntimeEnvironment::process(), + ) + } + + fn resolve( + format: OutputFormat, + renderer: RendererArg, + color: ColorArg, + environment: RuntimeEnvironment, + ) -> Self { + let renderer = match renderer { + RendererArg::Terminal => Renderer::Terminal, + RendererArg::Plain => Renderer::Plain, + RendererArg::GithubActions => Renderer::GithubActions, + RendererArg::Auto => { + if environment.github_actions { + Renderer::GithubActions + } else if environment.ci || !environment.stdout_terminal { + Renderer::Plain + } else { + Renderer::Terminal + } + } + }; + let color = match color { + ColorArg::Always => renderer != Renderer::GithubActions, + ColorArg::Never => false, + ColorArg::Auto => { + renderer == Renderer::Terminal && !environment.no_color && !environment.term_dumb + || environment.force_color + && renderer == Renderer::Terminal + && !environment.no_color + } + }; + Self { + format, + renderer, + color, } - OutputFormat::Text => render_text(envelope), } } -fn render_text(envelope: &Envelope) { +fn env_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(value) => !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" + ), + Err(_) => false, + } +} + +/// Render one result envelope. JSON is deliberately handled before renderer +/// selection so it can never receive ANSI, progress, or workflow commands. +pub(crate) fn render(envelope: &Envelope, presentation: Presentation) { + if presentation.format == OutputFormat::Json { + let value = serde_json::to_value(envelope).expect("envelope serializes"); + let text = serde_json::to_string_pretty(&value).expect("envelope serializes"); + println!("{text}"); + return; + } + + match presentation.renderer { + Renderer::GithubActions => render_github(envelope), + Renderer::Terminal | Renderer::Plain => render_text(envelope, presentation.color), + } +} + +fn render_text(envelope: &Envelope, color: bool) { for diagnostic in &envelope.diagnostics { - render_diagnostic(diagnostic); + render_diagnostic(diagnostic, color); } if envelope.command == "check" { render_ostw_summary(envelope); @@ -47,76 +152,185 @@ fn render_text(envelope: &Envelope) { } } -/// Print the OSTW project outcome carried by a `check` result (#117): the -/// entry point and the per-file parse status. Rendered even when diagnostics -/// are present, so a partially-failing project still reports what parsed. -fn render_ostw_summary(envelope: &Envelope) { - let Ok(value) = serde_json::to_value(envelope) else { - return; +fn render_github(envelope: &Envelope) { + for diagnostic in &envelope.diagnostics { + emit_diagnostic_annotation(diagnostic); + } + let value = serde_json::to_value(envelope).expect("envelope serializes"); + if let Some(findings) = value + .pointer("/result/findings") + .and_then(serde_json::Value::as_array) + { + for finding in findings { + emit_finding_annotation(finding); + } + } + + eprintln!( + "::group::{}", + escape_workflow_data(&format!("wright {}", envelope.command)) + ); + if envelope.command == "compile" { + render_compile(envelope); + } else if envelope.command == "convert" { + render_convert(envelope); + } else { + let status = summary_status(envelope, &value); + eprintln!("{status} {}", envelope.command); + } + eprintln!("::endgroup::"); + emit_summary(envelope, &value); +} + +fn emit_diagnostic_annotation(diagnostic: &wright_driver::Diagnostic) { + let kind = match diagnostic.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "notice", }; - let Some(ostw) = value.pointer("/result/ostw") else { - return; + let mut properties = vec![format!( + "title={}", + escape_workflow_property(&diagnostic.code) + )]; + if let Some(span) = diagnostic + .span + .as_ref() + .filter(|span| is_real_source_path(&span.path)) + { + properties.insert(0, format!("file={}", escape_workflow_property(&span.path))); + properties.push(format!("line={}", span.start.line)); + properties.push(format!("col={}", span.start.col)); + properties.push(format!("endLine={}", span.end.line)); + properties.push(format!("endColumn={}", span.end.col)); + } + eprintln!( + "::{kind} {}::{}", + properties.join(","), + escape_workflow_data(&diagnostic.message) + ); +} + +fn emit_finding_annotation(finding: &serde_json::Value) { + let severity = match finding.get("severity").and_then(serde_json::Value::as_str) { + Some("error") => Severity::Error, + Some("warning") => Severity::Warning, + _ => Severity::Info, }; - let entry = ostw - .get("entry") + let span = finding.get("span").filter(|span| span.is_object()); + let Some(span) = span else { return }; + let path = span + .get("path") + .and_then(serde_json::Value::as_str) + .filter(|path| is_real_source_path(path)); + let Some(path) = path else { return }; + let line = span + .pointer("/start/line") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let col = span + .pointer("/start/col") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let end_line = span + .pointer("/end/line") + .and_then(serde_json::Value::as_u64) + .unwrap_or(line); + let end_col = span + .pointer("/end/col") + .and_then(serde_json::Value::as_u64) + .unwrap_or(col); + let code = finding + .get("code") + .and_then(serde_json::Value::as_str) + .unwrap_or("finding"); + let message = finding + .get("message") .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let files = ostw - .get("files") - .and_then(serde_json::Value::as_array) - .cloned() .unwrap_or_default(); - let sources: Vec<_> = files - .iter() - .filter(|file| { - file.get("source") - .and_then(|s| s.as_bool()) - .unwrap_or(false) - }) - .collect(); - let parsed = sources + let kind = match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "notice", + }; + eprintln!( + "::{kind} file={},line={line},col={col},endLine={end_line},endColumn={end_col},title={}::{}", + escape_workflow_property(path), + escape_workflow_property(code), + escape_workflow_data(message) + ); +} + +fn summary_status( + envelope: &Envelope, + value: &serde_json::Value, +) -> &'static str { + if !envelope.ok + || envelope + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error) + { + "ERROR" + } else if envelope + .diagnostics .iter() - .filter(|file| { - file.get("parsed") - .and_then(|p| p.as_bool()) - .unwrap_or(false) - }) - .count(); - let inventory = ostw - .get("inventory") - .and_then(serde_json::Value::as_array) - .map(|list| list.len()) - .unwrap_or(0); - println!( - "ostw project: entry {entry}, {parsed}/{} import-reachable sources parsed (inventory {inventory})", - sources.len() + .any(|diagnostic| diagnostic.severity == Severity::Warning) + || value + .pointer("/result/findings") + .and_then(serde_json::Value::as_array) + .is_some_and(|findings| !findings.is_empty()) + { + "WARN" + } else { + "PASS" + } +} + +fn emit_summary(envelope: &Envelope, value: &serde_json::Value) { + let status = summary_status(envelope, value); + let line = format!( + "Wright `{}`: **{status}** (exit {})", + envelope.command, envelope.exit ); - for file in &files { - let path = file - .get("path") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let source = file - .get("source") - .and_then(|s| s.as_bool()) - .unwrap_or(false); - if !source { - println!(" {path} (project file)"); - } else { - let status = if file - .get("parsed") - .and_then(|p| p.as_bool()) - .unwrap_or(false) - { - "parsed" - } else { - "parse-error" - }; - println!(" {path} {status}"); + if let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") { + let result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| writeln!(file, "{line}")); + if let Err(error) = result { + eprintln!( + "::warning title=Wright summary::{}", + escape_workflow_data(&error.to_string()) + ); } + } else { + eprintln!( + "::notice title=Wright summary::{}", + escape_workflow_data(&line) + ); } } +/// GitHub workflow command escaping: properties additionally escape `:` and +/// `,`; command data only needs `%`, CR, and LF escaping. +pub(crate) fn escape_workflow_property(value: &str) -> String { + escape_workflow_data(value) + .replace(':', "%3A") + .replace(',', "%2C") +} + +pub(crate) fn escape_workflow_data(value: &str) -> String { + value + .replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +fn is_real_source_path(path: &str) -> bool { + !path.is_empty() && !path.starts_with('<') +} + fn render_compile(envelope: &Envelope) { let value = serde_json::to_value(envelope).expect("envelope serializes"); let Some(output) = value.pointer("/result/output") else { @@ -127,11 +341,10 @@ fn render_compile(envelope: &Envelope) { .get("text") .and_then(serde_json::Value::as_str) .unwrap_or_default(); - // The compiled artifact is the command result: write it verbatim. - print!("{text}"); - if !text.ends_with('\n') { - println!(); + if output.get("written_to").and_then(serde_json::Value::as_str) != Some("stdout") { + return; } + print!("{text}"); } fn render_convert(envelope: &Envelope) { @@ -144,11 +357,7 @@ fn render_convert(envelope: &Envelope) { .get("text") .and_then(serde_json::Value::as_str) .unwrap_or_default(); - // The reconstructed source is the command result: write it verbatim. print!("{text}"); - if !text.ends_with('\n') { - println!(); - } } fn render_analyze(envelope: &Envelope) { @@ -222,8 +431,8 @@ fn render_lint(envelope: &Envelope) { .and_then(serde_json::Value::as_str) .unwrap_or_default(); match boundedness { - Some(boundedness) => println!( - " {severity}[{code}] (evidence: {evidence}) (boundedness: {boundedness}): {message}" + Some(value) => println!( + " {severity}[{code}] (evidence: {evidence}) (boundedness: {value}): {message}" ), None => println!(" {severity}[{code}] (evidence: {evidence}): {message}"), } @@ -249,15 +458,11 @@ fn render_inspect(envelope: &Envelope) { .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(); - - let rules_count = program + let count = program .get("rules") .and_then(serde_json::Value::as_u64) .unwrap_or(0); - println!( - "inspect: {rules_count} rule(s), {} symbol(s)", - symbols.len() - ); + println!("inspect: {count} rule(s), {} symbol(s)", symbols.len()); for rule in &rules { let id = rule .get("id") @@ -286,14 +491,86 @@ fn render_inspect(envelope: &Envelope) { } } -fn render_diagnostic(diagnostic: &wright_driver::Diagnostic) { +fn render_ostw_summary(envelope: &Envelope) { + let Ok(value) = serde_json::to_value(envelope) else { + return; + }; + let Some(ostw) = value.pointer("/result/ostw") else { + return; + }; + let entry = ostw + .get("entry") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let files = ostw + .get("files") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let sources: Vec<_> = files + .iter() + .filter(|file| { + file.get("source") + .and_then(|s| s.as_bool()) + .unwrap_or(false) + }) + .collect(); + let parsed = sources + .iter() + .filter(|file| { + file.get("parsed") + .and_then(|p| p.as_bool()) + .unwrap_or(false) + }) + .count(); + let inventory = ostw + .get("inventory") + .and_then(serde_json::Value::as_array) + .map(|list| list.len()) + .unwrap_or(0); + println!( + "ostw project: entry {entry}, {parsed}/{} import-reachable sources parsed (inventory {inventory})", + sources.len() + ); + for file in &files { + let path = file + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !file + .get("source") + .and_then(|s| s.as_bool()) + .unwrap_or(false) + { + println!(" {path} (project file)"); + } else { + let status = if file + .get("parsed") + .and_then(|p| p.as_bool()) + .unwrap_or(false) + { + "parsed" + } else { + "parse-error" + }; + println!(" {path} {status}"); + } + } +} + +fn render_diagnostic(diagnostic: &wright_driver::Diagnostic, color: bool) { let severity = match diagnostic.severity { Severity::Error => "error", Severity::Warning => "warning", Severity::Info => "info", }; + let label = if color { + ansi_severity(diagnostic.severity, severity) + } else { + severity.to_string() + }; eprintln!( - "{severity}[{}] ({}): {}", + "{label}[{}] ({}): {}", diagnostic.code, diagnostic.stage.as_str(), diagnostic.message @@ -303,6 +580,15 @@ fn render_diagnostic(diagnostic: &wright_driver::Diagnostic) { } } +fn ansi_severity(severity: Severity, value: &str) -> String { + let code = match severity { + Severity::Error => "31", + Severity::Warning => "33", + Severity::Info => "36", + }; + format!("\x1b[{code}m{value}\x1b[0m") +} + fn print_span(span: &serde_json::Value, indent: &str) { let path = span .get("path") @@ -318,3 +604,80 @@ fn print_span(span: &serde_json::Value, indent: &str) { .unwrap_or(0); println!("{indent}--> {path}:{line}:{col}"); } + +#[cfg(test)] +mod tests { + use super::*; + + fn environment() -> RuntimeEnvironment { + RuntimeEnvironment { + github_actions: false, + ci: false, + stdout_terminal: true, + no_color: false, + force_color: false, + term_dumb: false, + } + } + + #[test] + fn auto_renderer_prefers_github_actions_then_ci_then_terminal() { + let mut env = environment(); + assert_eq!( + Presentation::resolve(OutputFormat::Text, RendererArg::Auto, ColorArg::Auto, env) + .renderer, + Renderer::Terminal + ); + env.ci = true; + assert_eq!( + Presentation::resolve(OutputFormat::Text, RendererArg::Auto, ColorArg::Auto, env) + .renderer, + Renderer::Plain + ); + env.github_actions = true; + assert_eq!( + Presentation::resolve(OutputFormat::Text, RendererArg::Auto, ColorArg::Auto, env) + .renderer, + Renderer::GithubActions + ); + } + + #[test] + fn explicit_renderer_and_color_override_detection() { + let mut env = environment(); + env.github_actions = true; + env.no_color = true; + let terminal = Presentation::resolve( + OutputFormat::Text, + RendererArg::Terminal, + ColorArg::Always, + env, + ); + assert_eq!(terminal.renderer, Renderer::Terminal); + assert!(terminal.color); + let plain = Presentation::resolve( + OutputFormat::Text, + RendererArg::Plain, + ColorArg::Always, + env, + ); + assert_eq!(plain.renderer, Renderer::Plain); + assert!( + plain.color, + "explicit color wins over auto renderer detection" + ); + let never = Presentation::resolve( + OutputFormat::Text, + RendererArg::Terminal, + ColorArg::Never, + env, + ); + assert!(!never.color); + } + + #[test] + fn workflow_command_escaping_is_split_by_context() { + assert_eq!(escape_workflow_property("a,b:c%\n"), "a%2Cb%3Ac%25%0A"); + assert_eq!(escape_workflow_data("a,b:c%\n"), "a,b:c%25%0A"); + } +} diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 9f1e89a..46516cd 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -63,6 +63,22 @@ fn run(args: &[&str]) -> std::process::Output { .expect("wright runs") } +fn run_with_env(args: &[&str], variables: &[(&str, &str)]) -> std::process::Output { + let mut command = Command::new(wright()); + command + .args(args) + .stdin(Stdio::null()) + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env_remove("GITHUB_STEP_SUMMARY") + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR"); + for (name, value) in variables { + command.env(name, value); + } + command.output().expect("wright runs") +} + fn run_with_stdin(args: &[&str], stdin: &str) -> std::process::Output { let mut child = Command::new(wright()) .args(args) @@ -100,6 +116,19 @@ fn compile_over_workshop_file_emits_correct_text() { fn compile_writes_output_file_and_reports_envelope() { let path = temp_file("basic.txt", &corpus_workshop("synthetic/basic-rule")); let out_path = temp_file("emitted.txt", ""); + let text_output = run(&[ + "compile", + path.to_str().unwrap(), + "-o", + out_path.to_str().unwrap(), + ]); + assert!(text_output.status.success()); + assert!( + text_output.stdout.is_empty(), + "-o keeps artifacts off stdout" + ); + assert!(text_output.stderr.is_empty()); + let output = run(&[ "compile", path.to_str().unwrap(), @@ -558,9 +587,196 @@ fn version_and_help_are_documented_contract_surfaces() { for command in ["compile", "convert", "check", "analyze", "lint", "inspect"] { assert!(help.contains(command), "help documents {command}"); } + for option in [ + "--kind", + "--target", + "--locale", + "--root", + "--profile", + "--format", + "--renderer", + "--color", + "--disable-rule", + "--rule-severity", + ] { + assert!(help.contains(option), "top-level help documents {option}"); + } assert!(help.contains("EXIT CODES")); } +#[test] +fn completion_is_generated_for_all_supported_shells() { + for shell in ["bash", "zsh", "fish", "powershell"] { + let output = run(&["completion", shell]); + assert!(output.status.success(), "{shell}: {:?}", output.status); + assert!(output.stderr.is_empty(), "{shell}: stderr is not clean"); + let completion = String::from_utf8_lossy(&output.stdout); + assert!(completion.contains("compile"), "{shell}: {completion}"); + assert!(completion.contains("renderer"), "{shell}: {completion}"); + assert!(completion.contains("color"), "{shell}: {completion}"); + } +} + +#[test] +fn explicit_renderer_and_color_overrides_are_respected() { + let path = temp_file("broken.txt", "rule (\"x\") { event { Ongoing - Global; }"); + + let github = run_with_env( + &[ + "check", + path.to_str().unwrap(), + "--renderer", + "github-actions", + ], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(github.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&github.stderr).contains("::error")); + assert!(String::from_utf8_lossy(&github.stderr).contains("::group::")); + + let plain = run_with_env( + &["check", path.to_str().unwrap(), "--renderer", "plain"], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(plain.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&plain.stderr).contains("::error")); + assert!(!String::from_utf8_lossy(&plain.stderr).contains("\x1b[")); + + let color = run_with_env( + &[ + "check", + path.to_str().unwrap(), + "--renderer", + "terminal", + "--color", + "always", + ], + &[("GITHUB_ACTIONS", "true"), ("NO_COLOR", "1")], + ); + assert_eq!(color.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&color.stderr).contains("\x1b[")); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn json_and_source_stdout_stay_pure_in_github_actions() { + let path = temp_file("broken.txt", "rule (\"x\") { event { Ongoing - Global; }"); + let json = run_with_env( + &[ + "check", + path.to_str().unwrap(), + "-f", + "json", + "--renderer", + "github-actions", + "--color", + "always", + ], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(json.status.code(), Some(1)); + assert!(json.stderr.is_empty()); + let envelope = parse_json(&json.stdout); + assert_eq!(envelope["wright"]["contract"], "wright-result/v1"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + + let source = corpus_workshop("synthetic/basic-rule"); + let path = temp_file("basic.txt", &source); + let compile = run_with_env( + &[ + "compile", + path.to_str().unwrap(), + "--renderer", + "github-actions", + ], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(compile.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&compile.stdout); + assert!(stdout.contains("Disable Inspector Recording")); + assert!(!stdout.contains("::")); + assert!(String::from_utf8_lossy(&compile.stderr).contains("::group::")); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn source_artifacts_are_byte_exact_in_plain_and_github_renderers() { + let source = corpus_workshop("synthetic/basic-rule"); + let path = temp_file("basic.txt", &source); + + let expected = parse_json(&run(&["compile", path.to_str().unwrap(), "-f", "json"]) + .stdout)["result"]["output"]["text"] + .as_str() + .unwrap() + .as_bytes() + .to_vec(); + for renderer in ["plain", "github-actions"] { + let output = run_with_env( + &["compile", path.to_str().unwrap(), "--renderer", renderer], + &[("GITHUB_ACTIONS", "true")], + ); + assert!(output.status.success(), "{renderer}"); + assert_eq!(output.stdout, expected, "{renderer} must preserve bytes"); + } + + for target in ["opy", "ostw"] { + let fixture = if target == "opy" { + workspace_fixture( + "crates/wright-opy/tests/fixtures/reconstruct/variables-declarations.ws", + ) + } else { + workspace_fixture("compatibility/ostw/reconstruction/surface-basic/workshop.txt") + }; + let expected = parse_json( + &run(&["convert", "--target", target, &fixture, "-f", "json"]).stdout, + )["result"]["text"] + .as_str() + .unwrap() + .as_bytes() + .to_vec(); + for renderer in ["plain", "github-actions"] { + let output = run_with_env( + &[ + "convert", + "--target", + target, + &fixture, + "--renderer", + renderer, + ], + &[("GITHUB_ACTIONS", "true")], + ); + assert_eq!(output.status.code(), Some(0), "{target}/{renderer}"); + assert_eq!( + output.stdout, expected, + "{target}/{renderer} must preserve bytes" + ); + } + } + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn github_summary_uses_step_summary_file_when_available() { + let path = temp_file("broken.txt", "rule (\"x\") { event { Ongoing - Global; }"); + let summary = temp_file("summary.md", ""); + let output = run_with_env( + &[ + "check", + path.to_str().unwrap(), + "--renderer", + "github-actions", + ], + &[ + ("GITHUB_ACTIONS", "true"), + ("GITHUB_STEP_SUMMARY", summary.to_str().unwrap()), + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(!std::fs::read_to_string(&summary).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + #[test] fn opy_file_compiles_through_the_native_frontend() { let source = std::fs::read_to_string( diff --git a/docs/cli.md b/docs/cli.md index 7e0a74c..13c6d68 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,6 +4,36 @@ Status: accepted baseline (living driver and CLI contract) Scope: `wright` executable, `wright-driver` crate, and their machine-readable contracts +## CLI presentation and completion (#164) + +The `wright` command model is defined once with `clap`. The same model drives +argv parsing, generated help, and the static completion entry point: + +```text +wright completion bash +wright completion zsh +wright completion fish +wright completion powershell +``` + +Workflow commands accept these CLI-only presentation options: + +* `--format text|json` selects human or machine output (`-f` remains an alias). +* `--renderer auto|terminal|plain|github-actions` selects the presentation + environment. `auto` selects GitHub Actions when `GITHUB_ACTIONS` is truthy, + plain output for generic `CI` or a non-TTY, and terminal output otherwise. +* `--color auto|always|never` controls ANSI color. Explicit options take + precedence over environment detection; GitHub Actions keeps workflow + command lines free of ANSI even when color is explicitly requested. + +JSON output is one `wright-result/v1` envelope on stdout with no ANSI, progress, +or workflow commands. `compile` and `convert` source artifacts remain the only +stdout payload in text mode, including when GitHub Actions presentation is +selected. GitHub Actions diagnostics and findings are emitted as escaped +workflow annotations; grouping is sent to the workflow command stream and a +concise PASS/WARN/ERROR line is appended to `GITHUB_STEP_SUMMARY` when the +runner provides that file. + This document is the normative contract for the compiler driver and CLI. It defines the shared driver model, the command surface, exit codes, stdout/stderr ownership, and the `wright-result/v1` envelope that CI and From 7c25fe38dc2aa884dac02cc1da6b331a3d1062dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:20:59 +0800 Subject: [PATCH 2/3] test(cli): isolate default presentation environment Refs #164 --- crates/wright-cli/tests/cli.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 46516cd..09a09ab 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -56,11 +56,7 @@ fn temp_dir() -> PathBuf { } fn run(args: &[&str]) -> std::process::Output { - Command::new(wright()) - .args(args) - .stdin(Stdio::null()) - .output() - .expect("wright runs") + run_with_env(args, &[]) } fn run_with_env(args: &[&str], variables: &[(&str, &str)]) -> std::process::Output { From 1641f9549c20e56797429c38cd36737d43dc7d56 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:58 +0800 Subject: [PATCH 3/3] fix(cli): aggregate GitHub summary severity Refs #164 --- .github/workflows/ci.yml | 39 +++++++++ crates/wright-cli/src/present.rs | 133 ++++++++++++++++++++++++++----- docs/cli.md | 4 +- 3 files changed, 157 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb00b17..75a720e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,45 @@ jobs: - name: Run tests run: cargo test --locked --workspace --all-targets --all-features + cli-github-actions-smoke: + name: Wright CLI GitHub Actions smoke + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Build wright CLI + run: cargo build --locked -p wright-cli + + - name: Exercise auto GitHub Actions renderer + shell: bash + run: | + set -euo pipefail + fixture="$RUNNER_TEMP/wright-gha-smoke.ws" + stdout="$RUNNER_TEMP/wright-gha-smoke.stdout" + stderr="$RUNNER_TEMP/wright-gha-smoke.stderr" + cat > "$fixture" <<'EOF' + rule ("gha smoke") { + event { + Ongoing - Global; + } + actions { + Disable Inspector Recording; + } + } + EOF + target/debug/wright check "$fixture" > "$stdout" 2> "$stderr" + test ! -s "$stdout" + grep -Fq '::group::wright check' "$stderr" + grep -Fq '::endgroup::' "$stderr" + ! grep -Fq 'title=Wright summary' "$stderr" + grep -Fq 'Wright `check`: **PASS** (exit 0)' "$GITHUB_STEP_SUMMARY" + lpp-client-integration: name: LPP client integration (#142, #139) runs-on: ubuntu-latest diff --git a/crates/wright-cli/src/present.rs b/crates/wright-cli/src/present.rs index ab8cca9..71127da 100644 --- a/crates/wright-cli/src/present.rs +++ b/crates/wright-cli/src/present.rs @@ -260,30 +260,59 @@ fn emit_finding_annotation(finding: &serde_json::Value) { ); } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum SummaryStatus { + Pass, + Warn, + Error, +} + +impl SummaryStatus { + fn as_str(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::Warn => "WARN", + Self::Error => "ERROR", + } + } + + fn from_finding_severity(severity: &str) -> Self { + match severity { + "error" => Self::Error, + "warning" => Self::Warn, + "info" | "notice" => Self::Pass, + _ => Self::Warn, + } + } +} + fn summary_status( envelope: &Envelope, value: &serde_json::Value, ) -> &'static str { - if !envelope.ok - || envelope - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error) - { - "ERROR" - } else if envelope - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Warning) - || value - .pointer("/result/findings") - .and_then(serde_json::Value::as_array) - .is_some_and(|findings| !findings.is_empty()) - { - "WARN" + let mut status = if envelope.ok { + SummaryStatus::Pass } else { - "PASS" + SummaryStatus::Error + }; + for diagnostic in &envelope.diagnostics { + status = status.max(match diagnostic.severity { + Severity::Error => SummaryStatus::Error, + Severity::Warning => SummaryStatus::Warn, + Severity::Info => SummaryStatus::Pass, + }); + } + if let Some(findings) = value + .pointer("/result/findings") + .and_then(serde_json::Value::as_array) + { + for finding in findings { + if let Some(severity) = finding.get("severity").and_then(serde_json::Value::as_str) { + status = status.max(SummaryStatus::from_finding_severity(severity)); + } + } } + status.as_str() } fn emit_summary(envelope: &Envelope, value: &serde_json::Value) { @@ -680,4 +709,72 @@ mod tests { assert_eq!(escape_workflow_property("a,b:c%\n"), "a%2Cb%3Ac%25%0A"); assert_eq!(escape_workflow_data("a,b:c%\n"), "a,b:c%25%0A"); } + + fn summary_envelope( + ok: bool, + diagnostics: Vec, + findings: serde_json::Value, + ) -> (Envelope, serde_json::Value) { + let envelope = Envelope { + wright: wright_driver::result::VersionInfo { + version: "test".to_string(), + contract: "wright-result/v1".to_string(), + }, + command: "lint".to_string(), + ok, + exit: if ok { 0 } else { 1 }, + diagnostics, + result: serde_json::json!({"findings": findings}), + }; + let value = serde_json::to_value(&envelope).unwrap(); + (envelope, value) + } + + fn diagnostic(severity: Severity) -> wright_driver::Diagnostic { + wright_driver::Diagnostic { + code: "test".to_string(), + stage: wright_driver::Stage::Analysis, + severity, + message: "test".to_string(), + span: None, + source: None, + } + } + + #[test] + fn summary_info_only_is_pass() { + let (envelope, value) = summary_envelope( + true, + vec![diagnostic(Severity::Info)], + serde_json::json!([ + {"severity": "info"}, + {"severity": "notice"} + ]), + ); + assert_eq!(summary_status(&envelope, &value), "PASS"); + } + + #[test] + fn summary_warning_over_info_is_warn() { + let (envelope, value) = summary_envelope( + true, + vec![diagnostic(Severity::Warning)], + serde_json::json!([{"severity": "info"}]), + ); + assert_eq!(summary_status(&envelope, &value), "WARN"); + } + + #[test] + fn summary_error_over_warning_is_error() { + let (envelope, value) = summary_envelope( + true, + vec![diagnostic(Severity::Error)], + serde_json::json!([ + {"severity": "warning"}, + {"severity": "error"}, + {"severity": "info"} + ]), + ); + assert_eq!(summary_status(&envelope, &value), "ERROR"); + } } diff --git a/docs/cli.md b/docs/cli.md index 13c6d68..c374cf3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,7 +32,9 @@ stdout payload in text mode, including when GitHub Actions presentation is selected. GitHub Actions diagnostics and findings are emitted as escaped workflow annotations; grouping is sent to the workflow command stream and a concise PASS/WARN/ERROR line is appended to `GITHUB_STEP_SUMMARY` when the -runner provides that file. +runner provides that file. The summary uses the highest structured severity: +errors produce `ERROR`, warnings produce `WARN`, and info/notice-only results +produce `PASS`. This document is the normative contract for the compiler driver and CLI. It defines the shared driver model, the command surface, exit codes,