diff --git a/Cargo.lock b/Cargo.lock index 38649068..6ae09131 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,16 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1399,6 +1409,7 @@ dependencies = [ "camino", "cap-std", "clap", + "clap_mangen", "digest", "glob", "indexmap", @@ -1955,6 +1966,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rstest" version = "0.18.2" diff --git a/Cargo.toml b/Cargo.toml index 145bd7d2..4363f43f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,13 +15,8 @@ license = "ISC" description = "A YAML-powered Ninja/Jinja hybrid build system." [package.metadata.ortho_config] -root_type = "netsuke::cli::Cli" -locales = ["en-US", "es-ES"] - -[package.metadata.ortho_config.windows] -module_name = "Netsuke" -include_common_parameters = true -split_subcommands_into_functions = false +root_type = "netsuke::cli::CliConfig" +locales = ["en-US", "es-ES"] [features] default = [] @@ -31,7 +26,7 @@ legacy-digests = ["sha1", "md5"] clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde-saphyr = "0.0.6" -minijinja = { version = "2.18.0", features = ["loader"] } +minijinja = { version = "2.12.0", features = ["loader"] } cap-std = { version = "3.4.4", features = ["fs_utf8"] } camino = "1.2.0" semver = { version = "1", features = ["serde"] } @@ -66,10 +61,12 @@ sys-locale = "0.3.2" [build-dependencies] clap = { version = "4.5.0", features = ["derive"] } +clap_mangen = "0.2.29" ortho_config = { version = "0.8.0", features = ["serde_json"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } thiserror = "1" +time = { version = "0.3.44", features = ["formatting"] } tracing = "0.1" [lints.clippy] diff --git a/build.rs b/build.rs index d47ab917..2a71f44d 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,29 @@ //! Build script for Netsuke. //! -//! This script audits localization keys declared in -//! `src/localization/keys.rs` against the Fluent bundles in -//! `locales/*/messages.ftl`, failing the build if any declared key is missing -//! from a locale. -use clap::ArgMatches; -use std::{ffi::OsString, sync::Arc}; +//! This script performs two main tasks: +//! - Generate the CLI manual page into `target/generated-man//` for release +//! packaging. +//! - Audit localization keys declared in `src/localization/keys.rs` against the Fluent bundles +//! in `locales/*/messages.ftl`, failing the build if any declared key is missing from a +//! locale. +use clap::{ArgMatches, CommandFactory}; +use clap_mangen::Man; +use std::{ + env, + ffi::OsString, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use time::{OffsetDateTime, format_description::well_known::Iso8601}; + +const FALLBACK_DATE: &str = "1970-01-01"; + +type OutputModeResolveWith = fn( + Option, + Option, + fn(&str) -> Option, +) -> output_mode::OutputMode; #[path = "src/cli/mod.rs"] mod cli; @@ -43,49 +61,145 @@ type ResolveThemeFn = fn( fn(&str) -> Option, ) -> theme::ResolvedTheme; -type ThemeContextCtor = fn( - Option, - Option, - output_mode::OutputMode, -) -> theme::ThemeContext; -type ResolveMergedDiagJsonFn = fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult; -type MergeWithConfigFn = fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult; - -/// Anchors all shared-module symbols so they remain linked when the build script is compiled -/// without tests. -const fn assert_symbols_linked() { +fn manual_date() -> String { + let Ok(raw) = env::var("SOURCE_DATE_EPOCH") else { + return FALLBACK_DATE.into(); + }; + + let Ok(ts) = raw.parse::() else { + println!( + "cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; expected integer seconds since Unix epoch; falling back to {FALLBACK_DATE}" + ); + return FALLBACK_DATE.into(); + }; + + let Ok(dt) = OffsetDateTime::from_unix_timestamp(ts) else { + println!( + "cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; not a valid Unix timestamp; falling back to {FALLBACK_DATE}" + ); + return FALLBACK_DATE.into(); + }; + + dt.format(&Iso8601::DATE).unwrap_or_else(|_| { + println!( + "cargo:warning=Invalid SOURCE_DATE_EPOCH '{raw}'; formatting failed; falling back to {FALLBACK_DATE}" + ); + FALLBACK_DATE.into() + }) +} + +fn out_dir_for_target_profile() -> PathBuf { + let target = env::var("TARGET").unwrap_or_else(|_| "unknown-target".into()); + let profile = env::var("PROFILE").unwrap_or_else(|_| "unknown-profile".into()); + PathBuf::from(format!("target/generated-man/{target}/{profile}")) +} + +fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result { + fs::create_dir_all(dir)?; + let destination = dir.join(page_name); + let tmp = dir.join(format!("{page_name}.tmp")); + fs::write(&tmp, data)?; + if destination.exists() { + fs::remove_file(&destination)?; + } + fs::rename(&tmp, &destination)?; + Ok(destination) +} + +const fn verify_public_api_symbols() { + // Exercise CLI localization, config merge, and host pattern symbols so the + // shared modules remain linked when the build script is compiled without + // tests. + const _: usize = std::mem::size_of::(); + const _: usize = std::mem::size_of::(); + const _: usize = std::mem::size_of::(); + const _: usize = std::mem::size_of::(); const _: usize = std::mem::size_of::(); const _: fn(&[OsString]) -> Option = cli::locale_hint_from_args; const _: fn(&[OsString]) -> Option = cli::diag_json_hint_from_args; const _: fn(&str) -> Option = cli_l10n::parse_bool_hint; - const _: ResolveMergedDiagJsonFn = cli::resolve_merged_diag_json; - const _: MergeWithConfigFn = cli::merge_with_config; + const _: fn(&cli::Cli, &ArgMatches) -> bool = cli::resolve_merged_diag_json; + const _: fn(&cli::Cli, &ArgMatches) -> ortho_config::OrthoResult = + cli::merge_with_config; const _: LocalizedParseFn = cli::parse_with_localizer_from; - const _: fn(&cli::Cli) -> cli::config::CliConfig = cli::Cli::config; - const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_diag_json; + const _: fn(&cli::Cli) -> Option = cli::Cli::no_emoji_override; + const _: fn(&cli::Cli) -> bool = cli::Cli::progress_enabled; const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_progress; + const _: fn(&cli::Cli) -> bool = cli::Cli::resolved_diag_json; const _: fn(&str) -> Result = HostPattern::parse; const _: fn(&HostPattern, host_pattern::HostCandidate<'_>) -> bool = HostPattern::matches; - const _: fn(Option, Option) -> output_mode::OutputMode = + const _: fn(Option, Option) -> output_mode::OutputMode = output_mode::resolve; - const _: fn(&cli::config::CliConfig) -> bool = cli::config::CliConfig::resolved_diag_json; - const _: fn(&cli::config::CliConfig) -> bool = cli::config::CliConfig::resolved_progress; - const _: ThemeContextCtor = theme::ThemeContext::new; + const _: OutputModeResolveWith = output_mode::resolve_with; + const _: fn( + Option, + Option, + output_mode::OutputMode, + ) -> theme::ThemeContext = theme::ThemeContext::new; const _: ResolveThemeFn = theme::resolve_theme; } -/// Emits Cargo rerun directives for all inputs that affect the build output. fn emit_rerun_directives() { println!("cargo:rerun-if-changed=src/cli/mod.rs"); + println!("cargo:rerun-if-changed=src/cli/config.rs"); + println!("cargo:rerun-if-changed=src/cli/merge.rs"); + println!("cargo:rerun-if-changed=src/cli/parser.rs"); println!("cargo:rerun-if-changed=src/cli/parsing.rs"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_NAME"); + println!("cargo:rerun-if-env-changed=CARGO_BIN_NAME"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_DESCRIPTION"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_AUTHORS"); + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + println!("cargo:rerun-if-env-changed=TARGET"); + println!("cargo:rerun-if-env-changed=PROFILE"); println!("cargo:rerun-if-changed=src/localization/keys.rs"); println!("cargo:rerun-if-changed=locales/en-US/messages.ftl"); println!("cargo:rerun-if-changed=locales/es-ES/messages.ftl"); } +fn generate_man_page(out_dir: &Path) -> Result<(), Box> { + let cmd = cli::Cli::command(); + let name = cmd + .get_bin_name() + .unwrap_or_else(|| cmd.get_name()) + .to_owned(); + let cargo_bin = env::var("CARGO_BIN_NAME") + .or_else(|_| env::var("CARGO_PKG_NAME")) + .unwrap_or_else(|_| name.clone()); + if name != cargo_bin { + return Err(format!( + "CLI name {name} differs from Cargo bin/package name {cargo_bin}; packaging expects {cargo_bin}.1" + ) + .into()); + } + let version = env::var("CARGO_PKG_VERSION").map_err( + |_| "CARGO_PKG_VERSION must be set by Cargo; cannot render manual page without it.", + )?; + let man = Man::new(cmd) + .section("1") + .source(format!("{cargo_bin} {version}")) + .date(manual_date()); + let mut buf = Vec::new(); + man.render(&mut buf)?; + let page_name = format!("{cargo_bin}.1"); + write_man_page(&buf, out_dir, &page_name)?; + if let Some(extra_dir) = env::var_os("OUT_DIR") { + let extra_dir_path = PathBuf::from(extra_dir); + if let Err(err) = write_man_page(&buf, &extra_dir_path, &page_name) { + println!( + "cargo:warning=Failed to stage manual page in OUT_DIR ({}): {err}", + extra_dir_path.display() + ); + } + } + Ok(()) +} + fn main() -> Result<(), Box> { - assert_symbols_linked(); + verify_public_api_symbols(); emit_rerun_directives(); build_l10n_audit::audit_localization_keys()?; - Ok(()) + let out_dir = out_dir_for_target_profile(); + generate_man_page(&out_dir) } diff --git a/docs/execplans/3-11-1-cli-config-struct.md b/docs/execplans/3-11-1-cli-config-struct.md new file mode 100644 index 00000000..b47b9d88 --- /dev/null +++ b/docs/execplans/3-11-1-cli-config-struct.md @@ -0,0 +1,549 @@ +# Introduce `CliConfig` as the layered CLI configuration schema + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETED + +No `PLANS.md` file exists in this repository. + +## Purpose / big picture + +Roadmap item `3.11.1` asks for a dedicated `CliConfig` struct derived with +`OrthoConfig` so Netsuke has one explicit, typed schema for layered CLI +configuration. This plan now targets `ortho_config` `0.8.0` and uses the +repository copy of `docs/ortho-config-users-guide.md`, which has been replaced +with the upstream `v0.8.0` guide. At planning time the repository already had +partial layered configuration, but it was centered on `src/cli/mod.rs`, where +`Cli` served three roles at once: + +1. Clap parser. +2. OrthoConfig merge target. +3. Runtime command model passed into the runner. + +That coupling makes the code difficult to extend. It also leaves roadmap fields +such as colour policy, spinner mode, output format, default targets, and theme +either implicit or missing. + +After this change, a novice should be able to point at one config schema and +see how Netsuke resolves: + +- verbosity +- locale +- colour policy +- spinner mode +- progress and accessible output behaviour +- output format +- default build targets +- theme selection + +Observable success means: + +1. `CliConfig` exists and is the authoritative OrthoConfig-derived schema. +2. Clap parsing still works, but parsing and layered configuration are no + longer the same type. +3. Configuration files, environment variables, and CLI flags resolve through + the same typed fields with documented precedence. +4. Unit tests (`rstest`) and behavioural tests (`rstest-bdd` v0.5.0) cover + happy paths, unhappy paths, and precedence edge cases. +5. `docs/users-guide.md`, `docs/netsuke-design.md`, and `docs/roadmap.md` + reflect the final behaviour. + +## Constraints + +- Keep existing top-level commands stable: `build`, `clean`, `graph`, and + `manifest` must continue to parse and dispatch. +- Upgrade every in-repo `ortho_config` dependency to `0.8.0`, and add or + upgrade `ortho_config_macros` to `0.8.0` if the final implementation uses + derive-generated selected-subcommand merging. +- Keep the toolchain at Rust `1.88` or newer. The current repository already + uses Rust `1.89.0`, so this is a compatibility floor rather than a required + toolchain migration. +- Do not regress localized Clap help or localized runtime diagnostics. +- Use `ortho_config` as the primary merge mechanism rather than adding another + bespoke loader. +- When derive-generated code touches `figment`, `uncased`, or `xdg`, prefer the + `ortho_config::...` re-exports described by the `0.8.0` guide unless the + application source genuinely needs those crates directly. +- Preserve the current configuration discovery behaviour for this milestone. + Do not expose the visible `--config` / `NETSUKE_CONFIG` interface here; that + belongs to roadmap item `3.11.3`. +- Preserve existing user-facing flags where feasible. If a new canonical field + supersedes an old one, keep a compatibility path unless the user explicitly + approves a breaking change. +- If `cli_default_as_absent` is used on any new or refactored field, use typed + Clap defaults (`default_value_t` / `default_values_t`) rather than + string-based `default_value`, and pass `ArgMatches` into merge flows so + explicit CLI overrides remain distinguishable from inferred defaults. +- No source file may exceed 400 lines. This is a hard constraint, not a style + preference. +- All new or changed public types and modules require Rustdoc and module-level + documentation. +- Behaviour must be validated with both `rstest` unit tests and + `rstest-bdd` v0.5.0 behavioural tests. +- The implementation must end with successful `make check-fmt`, `make lint`, + and `make test` runs captured via `tee` and `set -o pipefail`. +- Documentation updates are part of the feature, not follow-up work. + +## Tolerances (exception triggers) + +- Scope: if implementation requires more than 18 files or more than 900 net + new lines, stop and escalate. +- Interfaces: if the runner or subcommand API must change in a way that breaks + existing tests or user-facing CLI syntax, stop and escalate. +- Dependencies: if implementation requires a new runtime dependency beyond + `ortho_config` facilities already in use, stop and escalate. +- Compatibility: if preserving `--no-emoji` compatibility while introducing a + canonical theme field becomes impossible without ambiguous precedence, stop + and escalate. +- Investigation: if configuration merging still behaves unexpectedly after + three red/green cycles, stop and document the blocking case before proceeding. + +## Risks + +- Risk: `src/cli/mod.rs` was already 398 lines at planning time, so any + additive work there would violate the file-size limit. Severity: high. + Likelihood: high. Mitigation: split parser definitions, config schema, and + merge helpers into separate modules before adding new fields. + +- Risk: the repository already has partial OrthoConfig support, manual config + discovery, and merge tests. A careless refactor could duplicate logic rather + than simplifying it. Severity: medium. Likelihood: high. Mitigation: treat + this as a consolidation task and delete superseded merge code as part of the + same change. + +- Risk: the repository currently depends on `ortho_config 0.7.0`, while this + plan now targets `0.8.0`. Severity: medium. Likelihood: high. Mitigation: + treat the crate upgrade as part of the same branch and apply the documented + migration rules up front instead of retrofitting them after the schema + refactor lands. + +- Risk: roadmap item `3.11.1` reaches into future roadmap items by naming + output format and theme before `3.10.3` and `3.12.*` are complete. Severity: + medium. Likelihood: high. Mitigation: introduce typed config fields now, but + limit behaviour to what the current product can honour and fail clearly on + unsupported values. + +- Risk: build default targets are subcommand-specific, while most other config + is global. Severity: medium. Likelihood: medium. Mitigation: use + OrthoConfig's subcommand configuration support for `build` instead of forcing + targets into the global namespace. + +## Progress + +- [x] (2026-03-07) Reviewed roadmap `3.11.1`, the OrthoConfig guide, current + CLI/config code, and nearby execplans. +- [x] (2026-03-07) Drafted this ExecPlan. +- [x] (2026-03-09) Continued implementation from the existing branch state. +- [x] (2026-03-09) Stage A: split parser, config schema, and merge + responsibilities. +- [x] (2026-03-09) Stage B: introduce typed config groups and compatibility + mapping. +- [x] (2026-03-09) Stage C: wire global and subcommand merges through + `CliConfig`. +- [x] (2026-03-09) Stage D: add unit and behavioural coverage. +- [x] (2026-03-09) Stage E: update user/design docs, mark roadmap item done, + and run all quality gates. + +## Surprises & Discoveries + +- The codebase already derives `OrthoConfig` on `Cli`; roadmap `3.11.1` is + therefore a refactor-and-expansion task, not a greenfield introduction. +- The repository is already on Rust `1.89.0`, which satisfies the + `ortho_config 0.8.0` minimum of Rust `1.88`. The crate version is the real + migration step; the compiler floor is not. +- The repository already documented configuration discovery in + `docs/users-guide.md` and already had merge tests in + `tests/cli_tests/merge.rs`. The implementation needed to preserve these + guarantees while changing the type layout. +- `rstest-bdd` feature-file edits may require touching `tests/bdd_tests.rs` to + force Cargo to rebuild generated scenarios. +- The new configuration-preferences behaviour-driven development (BDD) + coverage initially flaked only in the full suite because + `NETSUKE_CONFIG_PATH` is process-global. Holding + `test_support/src/env_lock.rs`'s `EnvLock` for the whole scenario fixed the + race without weakening the coverage. + +## Decision Log + +- Decision: introduce a dedicated `CliConfig` type and treat the existing CLI + parser as a separate concern. Rationale: parsing tokens and merging layered + configuration are related but not identical jobs, and combining them has + already pushed the current module to the file-size limit. Date/Author: + 2026-03-07 (Codex, plan draft) + +- Decision: use OrthoConfig subcommand configuration for build defaults rather + than a global `default_targets` field. Rationale: target lists only make + sense for `build`; placing them under `cmds.build` matches the OrthoConfig + user's guide and avoids leaking command-specific semantics into global + config. Date/Author: 2026-03-07 (Codex, plan draft) + +- Decision: keep current hidden config-path override behaviour in this + milestone and defer the visible `--config` / `NETSUKE_CONFIG` surface to + roadmap item `3.11.3`. Rationale: the roadmap splits schema introduction from + config-path UX, and this plan should not silently complete later work. + Date/Author: 2026-03-07 (Codex, plan draft) + +- Decision: make `theme` the canonical presentation setting and treat + `no_emoji` as a compatibility alias that resolves to the ASCII theme. + Rationale: roadmap `3.12.*` already talks about themes, while the current + implementation only exposes `no_emoji`; a compatibility bridge avoids + breaking existing users. Date/Author: 2026-03-07 (Codex, plan draft) + +- Decision: add typed enums for colour policy, spinner mode, output format, + and theme even where runtime behaviour is still limited. Rationale: the + schema should become explicit now so future milestones extend behaviour + without reworking config names a second time. Unsupported combinations must + fail with actionable diagnostics. Date/Author: 2026-03-07 (Codex, plan draft) + +- Decision: treat `ortho_config 0.8.0` as the baseline for this work and align + the implementation with its migration rules. Rationale: the local guide now + reflects `v0.8.0`, and the implementation plan should not be written against + `0.7.x` semantics. The repository does not currently alias the dependency + name, so `#[ortho_config(crate = "...")]` is not required unless that changes + during implementation. Date/Author: 2026-03-08 (Codex, plan revision) + +- Decision: keep `Cli` as the parser/runtime command carrier while moving all + layered schema responsibilities into `CliConfig`. Rationale: this achieved + the roadmap separation with a smaller, safer surface-area change while + preserving runner tests and command-dispatch call sites. Date/Author: + 2026-03-09 (Codex, implementation) + +- Decision: validate `output_format = "json"` as unsupported for now instead of + silently accepting it. Rationale: roadmap item `3.10.3` is still open, so + accepting JSON output configuration without delivering the behaviour would be + misleading. Date/Author: 2026-03-09 (Codex, implementation) + +These decisions must be recorded in `docs/netsuke-design.md` during +implementation if they remain unchanged after coding begins. + +## Outcomes & Retrospective + +Completed on 2026-03-09. + +Implemented results: + +- Added `src/cli/config.rs`'s `CliConfig` as the authoritative + OrthoConfig-derived schema and split the CLI module into parser, config, and + merge submodules. +- Upgraded `ortho_config` to `0.8.0`. +- Kept `Cli` as the parser/runtime command carrier while rooting configuration + merge in `CliConfig`. +- Added typed config fields for `colour_policy`, `spinner_mode`, + `output_format`, and `theme`. +- Canonicalized `no_emoji = true` to the ASCII theme while rejecting + contradictory combinations. +- Wired `[cmds.build] targets` and `emit` defaults into the runtime build + command when the user does not supply explicit CLI values. +- Added unit coverage in `tests/cli_tests/merge.rs` plus behavioural coverage + in `tests/features/configuration_preferences.feature`. +- Updated the user guide, design document, and roadmap entry for `3.11.1`. + +Quality-gate evidence: + +- `make check-fmt` +- `make lint` +- `make test` +- `make markdownlint` (with `markdownlint-cli2` available on `PATH`) +- `make nixie` + +Lessons learned: + +- Keeping the parser/runtime type stable while introducing a separate merge + schema is a pragmatic migration path when downstream code already consumes + the parser type pervasively. +- Behaviour-driven development (BDD) coverage that touches process-wide + environment variables must hold `EnvLock` for the full scenario, not only for + individual mutations. + +## Context and orientation + +Historical planning context for this change was spread across the following +files: + +- `src/cli/mod.rs`: then contained the `Cli` type, Clap parser, OrthoConfig + derive, validation parsers, and merge logic. +- `Cargo.toml`: then pinned `ortho_config = "0.7.0"` and + `rust-version = "1.89.0"`. +- `rust-toolchain.toml`: then pinned toolchain `1.89.0`, which already + satisfied the `0.8.0` minimum. +- `src/main.rs`: startup parse/merge flow and runtime localization bootstrap. +- `src/output_mode.rs`: accessible versus standard output mode resolution. +- `src/output_prefs.rs`: emoji-aware semantic prefixes and then-current + `no_emoji` handling. +- `src/runner/mod.rs`: uses merged CLI state to choose output mode, progress + behaviour, and build targets. +- `tests/cli_tests/merge.rs`: merge precedence coverage. +- `tests/cli_tests/parsing.rs` and `tests/features/cli.feature`: parse-only + coverage. + +Two important facts shape this plan: + +1. The repo already has a layered configuration story. +2. The missing piece is a stable, explicit schema that separates merged config + from raw CLI parsing and adds the roadmap fields that do not yet exist. + +## Target architecture + +The implementation should converge on three layers of types. + +1. A Clap-facing parser model, still responsible for token parsing and + user-facing command syntax. +2. A layered configuration model rooted at `CliConfig`, derived with + `OrthoConfig`, `Serialize`, and `Deserialize`, using `0.8.0` semantics. +3. A runtime model passed into the runner after configuration and subcommand + selection have been resolved. + +The preferred shape is: + +- `src/cli/mod.rs`: parser entry points and minimal top-level glue. +- `src/cli/config.rs`: `CliConfig` plus nested typed config groups. +- `src/cli/merge.rs` or similar: conversion from parsed CLI overrides plus + OrthoConfig layer composition into the merged runtime shape. +- `BuildArgs` (or a renamed build-config type) derived with `OrthoConfig` so + `cmds.build` configuration can supply default targets and optional emit-path + defaults. + +The config schema should cover at least these concepts: + +- `verbose` +- `locale` +- `colour_policy` +- `spinner_mode` +- `output_format` +- `theme` +- `progress` +- `accessible` +- current fetch-policy settings +- build default targets through subcommand configuration + +For this milestone, a valid example config should look like this: + +```toml +verbose = true +locale = "es-ES" +colour_policy = "auto" +spinner_mode = "auto" +output_format = "human" +theme = "ascii" +progress = true +accessible = false + +[cmds.build] +targets = ["all"] +``` + +The final user guide must explain the actual accepted values and any +compatibility aliases such as `no_emoji = true`. + +## Plan of work + +### Stage A: split responsibilities before adding new fields + +Start by reducing the blast radius in `src/cli/mod.rs`. Move the merge logic +and the future `CliConfig` definition out of that file first. The goal is to +make later edits mechanical instead of risky. This stage also establishes the +`ortho_config 0.8.0` baseline before higher-level schema refactors pile on. + +Concrete work in this stage: + +1. Extract the current OrthoConfig-driven merge helpers into a dedicated module. +2. Introduce a parser-only root type if needed (`Cli`, `CliArgs`, or similar), + while keeping command syntax unchanged. +3. Upgrade `ortho_config` to `0.8.0` and confirm the repository still builds + against Rust `1.89.0` without toolchain changes. +4. Keep existing parsing tests green before introducing new schema fields. + +Acceptance for Stage A: + +- CLI parsing tests still pass unchanged. +- No file exceeds 400 lines. +- No behaviour changes are visible yet. + +### Stage B: introduce `CliConfig` and typed config groups + +Create `CliConfig` as the authoritative layered configuration schema. Use typed +enums or newtypes where values are semantic rather than free-form text. + +Concrete work in this stage: + +1. Define `CliConfig` and any nested groups needed to keep modules readable. +2. Add typed enums for: + - `ColourPolicy` + - `SpinnerMode` + - `OutputFormat` + - `Theme` +3. Decide and implement compatibility handling for the current `no_emoji` + surface. +4. Use OrthoConfig discovery attributes on `CliConfig` to preserve the current + `NETSUKE_CONFIG_PATH` and hidden config-path behaviour. +5. Keep global fields optional where layered precedence requires absence to be + meaningful. +6. Where Clap defaults are required, use typed defaults so + `cli_default_as_absent` remains valid under `0.8.0`. + +Acceptance for Stage B: + +- `CliConfig` can be merged from defaults, file, env, and CLI layers in unit + tests without invoking the full parser. +- Invalid enum values fail with actionable errors. +- Existing config-discovery paths still work. + +### Stage C: merge global config plus selected subcommand defaults + +This stage makes `CliConfig` drive runtime behaviour instead of the current +all-in-one `Cli` type. + +Concrete work in this stage: + +1. Replace `Cli::merge_with_config` with a merge path rooted in `CliConfig`. +2. Merge `build` subcommand defaults using OrthoConfig's subcommand support so + `[cmds.build] targets = [...]` becomes the default target list when the user + does not pass targets explicitly. +3. Convert the merged config plus parsed command into the runtime shape + consumed by `src/runner/mod.rs`. +4. Update output-mode and output-preference resolution to consume the new typed + fields rather than a loose collection of booleans. +5. Keep startup locale resolution intact so localized Clap help still works + before full merge. +6. If selected-subcommand merge derives are introduced, add + `ortho_config_macros 0.8.0` and pass `ArgMatches` where required by + `cli_default_as_absent`. + +Acceptance for Stage C: + +- Running `netsuke` with no explicit targets can pick up configured build + targets from `cmds.build`. +- CLI overrides still beat environment and file values. +- Existing commands continue to dispatch correctly. + +### Stage D: add test coverage for happy and unhappy paths + +Unit tests should prove the schema and merge logic. Behavioural tests should +prove the user-visible outcomes. + +Unit coverage to add with `rstest`: + +- defaults < file < env < CLI precedence for representative global fields +- `theme` / `no_emoji` compatibility mapping +- enum parsing failures for `colour_policy`, `spinner_mode`, and + `output_format` +- build-target defaulting through `cmds.build` +- unsupported but syntactically valid combinations fail clearly when required +- any `cli_default_as_absent` fields continue to prefer file/env values when + the user did not explicitly supply the CLI flag + +Behavioural coverage to add with `rstest-bdd` v0.5.0: + +- config file supplies default build targets and `netsuke` uses them +- CLI `--locale` or `--verbose` overrides file and env values +- invalid config values produce user-facing diagnostics +- compatibility alias (`no_emoji`) still produces ASCII-themed output + +Prefer a dedicated feature file such as +`tests/features/configuration_preferences.feature` plus matching step +definitions, rather than overloading the existing CLI-parsing feature with +merge semantics. + +### Stage E: document the final contract and close the roadmap item + +After behaviour is working and tested, update the docs as part of the same +change. + +Documentation work: + +1. Update `docs/users-guide.md` with the new config schema, precedence rules, + accepted values, and example TOML. +2. Update `docs/netsuke-design.md` to record: + - separation of parser and config schema + - subcommand config for default build targets + - canonical theme handling and `no_emoji` compatibility + - which output-format values are supported in this milestone +3. Mark roadmap item `3.11.1` as done in `docs/roadmap.md`. + +Acceptance for Stage E: + +- User docs match the shipped behaviour. +- Design docs capture the decisions from this plan that survived + implementation. +- Only roadmap item `3.11.1` is marked done unless later work is intentionally + completed and validated too. + +## Concrete steps + +Run all commands from the repository root. + +Before editing feature files, remember the existing `rstest-bdd` gotcha: + +```sh +touch tests/bdd_tests.rs +``` + +Use `tee` and `set -o pipefail` for every long-running gate: + +```sh +set -o pipefail +make check-fmt 2>&1 | tee /tmp/netsuke-check-fmt.log +``` + +```sh +set -o pipefail +make lint 2>&1 | tee /tmp/netsuke-lint.log +``` + +```sh +set -o pipefail +make test 2>&1 | tee /tmp/netsuke-test.log +``` + +Because docs will change, also run: + +```sh +set -o pipefail +make markdownlint 2>&1 | tee /tmp/netsuke-markdownlint.log +``` + +```sh +set -o pipefail +make nixie 2>&1 | tee /tmp/netsuke-nixie.log +``` + +```sh +set -o pipefail +make fmt 2>&1 | tee /tmp/netsuke-fmt.log +``` + +After `make fmt`, inspect `git status --short` and remove incidental edits in +unrelated files before finalizing the change. + +The local OrthoConfig reference for this task is now the `v0.8.0` guide at +`docs/ortho-config-users-guide.md`. Do not rely on older `0.7.x` examples when +the two guides disagree. + +## Validation and acceptance + +The feature is complete only when all of the following are true: + +1. `CliConfig` is the authoritative OrthoConfig-derived schema. +2. Parser-only code and merge-only code are separated enough to keep files + under the 400-line limit. +3. `build` default targets can be supplied through configuration without + breaking explicit CLI targets. +4. Typed config values are documented and validated with clear unhappy-path + errors. +5. Unit tests and behavioural tests cover both precedence and failure cases. +6. `make check-fmt`, `make lint`, `make test`, `make markdownlint`, and + `make nixie` succeed. +7. `docs/users-guide.md`, `docs/netsuke-design.md`, and `docs/roadmap.md` are + updated. + +## Idempotence and recovery + +This work should be implemented in small, reversible steps. If a stage fails, +return the tree to the last green checkpoint, update this ExecPlan's +`Decision Log` and `Progress`, and retry with a narrower diff rather than +stacking speculative fixes. If formatting tools touch unrelated Markdown files, +restore only the incidental edits created during the current turn before +proceeding. diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index bb21c4d8..189e5bcb 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -400,6 +400,37 @@ The cleaner model is: - `always`: When set to `true`, the target runs on every invocation regardless of timestamps or dependencies. The default value is `false`. + +### 2.7 Table: Netsuke Manifest vs. Makefile + +To illustrate the ergonomic advantages of the Netsuke schema, the following +table compares a simple C compilation project defined in both a traditional +`Makefile` and a `Netsukefile` file. The comparison highlights Netsuke's +explicit, structured, and self-documenting nature. + +| Feature | Makefile Example | Netsukefile Example | +| --------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Variables | CC=gcc | { vars: { cc: gcc } } | +| Macros | define greet\\t@echo Hello $$1endef | { macros: { signature: "greet(name)", body: "Hello {{ name }}" } } | +| Rule Definition | %.o: %.c\\n\\t$(CC) -c $< -o $@ | { rules: { name: compile, command: "{{ cc }} -c {{ ins }} -o {{ outs }}", description: "Compiling {{ outs }}" } } | +| Target Build | my_program: main.o utils.o\\t$(CC) $^ -o $@ | { targets: { name: my_program, rule: link, sources: [main.o, utils.o] } | +| Readability | Relies on cryptic automatic variables ($@, $\<, $^) and implicit pattern matching. | Uses explicit, descriptive keys (name, rule, sources) and standard YAML list/map syntax. | + +### 2.7 Table: Netsuke Manifest vs. Makefile + +To illustrate the ergonomic advantages of the Netsuke schema, the following +table compares a simple C compilation project defined in both a traditional +`Makefile` and a `Netsukefile` file. The comparison highlights Netsuke's +explicit, structured, and self-documenting nature. + +| Feature | Makefile Example | Netsukefile Example | +| --------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Variables | CC=gcc | { vars: { cc: gcc } } | +| Macros | define greet\\t@echo Hello $$1endef | { macros: { signature: "greet(name)", body: "Hello {{ name }}" } } | +| Rule Definition | %.o: %.c\\n\\t$(CC) -c $< -o $@ | { rules: { name: compile, command: "{{ cc }} -c {{ ins }} -o {{ outs }}", description: "Compiling {{ outs }}" } } | +| Target Build | my_program: main.o utils.o\\t$(CC) $^ -o $@ | { targets: { name: my_program, rule: link, sources: [main.o, utils.o] } | +| Readability | Relies on cryptic automatic variables ($@, $\<, $^) and implicit pattern matching. | Uses explicit, descriptive keys (name, rule, sources) and standard YAML list/map syntax. | + ### 2.5 Generated Targets and Actions with `foreach` Large sets of similar outputs or setup actions can clutter a manifest when @@ -2392,35 +2423,52 @@ the targets listed in the `defaults` section of the manifest are built. ### 8.4 Design Decisions -The CLI is implemented using clap's derive API in `src/cli/mod.rs`. Netsuke -applies `Cli::with_default_command` after parsing so invoking `netsuke` with no -explicit command still triggers a build. Configuration is layered with -OrthoConfig (defaults, configuration files, environment variables, then CLI -overrides) while treating clap defaults as absent so file or environment values -are not masked. Explicit config selection is resolved before discovery with -precedence `--config` > `NETSUKE_CONFIG` > legacy `NETSUKE_CONFIG_PATH`; -`-C/--directory` only affects project-root discovery. Environment variables use -the `NETSUKE_` prefix with `__` as a nesting separator. CLI help and clap -errors are localized via Fluent resources; locale resolution is handled in -`src/locale_resolution.rs` with the precedence `--locale` -> `NETSUKE_LOCALE` --> configuration `locale` -> system default. System locale strings are -normalized by stripping encoding suffixes (such as `.UTF-8`), removing variant -suffixes (such as `@latin`), and replacing underscores with hyphens before -validation. English plus Spanish catalogues ship in `locales/`; unsupported -locales fall back to `en-US`. Runtime diagnostics (for example manifest -parsing, stdlib template errors, and runner failures) use the same Fluent -localizer so the locale selection is consistent across user-facing output. A -build-time audit in `build.rs` validates that all referenced Fluent message -keys exist in the bundled catalogues, ensuring missing strings fail CI before -release. CLI execution and dispatch live in `src/runner.rs`, keeping `main.rs` -focused on parsing. Process management, Ninja invocation, argument redaction, -and the temporary file helpers reside in `src/runner/process.rs`, allowing the -runner entry point to delegate low-level concerns. The working directory flag -mirrors Ninja's `-C` option but is resolved internally: Netsuke runs Ninja with -a configured working directory and resolves relative output paths (for example -`build --emit` and `manifest`) under the same directory so behaviour matches a -real directory change. Error scenarios are validated using clap's `ErrorKind` -enumeration in unit tests and via Cucumber steps for behavioural coverage. +The parser-facing `Cli` type is now defined in `src/cli/parser.rs`, while +layered configuration lives in a dedicated `CliConfig` struct derived with +OrthoConfig in `src/cli/config.rs`. The top-level `src/cli/mod.rs` module +re-exports that public CLI surface. This separation keeps parsing, +configuration discovery, and runtime command selection as distinct concerns +while preserving the existing command syntax. Invoking `netsuke` with no +explicit subcommand still resolves to `build`, and the `build` command can now +take default `emit` and `targets` values from `[cmds.build]` in configuration +files or `NETSUKE_CMDS__BUILD__*` environment variables. Explicit CLI targets +or `--emit` values still override those defaults. + +Configuration is layered in the order defaults -> configuration files -> +environment variables -> CLI overrides. Discovery honours `NETSUKE_CONFIG_PATH` +and the standard OrthoConfig search order; environment variables use the +`NETSUKE_` prefix with `__` as a nesting separator. The schema now explicitly +covers verbosity, locale, accessible mode, progress, colour policy, spinner +mode, output format, theme selection, fetch policy, and build defaults. `theme` +is the canonical presentation setting; the older `no_emoji` field remains as a +compatibility alias that canonicalizes to the ASCII theme. Conflicting +combinations such as `theme = "unicode"` together with `no_emoji = true` fail +during merge. `spinner_mode` likewise validates against the legacy `progress` +boolean so contradictory inputs are rejected early. `output_format` is typed +now, but only `human` is accepted until the future JSON diagnostics milestone +lands. + +CLI help and clap errors are localized via Fluent resources; locale resolution +is handled in `src/locale_resolution.rs` with the precedence `--locale` -> +`NETSUKE_LOCALE` -> configuration `locale` -> system default. System locale +strings are normalized by stripping encoding suffixes (such as `.UTF-8`), +removing variant suffixes (such as `@latin`), and replacing underscores with +hyphens before validation. English plus Spanish catalogues ship in `locales/`; +unsupported locales fall back to `en-US`. Runtime diagnostics (for example +manifest parsing, stdlib template errors, and runner failures) use the same +Fluent localizer so the locale selection is consistent across user-facing +output. A build-time audit in `build.rs` validates that all referenced Fluent +message keys exist in the bundled catalogues, ensuring missing strings fail CI +before release. CLI execution and dispatch live in `src/runner.rs`, keeping +`main.rs` focused on parsing. Process management, Ninja invocation, argument +redaction, and the temporary file helpers reside in `src/runner/process.rs`, +allowing the runner entry point to delegate low-level concerns. The working +directory flag mirrors Ninja's `-C` option but is resolved internally: Netsuke +runs Ninja with a configured working directory and resolves relative output +paths (for example `build --emit` and `manifest`) under the same directory so +behaviour matches a real directory change. Error scenarios are validated using +clap's `ErrorKind` enumeration in unit tests and via Cucumber steps for +behavioural coverage. Real-time stage reporting now uses a six-stage model in `src/status.rs` backed by `indicatif::MultiProgress` for standard terminals. The reporter keeps one @@ -2447,15 +2495,6 @@ Timing summaries are completion diagnostics. They are suppressed when verbose mode is off and also suppressed on failed runs so failures do not imply a successful pipeline completion. -The preference schema is now modelled explicitly in `src/cli/config.rs` as a -shared `CliConfig` view with typed enums for `colour_policy`, `spinner_mode`, -and `output_format`. The concrete `Cli` parser remains the OrthoConfig merge -root for backward compatibility, but it exposes the extracted `CliConfig` -surface so Clap parsing, configuration files, environment variables, and -runtime resolution all reuse the same field vocabulary. `spinner_mode` -supersedes the legacy `progress` boolean when both are present, and -`output_format` likewise supersedes `diag_json`. - Theme resolution for CLI output is centralized in `src/theme.rs`. Netsuke resolves one theme through OrthoConfig layers (`--theme`, `NETSUKE_THEME`, config file, then mode defaults) and hands the resulting symbol and spacing @@ -2466,12 +2505,12 @@ and gives later roadmap items a stable snapshot surface for validating ASCII and Unicode renderings without duplicating formatting rules. Colour policy is resolved alongside theme and output-mode detection so `--colour-policy never` behaves like an internal `NO_COLOR`, while `always` bypasses `NO_COLOR` -auto-detection. Build dispatch also consults OrthoConfig `default_targets` -before falling back to manifest `defaults`, letting operators set user- or -workspace-level build defaults without editing the manifest itself. Accessible -reporter output, timing summaries, and the semantic prefix surface are guarded -by `insta` snapshots so spacing, prefix alignment, and wrapping regressions -fail with reviewable diffs instead of drifting silently. +auto-detection. Build dispatch also consults OrthoConfig `[cmds.build]` +defaults before falling back to manifest `defaults`, letting operators set +user- or workspace-level build defaults without editing the manifest itself. +Accessible reporter output, timing summaries, and the semantic prefix surface +are guarded by `insta` snapshots so spacing, prefix alignment, and wrapping +regressions fail with reviewable diffs instead of drifting silently. The Advanced Usage chapter in `docs/users-guide.md` is validated by behavioural tests in `tests/features/advanced_usage.feature`. Netsuke treats those diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 005ba120..10c902ac 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -93,10 +93,9 @@ if let Some(figment) = discovery.load_first()? { # } ``` -The repository ships `config/overrides.toml`, which extends -`config/baseline.toml` to set `is_excited = true`, provide a `Layered hello` -preamble, and swap the greet punctuation for `!!!`. Behavioural tests and demo -scripts assert the uppercase output to guard this layering. +After parsing the relevant subcommand struct, call `load_and_merge()?` on that +value (for example, `pr_args.load_and_merge()?`) to obtain the merged +configuration for that subcommand. ### Declarative merging @@ -1204,9 +1203,10 @@ Enum fields list their possible values in the OPTIONS description. ### Generating PowerShell help `cargo-orthohelp` can generate PowerShell external help in Microsoft Assistance -Markup Language (MAML) alongside a wrapper module so `Get-Help {BinName} -Full` -surfaces the same configuration metadata as the man page generator. Use the -`ps` format to emit the module layout under `powershell/`: +Markup Language (MAML) alongside a wrapper module, so +`Get-Help {BinName} -Full` surfaces the same configuration metadata as the man +page generator. Use the `ps` format to emit the module layout under +`powershell/`: ```bash cargo-orthohelp --format ps --out-dir target/orthohelp --locale en-US diff --git a/docs/roadmap.md b/docs/roadmap.md index 989519f8..5a9c2ee1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,572 +1,329 @@ -# Netsuke roadmap - -This roadmap tracks unfinished and future Netsuke work. Completed historical -foundations live in -[`docs/archive/roadmap-completed-foundations.md`](archive/roadmap-completed-foundations.md) - so the active roadmap can focus on remaining hypotheses without erasing prior -implementation detail. - -Task identifiers are globally unique across the active roadmap and the archive. -When a completed task moves to the archive, it keeps its original number and is -not repeated here. When a historical task is renamed under the command-line -interface (CLI) redesign, the active task states the mapping explicitly. - -## How to read this roadmap - -Each phase validates a product hypothesis: - -- Phase 3 validates that Netsuke can stay friendly for local human workflows - while becoming predictable for automation and agents. -- Phase 4 validates that the build compiler and its cross-platform behaviour - can be specified and checked rigorously. -- Phase 5 validates that repeated human, Continuous Integration (CI), editor, - and agent usage improves through introspection, profiles, run history, - delivery, and feedback. - -The roadmap keeps user-facing product grammar separate from implementation -detail. Public tasks name Netsuke capabilities first. Implementation adapters, -including OrthoConfig, appear only when they define ownership, dependency, or -validation boundaries. - -## External dependencies - -Netsuke depends on OrthoConfig for generic command/configuration/schema -machinery. Netsuke tasks cover integration, product policy, validation, and -local build-tool adaptation. They must not duplicate the shared infrastructure -owned by OrthoConfig. - -Relevant OrthoConfig roadmap dependencies: - -- OrthoConfig `5.2.3`: consumer dependency boundaries for Netsuke and Weaver. -- OrthoConfig `6.1.1` and `6.1.2`: recursive command metadata extraction. -- OrthoConfig `6.2.1` to `6.2.3`: ` context --json` schema, emission, - and downstream command naming. -- OrthoConfig `6.3.1` and `6.3.2`: skill manifest metadata and validation. -- OrthoConfig `7.1.1` to `7.1.3`: vocabulary policy and canonical global - option glossary. -- OrthoConfig `7.2.1` to `7.2.7`: non-interactive metadata, mutation metadata, - dual-renderer output metadata, structured result classes, stream contracts, - bounded-list metadata, and capability provenance. -- OrthoConfig `7.3.1`: shared exit-code and error-remediation metadata. -- OrthoConfig `8.1.1` and `8.1.2`: reference CLI structured result and - enumerable-error behaviour. -- OrthoConfig `9.1.1` to `9.1.3`: profile metadata, redaction, and profile - store helpers. -- OrthoConfig `9.2.1` and `9.2.2`: delivery-target parsing and feedback - storage helpers. -- OrthoConfig `9.3.1` to `9.3.3`: execution-ledger metadata, run-ledger nouns, - and run-ledger helper APIs. - -## Canonical public vocabulary - -These command and flag spellings are the public grammar assumed by this -roadmap. Examples must use this list unless a task explicitly extends it. - -- Top-level commands: `build`, `check`, `clean`, `generate`, `graph`, - `context`, `skill-path`, `runs`, `profile`, and `feedback`. -- Resource verbs: `list`, `get`, `save`, `delete`, `add`, `send`, and `prune`. -- Structured output: `--json`. -- Non-interactive execution: `--no-input`. -- Destructive confirmation: `--force`. -- Mutation preview: `--dry-run`. -- Pagination: `--limit` and `--cursor`. -- Output and delivery: `--output` and `--deliver`. -- Display policy flags: `--color auto|always|never`, - `--emoji auto|always|never`, `--progress auto|always|never`, and - `--accessibility auto|on|off`. - -## Historical task traceability - -The following previous roadmap tasks were assessed during the CLI-roadmap -rewrite: - -- `1.1.1` to `1.3.3`, `2.1.1` to `2.3.2`, and completed `3.x` foundation - tasks through `3.13.2` moved to the archive as completed foundations. -- `3.4.5`, `3.4.6`, `3.8.3`, `3.11.4`, `3.12.3`, `3.13.3`, `3.14.1`, and - `3.14.3` to `3.14.11` remain active under their existing numbers. -- `3.14.2` was completed after restoring coverage for top-level action - expansion and complementary `command_available(...)` branches. -- `3.14.4` remains open per this pull request's `3.14.2` objective, even - though this branch introduces supporting `command_available(...)` behaviour. -- Phase 4 remains active because none of its formal-verification work has been - delivered yet. -- New CLI-redesign work starts at `3.15` and Phase 5 so historical numbers are - not reused. - -## 3. Friendly polish and agent-consistent CLI foundations - -Hypothesis: Netsuke can keep a pleasant, accessible local command-line -experience while making every command predictable for CI, editor integrations, -and agents. - -### 3.4. Graph and explanation work - -- [ ] 3.4.5. Extend the graph subcommand with an optional `--html` renderer. - - [ ] Keep raw graph data available for automation. - - [ ] Add `--output ` for file-based graph artefacts. - - [ ] Document how `graph --html --output graph.html` differs from - structured graph inspection. - -- [ ] 3.4.6. Evaluate whether `netsuke explain ` should exist. - - [ ] Compare `explain` with richer diagnostics and documentation links. - - [ ] Avoid adding the command unless it has a clear user-facing workflow. - - [ ] If accepted, add `explain` to the canonical vocabulary before examples - use it. - -### 3.8. Accessibility verification - -- [ ] 3.8.3. Verify accessible output with assistive technology. - - [ ] Test screen-reader behaviour for diagnostics, progress, and summaries. - - [ ] Validate reduced-motion and no-colour modes. - - [ ] Record findings in the accessibility documentation. - -### 3.11. Configuration precedence verification - -- [ ] 3.11.4. Add OrthoConfig precedence-ladder regression tests. - - [ ] Depend on OrthoConfig `5.2.3` for consumer boundary guidance. - - [ ] Preserve Netsuke-specific precedence expectations for manifest path, - display policies, locale, and profile selection. - - [ ] Verify that CLI flags override environment, profile, project, user, - system, and default configuration layers. - -### 3.12. Terminal rendering verification - -- [ ] 3.12.3. Add terminal rendering regression tests. - - [ ] Verify `--color auto|always|never` policy behaviour. - - [ ] Verify `--emoji auto|always|never` policy behaviour. - - [ ] Verify `--progress auto|always|never` policy behaviour. - - [ ] Verify `--accessibility auto|on|off` behaviour. - -### 3.13. CI guidance - -- [ ] 3.13.3. Revise CI-focused guidance for the canonical CLI. - - [ ] Replace legacy diagnostics-only examples with `--json --no-input`. - - [ ] Include `check --json --no-input` and `build --json --no-input`. - - [ ] Keep examples friendly for humans who maintain CI scripts. - -### 3.14. Conditional action planning - -- [x] 3.14.1. Record manifest-time condition semantics for actions and targets. - See [netsuke-design.md §2.5](netsuke-design.md). - - [x] State that `foreach` and `when` are evaluated before typed Abstract - Syntax Tree (AST) deserialization, intermediate representation (IR) - generation, and Ninja execution. - - [x] Document that build-time branching belongs in recipes unless a future - runtime-condition feature is designed. -- [x] 3.14.2. Apply `foreach` and `when` expansion to top-level `actions`. - Requires 2.2.3. See [netsuke-design.md §2.5](netsuke-design.md). - - [x] Preserve the existing implicit `phony: true` action behaviour after - expansion. - - [x] Support complementary branches such as `when: command_available(...)` - and `when: not command_available(...)`. -- [ ] 3.14.3. Lower target and action `deps` into implicit IR and Ninja - dependency edges. Requires 1.2.2 and 1.3.2. See - [netsuke-design.md §§2.4 and 5.3](netsuke-design.md). - - [ ] Keep `sources` in the explicit recipe-input class used for `ins` and - `$in`. - - [ ] Add a separate implicit dependency class for `deps` so they affect - ordering and rebuild decisions without appearing in recipe arguments. - - [ ] Align cycle detection, generated Ninja output, and user-facing - dependency documentation. -- [ ] 3.14.4. Add `command_available(name, **kwargs)` as a non-throwing - executable probe. Requires 3.5.1. See - [executable discovery](netsuke-design.md#executable-discovery-filter-which). - - [ ] Reuse the `which` resolver and cache. - - [ ] Return `false` for absent commands instead of raising - `netsuke::jinja::which::not_found`. - - [ ] Preserve argument validation diagnostics for invalid options. -- [ ] 3.14.5. Add regression coverage for conditional action dependency - manifests. - - [ ] Test action-level `when` and action-level `foreach`. - - [ ] Test complementary nextest and legacy branches select exactly one - action. - - [ ] Test absent-command fallback without invoking `shell()`. - - [ ] Test `deps` lowering in the IR and emitted Ninja build statements. -- [ ] 3.14.6. Add rule-level `deps_from` for compiler dependency imports. - Requires 3.14.3. See - [netsuke-design.md §2.3](netsuke-design.md#planned-compiler-dependency-import). - - [ ] Parse `deps_from.format` and `deps_from.depfile` without accepting - rule-level `deps` as an alias. - - [ ] Validate the initial `gcc` and `msvc` dependency formats. - - [ ] Lower `deps_from` into the IR action `depfile` and Ninja `deps` - attributes. - - [ ] Add parser, IR, Ninja output, and user-guide coverage once the feature - is implemented. -- [ ] 3.14.7. Escape backend dollar syntax after Netsuke placeholder lowering. - Requires 1.3.2. See [netsuke-design.md §§2.6 and 5.4](netsuke-design.md). - - [ ] Preserve shell variables such as `$PATH`, `${CARGO:-cargo}`, and - `$RUSTFLAGS` in generated Ninja by emitting literal dollars as `$$`. - - [ ] Keep the IR free of Ninja-specific dollar escaping. - - [ ] Add command and script regression tests covering shell variables, - `$in` / `$out`, and unrelated identifiers such as `$input`. -- [ ] 3.14.8. Make Jinja command helpers match the documented ergonomics. - Requires 2.2.4 and 3.14.4. See - [netsuke-design.md §§4.4 and 4.5](netsuke-design.md). - - [ ] Add `env(name, default=...)` without changing the existing missing and - invalid UTF-8 diagnostics. - - [ ] Implement or remove the documented `shell_escape` helper so the user - guide and code agree. - - [ ] Add `shell_join` and `compact` helpers for deliberate shell recipes. - - [ ] Add documentation and tests showing optional `RUSTFLAGS` construction - without shell parameter expansion. -- [ ] 3.14.9. Add structured recipe environment mappings. - Requires 3.14.7 and 3.14.8. See - [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). - - [ ] Parse rule, target, and action `env` mappings with `value`, `default`, - `prepend`, `append`, and `unset` operations. - - [ ] Merge rule-level and target/action-level environment bindings during - IR generation. - - [ ] Emit backend-specific environment setup without exposing Ninja variable - syntax in the manifest contract. - - [ ] Test platform path-list separators for `prepend` and `append`. -- [ ] 3.14.10. Add structured `exec` recipes for argv-safe commands. - Requires 3.14.8 and 3.14.9. See - [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). - - [ ] Extend the recipe union with `exec.program` and `exec.args`. - - [ ] Reject manifests that combine `exec` with `rule`, `command`, or - `script`. - - [ ] Preserve list-valued argument expressions without accidental shell word - splitting. - - [ ] Add Ninja output and execution tests for arguments containing spaces, - shell metacharacters, and empty optional values. -- [ ] 3.14.11. Surface selected conditional actions without recipe `echo`. - Requires 3.14.2 and 3.14.4. See - [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). - - [ ] Add target/action `description` support and let it override referenced - rule descriptions for the concrete edge. - - [ ] Report selected action descriptions in normal Ninja progress output. - - [ ] In verbose mode, report why manifest-time `when` branches were included - or skipped. - - [ ] Do not add generic `debug`, `info`, or `warn` manifest keys unless a - later diagnostics design defines severity semantics. - -### 3.15. Canonical CLI redesign - -- [ ] 3.15.1. Replace the pre-0.1.0 command surface with canonical names. - - [ ] Rename `manifest` to `generate`. - - [ ] Remove `build --emit`; use `generate --output`. - - [ ] Add `check`, `context`, `skill-path`, `runs`, `profile`, and - `feedback`. - - [ ] Rename `--file` to `--manifest`, keeping `-f` as an intentional - shorthand. - - [ ] Depend on OrthoConfig `7.1.1` to `7.1.3` for shared vocabulary policy - and global option glossary. - -- [ ] 3.15.2. Add non-interactive and mutation-safety guarantees. - - [ ] Add root `--no-input`. - - [ ] Make prompts impossible unless a future explicit interactive mode is - added. - - [ ] Require `--force` for destructive operations. - - [ ] Require or support `--dry-run` for consequential operations. - - [ ] Make bare `clean` fail fast with a corrective hint. - - [ ] Depend on OrthoConfig `7.2.1`, `7.2.2`, and `8.1.1` for shared - non-interactive and mutation metadata. - -- [ ] 3.15.3. Replace diagnostics-only JSON with canonical structured output. - - [ ] Remove `--diag-json` and `--output-format`. - - [ ] Add root `--json`. - - [ ] Emit exactly one JSON result document on successful JSON-mode commands. - - [ ] Emit exactly one JSON diagnostic document on failing JSON-mode commands. - - [ ] Suppress progress, colour, emoji, tracing, and timing text in JSON mode. - - [ ] Snapshot every v1 JSON schema. - - [ ] Depend on OrthoConfig `7.2.3` to `7.2.5`, `7.3.1`, `8.1.1`, and - `8.1.2` for shared result, stream, exit-code, and enumerable-error - metadata. - -- [ ] 3.15.4. Replace legacy output preferences with canonical policy flags. - - [ ] Replace `--colour-policy` with `--color auto|always|never`. - - [ ] Replace `--spinner-mode` and boolean `--progress` with - `--progress auto|always|never`. - - [ ] Replace `--no-emoji` with `--emoji auto|always|never`. - - [ ] Add `--accessibility auto|on|off`. - - [ ] Update OrthoConfig field integration, environment names, config - examples, localization keys, and tests. - - [ ] Depend on OrthoConfig `7.1.2`, `7.1.3`, and `7.2.3` for shared flag - vocabulary and dual-renderer metadata. - -- [ ] 3.15.5. Add stable exit codes and enumerable errors. - - [ ] Define the Netsuke exit-code taxonomy in the design docs. - - [ ] Ensure every enum-like failure lists valid values. - - [ ] Add tests for CLI enums, config enums, manifest enums, stdlib options, - delivery schemes, profile names, and run states. - - [ ] Depend on OrthoConfig `7.3.1` and `8.1.2` for shared exit-code and - enumerable-error metadata. - -- [ ] 3.15.6. Bound every large response. - - [ ] Add `--limit` and `--cursor` where lists can grow. - - [ ] Add `--target` and `--depth` to graph inspection. - - [ ] Add truncation hints to JSON and human output. - - [ ] Bound build-log previews in JSON mode and reference log files. - - [ ] Depend on OrthoConfig `7.2.6` for bounded-list metadata. - -- [ ] 3.15.7. Add CLI vocabulary linting. - - [ ] Generate a command inventory from the real command surface. - - [ ] Fail CI on banned verbs and flags. - - [ ] Snapshot the canonical command surface. - - [ ] Keep the lint aligned with OrthoConfig `7.1.1` to `7.1.3`. - -## 4. Formal verification and property testing - -Hypothesis: Netsuke can state and check its core compiler invariants strongly -enough that future features do not erode deterministic build behaviour. This -phase preserves the detailed formal-verification workload from the previous -roadmap rather than compressing it into broad strategy items. - -Objective: To add bounded formal verification and generated testing where the -repository's semantic risk is highest, while keeping the existing build, lint, -and test workflow intact. See -[formal-verification-methods-in-netsuke.md](formal-verification-methods-in-netsuke.md). - -### 4.1. Verification tooling and gating - -- [x] 4.1.1. Add Kani tooling and local smoke targets. See - [formal-verification-methods-in-netsuke.md §Repository integration plan](formal-verification-methods-in-netsuke.md#repository-integration-plan). - - [x] Pin the supported Kani version under `tools/kani/`. - - [x] Add `scripts/install-kani.sh`. - - [x] Add `make kani`, `make kani-full`, and `make formal-pr`. -- [x] 4.1.2. Add a dedicated `kani-smoke` continuous integration (CI) job. - Requires 4.1.1. See - [formal-verification-methods-in-netsuke.md §Continuous integration (CI)](formal-verification-methods-in-netsuke.md#continuous-integration-ci). - - [x] Keep the existing `build-test` job unchanged. - - [x] Run only the bounded smoke harness set on pull requests. - - [x] Cache Kani tool downloads separately from ordinary Cargo artefacts. -- [ ] 4.1.3. Record the phase-1 scope boundary for Verus and Stateright. See - [formal-verification-methods-in-netsuke.md §Optional Verus proof kernel](formal-verification-methods-in-netsuke.md#optional-verus-proof-kernel) - and - [formal-verification-methods-in-netsuke.md §Stateright remains deferred](formal-verification-methods-in-netsuke.md#stateright-remains-deferred). - - [ ] Document Verus as optional and proof-kernel-only. - - [ ] Document Stateright as deferred until Netsuke gains a stateful - concurrent subsystem. - -### 4.2. Intermediate representation verification - -- [ ] 4.2.1. Add Kani harnesses for manifest-to-IR safety checks. Requires - 4.1.1. See - [formal-verification-methods-in-netsuke.md §Kani for the IR core](formal-verification-methods-in-netsuke.md#kani-for-the-ir-core). - - [ ] Prove duplicate-output rejection on bounded manifests (up to 10 nodes, - depth limit 20 edges). - - [ ] Prove empty-rule, multiple-rule, and missing-rule error selection. - - [ ] Prove self-edge and small bounded multi-node cycle rejection (same - limits). - - [ ] Prove missing dependencies do not create false cycles. -- [ ] 4.2.2. Add Kani harnesses for cycle canonicalization. Requires 4.2.1. - See - [formal-verification-methods-in-netsuke.md §Optional Verus proof kernel](formal-verification-methods-in-netsuke.md#optional-verus-proof-kernel). - - [ ] Prove preserved length and closed-cycle output. - - [ ] Prove the interior node multiset is preserved. - - [ ] Prove the selected start node is stable under the current ordering - rule. -- [ ] 4.2.3. Add Kani harnesses for command interpolation. Requires 4.1.1. See - [formal-verification-methods-in-netsuke.md §Kani for command interpolation](formal-verification-methods-in-netsuke.md#kani-for-command-interpolation). - - [ ] Prove `$in` and `$out` rewrite only at valid token boundaries (bounded - to 256-character commands with at most 8 placeholders). - - [ ] Prove backtick-delimited regions are preserved. - - [ ] Prove unmatched backticks are rejected. - - [ ] Prove successful results satisfy the current `shlex` guard. - -### 4.3. Determinism and manifest property testing - -- [ ] 4.3.1. Add Proptest coverage for deterministic Ninja emission. Requires - 4.1.1. See the - [Proptest section](formal-verification-methods-in-netsuke.md#proptest-for-determinism-and-manifest-semantics). - - [ ] Prove Ninja output is stable across equivalent insertion orders - (generated graphs bounded to 50 actions and 100 edges). - - [ ] Prove `default` target ordering is stable. - - [ ] Prove `path_key` is invariant for equivalent output sets. -- [ ] 4.3.2. Add Proptest coverage for manifest expansion invariants. Requires - 4.1.1. See the - [Proptest section](formal-verification-methods-in-netsuke.md#proptest-for-determinism-and-manifest-semantics). - - [ ] Prove `foreach` preserves non-control fields. - - [ ] Prove `when` is removed after evaluation. - - [ ] Prove `item` and `index` are injected correctly for each expansion. - - [ ] Prove static targets still honour `when`. -- [ ] 4.3.3. Add Proptest coverage for render stability. Requires 4.3.2. See - the - [Proptest section](formal-verification-methods-in-netsuke.md#proptest-for-determinism-and-manifest-semantics). - - [ ] Prove rendering is idempotent after Jinja syntax is exhausted. - - [ ] Prove variable rendering uses the intended snapshot semantics. - -### 4.4. Contract documentation and optional proof kernels - -- [ ] 4.4.1. Document the command placeholder contract in the README. Requires - 4.2.3. See - [formal-verification-methods-in-netsuke.md §Command placeholder contract](formal-verification-methods-in-netsuke.md#command-placeholder-contract). - - [ ] Add a "Security and command interpolation" section to the README. - - [ ] State the supported placeholders explicitly. - - [ ] State the current backtick-handling boundary explicitly. - - [ ] State whether `shlex::split` is part of the semantic acceptance - contract. -- [ ] 4.4.2. Document which dependency kinds participate in cycle detection in - the user guide. Requires 4.2.1. See - [formal-verification-methods-in-netsuke.md §Cycle-participation contract](formal-verification-methods-in-netsuke.md#cycle-participation-contract). - - [ ] Decide whether order-only dependencies participate. - - [ ] Decide whether implicit outputs participate. - - [ ] Document the chosen rule in the user guide's dependency and build-graph - semantics chapter. - - [ ] Align implementation, tests, and documentation with the chosen rule. -- [ ] 4.4.3. Evaluate a minimal Verus proof kernel for cycle canonicalization. - Requires 4.2.2 and 4.1.3. See - [formal-verification-methods-in-netsuke.md §Optional Verus proof kernel](formal-verification-methods-in-netsuke.md#optional-verus-proof-kernel). - - [ ] Keep the proof outside Cargo. - - [ ] Use proof-specific model types rather than production `HashMap` - structures. - - [ ] Accept the proof only if it remains narrower and cheaper than the Kani - equivalent. - -**Success criterion:** Netsuke ships bounded Kani smoke checks for the IR core, -generated property tests for deterministic emission and manifest semantics, and -documented verification contracts that keep optional Verus work narrow and -defer Stateright until the architecture justifies model checking. - -## 5. Agent-consistent compounding features - -Hypothesis: Netsuke becomes more valuable across repeated invocations when -humans, CI systems, editors, and agents can discover its surface, reuse local -configuration, inspect run history, route artefacts, and report friction. - -### 5.1. Context and schema generation - -- [ ] 5.1.1. Implement `netsuke context --json`. - - [ ] Emit compact versioned JSON by default. - - [ ] Include commands, flags, enums, exit codes, result schemas, - diagnostics schema, config schema, manifest schema, and stdlib metadata. - - [ ] Add `--detail` for expanded descriptions. - - [ ] Depend on OrthoConfig `5.2.3`, `6.1.1`, `6.1.2`, `6.2.1`, - `6.2.2`, `6.2.3`, and `7.2.7`. - -- [ ] 5.1.2. Add Netsuke-specific manifest and build-plan context. - - [ ] Include bounded target, default-target, graph, and stdlib previews. - - [ ] Include truncation hints for omitted manifest-derived detail. - - [ ] Keep implementation-adapter names out of public command examples. - -- [ ] 5.1.3. Implement `netsuke skill-path`. - - [ ] Add `docs/skills/netsuke/SKILL.md`. - - [ ] Validate the skill manifest against `netsuke context --json`. - - [ ] Depend on OrthoConfig `6.3.1` and `6.3.2`. - -- [ ] 5.1.4. Add schema and description-budget validation. - - [ ] Snapshot compact and detailed context output. - - [ ] Enforce description-size budgets in CI. - - [ ] Fail validation when the command surface and context drift. - -### 5.2. Run ledger - -- [ ] 5.2.1. Define the Netsuke run record model. - - [ ] Record run ID, command, targets, manifest fingerprint, status, - exit code, timings, artefacts, and log paths. - - [ ] Keep `runs` as the public noun to avoid collision with build-job - parallelism. - - [ ] Depend on OrthoConfig `9.3.1` and `9.3.2`. - -- [ ] 5.2.2. Persist Netsuke run records. - - [ ] Store project-local records under `.netsuke/runs/`. - - [ ] Recover cleanly from interrupted runs. - - [ ] Treat run persistence as product state, not generic configuration. - - [ ] Depend on OrthoConfig `9.3.3` where its helper APIs are available. - -- [ ] 5.2.3. Implement `runs list`, `runs get`, and `runs prune`. Requires: - 5.2.1, 5.2.2. - - [ ] Support `--json` on all run commands. - - [ ] Bound list output with `--limit` and `--cursor`. - - [ ] Require `--force` for pruning. - - [ ] Include recovery hints for interrupted builds. - -- [ ] 5.2.4. Add run-ledger validation and documentation. Requires: 5.2.3. - - [ ] Test interrupted writes and corrupted record recovery. - - [ ] Test human and JSON rendering. - - [ ] Document run history for local users, CI, and agents. - -### 5.3. Profiles - -- [ ] 5.3.1. Integrate named profiles with Netsuke configuration. - - [ ] Add root `--profile `. - - [ ] Apply precedence: - defaults < system config < user config < project config < profile < - environment < CLI. - - [ ] Surface available profiles in `context --json`. - - [ ] Depend on OrthoConfig `9.1.1`. - -- [ ] 5.3.2. Define profile redaction and secret handling. - - [ ] Avoid storing secrets by default. - - [ ] Redact sensitive values from human output and `context --json`. - - [ ] Depend on OrthoConfig `9.1.2`. - -- [ ] 5.3.3. Implement profile commands. - - [ ] Add `profile save`, `profile list`, `profile get`, and - `profile delete`. - - [ ] Require `--force` for destructive profile deletion. - - [ ] Depend on OrthoConfig `9.1.3`; if unavailable, implement only the - Netsuke-local adapter and mark the helper dependency as outstanding. - -- [ ] 5.3.4. Add profile validation and documentation. - - [ ] Test every precedence boundary. - - [ ] Test missing, invalid, and redacted profile values. - - [ ] Document local and CI profile workflows. - -### 5.4. Delivery and feedback - -- [ ] 5.4.1. Add structured delivery for Netsuke-owned artefacts. - - [ ] Support `--deliver=stdout`, `--deliver=file:`, and - `--deliver=webhook:` where applicable. - - [ ] Write file deliveries atomically. - - [ ] Surface webhook HTTP status in JSON results. - - [ ] Require explicit authenticated endpoint configuration for - `deliver:webhook`, including supported authentication schemes and required - configuration fields. - - [ ] Bound webhook timeouts and retry behaviour with documented maximum - retry counts, backoff strategy, and backoff limits. - - [ ] Enforce strict TLS and certificate authority validation by default, - document any override options, and specify certificate pinning behaviour. - - [ ] Redact webhook secrets from logs and JSON diagnostics, including - headers, tokens, credentials, and query parameters. - - [ ] Link implementation acceptance to - [`security-network-command-audit.md`](security-network-command-audit.md) - so `deliver:webhook` code paths cannot ship before meeting these - requirements. - - [ ] Depend on OrthoConfig `9.2.1` for generic delivery-target parsing. - -- [ ] 5.4.2. Keep delivery scoped to product-owned artefacts. - - [ ] Support generated manifests, graph output, reports, and JSON result - envelopes. - - [ ] Do not promise arbitrary build-output delivery until manifest artefact - ownership is modelled. - - [ ] Enumerate valid delivery schemes on error. - -- [ ] 5.4.3. Implement local-first feedback. - - [ ] Add `feedback add`, `feedback list`, and `feedback send`. - - [ ] Store feedback as JSON Lines locally by default. - - [ ] Require explicit upstream configuration and `feedback send --force` - for network submission. - - [ ] Depend on OrthoConfig `9.2.2` for generic feedback storage helpers. - -- [ ] 5.4.4. Add delivery and feedback validation. - - [ ] Test atomic file writes, webhook status reporting, and invalid schemes. - - [ ] Test local feedback storage and upstream-disabled behaviour. - - [ ] Surface delivery and feedback capabilities in `context --json`. - -### 5.5. Agent-facing validation and documentation - -- [ ] 5.5.1. Integrate the CLI vocabulary lint. - - [ ] Fail CI on banned verbs and flags. - - [ ] Check examples in docs as well as the command inventory. - - [ ] Depend on OrthoConfig `7.1.1` to `7.1.3`. - -- [ ] 5.5.2. Add non-interactive and stream-purity tests. - - [ ] Verify commands do not wait for stdin. - - [ ] Verify successful JSON mode writes exactly one stdout document and - empty stderr. - - [ ] Verify failing JSON mode writes empty stdout and exactly one stderr - diagnostic document. - - [ ] Depend on OrthoConfig `7.2.1`, `7.2.5`, and `8.1.1`. - -- [ ] 5.5.3. Add error-remediation and exit-code tests. - - [ ] Verify enum-like failures enumerate valid values. - - [ ] Verify stable exit classes for usage, manifest, not-found, external - tool, delivery, and interruption failures. - - [ ] Depend on OrthoConfig `7.3.1` and `8.1.2`. - -- [ ] 5.5.4. Update user and contributor documentation. - - [ ] Add automation examples that use only canonical vocabulary. - - [ ] Keep human-first local examples beside automation examples. - - [ ] Cross-link the archive so reviewers can trace where historical work - moved. +# Netsuke implementation roadmap + +This roadmap translates the [netsuke-design.md](netsuke-design.md) document +into a phased, actionable implementation plan. Each phase has a clear objective +and a checklist of tasks that must be completed to meet the success criteria. + +## 1. The Static Core + +Objective: To create a minimal, working build compiler capable of handling +static manifests without any templating. This phase validates the entire static +compilation pipeline from parsing to execution. + +### 1.1. CLI and manifest parsing + +- [x] 1.1.1. Implement initial clap CLI structure for build command and global + options. See [netsuke-design.md §8.2](netsuke-design.md). + - [x] Define --file, --directory, and --jobs options. +- [x] 1.1.2. Define core Abstract Syntax Tree data structures in `src/ast.rs`. + - [x] Implement NetsukeManifest, Rule, Target, StringOrList, and Recipe + structs. +- [x] 1.1.3. Annotate AST structs with serde attributes. + - [x] Add `#[derive(Deserialize)]` and `#[serde(deny_unknown_fields)]` to + enable serde_saphyr parsing. +- [x] 1.1.4. Implement netsuke_version field parsing with semver validation. +- [x] 1.1.5. Support `phony` and `always` boolean flags on targets. +- [x] 1.1.6. Parse actions list, treating each entry as a target with + `phony: true`. +- [x] 1.1.7. Implement YAML parsing logic to deserialize static Netsukefile into + NetsukeManifest AST. + +### 1.2. Intermediate Representation and validation + +- [x] 1.2.1. Define IR data structures in `src/ir.rs`. See + [netsuke-design.md §5.2](netsuke-design.md). + - [x] Implement BuildGraph, Action, and BuildEdge structs. + - [x] Keep IR backend-agnostic per design. +- [x] 1.2.2. Implement ir::from_manifest transformation logic. See + [netsuke-design.md §5.3](netsuke-design.md). + - [x] Convert AST to BuildGraph IR. +- [x] 1.2.3. Consolidate and deduplicate rules into ir::Action structs based on + property hash during transformation. +- [x] 1.2.4. Implement validation for rule, command, and script references. + - [x] Ensure references are valid and mutually exclusive. +- [x] 1.2.5. Implement cycle detection algorithm using depth-first search. See + [netsuke-design.md §5.3](netsuke-design.md). + - [x] Fail compilation on circular dependency detection. + +### 1.3. Code generation and execution + +- [x] 1.3.1. Implement Ninja file synthesizer in `src/ninja_gen.rs`. See + [netsuke-design.md §5.4](netsuke-design.md). + - [x] Traverse BuildGraph IR. +- [x] 1.3.2. Generate Ninja rule and build statements. + - [x] Write rule statements from ir::Action structs. + - [x] Write build statements from ir::BuildEdge structs. +- [x] 1.3.3. Implement process management in `main.rs`. + - [x] Invoke ninja executable as subprocess using `std::process::Command`. + +**Success criterion:** Netsuke can successfully take a Netsukefile without +Jinja syntax, compile it to a `build.ninja` file, and execute it via the ninja +subprocess to produce the correct build artefacts. Validated via CI workflow. + +## 2. The Dynamic Engine + +Objective: To integrate the minijinja templating engine, enabling dynamic build +configurations with variables, control flow, and custom functions. + +### 2.1. Jinja integration + +- [x] 2.1.1. Integrate the `minijinja` crate into the build pipeline. See + [netsuke-design.md §4.1](netsuke-design.md). +- [x] 2.1.2. Implement data-first parsing pipeline. + - [x] Stage 2: Parse manifest into `serde_json::Value` (Initial YAML Parsing). + - [x] Stage 3: Expand `foreach` and `when` entries with Jinja environment + (Template Expansion). + - [x] Stage 4: Deserialize expanded tree into typed AST and render remaining + string fields (Deserialization & Final Rendering). +- [x] 2.1.3. Create minijinja::Environment and populate with global vars from + manifest. See [netsuke-design.md §4.2](netsuke-design.md). + +### 2.2. Dynamic features and custom functions + +- [x] 2.2.1. Remove global first-pass Jinja parsing. + - [x] Ensure manifests are valid YAML before any templating occurs. +- [x] 2.2.2. Restrict Jinja expressions to string values only. + - [x] Forbid structural tags such as `{% if %}` and `{% for %}`. +- [x] 2.2.3. Implement `foreach` and `when` keys for target generation. See + [netsuke-design.md §2.5](netsuke-design.md). + - [x] Expose `item` and optional `index` variables. + - [x] Layer per-iteration locals over `target.vars` and manifest globals for + subsequent rendering phases. +- [x] 2.2.4. Implement `env(var_name)` custom Jinja function for reading system + environment variables. See [netsuke-design.md §4.4](netsuke-design.md). +- [x] 2.2.5. Implement `glob(pattern)` custom function for file path globbing. + See [netsuke-design.md §4.4](netsuke-design.md). + - [x] Sort results lexicographically. +- [x] 2.2.6. Support user-defined Jinja macros declared in top-level macros + list. See [netsuke-design.md §4.3](netsuke-design.md). + - [x] Register macros with environment before rendering. + +**Success criterion:** Netsuke can successfully build a manifest that uses +variables, conditional logic within string values, the `foreach` and `when` +keys, custom macros, and the `glob()` function to discover and operate on +source files. + +### 2.3. YAML parser migration + +- [x] 2.3.1. Draft ADR evaluating maintained replacements for `serde_yml`. + - [x] Evaluate `serde_yaml_ng` and alternatives. + - [x] Record migration decision. +- [x] 2.3.2. Migrate parser to `serde_saphyr`. + - [x] Exercise manifest fixtures to capture compatibility notes. + - [x] Document required mitigations. + +## 3. The "Friendly" polish + +Objective: To implement the advanced features that deliver a superior, secure, +and robust user experience, focusing on security, error reporting, the standard +library, and CLI ergonomics. + +### 3.1. Security and shell escaping + +- [x] 3.1.1. Integrate the `shell-quote` crate. +- [x] 3.1.2. Mandate shell-quote use for variable substitutions. See + [netsuke-design.md §6.2](netsuke-design.md). + - [x] Prevent command injection during IR generation. + - [x] Validate final command string with shlex. +- [x] 3.1.3. Emit POSIX-sh-compatible quoting. See + [netsuke-design.md §6.3](netsuke-design.md). + - [x] Use portable single-quote style rather than Bash-only forms. + - [x] Document and enforce bash execution if Bash-specific quoting is + required. +- [x] 3.1.4. Validate final command string is parsable using shlex crate after + interpolation. + +### 3.2. Actionable error reporting + +- [x] 3.2.1. Adopt `anyhow` and `thiserror` error handling strategy. See + [netsuke-design.md §7.2](netsuke-design.md). +- [x] 3.2.2. Define structured error types using thiserror in library modules. + See [netsuke-design.md §7.2](netsuke-design.md). + - [x] Implement IrGenError::RuleNotFound, IrGenError::CircularDependency, and + similar types. +- [x] 3.2.3. Use anyhow in application logic for human-readable context. + - [x] Apply `.with_context()` for error propagation. +- [x] 3.2.4. Use `miette` to render diagnostics with source spans and helpful + messages. See [netsuke-design.md §7.2](netsuke-design.md). +- [x] 3.2.5. Refactor all error-producing code to provide clear, contextual, and + actionable error messages. See [netsuke-design.md §7](netsuke-design.md). + +### 3.3. Template standard library + +- [x] 3.3.1. Implement basic file-system tests. See + [netsuke-design.md §4.7](netsuke-design.md). + - [x] Implement `dir`, `file`, `symlink`, `pipe`, `block_device`, + `char_device`, and legacy `device` tests. +- [x] 3.3.2. Implement path and file filters. See + [netsuke-design.md §4.7](netsuke-design.md). + - [x] Implement basename, dirname, with_suffix, realpath, contents, hash, and + similar filters. +- [x] 3.3.3. Implement generic collection filters. See + [netsuke-design.md §4.7](netsuke-design.md). + - [x] Implement `uniq`, `flatten`, and `group_by`. +- [x] 3.3.4. Implement network and command functions/filters. See + [netsuke-design.md §4.7](netsuke-design.md). + - [x] Implement fetch, shell, and grep. + - [x] Ensure shell marks templates as impure to disable caching. +- [x] 3.3.5. Implement time helpers. See + [netsuke-design.md §4.7](netsuke-design.md). + - [x] Implement `now` and `timedelta`. + +### 3.4. CLI and feature completeness + +- [x] 3.4.1. Implement `clean` subcommand. See + [netsuke-design.md §8.3](netsuke-design.md). + - [x] Invoke `ninja -t clean`. +- [x] 3.4.2. Implement `graph` subcommand. See + [netsuke-design.md §8.3](netsuke-design.md). + - [x] Invoke `ninja -t graph` to output DOT representation of dependency + graph. +- [x] 3.4.3. Refine all CLI output for clarity. + - [x] Ensure help messages are descriptive. + - [x] Ensure command feedback is intuitive. +- [x] 3.4.4. Implement `manifest` subcommand. See + [netsuke-design.md §8.3](netsuke-design.md). + - [x] Persist generated Ninja file without executing. + - [x] Include integration tests for writing to disk and streaming to stdout. +- [ ] 3.4.5. Extend graph subcommand with optional `--html` renderer. + - [ ] Produce browsable graph visualization. + - [ ] Document text-only fallback workflow. +- [ ] 3.4.6. Evaluate `netsuke explain ` command for diagnostic codes. + - [ ] Capture decision and rationale in architecture docs. + +### 3.5. Executable discovery filter + +- [x] 3.5.1. Implement cross-platform `which` MiniJinja filter and function + alias. See [netsuke-design.md §4.7](netsuke-design.md). + - [x] Expose `all`, `canonical`, `fresh`, and `cwd_mode` keyword arguments. +- [x] 3.5.2. Integrate finder with Stage 3/4 render cache. + - [x] Include `PATH`, optional `PATHEXT`, current directory, and option flags + in memoization key. + - [x] Keep helper pure by default. +- [x] 3.5.3. Provide LRU cache with metadata self-healing. + - [x] Avoid stale hits. + - [x] Honour `fresh=true` without discarding cached entries. +- [x] 3.5.4. Emit actionable diagnostics. + - [x] Implement `netsuke::jinja::which::not_found` and + `netsuke::jinja::which::args` diagnostics. + - [x] Include PATH previews and platform-appropriate hints. +- [x] 3.5.5. Cover POSIX and Windows behaviour with tests. + - [x] Test canonicalization, list-all mode, and cache validation with unit + tests. + - [x] Add MiniJinja fixtures asserting deterministic renders across repeated + invocations. + +### 3.6. Onboarding and defaults + +- [x] 3.6.1. Ensure default subcommand builds manifest defaults. + - [x] Emit guided error and hint for missing-manifest scenarios. See CLI + design. + - [x] Guard with integration tests. +- [x] 3.6.2. Curate OrthoConfig-generated Clap help output. + - [x] Ensure every subcommand and flag has plain-language, localizable + description. See style guide. +- [x] 3.6.3. Publish "Hello World" quick-start walkthrough. + - [x] Demonstrate running Netsuke end-to-end. + - [x] Exercise via documentation test or example build fixture. + +### 3.7. Localization with Fluent + +- [x] 3.7.1. Externalize user-facing strings into Fluent `.ftl` bundles. + - [x] Implement compile-time audit that fails CI on missing message keys. +- [x] 3.7.2. Implement locale resolution. + - [x] Support `--locale`, `NETSUKE_LOCALE`, configuration files, and system + defaults. + - [x] Fall back to `en-US` when translations are absent. +- [x] 3.7.3. Translator tooling and documentation published. + - [x] `docs/translators-guide.md` covers FTL syntax, key conventions, variable + catalogue, plural forms, and adding new locales. + - [x] Plural form examples (`example.files_processed`, `example.errors_found`) + added to en-US and es-ES FTL files with corresponding key constants. + - [x] Localization smoke tests verify en-US and es-ES message resolution. + +### 3.8. Accessibility and Section 508 compliance + +- [x] 3.8.1. Add accessible output mode. + - [x] Auto-enable for `TERM=dumb`, `NO_COLOR`, or explicit config. + - [x] Replace spinners with static status lines. + - [x] Guarantee textual labels for every status. +- [x] 3.8.2. Respect accessibility preferences. + - [x] Honour `NO_COLOR`, `NETSUKE_NO_EMOJI`, and ASCII-only preferences. + - [x] Keep semantic prefixes (Error, Warning, Success) in all modes. +- [ ] 3.8.3. Conduct assistive technology verification. + - [ ] Test with NVDA on Windows, VoiceOver on macOS, and a Linux screen + reader. + - [ ] Document results and corrective actions. + +### 3.9. Real-time feedback and progress + +- [x] 3.9.1. Integrate `indicatif::MultiProgress`. + - [x] Surface the six pipeline stages with persistent summaries. + - [x] Apply localization-aware labelling. +- [x] 3.9.2. Parse Ninja status lines to drive task progress. + - [x] Emit fallback textual updates when stdout is not a TTY or accessible + mode is active. +- [x] 3.9.3. Capture per-stage timing metrics in verbose mode. + - [x] Include metrics in completion summary. + - [x] Avoid noise in default output. + +### 3.10. Output channels and diagnostics + +- [x] 3.10.1. Guarantee status message and subprocess output ordering. + - [x] Stream Netsuke status messages to stderr. + - [x] Preserve subprocess output ordering on stdout. + - [x] Verify with end-to-end tests redirecting each stream. +- [x] 3.10.2. Introduce consistent prefixes for log differentiation. + - [x] Use localizable prefixes or indentation rules. + - [x] Support ASCII and Unicode themes. +- [x] 3.10.3. Deliver `--diag-json` machine-readable diagnostics mode. + - [x] Document schema. + - [x] Add snapshot tests to guard compatibility. + +### 3.11. Configuration and preferences + +- [x] 3.11.1. Introduce `CliConfig` struct derived with `OrthoConfig`. See + [ortho-config-users-guide.md](ortho-config-users-guide.md). + - [x] Share schema across Clap integration, configuration files, and + environment variables. + - [x] Cover verbosity, colour policy, locale, spinner mode, output format, + default targets, and theme. +- [ ] 3.11.2. Discover configuration files in project and user scopes. + - [ ] Honour env overrides and CLI precedence. + - [ ] Add integration tests for each precedence tier. See + [ortho-config-users-guide.md](ortho-config-users-guide.md). +- [ ] 3.11.3. Expose `--config ` and `NETSUKE_CONFIG`. + - [ ] Select alternative config files. + - [ ] Ship annotated sample configs in documentation. +- [ ] 3.11.4. Add regression tests for OrthoConfig precedence ladder. + - [ ] Test defaults < file < env < CLI precedence. See + [ortho-config-users-guide.md](ortho-config-users-guide.md). + +### 3.12. Visual design validation + +- [ ] 3.12.1. Define design tokens for colours, symbols, and spacing. + - [ ] Wire tokens through CLI theme system. + - [ ] Ensure ASCII and Unicode modes remain consistent. +- [ ] 3.12.2. Snapshot progress and status output for themes. + - [ ] Cover unicode and ascii themes. + - [ ] Guard alignment and wrapping against regressions. +- [ ] 3.12.3. Test output renderings across common terminals. + - [ ] Test Windows Console, PowerShell, and xterm-compatible shells. + - [ ] Document any conditional handling. + +### 3.13. User journey support + +- [ ] 3.13.1. Add smoke tests for novice flows. + - [ ] Test first run success, missing manifest, and help output. + - [ ] Confirm UX matches documented journey. +- [ ] 3.13.2. Extend user documentation with advanced usage chapter. + - [ ] Cover `clean`, `graph`, `manifest`, configuration layering, and JSON + diagnostics. +- [ ] 3.13.3. Provide CI-focused guidance. + - [ ] Include examples of consuming JSON diagnostics. + - [ ] Document configuring quiet/verbose modes for automation. + +**Success criterion:** Netsuke ships a localizable, accessible, and fully +configurable CLI that delivers real-time feedback, machine-readable +diagnostics, and the onboarding experience defined in the Netsuke CLI design +document. diff --git a/docs/users-guide.md b/docs/users-guide.md index 64a6c088..7ba2f61a 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -681,6 +681,66 @@ Environment variables use the `NETSUKE_` prefix (for example, `NETSUKE_JOBS=8`). Use `__` to separate nested keys when matching structured configuration. +The layered schema is rooted in `CliConfig`. Netsuke currently accepts these +top-level configuration keys: + +- `file = "Netsukefile"` +- `jobs = 8` +- `verbose = true|false` +- `locale = "en-US"` +- `fetch_allow_scheme = ["https"]` +- `fetch_allow_host = ["example.com"]` +- `fetch_block_host = ["blocked.example.com"]` +- `fetch_default_deny = true|false` +- `accessible = true|false` +- `progress = true|false` +- `theme = "auto"|"unicode"|"ascii"` +- `no_emoji = true|false` +- `spinner_mode = "auto"|"enabled"|"disabled"` +- `colour_policy = "auto"|"always"|"never"` +- `output_format = "human"` + +Build-only defaults live under `[cmds.build]`: + +- `emit = "out.ninja"` +- `targets = ["hello"]` + +Example: + +```toml +verbose = true +locale = "es-ES" +colour_policy = "auto" +spinner_mode = "auto" +output_format = "human" +theme = "ascii" +progress = true +accessible = false + +[cmds.build] +targets = ["hello"] +``` + +`[cmds.build].targets` is used only when the user does not pass explicit build +targets on the command line. Explicit CLI targets always win. + +`theme` is the canonical presentation setting. `no_emoji = true` remains as a +compatibility alias and resolves to the ASCII theme. Conflicting settings such +as `theme = "unicode"` with `no_emoji = true` are rejected during configuration +merge. + +`spinner_mode = "disabled"` is equivalent to disabling progress output unless +the user explicitly sets `progress = true`, which is treated as a conflict. +Likewise, `spinner_mode = "enabled"` conflicts with `progress = false`. + +`output_format = "json"` is intentionally rejected for now. Roadmap item +`3.10.3` will add JSON diagnostics; until then, the only supported value is +`"human"`. + +`colour_policy` is accepted and layered today, so users can standardize their +preferred setting, but Netsuke does not yet emit coloured terminal output, so +this value currently has no visible effect. + Use `--locale `, `NETSUKE_LOCALE`, or a `locale = "..."` entry in a configuration file to select localized CLI copy and error messages. Locale precedence is: command-line flag, environment variable, configuration file, diff --git a/src/cli/config.rs b/src/cli/config.rs index d0720f17..6e456b2f 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,72 +1,33 @@ -//! Typed CLI configuration preferences and compatibility resolution helpers. +//! Layered CLI configuration schema. //! -//! This module defines the user-facing preference surface that is layered -//! through `OrthoConfig` across defaults, config files, environment variables, -//! and CLI flags. +//! [`CliConfig`] is the single typed schema used for configuration discovery +//! and merging. It captures global CLI settings plus per-subcommand defaults +//! under the `cmds` namespace. -use clap::Args; -use ortho_config::OrthoConfig; -use serde::de::{self, Deserializer}; +use clap::ValueEnum; +use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook}; use serde::{Deserialize, Serialize}; use std::fmt; +use std::path::PathBuf; use std::str::FromStr; +use super::validation_error; +use crate::host_pattern::HostPattern; use crate::theme::ThemePreference; -/// Structured parse error for CLI configuration enums. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseConfigEnumError { - /// The original raw value that failed to parse. - pub raw: Box, - /// Canonical valid option strings for the enum. - pub valid_options: &'static [&'static str], -} - -impl fmt::Display for ParseConfigEnumError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "invalid value '{}'. Valid options: {}", - self.raw, - self.valid_options.join(", ") - ) - } -} - -impl std::error::Error for ParseConfigEnumError {} - -/// Colour output policy for human-readable CLI output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +/// Colour-output policy accepted by layered configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] +#[serde(rename_all = "kebab-case")] pub enum ColourPolicy { - /// Honour terminal and environment auto-detection. + /// Follow the host environment. #[default] Auto, - /// Force colour-capable behaviour even when `NO_COLOR` is set. + /// Force colour output on when available. Always, - /// Disable colour-capable behaviour and treat output as `NO_COLOR`. + /// Force colour output off. Never, } -impl ColourPolicy { - /// Canonical list of valid option strings. - pub const VALID_OPTIONS: &'static [&'static str] = &["auto", "always", "never"]; - - /// Parse a raw colour policy value. - /// - /// # Errors - /// - /// Returns the canonical valid options when parsing fails. - pub fn parse_raw(s: &str) -> Result { - let trimmed = s.trim().to_ascii_lowercase(); - match trimmed.as_str() { - "auto" => Ok(Self::Auto), - "always" => Ok(Self::Always), - "never" => Ok(Self::Never), - _ => Err(Self::VALID_OPTIONS), - } - } -} - impl fmt::Display for ColourPolicy { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -78,57 +39,30 @@ impl fmt::Display for ColourPolicy { } impl FromStr for ColourPolicy { - type Err = ParseConfigEnumError; + type Err = String; fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) + ::from_str(s, true).map_err(|_| format!("invalid colour policy '{s}'")) } } -impl<'de> Deserialize<'de> for ColourPolicy { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserialize_config_enum(deserializer, "colour policy") - } -} - -/// Progress spinner display mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +/// Spinner and progress rendering policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] +#[serde(rename_all = "kebab-case")] pub enum SpinnerMode { - /// Emit progress updates. + /// Follow Netsuke's default progress behaviour. #[default] + Auto, + /// Force progress summaries on. Enabled, - /// Suppress progress updates. + /// Disable progress summaries. Disabled, } -impl SpinnerMode { - /// Canonical list of valid option strings. - pub const VALID_OPTIONS: &'static [&'static str] = &["enabled", "disabled"]; - - /// Parse a raw spinner mode value. - /// - /// # Errors - /// - /// Returns the canonical valid options when parsing fails. - pub fn parse_raw(s: &str) -> Result { - let trimmed = s.trim().to_ascii_lowercase(); - match trimmed.as_str() { - "enabled" => Ok(Self::Enabled), - "disabled" => Ok(Self::Disabled), - _ => Err(Self::VALID_OPTIONS), - } - } -} - impl fmt::Display for SpinnerMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Auto => write!(f, "auto"), Self::Enabled => write!(f, "enabled"), Self::Disabled => write!(f, "disabled"), } @@ -136,60 +70,24 @@ impl fmt::Display for SpinnerMode { } impl FromStr for SpinnerMode { - type Err = ParseConfigEnumError; + type Err = String; fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) + ::from_str(s, true).map_err(|_| format!("invalid spinner mode '{s}'")) } } -impl<'de> Deserialize<'de> for SpinnerMode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserialize_config_enum(deserializer, "spinner mode") - } -} - -/// Diagnostic output format. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +/// Top-level diagnostics and output format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] +#[serde(rename_all = "kebab-case")] pub enum OutputFormat { - /// Human-readable diagnostics. + /// Human-readable terminal output. #[default] Human, /// Machine-readable JSON diagnostics. Json, } -impl OutputFormat { - /// Canonical list of valid option strings. - pub const VALID_OPTIONS: &'static [&'static str] = &["human", "json"]; - - /// Parse a raw output format value. - /// - /// # Errors - /// - /// Returns the canonical valid options when parsing fails. - pub fn parse_raw(s: &str) -> Result { - let trimmed = s.trim().to_ascii_lowercase(); - match trimmed.as_str() { - "human" => Ok(Self::Human), - "json" => Ok(Self::Json), - _ => Err(Self::VALID_OPTIONS), - } - } - - /// Return `true` when JSON diagnostics are enabled. - #[must_use] - pub const fn is_json(self) -> bool { - matches!(self, Self::Json) - } -} - impl fmt::Display for OutputFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -200,112 +98,209 @@ impl fmt::Display for OutputFormat { } impl FromStr for OutputFormat { - type Err = ParseConfigEnumError; + type Err = String; fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) + ::from_str(s, true).map_err(|_| format!("invalid output format '{s}'")) + } +} + +/// Presentation theme for semantic prefixes and glyph choices. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] +#[serde(rename_all = "kebab-case")] +pub enum Theme { + /// Follow the host environment. + #[default] + Auto, + /// Prefer the Unicode/emoji presentation. + Unicode, + /// Prefer ASCII-only output. + Ascii, +} + +impl From for ThemePreference { + fn from(value: Theme) -> Self { + match value { + Theme::Auto => Self::Auto, + Theme::Unicode => Self::Unicode, + Theme::Ascii => Self::Ascii, + } + } +} + +impl PartialEq for Theme { + fn eq(&self, other: &ThemePreference) -> bool { + ThemePreference::from(*self) == *other } } -impl<'de> Deserialize<'de> for OutputFormat { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserialize_config_enum(deserializer, "output format") +impl PartialEq for ThemePreference { + fn eq(&self, other: &Theme) -> bool { + *self == Self::from(*other) } } -fn deserialize_config_enum<'de, D, T>(deserializer: D, label: &str) -> Result -where - D: Deserializer<'de>, - T: FromStr, - T::Err: fmt::Display, -{ - let raw = String::deserialize(deserializer)?; - T::from_str(&raw).map_err(|err| de::Error::custom(format!("invalid {label}: {err}"))) +/// Layered defaults for the `build` subcommand. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BuildConfig { + /// Optional default path for the emitted Ninja manifest. + pub emit: Option, + /// Default targets used when the user does not pass any targets. + #[serde(default)] + pub targets: Vec, } -/// Preference-oriented configuration extracted from the top-level CLI surface. -#[derive(Debug, Clone, PartialEq, Eq, Args, Serialize, Deserialize, OrthoConfig, Default)] +/// Subcommand-specific layered defaults. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CommandConfigs { + /// Configuration that applies only to the `build` subcommand. + #[serde(default)] + pub build: BuildConfig, +} + +/// Authoritative schema for layered CLI configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, OrthoConfig)] +#[ortho_config(prefix = "NETSUKE", post_merge_hook)] pub struct CliConfig { + /// Path to the Netsuke manifest file to use. + #[ortho_config(default = default_manifest_path())] + pub file: PathBuf, + + /// Set the number of parallel build jobs. + pub jobs: Option, + /// Enable verbose diagnostic logging and completion timing summaries. - #[arg(short, long)] #[ortho_config(default = false)] pub verbose: bool, /// Locale tag for CLI copy (for example: en-US, es-ES). - #[arg(long, value_name = "LOCALE")] pub locale: Option, - /// Force accessible output mode on or off (overrides auto-detection). - #[arg(long)] - pub accessible: Option, + /// Additional URL schemes allowed for the `fetch` helper. + #[ortho_config(merge_strategy = "append")] + #[serde(default)] + pub fetch_allow_scheme: Vec, - /// Suppress emoji glyphs in output (overrides auto-detection). - #[arg(long)] - pub no_emoji: Option, + /// Hostnames permitted when default deny is enabled. + #[ortho_config(merge_strategy = "append")] + #[serde(default)] + pub fetch_allow_host: Vec, - /// CLI theme preset (auto, unicode, ascii). - #[arg(long, value_name = "THEME")] - pub theme: Option, + /// Hostnames that are always blocked. + #[ortho_config(merge_strategy = "append")] + #[serde(default)] + pub fetch_block_host: Vec, - /// Colour output policy (auto, always, never). - #[arg(long, value_name = "POLICY")] - pub colour_policy: Option, + /// Deny all hosts by default; only allow the declared allowlist. + #[ortho_config(default = false)] + pub fetch_default_deny: bool, - /// Force standard progress summaries on or off. - /// - /// When omitted, Netsuke enables progress summaries in standard mode. - #[arg(long)] - pub progress: Option, + /// Force accessible output mode on or off. + pub accessible: Option, - /// Spinner display mode (enabled, disabled). - #[arg(long, value_name = "MODE")] - pub spinner_mode: Option, + /// Compatibility alias for requesting the ASCII theme. + pub no_emoji: Option, /// Emit machine-readable diagnostics in JSON on stderr. - #[arg(long)] #[ortho_config(default = false)] pub diag_json: bool, - /// Diagnostic output format (human, json). - #[arg(long, value_name = "FORMAT")] + /// Force progress summaries on or off. + pub progress: Option, + + /// Preferred colour policy. + #[ortho_config(skip_cli)] + pub colour_policy: Option, + + /// Preferred spinner or progress mode. + #[ortho_config(skip_cli)] + pub spinner_mode: Option, + + /// Preferred diagnostics/output format. + #[ortho_config(skip_cli)] pub output_format: Option, - /// Default build targets used when none are specified on the CLI. - #[arg(long = "default-target", value_name = "TARGET")] + /// Preferred terminal theme. + #[ortho_config(skip_cli)] + pub theme: Option, + + /// Compatibility alias for default build targets at the config root. #[ortho_config(merge_strategy = "append")] + #[serde(default)] pub default_targets: Vec, + + /// Per-subcommand defaults. + #[ortho_config(skip_cli)] + #[serde(default)] + pub cmds: CommandConfigs, } -impl CliConfig { - /// Resolve whether JSON diagnostics should be active after merge. - #[must_use] - pub const fn resolved_diag_json(&self) -> bool { - match self.output_format { - Some(output_format) => output_format.is_json(), - None => self.diag_json, +impl Default for CliConfig { + fn default() -> Self { + Self { + file: default_manifest_path(), + jobs: None, + verbose: false, + locale: None, + fetch_allow_scheme: Vec::new(), + fetch_allow_host: Vec::new(), + fetch_block_host: Vec::new(), + fetch_default_deny: false, + accessible: None, + no_emoji: None, + diag_json: false, + progress: None, + colour_policy: None, + spinner_mode: None, + output_format: None, + theme: None, + default_targets: Vec::new(), + cmds: CommandConfigs::default(), } } +} - /// Resolve whether progress reporting should be active after merge. - #[must_use] - pub const fn resolved_progress(&self) -> bool { - match self.spinner_mode { - Some(SpinnerMode::Enabled) => true, - Some(SpinnerMode::Disabled) => false, - None => match self.progress { - Some(progress) => progress, - None => true, - }, - } +impl CliConfig { + pub(super) fn default_manifest_path() -> PathBuf { + default_manifest_path() + } +} +impl PostMergeHook for CliConfig { + fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> { + validate_theme_compatibility(self)?; + validate_spinner_mode_compatibility(self)?; + Ok(()) } } -#[cfg(test)] -#[path = "config_tests.rs"] -mod tests; +fn default_manifest_path() -> PathBuf { + PathBuf::from("Netsukefile") +} + +fn validate_theme_compatibility(config: &CliConfig) -> OrthoResult<()> { + match (config.theme, config.no_emoji) { + (Some(Theme::Unicode), Some(true)) => Err(validation_error( + "theme", + "theme = \"unicode\" conflicts with no_emoji = true; use theme = \"ascii\" instead", + )), + (Some(Theme::Ascii), Some(false)) => Err(validation_error( + "no_emoji", + "no_emoji = false conflicts with theme = \"ascii\"; remove the alias or choose theme = \"unicode\"", + )), + _ => Ok(()), + } +} + +fn validate_spinner_mode_compatibility(config: &CliConfig) -> OrthoResult<()> { + match (config.spinner_mode, config.progress) { + (Some(SpinnerMode::Disabled), Some(true)) => Err(validation_error( + "spinner_mode", + "spinner_mode = \"disabled\" conflicts with progress = true", + )), + (Some(SpinnerMode::Enabled), Some(false)) => Err(validation_error( + "progress", + "progress = false conflicts with spinner_mode = \"enabled\"", + )), + _ => Ok(()), + } +} diff --git a/src/cli/merge.rs b/src/cli/merge.rs new file mode 100644 index 00000000..7781db46 --- /dev/null +++ b/src/cli/merge.rs @@ -0,0 +1,416 @@ +//! Layer-composition and conversion helpers for CLI configuration. + +use clap::ArgMatches; +use clap::parser::ValueSource; +use ortho_config::declarative::LayerComposition; +use ortho_config::figment::{Figment, providers::Env}; +use ortho_config::uncased::Uncased; +use ortho_config::{ConfigDiscovery, MergeComposer, OrthoMergeExt, OrthoResult, sanitize_value}; +use ortho_config::{MergeLayer, load_config_file_as_chain}; +use serde::Serialize; +use std::borrow::Cow; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use super::config::{BuildConfig, CliConfig, Theme}; +use super::parser::{BuildArgs, Cli, Commands}; +use super::validation_error; +use crate::theme::ThemePreference; +const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG"; +const CONFIG_ENV_VAR_LEGACY: &str = "NETSUKE_CONFIG_PATH"; +const ENV_PREFIX: &str = "NETSUKE_"; + +/// Merge discovered configuration layers over parsed CLI input. +/// +/// # Errors +/// +/// Returns an [`ortho_config::OrthoError`] if layer composition or merging +/// fails. +pub fn merge_with_config(cli: &Cli, matches: &ArgMatches) -> OrthoResult { + let mut errors = Vec::new(); + let mut composer = MergeComposer::with_capacity(4); + + match sanitize_value(&CliConfig::default()) { + Ok(value) => composer.push_defaults(value), + Err(err) => errors.push(err), + } + + push_file_layers(cli, &mut composer, &mut errors); + + let env_provider = env_provider() + .map(|key| Uncased::new(key.as_str().to_ascii_uppercase())) + .split("__"); + match Figment::from(env_provider) + .extract::() + .into_ortho_merge() + { + Ok(value) => composer.push_environment(value), + Err(err) => errors.push(err), + } + + match cli_overrides_from_matches(cli, matches) { + Ok(value) if !is_empty_value(&value) => composer.push_cli(value), + Ok(_) => {} + Err(err) => errors.push(err), + } + + let composition = LayerComposition::new(composer.layers(), errors); + let merged = composition.into_merge_result(CliConfig::merge_from_layers)?; + validate_output_format_source(&merged, matches)?; + Ok(apply_config(cli, merged)) +} + +fn push_file_layers( + cli: &Cli, + composer: &mut MergeComposer, + errors: &mut Vec>, +) { + let layers_result = explicit_config_path(cli).map_or_else( + || collect_file_layers(cli.directory.as_deref()), + |path| load_layers_from_path(&path), + ); + match layers_result { + Ok(layers) => { + for layer in layers { + composer.push_layer(layer); + } + } + Err(err) => errors.push(err), + } +} + +fn env_provider() -> Env { + Env::prefixed(ENV_PREFIX) +} + +fn config_discovery(directory: Option<&PathBuf>) -> ConfigDiscovery { + let mut builder = ConfigDiscovery::builder("netsuke").env_var(CONFIG_ENV_VAR_LEGACY); + if let Some(dir) = directory { + builder = builder.clear_project_roots().add_project_root(dir); + } + builder.build() +} + +fn collect_file_layers(directory: Option<&Path>) -> OrthoResult>> { + let discovery = config_discovery(directory.map(PathBuf::from).as_ref()); + let mut file_layers = discovery.compose_layers(); + let mut errors = file_layers.required_errors; + if file_layers.value.is_empty() { + errors.append(&mut file_layers.optional_errors); + } + if let Some(err) = errors.into_iter().next() { + return Err(err); + } + + let project_file = project_scope_file_str(directory); + let has_project_layer = file_layers.value.iter().any(|layer| { + layer + .path() + .is_some_and(|path| project_file.as_deref() == Some(path.as_str())) + }); + if has_project_layer { + return Ok(file_layers.value); + } + + let project_layers = project_scope_layers(directory)?; + Ok(file_layers + .value + .into_iter() + .chain(project_layers) + .collect()) +} + +fn project_scope_file_str(directory: Option<&Path>) -> Option { + let root = directory + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok())?; + root.join(".netsuke.toml").to_str().map(String::from) +} + +fn project_scope_layers(directory: Option<&Path>) -> OrthoResult>> { + let root = directory + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()); + let Some(project_file) = root.map(|dir| dir.join(".netsuke.toml")) else { + return Ok(Vec::new()); + }; + match load_config_file_as_chain(&project_file) { + Ok(Some(chain)) => Ok(chain + .values + .into_iter() + .map(|(value, path)| MergeLayer::file(Cow::Owned(value), Some(path))) + .collect()), + Ok(None) => Ok(Vec::new()), + Err(err) => Err(err), + } +} + +fn explicit_config_path(cli: &Cli) -> Option { + cli.config + .clone() + .or_else(|| env_config_path(CONFIG_ENV_VAR)) + .or_else(|| env_config_path(CONFIG_ENV_VAR_LEGACY)) +} + +fn env_config_path(var_name: &str) -> Option { + std::env::var_os(var_name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn load_layers_from_path(path: &std::path::Path) -> OrthoResult>> { + match load_config_file_as_chain(path) { + Ok(Some(chain)) => Ok(chain + .values + .into_iter() + .map(|(value, layer_path)| MergeLayer::file(Cow::Owned(value), Some(layer_path))) + .collect()), + Ok(None) => Err(Arc::new(ortho_config::OrthoError::File { + path: path.to_path_buf(), + source: Box::new(io::Error::new( + io::ErrorKind::NotFound, + "explicit configuration file not found", + )), + })), + Err(err) => Err(err), + } +} + +fn is_empty_value(value: &Value) -> bool { + matches!(value, Value::Object(map) if map.is_empty()) +} + +fn diag_json_from_layer(value: &Value) -> Option { + value + .as_object() + .and_then(|map| map.get("diag_json")) + .and_then(Value::as_bool) +} + +fn collect_diag_file_layers(cli: &Cli) -> OrthoResult>> { + explicit_config_path(cli).map_or_else( + || collect_file_layers(cli.directory.as_deref()), + |path| load_layers_from_path(&path), + ) +} + +fn diag_json_from_matches(cli: &Cli, matches: &ArgMatches, discovered: bool) -> bool { + if matches.value_source("output_format") == Some(ValueSource::CommandLine) { + cli.resolved_diag_json() + } else if matches.value_source("diag_json") == Some(ValueSource::CommandLine) { + cli.diag_json + } else { + discovered + } +} + +fn diag_json_from_file_layers(cli: &Cli) -> OrthoResult { + let default = Cli::default().diag_json; + let layers = collect_diag_file_layers(cli)?; + let mut diag_json = default; + for layer in layers { + if let Some(layer_diag_json) = diag_json_from_layer(&layer.into_value()) { + diag_json = layer_diag_json; + } + } + Ok(diag_json) +} + +fn diag_json_from_env(fallback: bool) -> bool { + let env_provider = env_provider() + .map(|key| Uncased::new(key.as_str().to_ascii_uppercase())) + .split("__"); + Figment::from(env_provider) + .extract::() + .ok() + .and_then(|value| diag_json_from_layer(&value)) + .unwrap_or(fallback) +} + +fn validate_output_format_source(config: &CliConfig, matches: &ArgMatches) -> OrthoResult<()> { + if matches!(config.output_format, Some(super::OutputFormat::Json)) + && matches.value_source("output_format") != Some(ValueSource::CommandLine) + { + return Err(validation_error( + "output_format", + "output_format = \"json\" is not supported yet; pass --output-format json explicitly", + )); + } + Ok(()) +} + +/// Resolve the effective diagnostic JSON preference from the raw config layers. +/// +/// This is used before full config merging so startup and merge-time failures +/// can still honour `diag_json` values sourced from config files or the +/// environment. +#[must_use] +pub fn resolve_merged_diag_json(cli: &Cli, matches: &ArgMatches) -> bool { + let mut diag_json = + diag_json_from_file_layers(cli).unwrap_or_else(|_| Cli::default().diag_json); + diag_json = diag_json_from_env(diag_json); + diag_json_from_matches(cli, matches, diag_json) +} + +fn cli_overrides_from_matches(cli: &Cli, matches: &ArgMatches) -> OrthoResult { + let mut root = Map::new(); + + maybe_insert_explicit(matches, "file", &cli.file, &mut root)?; + maybe_insert_explicit(matches, "jobs", &cli.jobs, &mut root)?; + maybe_insert_explicit(matches, "verbose", &cli.verbose, &mut root)?; + maybe_insert_explicit(matches, "locale", &cli.locale, &mut root)?; + maybe_insert_explicit( + matches, + "fetch_allow_scheme", + &cli.fetch_allow_scheme, + &mut root, + )?; + maybe_insert_explicit( + matches, + "fetch_allow_host", + &cli.fetch_allow_host, + &mut root, + )?; + maybe_insert_explicit( + matches, + "fetch_block_host", + &cli.fetch_block_host, + &mut root, + )?; + maybe_insert_explicit( + matches, + "fetch_default_deny", + &cli.fetch_default_deny, + &mut root, + )?; + maybe_insert_explicit(matches, "accessible", &cli.accessible, &mut root)?; + maybe_insert_explicit(matches, "progress", &cli.progress, &mut root)?; + maybe_insert_explicit(matches, "no_emoji", &cli.no_emoji, &mut root)?; + maybe_insert_explicit(matches, "diag_json", &cli.diag_json, &mut root)?; + maybe_insert_explicit(matches, "colour_policy", &cli.colour_policy, &mut root)?; + maybe_insert_explicit(matches, "spinner_mode", &cli.spinner_mode, &mut root)?; + maybe_insert_explicit(matches, "output_format", &cli.output_format, &mut root)?; + maybe_insert_explicit(matches, "theme", &cli.theme, &mut root)?; + maybe_insert_default_targets(cli, matches, &mut root)?; + + if let Some(Commands::Build(args)) = cli.command.as_ref() + && let Some(build_matches) = matches.subcommand_matches("build") + { + let build = build_cli_overrides(args, build_matches)?; + if !build.is_empty() { + root.insert("cmds".to_owned(), json!({ "build": Value::Object(build) })); + } + } + + Ok(Value::Object(root)) +} + +fn build_cli_overrides(args: &BuildArgs, matches: &ArgMatches) -> OrthoResult> { + let mut build = Map::new(); + maybe_insert_explicit(matches, "emit", &args.emit, &mut build)?; + maybe_insert_explicit(matches, "targets", &args.targets, &mut build)?; + Ok(build) +} + +fn maybe_insert_default_targets( + cli: &Cli, + matches: &ArgMatches, + root: &mut Map, +) -> OrthoResult<()> { + if matches.value_source("default_targets") == Some(ValueSource::CommandLine) { + root.insert( + "cmds".to_owned(), + json!({ "build": { "targets": serialize_value("default_targets", &cli.default_targets)? } }), + ); + } + Ok(()) +} + +fn maybe_insert_explicit( + matches: &ArgMatches, + field: &str, + value: &T, + target: &mut Map, +) -> OrthoResult<()> +where + T: Serialize, +{ + if matches.value_source(field) == Some(ValueSource::CommandLine) { + target.insert(field.to_owned(), serialize_value(field, value)?); + } + Ok(()) +} + +fn serialize_value(field: &str, value: &T) -> OrthoResult +where + T: Serialize, +{ + serde_json::to_value(value).map_err(|err| validation_error(field, &err.to_string())) +} + +fn apply_config(parsed: &Cli, config: CliConfig) -> Cli { + let build_defaults = resolved_build_config(&config); + Cli { + file: config.file, + directory: parsed.directory.clone(), + config: parsed.config.clone(), + jobs: config.jobs, + verbose: config.verbose, + locale: config.locale, + fetch_allow_scheme: config.fetch_allow_scheme, + fetch_allow_host: config.fetch_allow_host, + fetch_block_host: config.fetch_block_host, + fetch_default_deny: config.fetch_default_deny, + accessible: config.accessible, + no_emoji: config.no_emoji, + diag_json: config.diag_json, + progress: config.progress, + colour_policy: config.colour_policy, + spinner_mode: config.spinner_mode, + output_format: config.output_format, + theme: canonical_theme(config.theme, config.no_emoji), + default_targets: build_defaults.targets.clone(), + command: Some(resolve_command(parsed.command.as_ref(), &build_defaults)), + } +} + +fn resolved_build_config(config: &CliConfig) -> BuildConfig { + let mut build = config.cmds.build.clone(); + if build.targets.is_empty() { + build.targets.clone_from(&config.default_targets); + } else if !config.default_targets.is_empty() { + let mut targets = config.default_targets.clone(); + targets.extend(build.targets); + build.targets = targets; + } + build +} + +fn resolve_command(parsed: Option<&Commands>, build_defaults: &BuildConfig) -> Commands { + match parsed { + Some(Commands::Build(args)) => Commands::Build(BuildArgs { + emit: args.emit.clone().or_else(|| build_defaults.emit.clone()), + targets: if args.targets.is_empty() { + build_defaults.targets.clone() + } else { + args.targets.clone() + }, + }), + Some(other) => other.clone(), + None => Commands::Build(BuildArgs { + emit: build_defaults.emit.clone(), + targets: build_defaults.targets.clone(), + }), + } +} + +fn canonical_theme(theme: Option, no_emoji: Option) -> Option { + match (theme, no_emoji) { + (Some(value), _) => Some(value.into()), + (None, Some(true)) => Some(ThemePreference::Ascii), + _ => None, + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7553f245..df1248ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,351 +1,28 @@ -//! Command line interface definition using clap. +//! Command-line parsing plus layered CLI configuration support. //! -//! This module defines the [`Cli`] structure and its subcommands. -//! It mirrors the design described in `docs/netsuke-design.md`. +//! The parser-facing [`Cli`] type remains responsible for user-facing command +//! syntax, while [`CliConfig`] is the authoritative OrthoConfig-derived schema +//! used to merge defaults, configuration files, environment variables, and CLI +//! overrides into the runtime shape consumed by the runner. -use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; -use ortho_config::localize_clap_error_with_command; -use ortho_config::{LocalizationArgs, Localizer, OrthoConfig}; -use serde::{Deserialize, Serialize}; -use std::ffi::OsString; -use std::path::PathBuf; +use ortho_config::OrthoError; use std::sync::Arc; -use crate::cli::config::{CliConfig, ColourPolicy, OutputFormat, SpinnerMode}; -use crate::cli_l10n::localize_command; -use crate::host_pattern::HostPattern; pub mod config; -mod config_merge; +mod merge; +mod parser; mod parsing; -mod validation; -use config_merge::default_manifest_path; -/// Merge a parsed CLI struct with layered configuration files. -pub use config_merge::merge_with_config; -/// Resolve whether JSON diagnostics should be active after config discovery. -pub use config_merge::resolve_merged_diag_json; -use validation::configure_validation_parsers; - -/// Maximum number of jobs accepted by the CLI. -const MAX_JOBS: usize = 64; -const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG"; -const CONFIG_ENV_VAR_LEGACY: &str = "NETSUKE_CONFIG_PATH"; -const ENV_PREFIX: &str = "NETSUKE_"; - -fn validation_message( - localizer: &dyn Localizer, - key: &'static str, - args: Option<&LocalizationArgs<'_>>, - fallback: &str, -) -> String { - localizer.message(key, args, fallback) -} - -/// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. -#[derive(Debug, Parser, Serialize, Deserialize, OrthoConfig)] -#[command(author, version, about, long_about = None)] -#[ortho_config(prefix = "NETSUKE")] -pub struct Cli { - /// Path to the Netsuke manifest file to use. - #[arg(short, long, value_name = "FILE", default_value = "Netsukefile")] - #[ortho_config(default = default_manifest_path())] - pub file: PathBuf, - - /// Run as if started in this directory. - /// - /// This affects manifest lookup, output paths, and config discovery. - #[arg(short = 'C', long, value_name = "DIR")] - pub directory: Option, - - /// Path to a configuration file, bypassing automatic discovery. - #[arg(long, value_name = "FILE")] - #[serde(skip)] - pub config: Option, - - /// Set the number of parallel build jobs. - /// - /// Values must be between 1 and 64. - #[arg(short, long, value_name = "N")] - pub jobs: Option, - - /// Additional URL schemes allowed for the `fetch` helper. - #[arg(long = "fetch-allow-scheme", value_name = "SCHEME")] - #[ortho_config(merge_strategy = "append")] - pub fetch_allow_scheme: Vec, - - /// Hostnames that are permitted when default deny is enabled. - /// - /// Supports wildcards such as `*.example.com`. - #[arg(long = "fetch-allow-host", value_name = "HOST")] - #[ortho_config(merge_strategy = "append")] - pub fetch_allow_host: Vec, - - /// Hostnames that are always blocked, even when allowed elsewhere. - /// - /// Supports wildcards such as `*.example.com`. - #[arg(long = "fetch-block-host", value_name = "HOST")] - #[ortho_config(merge_strategy = "append")] - pub fetch_block_host: Vec, - - /// Deny all hosts by default; only allow the declared allowlist. - #[arg(long = "fetch-default-deny")] - #[ortho_config(default = false)] - pub fetch_default_deny: bool, - - /// Enable verbose diagnostic logging and completion timing summaries. - #[arg(short, long)] - #[ortho_config(default = false)] - pub verbose: bool, - - /// Locale tag for CLI copy (for example: en-US, es-ES). - #[arg(long, value_name = "LOCALE")] - pub locale: Option, - - /// Force accessible output mode on or off (overrides auto-detection). - #[arg(long)] - pub accessible: Option, - - /// Suppress emoji glyphs in output (overrides auto-detection). - #[arg(long)] - pub no_emoji: Option, - - /// CLI theme preset (auto, unicode, ascii). - #[arg(long, value_name = "THEME")] - pub theme: Option, - - /// Colour output policy (auto, always, never). - #[arg(long, value_name = "POLICY")] - pub colour_policy: Option, - - /// Force standard progress summaries on or off. - /// - /// When omitted, Netsuke enables progress summaries in standard mode. - #[arg(long)] - pub progress: Option, - - /// Spinner display mode (enabled, disabled). - #[arg(long, value_name = "MODE")] - pub spinner_mode: Option, - - /// Emit machine-readable diagnostics in JSON on stderr. - #[arg(long)] - #[ortho_config(default = false)] - pub diag_json: bool, - - /// Diagnostic output format (human, json). - #[arg(long, value_name = "FORMAT")] - pub output_format: Option, - - /// Default build targets used when none are specified on the CLI. - #[arg(long = "default-target", value_name = "TARGET")] - #[ortho_config(merge_strategy = "append")] - pub default_targets: Vec, - - /// Optional subcommand to execute; defaults to `build` when omitted. - /// - /// `OrthoConfig` merging ignores this field; CLI parsing supplies it. - #[serde(skip)] - #[command(subcommand)] - #[ortho_config(skip_cli)] - pub command: Option, -} - -impl Cli { - /// Apply the default command if none was specified. - #[must_use] - pub fn with_default_command(mut self) -> Self { - if self.command.is_none() { - self.command = Some(Commands::Build(BuildArgs { - emit: None, - targets: Vec::new(), - })); - } - self - } -} - -impl Default for Cli { - fn default() -> Self { - Self { - file: default_manifest_path(), - directory: None, - config: None, - jobs: None, - verbose: false, - locale: None, - fetch_allow_scheme: Vec::new(), - fetch_allow_host: Vec::new(), - fetch_block_host: Vec::new(), - fetch_default_deny: false, - accessible: None, - no_emoji: None, - theme: None, - colour_policy: None, - progress: None, - spinner_mode: None, - diag_json: false, - output_format: None, - default_targets: Vec::new(), - command: None, - } - .with_default_command() - } -} - -impl From<&Cli> for CliConfig { - fn from(cli: &Cli) -> Self { - // Keep this projection in lock-step with the duplicated preference - // fields declared on `Cli` and `CliConfig`. - Self { - verbose: cli.verbose, - locale: cli.locale.clone(), - accessible: cli.accessible, - no_emoji: cli.no_emoji, - theme: cli.theme, - colour_policy: cli.colour_policy, - progress: cli.progress, - spinner_mode: cli.spinner_mode, - diag_json: cli.diag_json, - output_format: cli.output_format, - default_targets: cli.default_targets.clone(), - } - } -} - -impl Cli { - /// Return the extracted preference-oriented configuration view. - #[must_use] - pub fn config(&self) -> CliConfig { - CliConfig::from(self) - } - - /// Resolve whether JSON diagnostics should be active after merge. - #[must_use] - pub fn resolved_diag_json(&self) -> bool { - self.config().resolved_diag_json() - } - - /// Resolve whether progress reporting should be active after merge. - #[must_use] - pub fn resolved_progress(&self) -> bool { - self.config().resolved_progress() - } -} - -/// Arguments accepted by the `build` command. -#[derive(Debug, Args, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub struct BuildArgs { - /// Write the generated Ninja manifest to this path and retain it. - #[arg(long, value_name = "FILE")] - pub emit: Option, - - /// A list of specific targets to build. - #[serde(default)] - pub targets: Vec, -} - -/// Available top-level commands for Netsuke. -#[derive(Debug, Subcommand, PartialEq, Eq, Clone, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Commands { - /// Build specified targets (or default targets if none are given). - Build(BuildArgs), - - /// Remove build artefacts and intermediate files. - Clean, - - /// Display the build dependency graph in DOT format for visualisation. - Graph, - - /// Write the Ninja manifest to the specified file without invoking Ninja. - Manifest { - /// Output path for the generated Ninja file. - /// - /// Use `-` to write to stdout. - #[arg(value_name = "FILE")] - file: PathBuf, - }, -} - -/// Inspect raw arguments and extract the requested locale before full parsing. -#[must_use] -pub fn locale_hint_from_args(args: &[OsString]) -> Option { - crate::cli_l10n::locale_hint_from_args(args) -} - -/// Inspect raw arguments and extract the requested `--diag-json` state. -#[must_use] -pub fn diag_json_hint_from_args(args: &[OsString]) -> Option { - crate::cli_l10n::diag_json_hint_from_args(args) -} - -/// Parse CLI arguments with localized clap output. -/// -/// Returns both the parsed CLI struct and the `ArgMatches` required for -/// configuration merging. -/// -/// # Errors -/// -/// Returns a `clap::Error` with localization applied when parsing fails. -pub fn parse_with_localizer_from( - iter: I, - localizer: &Arc, -) -> Result<(Cli, ArgMatches), clap::Error> -where - I: IntoIterator, - T: Into + Clone, -{ - let mut command = localize_command(Cli::command(), localizer.as_ref()); - command = configure_validation_parsers(command, localizer); - let matches = command - .try_get_matches_from_mut(iter) - .map_err(|err| localize_clap_error_with_command(err, localizer.as_ref(), Some(&command)))?; - // Clone matches before from_arg_matches_mut consumes the values. - let matches_for_merge = matches.clone(); - let mut matches_for_parse = matches; - let cli = Cli::from_arg_matches_mut(&mut matches_for_parse).map_err(|clap_err| { - let with_cmd = clap_err.with_cmd(&command); - localize_clap_error_with_command(with_cmd, localizer.as_ref(), Some(&command)) - })?; - Ok((cli, matches_for_merge)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::theme::ThemePreference; - - #[test] - fn config_projection_preserves_shared_preferences() { - let cli = Cli { - verbose: true, - locale: Some(String::from("en-US")), - accessible: Some(true), - no_emoji: Some(false), - theme: Some(ThemePreference::Ascii), - colour_policy: Some(ColourPolicy::Always), - progress: Some(false), - spinner_mode: Some(SpinnerMode::Disabled), - diag_json: true, - output_format: Some(OutputFormat::Json), - default_targets: vec![String::from("build"), String::from("test")], - ..Cli::default() - }; - - let expected = CliConfig { - verbose: true, - locale: Some(String::from("en-US")), - accessible: Some(true), - no_emoji: Some(false), - theme: Some(ThemePreference::Ascii), - colour_policy: Some(ColourPolicy::Always), - progress: Some(false), - spinner_mode: Some(SpinnerMode::Disabled), - diag_json: true, - output_format: Some(OutputFormat::Json), - default_targets: vec![String::from("build"), String::from("test")], - }; - - assert_eq!(cli.config(), expected); - } +pub use config::{CliConfig, ColourPolicy, OutputFormat, SpinnerMode, Theme}; +pub use merge::{merge_with_config, resolve_merged_diag_json}; +pub use parser::{ + BuildArgs, Cli, Commands, diag_json_hint_from_args, locale_hint_from_args, + parse_with_localizer_from, +}; + +pub(super) fn validation_error(key: &str, message: &str) -> Arc { + Arc::new(OrthoError::Validation { + key: key.to_owned(), + message: message.to_owned(), + }) } diff --git a/src/cli/parser.rs b/src/cli/parser.rs new file mode 100644 index 00000000..a67284d1 --- /dev/null +++ b/src/cli/parser.rs @@ -0,0 +1,360 @@ +//! Clap-facing parser types and localized parsing helpers. + +use clap::builder::{TypedValueParser, ValueParser}; +use clap::error::ErrorKind; +use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use ortho_config::localize_clap_error_with_command; +use ortho_config::{LocalizationArgs, Localizer}; +use serde::{Deserialize, Serialize}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; + +use super::config::CliConfig; +use super::parsing::{ + parse_colour_policy, parse_host_pattern, parse_jobs, parse_locale, parse_output_format, + parse_scheme, parse_spinner_mode, parse_theme, +}; +use super::{ColourPolicy, OutputFormat, SpinnerMode}; +use crate::cli_l10n::localize_command; +pub use crate::cli_l10n::{diag_json_hint_from_args, locale_hint_from_args}; +use crate::host_pattern::HostPattern; +use crate::theme::ThemePreference; + +#[derive(Clone)] +struct LocalizedValueParser { + localizer: Arc, + parser: F, +} + +impl LocalizedValueParser { + fn new(localizer: Arc, parser: F) -> Self { + Self { localizer, parser } + } +} + +impl TypedValueParser for LocalizedValueParser +where + F: Fn(&dyn Localizer, &str) -> Result + Clone + Send + Sync + 'static, + T: Send + Sync + Clone + 'static, +{ + type Value = T; + + fn parse_ref( + &self, + cmd: &clap::Command, + _arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + let mut command = cmd.clone(); + let Some(raw_value) = value.to_str() else { + return Err(command.error(ErrorKind::InvalidUtf8, "invalid UTF-8")); + }; + (self.parser)(self.localizer.as_ref(), raw_value) + .map_err(|err| command.error(ErrorKind::ValueValidation, err)) + } +} + +pub(super) fn validation_message( + localizer: &dyn Localizer, + key: &'static str, + args: Option<&LocalizationArgs<'_>>, + fallback: &str, +) -> String { + localizer.message(key, args, fallback) +} + +/// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. +#[derive(Debug, Parser, Serialize, Deserialize)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Path to the Netsuke manifest file to use. + #[arg( + short, + long, + value_name = "FILE", + default_value_os_t = CliConfig::default_manifest_path() + )] + pub file: PathBuf, + + /// Run as if started in this directory. + /// + /// This affects manifest lookup, output paths, and config discovery. + #[arg(short = 'C', long, value_name = "DIR")] + pub directory: Option, + + /// Path to a configuration file, bypassing automatic discovery. + #[arg(long, value_name = "FILE")] + #[serde(skip)] + pub config: Option, + + /// Set the number of parallel build jobs. + /// + /// Values must be between 1 and 64. + #[arg(short, long, value_name = "N")] + pub jobs: Option, + + /// Enable verbose diagnostic logging and completion timing summaries. + #[arg(short, long)] + pub verbose: bool, + + /// Locale tag for CLI copy (for example: en-US, es-ES). + #[arg(long, value_name = "LOCALE")] + pub locale: Option, + + /// Additional URL schemes allowed for the `fetch` helper. + #[arg(long = "fetch-allow-scheme", value_name = "SCHEME")] + pub fetch_allow_scheme: Vec, + + /// Hostnames that are permitted when default deny is enabled. + /// + /// Supports wildcards such as `*.example.com`. + #[arg(long = "fetch-allow-host", value_name = "HOST")] + pub fetch_allow_host: Vec, + + /// Hostnames that are always blocked, even when allowed elsewhere. + /// + /// Supports wildcards such as `*.example.com`. + #[arg(long = "fetch-block-host", value_name = "HOST")] + pub fetch_block_host: Vec, + + /// Deny all hosts by default; only allow the declared allowlist. + #[arg(long = "fetch-default-deny")] + pub fetch_default_deny: bool, + + /// Force accessible output mode on or off (overrides auto-detection). + #[arg(long)] + pub accessible: Option, + + /// Suppress emoji glyphs in output (overrides auto-detection). + #[arg(long)] + pub no_emoji: Option, + + /// Emit machine-readable diagnostics in JSON on stderr. + #[arg(long)] + pub diag_json: bool, + + /// Force standard progress summaries on or off. + /// + /// When omitted, Netsuke enables progress summaries in standard mode. + #[arg(long)] + pub progress: Option, + + /// Override colour policy for terminal output. + #[arg(long, value_name = "POLICY")] + pub colour_policy: Option, + + /// Override spinner animation mode. + #[arg(long, value_name = "MODE")] + pub spinner_mode: Option, + + /// Override output format style. + #[arg(long, value_name = "FORMAT")] + pub output_format: Option, + + /// Override presentation theme. + #[arg(long, value_name = "THEME")] + pub theme: Option, + + /// Default build targets used when none are specified on the CLI. + #[arg(long = "default-target", value_name = "TARGET")] + pub default_targets: Vec, + + /// Optional subcommand to execute; defaults to `build` when omitted. + #[serde(skip)] + #[command(subcommand)] + pub command: Option, +} + +impl Cli { + /// Apply the default command if none was specified. + #[must_use] + pub fn with_default_command(mut self) -> Self { + if self.command.is_none() { + self.command = Some(Commands::Build(BuildArgs::default())); + } + self + } + + /// Return the effective emoji override for output preference resolution. + #[must_use] + pub const fn no_emoji_override(&self) -> Option { + match self.theme { + Some(ThemePreference::Ascii) => Some(true), + Some(ThemePreference::Unicode) => Some(false), + _ => { + if matches!(self.no_emoji, Some(true)) { + Some(true) + } else { + None + } + } + } + } + + /// Return whether progress summaries should be enabled. + #[must_use] + pub const fn progress_enabled(&self) -> bool { + match (self.progress, self.spinner_mode) { + (Some(value), _) => value, + (None, Some(SpinnerMode::Disabled)) => false, + _ => true, + } + } + + /// Compatibility alias for callers that predate `progress_enabled`. + #[must_use] + pub const fn resolved_progress(&self) -> bool { + self.progress_enabled() + } + + /// Return whether JSON diagnostics should be enabled. + #[must_use] + pub const fn resolved_diag_json(&self) -> bool { + match self.output_format { + Some(OutputFormat::Json) => true, + _ => self.diag_json, + } + } +} + +impl Default for Cli { + fn default() -> Self { + Self { + file: CliConfig::default_manifest_path(), + directory: None, + config: None, + jobs: None, + verbose: false, + locale: None, + fetch_allow_scheme: Vec::new(), + fetch_allow_host: Vec::new(), + fetch_block_host: Vec::new(), + fetch_default_deny: false, + accessible: None, + progress: None, + no_emoji: None, + diag_json: false, + colour_policy: None, + spinner_mode: None, + output_format: None, + theme: None, + default_targets: Vec::new(), + command: None, + } + .with_default_command() + } +} + +/// Arguments accepted by the `build` command. +#[derive(Debug, Args, PartialEq, Eq, Clone, Serialize, Deserialize, Default)] +pub struct BuildArgs { + /// Write the generated Ninja manifest to this path and retain it. + #[arg(long, value_name = "FILE")] + pub emit: Option, + + /// A list of specific targets to build. + #[serde(default)] + pub targets: Vec, +} + +/// Available top-level commands for Netsuke. +#[derive(Debug, Subcommand, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Commands { + /// Build specified targets (or default targets if none are given). + Build(BuildArgs), + + /// Remove build artefacts and intermediate files. + Clean, + + /// Display the build dependency graph in DOT format for visualisation. + Graph, + + /// Write the Ninja manifest to the specified file without invoking Ninja. + Manifest { + /// Output path for the generated Ninja file. + /// + /// Use `-` to write to stdout. + #[arg(value_name = "FILE")] + file: PathBuf, + }, +} + +/// Parse CLI arguments with localized clap output. +/// +/// Returns both the parsed CLI struct and the `ArgMatches` required for +/// configuration merging. +/// +/// # Errors +/// +/// Returns a `clap::Error` with localization applied when parsing fails. +pub fn parse_with_localizer_from( + iter: I, + localizer: &Arc, +) -> Result<(Cli, ArgMatches), clap::Error> +where + I: IntoIterator, + T: Into + Clone, +{ + let mut command = localize_command(Cli::command(), localizer.as_ref()); + command = configure_validation_parsers(command, localizer); + let matches = command + .try_get_matches_from_mut(iter) + .map_err(|err| localize_clap_error_with_command(err, localizer.as_ref(), Some(&command)))?; + let matches_for_merge = matches.clone(); + let mut matches_for_parse = matches; + let cli = Cli::from_arg_matches_mut(&mut matches_for_parse).map_err(|clap_err| { + let with_cmd = clap_err.with_cmd(&command); + localize_clap_error_with_command(with_cmd, localizer.as_ref(), Some(&command)) + })?; + Ok((cli, matches_for_merge)) +} + +fn configure_validation_parsers( + mut command: clap::Command, + localizer: &Arc, +) -> clap::Command { + let jobs_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_jobs); + let locale_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_locale); + let scheme_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_scheme); + let host_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_host_pattern); + let colour_policy_parser = + LocalizedValueParser::new(Arc::clone(localizer), parse_colour_policy); + let spinner_mode_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_spinner_mode); + let output_format_parser = + LocalizedValueParser::new(Arc::clone(localizer), parse_output_format); + let theme_parser = LocalizedValueParser::new(Arc::clone(localizer), parse_theme); + + command = command.mut_arg("jobs", |arg| { + arg.value_parser(ValueParser::new(jobs_parser)) + }); + command = command.mut_arg("locale", |arg| { + arg.value_parser(ValueParser::new(locale_parser)) + }); + command = command.mut_arg("fetch_allow_scheme", |arg| { + arg.value_parser(ValueParser::new(scheme_parser.clone())) + }); + command = command.mut_arg("fetch_allow_host", |arg| { + arg.value_parser(ValueParser::new(host_parser.clone())) + }); + command = command.mut_arg("fetch_block_host", |arg| { + arg.value_parser(ValueParser::new(host_parser)) + }); + command = command.mut_arg("colour_policy", |arg| { + arg.value_parser(ValueParser::new(colour_policy_parser)) + }); + command = command.mut_arg("spinner_mode", |arg| { + arg.value_parser(ValueParser::new(spinner_mode_parser)) + }); + command = command.mut_arg("output_format", |arg| { + arg.value_parser(ValueParser::new(output_format_parser)) + }); + command = command.mut_arg("theme", |arg| { + arg.value_parser(ValueParser::new(theme_parser)) + }); + command +} + +/// Maximum number of jobs accepted by the CLI. +pub(super) const MAX_JOBS: usize = 64; diff --git a/src/cli/parsing.rs b/src/cli/parsing.rs index a7ccd134..7719f184 100644 --- a/src/cli/parsing.rs +++ b/src/cli/parsing.rs @@ -1,186 +1,178 @@ //! CLI parsing helpers for clap value parsers. +use clap::ValueEnum; use ortho_config::{LanguageIdentifier, LocalizationArgs, Localizer}; use std::str::FromStr; -use crate::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; +use super::{ColourPolicy, OutputFormat, SpinnerMode}; use crate::host_pattern::HostPattern; use crate::localization::keys; use crate::theme::ThemePreference; -/// Trait implemented by config-backed CLI enums that share localized parsing. -/// -/// Implementors provide the Fluent key used for localized validation errors, -/// a short human-readable label used in fallback messages, and a parser -/// function pointer that accepts raw user input and returns either the parsed -/// enum or the valid option list for error reporting. -pub(super) trait CliConfigEnum: Sized { - /// Fluent key used when localized validation fails. - const L10N_KEY: &'static str; - /// Human-readable label used in the fallback error message. - const LABEL: &'static str; - /// Fluent argument name used for the invalid raw value. - const ARG_NAME: &'static str = "value"; - /// Parser for raw user input. - /// - /// Implementors should trim and normalize input the same way their CLI and - /// config-file parsing paths do, returning `Err(valid_options)` when the - /// value cannot be parsed. - const PARSE_RAW: fn(&str) -> Result; +pub(super) fn parse_jobs(localizer: &dyn Localizer, s: &str) -> Result { + let value: usize = s.parse().map_err(|_| { + let mut args = LocalizationArgs::default(); + args.insert("value", s.to_owned().into()); + super::parser::validation_message( + localizer, + keys::CLI_JOBS_INVALID_NUMBER, + Some(&args), + &format!("{s} is not a valid number"), + ) + })?; + if (1..=super::parser::MAX_JOBS).contains(&value) { + Ok(value) + } else { + let mut args = LocalizationArgs::default(); + args.insert("min", 1.to_string().into()); + args.insert("max", super::parser::MAX_JOBS.to_string().into()); + Err(super::parser::validation_message( + localizer, + keys::CLI_JOBS_OUT_OF_RANGE, + Some(&args), + &format!("jobs must be between 1 and {}", super::parser::MAX_JOBS), + )) + } } -impl CliConfigEnum for ColourPolicy { - const L10N_KEY: &'static str = keys::CLI_COLOUR_POLICY_INVALID; - const LABEL: &'static str = "colour policy"; - const PARSE_RAW: fn(&str) -> Result = Self::parse_raw; +/// Parse and normalise a URI scheme provided via CLI flags. +/// +/// Schemes must begin with an ASCII letter and may contain ASCII letters, +/// digits, `+`, `-`, or `.` characters. The result is returned in lowercase. +pub(super) fn parse_scheme(localizer: &dyn Localizer, s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(super::parser::validation_message( + localizer, + keys::CLI_SCHEME_EMPTY, + None, + "scheme must not be empty", + )); + } + let mut chars = trimmed.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) { + let mut args = LocalizationArgs::default(); + args.insert("scheme", s.to_owned().into()); + return Err(super::parser::validation_message( + localizer, + keys::CLI_SCHEME_INVALID_START, + Some(&args), + &format!("scheme '{s}' must start with an ASCII letter"), + )); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { + let mut args = LocalizationArgs::default(); + args.insert("scheme", s.to_owned().into()); + return Err(super::parser::validation_message( + localizer, + keys::CLI_SCHEME_INVALID, + Some(&args), + &format!("invalid scheme '{s}'"), + )); + } + Ok(trimmed.to_ascii_lowercase()) } -impl CliConfigEnum for SpinnerMode { - const L10N_KEY: &'static str = keys::CLI_SPINNER_MODE_INVALID; - const LABEL: &'static str = "spinner mode"; - const PARSE_RAW: fn(&str) -> Result = Self::parse_raw; +pub(super) fn parse_locale(localizer: &dyn Localizer, s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(super::parser::validation_message( + localizer, + keys::CLI_LOCALE_EMPTY, + None, + "locale must not be empty", + )); + } + LanguageIdentifier::from_str(trimmed) + .map(|lang| lang.to_string()) + .map_err(|_| { + let mut args = LocalizationArgs::default(); + args.insert("locale", trimmed.to_owned().into()); + super::parser::validation_message( + localizer, + keys::CLI_LOCALE_INVALID, + Some(&args), + &format!("invalid locale '{trimmed}'"), + ) + }) } -impl CliConfigEnum for OutputFormat { - const L10N_KEY: &'static str = keys::CLI_OUTPUT_FORMAT_INVALID; - const LABEL: &'static str = "output format"; - const PARSE_RAW: fn(&str) -> Result = Self::parse_raw; +pub(super) fn parse_colour_policy( + localizer: &dyn Localizer, + s: &str, +) -> Result { + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_COLOUR_POLICY_INVALID, + arg_name: "value", + }, + ) } -impl CliConfigEnum for ThemePreference { - const L10N_KEY: &'static str = keys::CLI_THEME_INVALID; - const LABEL: &'static str = "theme"; - const ARG_NAME: &'static str = "theme"; - const PARSE_RAW: fn(&str) -> Result = Self::parse_raw; +pub(super) fn parse_spinner_mode( + localizer: &dyn Localizer, + s: &str, +) -> Result { + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_SPINNER_MODE_INVALID, + arg_name: "value", + }, + ) } -/// A localizer-bound parser for CLI values requiring localized validation. -pub(super) struct LocalizedParser<'a> { - localizer: &'a dyn Localizer, +pub(super) fn parse_output_format( + localizer: &dyn Localizer, + s: &str, +) -> Result { + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_OUTPUT_FORMAT_INVALID, + arg_name: "value", + }, + ) } -impl<'a> LocalizedParser<'a> { - /// Create a parser bound to the provided localizer. - pub(super) fn new(localizer: &'a dyn Localizer) -> Self { - Self { localizer } - } - - /// Parse the `--jobs` CLI value into a bounded worker-count. - /// - /// Leading and trailing whitespace is ignored. Returns a localized `String` - /// error when the value is not a valid integer or falls outside - /// `1..=MAX_JOBS`. - pub(super) fn parse_jobs(&self, s: &str) -> Result { - let trimmed = s.trim(); - let value: usize = trimmed.parse().map_err(|_| { - let mut args = LocalizationArgs::default(); - args.insert("value", s.to_owned().into()); - super::validation_message( - self.localizer, - keys::CLI_JOBS_INVALID_NUMBER, - Some(&args), - &format!("{s} is not a valid number"), - ) - })?; - if (1..=super::MAX_JOBS).contains(&value) { - Ok(value) - } else { - let mut args = LocalizationArgs::default(); - args.insert("min", 1.to_string().into()); - args.insert("max", super::MAX_JOBS.to_string().into()); - Err(super::validation_message( - self.localizer, - keys::CLI_JOBS_OUT_OF_RANGE, - Some(&args), - &format!("jobs must be between 1 and {}", super::MAX_JOBS), - )) - } - } - - /// Parse and normalise a URI scheme provided via CLI flags. - /// - /// Schemes must begin with an ASCII letter and may contain ASCII letters, - /// digits, `+`, `-`, or `.` characters. The result is returned in lowercase. - pub(super) fn parse_scheme(&self, s: &str) -> Result { - let trimmed = s.trim(); - if trimmed.is_empty() { - return Err(super::validation_message( - self.localizer, - keys::CLI_SCHEME_EMPTY, - None, - "scheme must not be empty", - )); - } - let mut chars = trimmed.chars(); - if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) { - let mut args = LocalizationArgs::default(); - args.insert("scheme", s.to_owned().into()); - return Err(super::validation_message( - self.localizer, - keys::CLI_SCHEME_INVALID_START, - Some(&args), - &format!("scheme '{s}' must start with an ASCII letter"), - )); - } - if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) { - let mut args = LocalizationArgs::default(); - args.insert("scheme", s.to_owned().into()); - return Err(super::validation_message( - self.localizer, - keys::CLI_SCHEME_INVALID, - Some(&args), - &format!("invalid scheme '{s}'"), - )); - } - Ok(trimmed.to_ascii_lowercase()) - } +pub(super) fn parse_theme(localizer: &dyn Localizer, s: &str) -> Result { + ThemePreference::parse_raw(s).map_err(|_| { + let mut args = LocalizationArgs::default(); + args.insert("theme", s.to_owned().into()); + super::parser::validation_message( + localizer, + keys::CLI_THEME_INVALID, + Some(&args), + &format!("Invalid theme '{s}'"), + ) + }) +} - /// Parse a locale identifier supplied via CLI flags. - /// - /// Surrounding whitespace is ignored. On success this returns the - /// canonicalized locale string emitted by `LanguageIdentifier`; on failure - /// it returns a localized `String` describing the invalid input. - pub(super) fn parse_locale(&self, s: &str) -> Result { - let trimmed = s.trim(); - if trimmed.is_empty() { - return Err(super::validation_message( - self.localizer, - keys::CLI_LOCALE_EMPTY, - None, - "locale must not be empty", - )); - } - LanguageIdentifier::from_str(trimmed) - .map(|lang| lang.to_string()) - .map_err(|_| { - let mut args = LocalizationArgs::default(); - args.insert("locale", trimmed.to_owned().into()); - super::validation_message( - self.localizer, - keys::CLI_LOCALE_INVALID, - Some(&args), - &format!("invalid locale '{trimmed}'"), - ) - }) - } +/// Bundles the static localisation metadata needed by [`parse_value_enum`]. +#[derive(Copy, Clone)] +struct ParseEnumSpec { + key: &'static str, + arg_name: &'static str, +} - /// Parse a config-backed CLI enum using its shared localization contract. - /// - /// The parser delegates to [`CliConfigEnum::PARSE_RAW`]. Successful parses - /// return the concrete enum value. Failures return a localized `String` - /// built from [`CliConfigEnum::L10N_KEY`] and the raw user input. - pub(super) fn parse_cli_config_enum(&self, s: &str) -> Result { - (T::PARSE_RAW)(s).map_err(|_| { - let mut args = LocalizationArgs::default(); - args.insert(T::ARG_NAME, s.to_owned().into()); - super::validation_message( - self.localizer, - T::L10N_KEY, - Some(&args), - &format!("invalid {} '{s}'", T::LABEL), - ) - }) - } +fn parse_value_enum(localizer: &dyn Localizer, s: &str, spec: ParseEnumSpec) -> Result +where + T: ValueEnum, +{ + T::from_str(s, true).map_err(|_| { + let mut args = LocalizationArgs::default(); + args.insert(spec.arg_name, s.to_owned().into()); + super::parser::validation_message( + localizer, + spec.key, + Some(&args), + &format!("Invalid '{s}'"), + ) + }) } /// Parse a host pattern supplied via CLI flags. @@ -188,174 +180,9 @@ impl<'a> LocalizedParser<'a> { /// The returned [`HostPattern`] retains both the wildcard flag and the /// normalised host body so downstream configuration can reuse the parsed /// structure without reparsing strings. -pub(super) fn parse_host_pattern(s: &str) -> Result { +pub(super) fn parse_host_pattern( + _localizer: &dyn Localizer, + s: &str, +) -> Result { HostPattern::parse(s).map_err(|err| err.to_string()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; - use rstest::{fixture, rstest}; - use std::fmt::Write as _; - - /// Mock localizer for testing localized parser error messages. - struct MockLocalizer; - - impl Localizer for MockLocalizer { - fn lookup(&self, key: &str, lookup_args: Option<&LocalizationArgs>) -> Option { - let mut rendered = String::from(key); - if let Some(args) = lookup_args { - rendered.push_str(": "); - write!(&mut rendered, "{args:?}") - .expect("writing debug args into a String should succeed"); - } - Some(rendered) - } - } - - #[fixture] - fn parser() -> LocalizedParser<'static> { - static LOCALIZER: MockLocalizer = MockLocalizer; - LocalizedParser::new(&LOCALIZER) - } - - #[rstest] - #[case::trimmed(" 4 ", 4)] - fn parse_jobs_valid_inputs( - parser: LocalizedParser<'static>, - #[case] input: &str, - #[case] expected: usize, - ) { - let result = parser.parse_jobs(input); - match result { - Ok(jobs) => assert_eq!(jobs, expected), - Err(e) => panic!("Expected Ok({expected}), got Err: {e}"), - } - } - - #[rstest] - #[case::auto("auto", ThemePreference::Auto)] - #[case::unicode("unicode", ThemePreference::Unicode)] - #[case::ascii("ascii", ThemePreference::Ascii)] - #[case::auto_uppercase("AUTO", ThemePreference::Auto)] - #[case::unicode_mixed("Unicode", ThemePreference::Unicode)] - #[case::ascii_with_whitespace(" ascii ", ThemePreference::Ascii)] - fn parse_theme_valid_inputs( - parser: LocalizedParser<'static>, - #[case] input: &str, - #[case] expected: ThemePreference, - ) { - let result = parser.parse_cli_config_enum::(input); - match result { - Ok(theme) => assert_eq!(theme, expected), - Err(e) => panic!("Expected Ok({expected:?}), got Err: {e}"), - } - } - - #[rstest] - #[case::invalid_word("invalid")] - #[case::empty("")] - #[case::number("123")] - #[case::close_typo("unicod")] - fn parse_theme_invalid_inputs(parser: LocalizedParser<'static>, #[case] input: &str) { - let result = parser.parse_cli_config_enum::(input); - match result { - Err(error_msg) => { - assert!(!error_msg.is_empty(), "Error message should not be empty"); - } - Ok(theme) => panic!("Expected Err for input '{input}', got Ok({theme:?})"), - } - } - - #[rstest] - #[case::auto("auto", ColourPolicy::Auto)] - #[case::always("ALWAYS", ColourPolicy::Always)] - #[case::never(" never ", ColourPolicy::Never)] - fn parse_colour_policy_valid_inputs( - parser: LocalizedParser<'static>, - #[case] input: &str, - #[case] expected: ColourPolicy, - ) { - let result = parser.parse_cli_config_enum::(input); - match result { - Ok(policy) => assert_eq!(policy, expected), - Err(e) => panic!("Expected Ok({expected:?}), got Err: {e}"), - } - } - - #[rstest] - #[case::invalid("loud")] - #[case::empty("")] - fn parse_colour_policy_invalid_inputs(parser: LocalizedParser<'static>, #[case] input: &str) { - let result = parser.parse_cli_config_enum::(input); - match result { - Err(error_msg) => assert!( - error_msg.starts_with(keys::CLI_COLOUR_POLICY_INVALID), - "expected error to start with {:?}, got {error_msg:?}", - keys::CLI_COLOUR_POLICY_INVALID, - ), - Ok(policy) => panic!("Expected Err for input '{input}', got Ok({policy:?})"), - } - } - - #[rstest] - #[case::enabled("enabled", SpinnerMode::Enabled)] - #[case::disabled("DISABLED", SpinnerMode::Disabled)] - fn parse_spinner_mode_valid_inputs( - parser: LocalizedParser<'static>, - #[case] input: &str, - #[case] expected: SpinnerMode, - ) { - let result = parser.parse_cli_config_enum::(input); - match result { - Ok(mode) => assert_eq!(mode, expected), - Err(e) => panic!("Expected Ok({expected:?}), got Err: {e}"), - } - } - - #[rstest] - #[case::invalid("paused")] - #[case::empty("")] - fn parse_spinner_mode_invalid_inputs(parser: LocalizedParser<'static>, #[case] input: &str) { - let result = parser.parse_cli_config_enum::(input); - match result { - Err(error_msg) => assert!( - error_msg.starts_with(keys::CLI_SPINNER_MODE_INVALID), - "expected error to start with {:?}, got {error_msg:?}", - keys::CLI_SPINNER_MODE_INVALID, - ), - Ok(mode) => panic!("Expected Err for input '{input}', got Ok({mode:?})"), - } - } - - #[rstest] - #[case::human("human", OutputFormat::Human)] - #[case::json("JSON", OutputFormat::Json)] - fn parse_output_format_valid_inputs( - parser: LocalizedParser<'static>, - #[case] input: &str, - #[case] expected: OutputFormat, - ) { - let result = parser.parse_cli_config_enum::(input); - match result { - Ok(format) => assert_eq!(format, expected), - Err(e) => panic!("Expected Ok({expected:?}), got Err: {e}"), - } - } - - #[rstest] - #[case::invalid("tap")] - #[case::empty("")] - fn parse_output_format_invalid_inputs(parser: LocalizedParser<'static>, #[case] input: &str) { - let result = parser.parse_cli_config_enum::(input); - match result { - Err(error_msg) => assert!( - error_msg.starts_with(keys::CLI_OUTPUT_FORMAT_INVALID), - "expected error to start with {:?}, got {error_msg:?}", - keys::CLI_OUTPUT_FORMAT_INVALID, - ), - Ok(format) => panic!("Expected Err for input '{input}', got Ok({format:?})"), - } - } -} diff --git a/src/main.rs b/src/main.rs index fac813ed..3da50cc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,10 @@ use clap::ArgMatches; use clap::error::ErrorKind; use miette::Report; +use netsuke::theme::ThemeContext; use netsuke::{ cli, cli_localization, diagnostic_json, locale_resolution, localization, manifest, output_mode, - output_prefs, runner, theme::ThemeContext, + output_prefs, runner, }; use ortho_config::Localizer; use std::ffi::OsString; @@ -52,16 +53,13 @@ fn run_with_args( Ok(parsed) => parsed, Err(code) => return code, }; - let mode = match resolve_diag_mode_or_exit(&parsed_cli, &matches, startup_mode) { - Ok(mode) => mode, - Err(code) => return code, - }; + let mode = DiagMode::from_json_enabled(cli::resolve_merged_diag_json(&parsed_cli, &matches)); let merged_cli = match merge_cli_or_exit(&parsed_cli, &matches, mode) { Ok(merged) => merged, Err(code) => return code, }; - let runtime_mode = DiagMode::from_json_enabled(merged_cli.resolved_diag_json()); + let runtime_mode = DiagMode::from_json_enabled(merged_cli.diag_json); configure_runtime(&merged_cli, system_locale, runtime_mode); let output_mode = output_mode::resolve(merged_cli.accessible, merged_cli.colour_policy); let prefs = output_prefs::resolve_from_theme( @@ -117,34 +115,25 @@ fn parse_cli_or_exit( } } -fn handle_config_load_error(err: &(dyn std::error::Error + 'static), mode: DiagMode) -> ExitCode { - if mode.is_json() { - diagnostic_json::emit_or_fallback(diagnostic_json::render_error_json(err)) - } else { - init_tracing(Level::ERROR); - tracing::error!(error = %err, "configuration load failed"); - ExitCode::FAILURE - } -} - -fn resolve_diag_mode_or_exit( - parsed_cli: &cli::Cli, - matches: &ArgMatches, - startup_mode: DiagMode, -) -> Result { - cli::resolve_merged_diag_json(parsed_cli, matches) - .map(DiagMode::from_json_enabled) - .map_err(|err| handle_config_load_error(err.as_ref(), startup_mode)) -} - fn merge_cli_or_exit( parsed_cli: &cli::Cli, matches: &ArgMatches, mode: DiagMode, ) -> Result { - cli::merge_with_config(parsed_cli, matches) - .map(cli::Cli::with_default_command) - .map_err(|err| handle_config_load_error(err.as_ref(), mode)) + match cli::merge_with_config(parsed_cli, matches) { + Ok(merged) => Ok(merged.with_default_command()), + Err(err) => { + if mode.is_json() { + Err(diagnostic_json::emit_or_fallback( + diagnostic_json::render_error_json(err.as_ref()), + )) + } else { + init_tracing(Level::ERROR); + tracing::error!(error = %err, "configuration load failed"); + Err(ExitCode::FAILURE) + } + } + } } fn configure_runtime( @@ -155,7 +144,6 @@ fn configure_runtime( let runtime_locale = locale_resolution::resolve_runtime_locale(merged_cli, system_locale); let runtime_localizer = Arc::from(cli_localization::build_localizer(runtime_locale.as_deref())); localization::set_localizer(Arc::clone(&runtime_localizer)); - apply_process_colour_policy(merged_cli.colour_policy); if !mode.is_json() { let max_level = if merged_cli.verbose { @@ -167,24 +155,6 @@ fn configure_runtime( } } -fn apply_process_colour_policy(policy: Option) { - match policy { - Some(cli::config::ColourPolicy::Never) => { - // Align downstream human-facing libraries with the configured policy. - unsafe { - std::env::set_var("NO_COLOR", "1"); - } - } - Some(cli::config::ColourPolicy::Always) => { - // Clear inherited disablement so child libraries can honour forced colour. - unsafe { - std::env::remove_var("NO_COLOR"); - } - } - Some(cli::config::ColourPolicy::Auto) | None => {} - } -} - fn handle_runner_error( err: anyhow::Error, prefs: output_prefs::OutputPrefs, diff --git a/src/output_mode.rs b/src/output_mode.rs index 27372849..8e6c82c5 100644 --- a/src/output_mode.rs +++ b/src/output_mode.rs @@ -5,7 +5,7 @@ //! is auto-detected from the `NO_COLOR` and `TERM` environment variables, or //! forced via explicit configuration. -use crate::cli::config::ColourPolicy; +use crate::cli::ColourPolicy; use std::env; /// Whether terminal output should use accessible (static text) or standard diff --git a/src/runner/mod.rs b/src/runner/mod.rs index f8814cbe..247a7d80 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -136,12 +136,12 @@ const fn should_force_text_task_updates(mode: OutputMode, stdout_is_tty: bool) - /// Returns an error if manifest generation or the Ninja process fails. pub fn run(cli: &Cli, prefs: OutputPrefs) -> Result<()> { let mode = output_mode::resolve(cli.accessible, cli.colour_policy); - let progress_enabled = cli.resolved_progress() && !cli.resolved_diag_json(); + let progress_enabled = cli.progress_enabled() && !cli.diag_json; let stdout_is_tty = std::io::stdout().is_terminal(); let reporter = make_reporter(ReporterOptions { mode, progress_enabled, - verbose: cli.verbose && !cli.resolved_diag_json(), + verbose: cli.verbose && !cli.diag_json, prefs, stdout_is_tty, }); diff --git a/src/theme.rs b/src/theme.rs index e4acd055..e2673193 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -8,7 +8,7 @@ use std::fmt; use std::str::FromStr; -use crate::cli::config::ColourPolicy; +use crate::cli::ColourPolicy; use crate::output_mode::{OutputMode, no_color_active_with}; use serde::{Deserialize, Serialize}; diff --git a/test_support/src/netsuke.rs b/test_support/src/netsuke.rs index 4d1d3fa5..d5977c52 100644 --- a/test_support/src/netsuke.rs +++ b/test_support/src/netsuke.rs @@ -55,10 +55,15 @@ pub struct NetsukeRun { /// Returns an error when `netsuke` cannot be located or the process cannot be /// spawned. pub fn run_netsuke_in(current_dir: &Path, args: &[&str]) -> Result { + let isolated_config_home = current_dir.join(".config"); let mut cmd = assert_cmd::Command::new(netsuke_executable()?); let output = cmd .current_dir(current_dir) .env("PATH", "") + .env_remove("NETSUKE_CONFIG_PATH") + .env_remove("NETSUKE_OUTPUT_FORMAT") + .env("HOME", current_dir) + .env("XDG_CONFIG_HOME", &isolated_config_home) .args(args) .output() .context("run netsuke command")?; diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 7d211da5..ff761e5e 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -26,7 +26,7 @@ use std::ffi::OsString; use std::path::PathBuf; use std::sync::MutexGuard; use test_support::PathGuard; -use test_support::env::NinjaEnvGuard; +use test_support::env::{NinjaEnvGuard, restore_many_locked}; use test_support::env_lock::EnvLock; use test_support::http::HttpServer; @@ -150,73 +150,48 @@ pub struct TestWorld { /// Snapshot of pre-scenario values for environment variables that were overridden. pub env_vars: RefCell>>, /// Values to forward to child netsuke processes for scenario-tracked variables. - /// Populated at the same time as `env_vars` so `build_netsuke_command` never - /// reads the process environment for scenario-controlled vars. pub env_vars_forward: RefCell>, - /// Scenario-scoped lock for process-global environment mutations (CWD, env vars). - /// Held for the entire scenario duration when acquired via `ensure_env_lock()`. + /// Scenario-scoped guard that serialises environment mutations when needed. pub env_lock: RefCell>, - /// Original working directory before any chdir operations, captured when `env_lock` is acquired. + /// Original working directory before any scenario-level chdir. pub original_cwd: RefCell>, } impl TestWorld { - /// Track an environment variable for later restoration and forward to child processes. - /// - /// # Parameters - /// - `key`: The environment variable name - /// - `previous`: The previous value (if any) to restore on drop - /// - `new_value`: The new value to forward to child netsuke processes (if `Some`) + /// Acquire the scenario environment lock and capture the starting CWD. + pub fn ensure_env_lock(&self) { + if self.env_lock.borrow().is_some() { + return; + } + let lock = EnvLock::acquire(); + let cwd = std::env::current_dir().ok(); + *self.original_cwd.borrow_mut() = cwd; + *self.env_lock.borrow_mut() = Some(lock); + } + + /// Track an environment variable for later restoration. pub fn track_env_var( &self, key: String, previous: Option, - new_value: Option, + forward_value: Option, ) { - self.env_vars - .borrow_mut() - .entry(key.clone()) - .or_insert(previous); - if let Some(value) = new_value { - self.env_vars_forward.borrow_mut().insert(key, value); - } else { - // When unsetting, remove from forward map to ensure it's not inherited - self.env_vars_forward.borrow_mut().remove(&key); + if let Some(value) = forward_value { + self.env_vars_forward + .borrow_mut() + .insert(key.clone(), value); } + self.env_vars.borrow_mut().entry(key).or_insert(previous); } - /// Restore environment variables without acquiring [`EnvLock`]. - /// - /// This variant assumes the caller already holds [`EnvLock`]. Use this in the - /// `Drop` path to avoid re-acquiring the lock. - /// - /// # Safety - /// - /// Caller must hold [`EnvLock`] for the duration of this call. + /// Restore any environment variables overridden during the scenario. unsafe fn restore_environment_locked(&self) { let vars = std::mem::take(&mut *self.env_vars.borrow_mut()); if !vars.is_empty() { - // SAFETY: Caller guarantees EnvLock is held. - unsafe { test_support::env::restore_many_locked(vars) }; - } - } - - /// Ensure the scenario-scoped environment lock is acquired. - /// - /// This lock serialises process-global mutations like `std::env::set_current_dir` - /// and environment variable changes across all parallel test threads. The lock - /// is held for the entire scenario duration and automatically released in Drop. - /// - /// When first acquired, captures the current working directory for later restoration. - pub fn ensure_env_lock(&self) { - if self.env_lock.borrow().is_none() { - // Acquire lock FIRST to ensure we capture CWD in a stable state - *self.env_lock.borrow_mut() = Some(EnvLock::acquire()); - // Now capture CWD while holding the lock - if let Ok(cwd) = std::env::current_dir() { - *self.original_cwd.borrow_mut() = Some(cwd); - } + // SAFETY: The caller holds EnvLock. + unsafe { restore_many_locked(vars) }; } + self.env_vars_forward.borrow_mut().clear(); } /// Shut down the active HTTP server fixture. @@ -245,20 +220,13 @@ impl Drop for TestWorld { self.ninja_env_guard.borrow_mut().take(); self.localization_guard.borrow_mut().take(); self.localization_lock.borrow_mut().take(); - - // Restore original CWD and environment variables before releasing env_lock. - // This must happen WHILE env_lock is still held to maintain serialization. if self.env_lock.borrow().is_some() { if let Some(original_cwd) = self.original_cwd.borrow_mut().take() { - // Restore to captured CWD; ignore errors since this is cleanup code in Drop drop(std::env::set_current_dir(original_cwd)); } - // Restore environment variables while lock is still held - // SAFETY: We hold EnvLock via self.env_lock. + // SAFETY: EnvLock is still held. unsafe { self.restore_environment_locked() }; } - - // Release env_lock after all mutations are complete self.env_lock.borrow_mut().take(); self.stdlib_text.clear(); } @@ -326,68 +294,3 @@ impl RefCellOptionExt for RefCell> { self.borrow_mut().take() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ensure_env_lock_acquires_lock_on_first_call() { - let world = TestWorld::default(); - assert!( - world.env_lock.borrow().is_none(), - "env_lock should be None before ensure_env_lock is called" - ); - world.ensure_env_lock(); - assert!( - world.env_lock.borrow().is_some(), - "env_lock should be Some after ensure_env_lock is called" - ); - } - - #[test] - fn ensure_env_lock_is_idempotent() { - let world = TestWorld::default(); - world.ensure_env_lock(); - world.ensure_env_lock(); - assert!(world.env_lock.borrow().is_some()); - } - - #[test] - fn ensure_env_lock_captures_cwd() { - let world = TestWorld::default(); - assert!( - world.original_cwd.borrow().is_none(), - "original_cwd should be None before ensure_env_lock is called" - ); - world.ensure_env_lock(); - assert!( - world.original_cwd.borrow().is_some(), - "original_cwd should be captured when env_lock is first acquired" - ); - let captured = world.original_cwd.borrow().clone().expect("captured CWD"); - let actual = std::env::current_dir().expect("current_dir"); - assert_eq!( - captured, actual, - "captured CWD should match the actual CWD at the time of lock acquisition" - ); - } - - #[test] - fn drop_restores_cwd_after_ensure_env_lock() { - let original = std::env::current_dir().expect("current_dir"); - let temp = tempfile::tempdir().expect("tempdir"); - - { - let world = TestWorld::default(); - world.ensure_env_lock(); - std::env::set_current_dir(temp.path()).expect("chdir"); - } - - assert_eq!( - std::env::current_dir().expect("current_dir"), - original, - "Drop should restore the CWD captured at ensure_env_lock time" - ); - } -} diff --git a/tests/bdd/steps/configuration_preferences.rs b/tests/bdd/steps/configuration_preferences.rs new file mode 100644 index 00000000..9704ac0a --- /dev/null +++ b/tests/bdd/steps/configuration_preferences.rs @@ -0,0 +1,336 @@ +//! Step definitions for layered CLI configuration preferences. + +use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; +use crate::bdd::helpers::parse_store::store_parse_outcome; +use crate::bdd::helpers::tokens::build_tokens; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use clap::ValueEnum as _; +use netsuke::cli::{Cli, ColourPolicy, Commands, OutputFormat, SpinnerMode, Theme}; +use netsuke::cli_localization; +use netsuke::output_prefs; +use netsuke::theme::ThemePreference; +use rstest_bdd_macros::{given, then, when}; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use test_support::display_error_chain; +use test_support::env_lock::EnvLock; + +const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG_PATH"; +const LOCALE_ENV_VAR: &str = "NETSUKE_LOCALE"; + +fn workspace_path(world: &TestWorld) -> Result { + let temp = world.temp_dir.borrow(); + let dir = temp + .as_ref() + .context("temp dir has not been initialised for configuration steps")?; + Ok(dir.path().to_path_buf()) +} + +fn ensure_env_lock(world: &TestWorld) { + if world.env_lock.borrow().is_none() { + *world.env_lock.borrow_mut() = Some(EnvLock::acquire()); + } +} + +fn write_config(world: &TestWorld, contents: &str) -> Result<()> { + ensure_env_lock(world); + let workspace = workspace_path(world)?; + let path = workspace.join("netsuke.toml"); + fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?; + let previous = std::env::var_os(CONFIG_ENV_VAR); + // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario. + unsafe { std::env::set_var(CONFIG_ENV_VAR, path.as_os_str()) }; + world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous, None); + Ok(()) +} + +fn merge_cli(world: &TestWorld, args: &str) { + let tokens = build_tokens(args); + let localizer = Arc::from(cli_localization::build_localizer(None)); + let outcome = netsuke::cli::parse_with_localizer_from(tokens, &localizer) + .and_then(|(cli, matches)| { + netsuke::cli::merge_with_config(&cli, &matches).map_err(|err| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + display_error_chain(err.as_ref()), + ) + }) + }) + .map(Cli::with_default_command) + .map_err(|err| err.to_string()); + store_parse_outcome(&world.cli, &world.cli_error, outcome); +} + +/// Convert a clap `ValueEnum` to its canonical name string. +/// +/// # Panics +/// +/// Panics if the enum variant does not have a possible value (which should +/// never happen for well-formed `ValueEnum` implementations). +fn enum_name(value: &T) -> String { + value + .to_possible_value() + .unwrap_or_else(|| panic!("all ValueEnum variants must have a possible value")) + .get_name() + .to_owned() +} + +/// Write a theme value to the config file. +fn write_theme_config(world: &TestWorld, theme: Theme) -> Result<()> { + write_config(world, &format!("theme = \"{}\"\n", enum_name(&theme))) +} + +/// Write a colour policy value to the config file. +fn write_colour_policy_config(world: &TestWorld, policy: ColourPolicy) -> Result<()> { + write_config( + world, + &format!("colour_policy = \"{}\"\n", enum_name(&policy)), + ) +} + +/// Write a spinner mode value to the config file. +fn write_spinner_mode_config(world: &TestWorld, mode: SpinnerMode) -> Result<()> { + write_config(world, &format!("spinner_mode = \"{}\"\n", enum_name(&mode))) +} + +/// Write an output format value to the config file. +fn write_output_format_config(world: &TestWorld, format: OutputFormat) -> Result<()> { + write_config( + world, + &format!("output_format = \"{}\"\n", enum_name(&format)), + ) +} + +/// Set the `NETSUKE_THEME` environment variable. +fn set_env_theme(world: &TestWorld, theme: Theme) { + ensure_env_lock(world); + let previous = std::env::var_os("NETSUKE_THEME"); + let value = enum_name(&theme); + // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario. + unsafe { std::env::set_var("NETSUKE_THEME", OsStr::new(&value)) }; + world.track_env_var("NETSUKE_THEME".to_owned(), previous, None); +} + +/// Set the `NETSUKE_COLOUR_POLICY` environment variable. +fn set_env_colour_policy(world: &TestWorld, policy: ColourPolicy) { + ensure_env_lock(world); + let previous = std::env::var_os("NETSUKE_COLOUR_POLICY"); + let value = enum_name(&policy); + // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario. + unsafe { std::env::set_var("NETSUKE_COLOUR_POLICY", OsStr::new(&value)) }; + world.track_env_var("NETSUKE_COLOUR_POLICY".to_owned(), previous, None); +} + +/// Set the `NETSUKE_SPINNER_MODE` environment variable. +fn set_env_spinner_mode(world: &TestWorld, mode: SpinnerMode) { + ensure_env_lock(world); + let previous = std::env::var_os("NETSUKE_SPINNER_MODE"); + let value = enum_name(&mode); + // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario. + unsafe { std::env::set_var("NETSUKE_SPINNER_MODE", OsStr::new(&value)) }; + world.track_env_var("NETSUKE_SPINNER_MODE".to_owned(), previous, None); +} + +/// Assert a merged CLI field value matches the expected value. +fn assert_merged_field(world: &TestWorld, extract: F, expected: T, label: &str) -> Result<()> +where + T: Copy + PartialEq + std::fmt::Debug, + F: FnOnce(&Cli) -> Option, +{ + let value = world + .cli + .with_ref(|cli| extract(cli)) + .context("expected merged CLI to be available")?; + ensure!( + value == Some(expected), + "expected merged {label} to be {expected:?}, got {value:?}", + ); + Ok(()) +} + +#[given("the Netsuke config file sets build targets to {target:string}")] +fn config_sets_build_targets(world: &TestWorld, target: &str) -> Result<()> { + write_config(world, &format!("[cmds.build]\ntargets = [\"{target}\"]\n")) +} + +#[given("the Netsuke config file sets locale to {locale:string}")] +fn config_sets_locale(world: &TestWorld, locale: &str) -> Result<()> { + write_config(world, &format!("locale = \"{locale}\"\n")) +} + +#[given("the NETSUKE_LOCALE environment variable is {locale:string}")] +#[expect( + clippy::unnecessary_wraps, + reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381" +)] +fn set_environment_locale_override(world: &TestWorld, locale: &str) -> Result<()> { + ensure_env_lock(world); + let previous = std::env::var_os(LOCALE_ENV_VAR); + // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario. + unsafe { std::env::set_var(LOCALE_ENV_VAR, OsStr::new(locale)) }; + world.track_env_var(LOCALE_ENV_VAR.to_owned(), previous, None); + Ok(()) +} + +#[given("the Netsuke config file sets output format to {format:string}")] +fn config_sets_output_format(world: &TestWorld, format: &str) -> Result<()> { + let typed = OutputFormat::from_str(format, true) + .map_err(|err| anyhow!("invalid output format '{format}': {err}"))?; + write_output_format_config(world, typed) +} + +#[given("the Netsuke config file sets no_emoji to true")] +fn config_sets_no_emoji(world: &TestWorld) -> Result<()> { + write_config(world, "no_emoji = true\n") +} + +#[given("the Netsuke config file sets theme to {theme:string}")] +fn config_sets_theme(world: &TestWorld, theme: &str) -> Result<()> { + let typed = + Theme::from_str(theme, true).map_err(|err| anyhow!("invalid theme '{theme}': {err}"))?; + write_theme_config(world, typed) +} + +#[given("the Netsuke config file sets colour policy to {policy:string}")] +fn config_sets_colour_policy(world: &TestWorld, policy: &str) -> Result<()> { + let typed = ColourPolicy::from_str(policy, true) + .map_err(|err| anyhow!("invalid colour policy '{policy}': {err}"))?; + write_colour_policy_config(world, typed) +} + +#[given("the Netsuke config file sets spinner mode to {mode:string}")] +fn config_sets_spinner_mode(world: &TestWorld, mode: &str) -> Result<()> { + let typed = SpinnerMode::from_str(mode, true) + .map_err(|err| anyhow!("invalid spinner mode '{mode}': {err}"))?; + write_spinner_mode_config(world, typed) +} + +#[given("the NETSUKE_THEME environment variable is {theme:string}")] +fn set_environment_theme_override(world: &TestWorld, theme: &str) -> Result<()> { + let typed = + Theme::from_str(theme, true).map_err(|err| anyhow!("invalid theme '{theme}': {err}"))?; + set_env_theme(world, typed); + Ok(()) +} + +#[given("the NETSUKE_COLOUR_POLICY environment variable is {policy:string}")] +fn set_environment_colour_policy_override(world: &TestWorld, policy: &str) -> Result<()> { + let typed = ColourPolicy::from_str(policy, true) + .map_err(|err| anyhow!("invalid colour policy '{policy}': {err}"))?; + set_env_colour_policy(world, typed); + Ok(()) +} + +#[given("the NETSUKE_SPINNER_MODE environment variable is {mode:string}")] +fn set_environment_spinner_mode_override(world: &TestWorld, mode: &str) -> Result<()> { + let typed = SpinnerMode::from_str(mode, true) + .map_err(|err| anyhow!("invalid spinner mode '{mode}': {err}"))?; + set_env_spinner_mode(world, typed); + Ok(()) +} + +#[expect( + clippy::unnecessary_wraps, + reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381" +)] +#[when("the CLI is parsed and merged with {args:string}")] +fn parse_and_merge_cli(world: &TestWorld, args: &str) -> Result<()> { + merge_cli(world, args); + Ok(()) +} + +#[when("merged output preferences are resolved")] +fn resolve_merged_output_prefs(world: &TestWorld) -> Result<()> { + let prefs = world + .cli + .with_ref(|cli| output_prefs::resolve(cli.no_emoji_override())) + .ok_or_else(|| anyhow!("expected merged CLI before resolving output prefs"))?; + world.output_prefs.set(prefs); + Ok(()) +} + +#[then("the merged CLI uses build target {target:string}")] +fn merged_cli_uses_build_target(world: &TestWorld, target: &str) -> Result<()> { + let command = world + .cli + .with_ref(|cli| cli.command.clone()) + .context("expected merged CLI to be available")? + .context("expected merged CLI command to be set")?; + match command { + Commands::Build(args) => ensure!( + args.targets.first().map(String::as_str) == Some(target), + "expected first merged build target '{target}', got {:?}", + args.targets, + ), + other => bail!("expected merged build command, got {other:?}"), + } + Ok(()) +} + +#[then("the merged locale is {locale:string}")] +fn merged_locale_is(world: &TestWorld, locale: &str) -> Result<()> { + let merged_locale = world + .cli + .with_ref(|cli| cli.locale.clone()) + .context("expected merged CLI to be available")?; + ensure!( + merged_locale.as_deref() == Some(locale), + "expected merged locale '{locale}', got {merged_locale:?}", + ); + Ok(()) +} + +#[then("verbose mode is enabled in the merged CLI")] +fn merged_verbose_enabled(world: &TestWorld) -> Result<()> { + let verbose = world + .cli + .with_ref(|cli| cli.verbose) + .context("expected merged CLI to be available")?; + ensure!(verbose, "expected merged verbose mode to be enabled"); + Ok(()) +} + +#[then("the merged theme is ascii")] +fn merged_theme_is_ascii(world: &TestWorld) -> Result<()> { + assert_merged_field(world, |cli| cli.theme, ThemePreference::Ascii, "theme") +} + +#[then("the merge error should contain {fragment:string}")] +fn merge_error_contains(world: &TestWorld, fragment: &str) -> Result<()> { + let error = world + .cli_error + .get() + .context("expected a merge error to be captured")?; + ensure!( + error.contains(fragment), + "expected merge error to contain '{fragment}', got '{error}'", + ); + Ok(()) +} + +#[then("the merged theme is unicode")] +fn merged_theme_is_unicode(world: &TestWorld) -> Result<()> { + assert_merged_field(world, |cli| cli.theme, ThemePreference::Unicode, "theme") +} + +#[then("the merged colour policy is always")] +fn merged_colour_policy_is_always(world: &TestWorld) -> Result<()> { + assert_merged_field( + world, + |cli| cli.colour_policy, + ColourPolicy::Always, + "colour policy", + ) +} + +#[then("the merged spinner mode is enabled")] +fn merged_spinner_mode_is_enabled(world: &TestWorld) -> Result<()> { + assert_merged_field( + world, + |cli| cli.spinner_mode, + SpinnerMode::Enabled, + "spinner mode", + ) +} diff --git a/tests/bdd/steps/locale_resolution.rs b/tests/bdd/steps/locale_resolution.rs index 934180bd..d8ddee5c 100644 --- a/tests/bdd/steps/locale_resolution.rs +++ b/tests/bdd/steps/locale_resolution.rs @@ -6,7 +6,7 @@ use crate::bdd::fixtures::TestWorld; use crate::bdd::helpers::tokens::build_tokens; use anyhow::{Context, Result, ensure}; -use netsuke::cli::Cli; +use netsuke::cli::{Cli, CliConfig}; use netsuke::cli_localization; use netsuke::locale_resolution; use netsuke::localization::keys; @@ -17,7 +17,7 @@ use test_support::locale_stubs::{StubEnv, StubSystemLocale}; fn merge_locale_layers(world: &TestWorld) -> Result { let mut composer = MergeComposer::new(); - let defaults = sanitize_value(&Cli::default())?; + let defaults = sanitize_value(&CliConfig::default())?; composer.push_defaults(defaults); if let Some(locale) = world.locale_config.get() { @@ -32,7 +32,11 @@ fn merge_locale_layers(world: &TestWorld) -> Result { composer.push_cli(json!({ "locale": locale })); } - Cli::merge_from_layers(composer.layers()).context("merge locale layers") + let merged = CliConfig::merge_from_layers(composer.layers()).context("merge locale layers")?; + Ok(Cli { + locale: merged.locale, + ..Cli::default() + }) } fn record_resolved_locale(world: &TestWorld, resolved: Option<&str>) { diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 8cab3c75..1d857492 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -21,6 +21,7 @@ mod cli_config; mod cli_parsing; mod conditional_manifest; mod configuration_discovery; +mod configuration_preferences; #[cfg(unix)] mod fs; mod ir; diff --git a/tests/cli_tests/helpers.rs b/tests/cli_tests/helpers.rs index 816488f9..7c5381b4 100644 --- a/tests/cli_tests/helpers.rs +++ b/tests/cli_tests/helpers.rs @@ -1,266 +1,7 @@ //! Shared helpers for CLI tests. -use anyhow::{Context, Result, ensure}; -use netsuke::cli::Cli; -use netsuke::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; -use netsuke::theme::ThemePreference; -use ortho_config::MergeComposer; -use serde_json::json; use std::ffi::OsString; -use std::path::Path; pub(super) fn os_args(args: &[&str]) -> Vec { args.iter().map(|arg| OsString::from(*arg)).collect() } - -/// RAII guard that restores the CWD on drop. Acquire after `EnvLock` so -/// the CWD is restored before the lock releases. -pub(super) struct CwdGuard(std::path::PathBuf); - -impl CwdGuard { - pub(super) fn acquire() -> anyhow::Result { - Ok(Self( - std::env::current_dir().context("capture current working directory")?, - )) - } -} - -impl Drop for CwdGuard { - fn drop(&mut self) { - drop(std::env::set_current_dir(&self.0)); - } -} - -pub(super) fn build_precedence_and_append_composer(defaults: serde_json::Value) -> MergeComposer { - let mut composer = MergeComposer::new(); - let mut seeded_defaults = defaults; - let Some(defaults_object) = seeded_defaults.as_object_mut() else { - panic!("defaults should be an object"); - }; - defaults_object.insert("jobs".to_owned(), json!(1)); - defaults_object.insert("fetch_allow_scheme".to_owned(), json!(["https"])); - defaults_object.insert("progress".to_owned(), json!(true)); - defaults_object.insert("diag_json".to_owned(), json!(false)); - defaults_object.insert("theme".to_owned(), json!("auto")); - defaults_object.insert("colour_policy".to_owned(), json!("auto")); - defaults_object.insert("spinner_mode".to_owned(), json!("enabled")); - defaults_object.insert("output_format".to_owned(), json!("human")); - defaults_object.insert("default_targets".to_owned(), json!(["fmt"])); - composer.push_defaults(seeded_defaults); - composer.push_file( - json!({ - "file": "Configfile", - "jobs": 2, - "fetch_allow_scheme": ["http"], - "locale": "en-US", - "progress": false, - "diag_json": true, - "theme": "ascii", - "colour_policy": "never", - "spinner_mode": "disabled", - "output_format": "json", - "default_targets": ["lint"] - }), - None, - ); - composer.push_environment(json!({ - "jobs": 3, - "fetch_allow_scheme": ["ftp"], - "progress": true, - "diag_json": false, - "theme": "unicode", - "colour_policy": "always", - "spinner_mode": "enabled", - "output_format": "human", - "default_targets": ["test"] - })); - composer.push_cli(json!({ - "jobs": 4, - "fetch_allow_scheme": ["git"], - "progress": false, - "diag_json": true, - "theme": "ascii", - "colour_policy": "never", - "spinner_mode": "disabled", - "output_format": "json", - "default_targets": ["build"], - "verbose": true - })); - composer -} - -pub(super) fn assert_precedence_and_append_invariants(merged: &Cli) -> Result<()> { - ensure!( - merged.file.as_path() == Path::new("Configfile"), - "file layer should override defaults", - ); - ensure!(merged.jobs == Some(4), "CLI layer should override jobs"); - ensure!( - merged.fetch_allow_scheme == vec!["https", "http", "ftp", "git"], - "list values should append in layer order", - ); - ensure!( - merged.progress == Some(false), - "CLI layer should override progress setting", - ); - ensure!( - merged.diag_json, - "CLI layer should override diag_json setting", - ); - ensure!( - merged.locale.as_deref() == Some("en-US"), - "file layer should populate locale when CLI does not override", - ); - ensure!( - merged.theme == Some(ThemePreference::Ascii), - "CLI layer should override theme selection", - ); - ensure!( - merged.colour_policy == Some(ColourPolicy::Never), - "CLI layer should override colour_policy", - ); - ensure!( - merged.spinner_mode == Some(SpinnerMode::Disabled), - "CLI layer should override spinner_mode", - ); - ensure!( - merged.output_format == Some(OutputFormat::Json), - "CLI layer should override output_format", - ); - ensure!( - merged.default_targets == vec!["fmt", "lint", "test", "build"], - "default_targets should append in layer order", - ); - ensure!( - merged.resolved_diag_json(), - "output_format=json should resolve to diagnostic JSON", - ); - ensure!( - !merged.resolved_progress(), - "spinner_mode=disabled should resolve to no progress", - ); - ensure!(merged.verbose, "CLI layer should set verbose"); - Ok(()) -} - -pub(super) fn assert_config_skips_empty_cli_layer_invariants(merged: &Cli) -> Result<()> { - ensure!( - merged.file.as_path() == Path::new("Configfile"), - "config file should override the default manifest path", - ); - ensure!( - merged.verbose, - "config file should override the default verbose flag", - ); - ensure!( - merged.fetch_default_deny, - "config file should override the default deny flag", - ); - ensure!( - merged.jobs == Some(4), - "environment variables should override config when CLI has no value", - ); - ensure!( - merged.fetch_allow_scheme == vec!["https".to_owned()], - "config values should apply when CLI overrides are empty", - ); - ensure!( - merged.locale.as_deref() == Some("es-ES"), - "config locale should be retained when CLI does not override", - ); - ensure!( - merged.progress == Some(false), - "config progress should apply when CLI and env do not override", - ); - ensure!( - merged.theme == Some(ThemePreference::Unicode), - "environment theme should override config when CLI has no value", - ); - ensure!( - merged.colour_policy == Some(ColourPolicy::Always), - "environment colour_policy should override config", - ); - ensure!( - merged.spinner_mode == Some(SpinnerMode::Disabled), - "config spinner_mode should apply when env does not override", - ); - ensure!( - merged.output_format == Some(OutputFormat::Json), - "config output_format should apply when env does not override", - ); - ensure!( - merged.default_targets == vec![String::from("hello")], - "config default_targets should be retained", - ); - ensure!( - merged.resolved_diag_json(), - "config output_format should resolve to JSON diagnostics", - ); - ensure!( - !merged.resolved_progress(), - "config spinner_mode should resolve to disabled progress", - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Unix config-environment fixture -// --------------------------------------------------------------------------- - -/// Isolated test environment for Unix config-discovery tests. -/// -/// Creates empty temporary directories for home and project, acquires -/// `EnvLock`, and sets `HOME`, `XDG_CONFIG_HOME`, and `XDG_CONFIG_DIRS` -/// to empty paths so host-level config files cannot leak into assertions. -#[cfg(unix)] -pub(super) struct UnixConfigTestEnv { - _cwd_guard: CwdGuard, - pub(super) temp_home: tempfile::TempDir, - pub(super) temp_project: tempfile::TempDir, - _xdg_home: tempfile::TempDir, - _home_guard: test_support::EnvVarGuard, - _xdg_home_guard: test_support::EnvVarGuard, - _xdg_dirs_guard: test_support::EnvVarGuard, - _config_path_guard: test_support::EnvVarGuard, - _config_guard: test_support::EnvVarGuard, - _diag_json_guard: test_support::EnvVarGuard, - _output_format_guard: test_support::EnvVarGuard, - pub(super) _env_lock: test_support::env_lock::EnvLock, -} - -#[cfg(unix)] -#[rstest::fixture] -pub(super) fn unix_config_env() -> anyhow::Result { - use anyhow::Context; - use std::ffi::OsStr; - use tempfile::tempdir; - use test_support::EnvVarGuard; - use test_support::env_lock::EnvLock; - - let env_lock = EnvLock::acquire(); - let temp_home = tempdir().context("create temporary home directory")?; - let temp_project = tempdir().context("create temporary project directory")?; - let cwd_guard = CwdGuard::acquire().context("capture current working directory")?; - let xdg_home = tempdir().context("create temporary XDG config home")?; - let home_guard = EnvVarGuard::set("HOME", temp_home.path().as_os_str()); - let xdg_home_guard = EnvVarGuard::set("XDG_CONFIG_HOME", xdg_home.path().as_os_str()); - let xdg_dirs_guard = EnvVarGuard::set("XDG_CONFIG_DIRS", OsStr::new("")); - let config_path_guard = EnvVarGuard::remove("NETSUKE_CONFIG_PATH"); - let config_guard = EnvVarGuard::remove("NETSUKE_CONFIG"); - let diag_json_guard = EnvVarGuard::remove("NETSUKE_DIAG_JSON"); - let output_format_guard = EnvVarGuard::remove("NETSUKE_OUTPUT_FORMAT"); - Ok(UnixConfigTestEnv { - _cwd_guard: cwd_guard, - temp_home, - temp_project, - _xdg_home: xdg_home, - _home_guard: home_guard, - _xdg_home_guard: xdg_home_guard, - _xdg_dirs_guard: xdg_dirs_guard, - _config_path_guard: config_path_guard, - _config_guard: config_guard, - _diag_json_guard: diag_json_guard, - _output_format_guard: output_format_guard, - _env_lock: env_lock, - }) -} diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 1b769a29..64037b2e 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -3,37 +3,180 @@ //! These tests validate `OrthoConfig` layer precedence (defaults, file, env, //! CLI) and list-value appending. -#[cfg(unix)] -use super::helpers::unix_config_env; -use super::helpers::{ - CwdGuard, assert_config_skips_empty_cli_layer_invariants, - assert_precedence_and_append_invariants, build_precedence_and_append_composer, -}; use anyhow::{Context, Result, ensure}; -use netsuke::cli::Cli; +use netsuke::cli::{CliConfig, Theme}; use netsuke::cli_localization; -use netsuke::theme::ThemePreference; use ortho_config::{MergeComposer, sanitize_value}; use rstest::{fixture, rstest}; use serde_json::json; use std::ffi::OsStr; use std::fs; +use std::path::Path; use std::sync::Arc; use tempfile::tempdir; use test_support::{EnvVarGuard, env_lock::EnvLock}; #[fixture] fn default_cli_json() -> Result { - Ok(sanitize_value(&Cli::default())?) + Ok(sanitize_value(&CliConfig::default())?) +} + +fn with_config_file(toml_content: &str, cli_args: &[&str], f: F) -> anyhow::Result +where + F: FnOnce(netsuke::cli::Cli) -> anyhow::Result, +{ + let _env_lock = test_support::env_lock::EnvLock::acquire(); + let temp_dir = tempfile::tempdir().context("create temporary config directory")?; + let config_path = temp_dir.path().join("netsuke.toml"); + std::fs::write(&config_path, toml_content).context("write netsuke.toml")?; + let _config_guard = + test_support::EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); + let localizer = std::sync::Arc::from(netsuke::cli_localization::build_localizer(None)); + let (cli, matches) = netsuke::cli::parse_with_localizer_from(cli_args, &localizer) + .context("parse CLI args for merge")?; + let merged = netsuke::cli::merge_with_config(&cli, &matches) + .context("merge CLI and configuration layers")?; + f(merged) +} + +fn assert_build_targets( + toml_content: &str, + cli_args: &[&str], + expected_targets: &[String], +) -> anyhow::Result<()> { + with_config_file(toml_content, cli_args, |merged| { + let Some(netsuke::cli::Commands::Build(args)) = merged.command else { + anyhow::bail!("expected merged command to be build"); + }; + ensure!( + args.targets == expected_targets, + "build targets mismatch: got {:?}, expected {:?}", + args.targets, + expected_targets, + ); + Ok(()) + }) +} + +#[derive(Debug, Copy, Clone)] +enum ExpectedValidationError { + ThemeUnicodeWithNoEmoji, + SpinnerDisabledWithProgress, + UnsupportedOutputFormat, +} + +impl ExpectedValidationError { + const fn expected_fragment(self) -> &'static str { + match self { + Self::ThemeUnicodeWithNoEmoji => "theme = \"unicode\" conflicts with no_emoji = true", + Self::SpinnerDisabledWithProgress => { + "spinner_mode = \"disabled\" conflicts with progress = true" + } + Self::UnsupportedOutputFormat => "output_format = \"json\" is not supported yet", + } + } +} + +fn merge_defaults_with_file_layer( + defaults: serde_json::Value, + file_layer: serde_json::Value, +) -> anyhow::Result { + let mut composer = ortho_config::MergeComposer::new(); + composer.push_defaults(defaults); + composer.push_file(file_layer, None); + netsuke::cli::CliConfig::merge_from_layers(composer.layers()).map_err(anyhow::Error::from) +} + +fn assert_merge_rejects( + defaults: serde_json::Value, + file_layer: serde_json::Value, + expected_error: ExpectedValidationError, +) -> anyhow::Result<()> { + let err = if matches!( + expected_error, + ExpectedValidationError::UnsupportedOutputFormat + ) { + match with_config_file("output_format = \"json\"\n", &["netsuke"], |_| Ok(())) { + Ok(()) => anyhow::bail!("merge should have returned an error"), + Err(err) => err, + } + } else { + match merge_defaults_with_file_layer(defaults, file_layer) { + Ok(value) => anyhow::bail!("merge should have returned an error; got {value:#?}"), + Err(err) => err, + } + }; + ensure!( + err.chain().any(|cause| cause + .to_string() + .contains(expected_error.expected_fragment())), + "unexpected error text: {err:#}", + ); + Ok(()) } #[rstest] fn cli_merge_layers_respects_precedence_and_appends_lists( default_cli_json: Result, ) -> Result<()> { - let composer = build_precedence_and_append_composer(default_cli_json?); - let merged = Cli::merge_from_layers(composer.layers())?; - assert_precedence_and_append_invariants(&merged) + let mut composer = MergeComposer::new(); + let mut defaults = default_cli_json?; + let defaults_object = defaults + .as_object_mut() + .context("defaults should be an object")?; + defaults_object.insert("jobs".to_owned(), json!(1)); + defaults_object.insert("fetch_allow_scheme".to_owned(), json!(["https"])); + defaults_object.insert("progress".to_owned(), json!(true)); + defaults_object.insert("diag_json".to_owned(), json!(false)); + composer.push_defaults(defaults); + composer.push_file( + json!({ + "file": "Configfile", + "jobs": 2, + "fetch_allow_scheme": ["http"], + "locale": "en-US", + "progress": false, + "diag_json": true + }), + None, + ); + composer.push_environment(json!({ + "jobs": 3, + "fetch_allow_scheme": ["ftp"], + "progress": true, + "diag_json": false + })); + composer.push_cli(json!({ + "jobs": 4, + "fetch_allow_scheme": ["git"], + "progress": false, + "diag_json": true, + "verbose": true + })); + let merged = CliConfig::merge_from_layers(composer.layers())?; + ensure!( + merged.file.as_path() == Path::new("Configfile"), + "file layer should override defaults", + ); + ensure!(merged.jobs == Some(4), "CLI layer should override jobs"); + ensure!( + merged.fetch_allow_scheme == vec!["https", "http", "ftp", "git"], + "list values should append in layer order", + ); + ensure!( + merged.progress == Some(false), + "CLI layer should override progress setting", + ); + ensure!( + merged.diag_json, + "CLI layer should override diag_json setting", + ); + ensure!( + merged.locale.as_deref() == Some("en-US"), + "file layer should populate locale when CLI does not override", + ); + ensure!(merged.verbose, "CLI layer should set verbose"); + Ok(()) } #[rstest] @@ -50,61 +193,57 @@ fetch_default_deny = true locale = "es-ES" progress = false diag_json = true -theme = "ascii" -colour_policy = "never" -spinner_mode = "disabled" -output_format = "json" -default_targets = ["hello"] "#; fs::write(&config_path, config).context("write netsuke.toml")?; let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); let _jobs_guard = EnvVarGuard::set("NETSUKE_JOBS", OsStr::new("4")); - let _theme_guard = EnvVarGuard::set("NETSUKE_THEME", OsStr::new("unicode")); - let _colour_policy_guard = EnvVarGuard::set("NETSUKE_COLOUR_POLICY", OsStr::new("always")); let _scheme_guard = EnvVarGuard::remove("NETSUKE_FETCH_ALLOW_SCHEME"); - let _diag_json_guard = EnvVarGuard::remove("NETSUKE_DIAG_JSON"); - let _output_format_guard = EnvVarGuard::remove("NETSUKE_OUTPUT_FORMAT"); - let _netsuke_config_guard = EnvVarGuard::remove("NETSUKE_CONFIG"); let localizer = Arc::from(cli_localization::build_localizer(None)); let (cli, matches) = netsuke::cli::parse_with_localizer_from(["netsuke"], &localizer) .context("parse CLI args for merge")?; ensure!( - netsuke::cli::resolve_merged_diag_json(&cli, &matches)?, + netsuke::cli::resolve_merged_diag_json(&cli, &matches), "pre-merge diagnostic mode should honour config diag_json", ); let merged = netsuke::cli::merge_with_config(&cli, &matches) .context("merge CLI and configuration layers")? .with_default_command(); - assert_config_skips_empty_cli_layer_invariants(&merged) -} - -#[rstest] -fn cli_merge_with_config_prefers_cli_theme_over_env_and_file() -> Result<()> { - let _env_lock = EnvLock::acquire(); - let temp_dir = tempdir().context("create temporary config directory")?; - let config_path = temp_dir.path().join("netsuke.toml"); - fs::write(&config_path, "theme = \"ascii\"\n").context("write netsuke.toml")?; - - let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); - let _theme_guard = EnvVarGuard::set("NETSUKE_THEME", OsStr::new("unicode")); - let _diag_json_guard = EnvVarGuard::remove("NETSUKE_DIAG_JSON"); - let _output_format_guard = EnvVarGuard::remove("NETSUKE_OUTPUT_FORMAT"); - let _netsuke_config_guard = EnvVarGuard::remove("NETSUKE_CONFIG"); - - let localizer = Arc::from(cli_localization::build_localizer(None)); - let (cli, matches) = - netsuke::cli::parse_with_localizer_from(["netsuke", "--theme", "ascii"], &localizer) - .context("parse CLI args for theme override merge")?; - let merged = netsuke::cli::merge_with_config(&cli, &matches) - .context("merge theme across CLI, env, and config layers")? - .with_default_command(); ensure!( - merged.theme == Some(ThemePreference::Ascii), - "CLI theme should override env and config layers", + merged.file.as_path() == Path::new("Configfile"), + "config file should override the default manifest path", + ); + ensure!( + merged.verbose, + "config file should override the default verbose flag", + ); + ensure!( + merged.fetch_default_deny, + "config file should override the default deny flag", + ); + ensure!( + merged.jobs == Some(4), + "environment variables should override config when CLI has no value", + ); + ensure!( + merged.fetch_allow_scheme == vec!["https".to_owned()], + "config values should apply when CLI overrides are empty", + ); + ensure!( + merged.locale.as_deref() == Some("es-ES"), + "config locale should be retained when CLI does not override", ); + ensure!( + merged.progress == Some(false), + "config progress should apply when CLI and env do not override", + ); + ensure!( + merged.diag_json, + "config diag_json should apply when CLI and env do not override", + ); + Ok(()) } @@ -119,7 +258,7 @@ fn cli_merge_layers_prefers_cli_then_env_then_file_for_locale( composer.push_environment(json!({ "locale": "es-ES" })); composer.push_cli(json!({ "locale": "en-US" })); - let merged = Cli::merge_from_layers(composer.layers())?; + let merged = CliConfig::merge_from_layers(composer.layers())?; ensure!( merged.locale.as_deref() == Some("en-US"), "CLI locale should override env and file layers", @@ -127,92 +266,62 @@ fn cli_merge_layers_prefers_cli_then_env_then_file_for_locale( Ok(()) } -#[cfg(unix)] #[rstest] -fn resolve_merged_diag_json_handles_malformed_project_config( - unix_config_env: Result, -) -> Result<()> { - let env = unix_config_env?; - - // User config: valid, sets output_format=json - let user_config = env.temp_home.path().join(".netsuke.toml"); - fs::write(&user_config, "output_format = \"json\"\n").context("write user .netsuke.toml")?; - - // Project config: malformed (missing closing quote) - let project_config = env.temp_project.path().join(".netsuke.toml"); - fs::write(&project_config, "theme = \"ascii\n") - .context("write malformed project .netsuke.toml")?; - - let _env_lock = EnvLock::acquire(); - let _cwd_guard = CwdGuard::acquire()?; - std::env::set_current_dir(&env.temp_project).context("change to project directory")?; - - let localizer = Arc::from(cli_localization::build_localizer(None)); - let (cli, matches) = netsuke::cli::parse_with_localizer_from(["netsuke"], &localizer) - .context("parse CLI for malformed project config test")?; - - let error = netsuke::cli::resolve_merged_diag_json(&cli, &matches) - .expect_err("malformed project config should surface before merge"); - ensure!( - format!("{error:?}").contains(".netsuke.toml"), - "error should mention the malformed project config" - ); - - let merge_error = netsuke::cli::merge_with_config(&cli, &matches) - .expect_err("merge_with_config should fail for malformed project config"); - ensure!( - format!("{merge_error:?}").contains(".netsuke.toml"), - "merge error should mention the malformed project config" - ); - - Ok(()) +fn cli_config_build_defaults_apply_when_cli_targets_are_absent() -> Result<()> { + assert_build_targets( + r#" +[cmds.build] +targets = ["all", "docs"] +"#, + &["netsuke"], + &[String::from("all"), String::from("docs")], + ) } -#[cfg(unix)] #[rstest] -fn resolve_merged_diag_json_does_not_discover_after_explicit_config_error( - unix_config_env: Result, -) -> Result<()> { - let env = unix_config_env?; - - fs::write( - env.temp_project.path().join(".netsuke.toml"), - "output_format = \"json\"\n", +fn cli_config_explicit_targets_override_configured_build_defaults() -> Result<()> { + assert_build_targets( + r#" +[cmds.build] +targets = ["all"] +"#, + &["netsuke", "build", "lint"], + &[String::from("lint")], ) - .context("write project .netsuke.toml")?; - - let explicit_config = env.temp_project.path().join("broken.toml"); - fs::write(&explicit_config, "theme = \"ascii\n").context("write malformed explicit config")?; - - let _env_lock = EnvLock::acquire(); - let _cwd_guard = CwdGuard::acquire()?; - std::env::set_current_dir(&env.temp_project).context("change to project directory")?; - - let config_arg = explicit_config.to_string_lossy().into_owned(); - let localizer = Arc::from(cli_localization::build_localizer(None)); - let (cli, matches) = - netsuke::cli::parse_with_localizer_from(["netsuke", "--config", &config_arg], &localizer) - .context("parse CLI with malformed explicit config")?; - - let error = netsuke::cli::resolve_merged_diag_json(&cli, &matches) - .expect_err("malformed explicit config should surface before discovery fallback"); - ensure!( - format!("{error:?}").contains("broken.toml"), - "error should mention the malformed explicit config" - ); +} - let (cli_with_diag, matches_with_diag) = netsuke::cli::parse_with_localizer_from( - ["netsuke", "--config", &config_arg, "--diag-json"], - &localizer, - ) - .context("parse CLI with explicit diagnostic JSON flag")?; - let diag_flag_error = - netsuke::cli::resolve_merged_diag_json(&cli_with_diag, &matches_with_diag) - .expect_err("malformed explicit config should surface even with --diag-json"); - ensure!( - format!("{diag_flag_error:?}").contains("broken.toml"), - "error should mention the malformed explicit config" - ); +#[rstest] +#[case( + json!({ "theme": "unicode", "no_emoji": true }), + ExpectedValidationError::ThemeUnicodeWithNoEmoji, +)] +#[case( + json!({ "spinner_mode": "disabled", "progress": true }), + ExpectedValidationError::SpinnerDisabledWithProgress, +)] +#[case( + json!({ "output_format": "json" }), + ExpectedValidationError::UnsupportedOutputFormat, +)] +fn cli_config_rejects_conflicting_or_unsupported_settings( + default_cli_json: Result, + #[case] file_layer: serde_json::Value, + #[case] expected_error: ExpectedValidationError, +) -> Result<()> { + assert_merge_rejects(default_cli_json?, file_layer, expected_error) +} - Ok(()) +#[rstest] +fn cli_runtime_canonicalizes_ascii_theme_from_no_emoji_alias() -> Result<()> { + with_config_file("no_emoji = true\n", &["netsuke"], |merged| { + ensure!( + merged.theme == Some(Theme::Ascii.into()), + "no_emoji compatibility alias should canonicalize to the ASCII theme", + ); + ensure!( + merged.no_emoji == Some(true), + "no_emoji alias should remain available in the runtime CLI", + ); + Ok(()) + }) } diff --git a/tests/cli_tests/parsing.rs b/tests/cli_tests/parsing.rs index 43cad615..c136a241 100644 --- a/tests/cli_tests/parsing.rs +++ b/tests/cli_tests/parsing.rs @@ -3,10 +3,12 @@ use anyhow::{Context, Result, ensure}; use clap::error::ErrorKind; use netsuke::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; -use netsuke::cli::{BuildArgs, Commands}; +use netsuke::cli::{BuildArgs, Cli, Commands, Theme}; use netsuke::cli_localization; use netsuke::host_pattern::HostPattern; -use netsuke::theme::ThemePreference; +use netsuke::output_mode::OutputMode; +use netsuke::output_prefs; +use netsuke::theme::{ThemeContext, ThemePreference}; use rstest::rstest; use std::path::PathBuf; use std::sync::Arc; @@ -280,3 +282,49 @@ fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) ); Ok(()) } + +#[test] +fn no_emoji_override_false_defers_to_environment_suppression() -> Result<()> { + let cli = Cli { + no_emoji: Some(false), + ..Cli::default() + }; + + let prefs = output_prefs::resolve_from_theme_with( + cli.theme, + ThemeContext::new(cli.no_emoji, cli.colour_policy, OutputMode::Standard), + |key| match key { + "NETSUKE_NO_EMOJI" => Some(String::from("1")), + _ => None, + }, + ); + + ensure!( + !prefs.emoji_allowed(), + "no_emoji = false should defer to environment suppression", + ); + Ok(()) +} + +#[test] +fn no_emoji_override_honours_unicode_theme_over_environment_suppression() -> Result<()> { + let cli = Cli { + theme: Some(Theme::Unicode.into()), + ..Cli::default() + }; + + let prefs = output_prefs::resolve_from_theme_with( + cli.theme, + ThemeContext::new(cli.no_emoji, cli.colour_policy, OutputMode::Standard), + |key| match key { + "NETSUKE_NO_EMOJI" => Some(String::from("1")), + _ => None, + }, + ); + + ensure!( + prefs.emoji_allowed(), + "theme = unicode should remain authoritative over environment suppression", + ); + Ok(()) +} diff --git a/tests/features/configuration_preferences.feature b/tests/features/configuration_preferences.feature new file mode 100644 index 00000000..187a7c37 --- /dev/null +++ b/tests/features/configuration_preferences.feature @@ -0,0 +1,100 @@ +Feature: Configuration preferences + + Scenario: Configured build targets become the default build command targets + Given a minimal Netsuke workspace + And the Netsuke config file sets build targets to "hello" + When the CLI is parsed and merged with "" + Then parsing succeeds + And the merged CLI uses build target "hello" + + Scenario: CLI locale and verbose flags override configuration and environment + Given an empty workspace + And the Netsuke config file sets locale to "es-ES" + And the NETSUKE_LOCALE environment variable is "fr-FR" + When the CLI is parsed and merged with "--locale en-US --verbose" + Then parsing succeeds + And the merged locale is "en-US" + And verbose mode is enabled in the merged CLI + + Scenario: Unsupported output format fails during configuration merge + Given an empty workspace + And the Netsuke config file sets output format to "json" + When the CLI is parsed and merged with "" + Then an error should be returned + And the merge error should contain "output_format = " + + Scenario: no_emoji compatibility alias resolves to the ASCII theme + Given a minimal Netsuke workspace + And the Netsuke config file sets no_emoji to true + When the CLI is parsed and merged with "" + And merged output preferences are resolved + And the success prefix is rendered + Then parsing succeeds + And the merged theme is ascii + And the prefix contains no non-ASCII characters + + Scenario: CLI theme flag overrides configuration file + Given an empty workspace + And the Netsuke config file sets theme to "unicode" + When the CLI is parsed and merged with "--theme ascii" + Then parsing succeeds + And the merged theme is ascii + + Scenario: CLI theme flag overrides environment variable + Given an empty workspace + And the NETSUKE_THEME environment variable is "unicode" + When the CLI is parsed and merged with "--theme ascii" + Then parsing succeeds + And the merged theme is ascii + + Scenario: CLI theme flag has highest precedence over env and config + Given an empty workspace + And the Netsuke config file sets theme to "unicode" + And the NETSUKE_THEME environment variable is "auto" + When the CLI is parsed and merged with "--theme ascii" + Then parsing succeeds + And the merged theme is ascii + + Scenario: CLI colour policy flag overrides configuration file + Given an empty workspace + And the Netsuke config file sets colour policy to "never" + When the CLI is parsed and merged with "--colour-policy always" + Then parsing succeeds + And the merged colour policy is always + + Scenario: CLI colour policy flag overrides environment variable + Given an empty workspace + And the NETSUKE_COLOUR_POLICY environment variable is "never" + When the CLI is parsed and merged with "--colour-policy always" + Then parsing succeeds + And the merged colour policy is always + + Scenario: CLI spinner mode flag overrides configuration file + Given an empty workspace + And the Netsuke config file sets spinner mode to "disabled" + When the CLI is parsed and merged with "--spinner-mode enabled" + Then parsing succeeds + And the merged spinner mode is enabled + + Scenario: CLI spinner mode flag overrides environment variable + Given an empty workspace + And the NETSUKE_SPINNER_MODE environment variable is "disabled" + When the CLI is parsed and merged with "--spinner-mode enabled" + Then parsing succeeds + And the merged spinner mode is enabled + + Scenario: Environment variable overrides configuration for theme + Given an empty workspace + And the Netsuke config file sets theme to "ascii" + And the NETSUKE_THEME environment variable is "unicode" + When the CLI is parsed and merged with "" + Then parsing succeeds + And the merged theme is unicode + + Scenario: Environment variable overrides configuration for colour policy + Given an empty workspace + And the Netsuke config file sets colour policy to "auto" + And the NETSUKE_COLOUR_POLICY environment variable is "always" + When the CLI is parsed and merged with "" + Then parsing succeeds + And the merged colour policy is always