From bc80acfdd608f077f6d0d5c2da928d5586794477 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 8 Mar 2026 00:03:10 +0000 Subject: [PATCH 01/19] docs(execplans): add detailed ExecPlan for CliConfig schema introduction Add a comprehensive execution plan document outlining the introduction of the `CliConfig` struct as the layered CLI configuration schema. This living document covers the purpose, constraints, tolerances, risks, progress stages, design decisions, and future steps for implementing a typed `CliConfig` configuration with OrthoConfig integration, separating CLI parsing from configuration merging, and improving schema explicitness for Netsuke project roadmap item 3.11.1. Co-authored-by: devboxerhub[bot] --- docs/execplans/3-11-1-cli-config-struct.md | 461 +++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 docs/execplans/3-11-1-cli-config-struct.md 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..6ec7bfd4 --- /dev/null +++ b/docs/execplans/3-11-1-cli-config-struct.md @@ -0,0 +1,461 @@ +# 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: DRAFT + +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. Today the repository already has partial layered configuration, +but it is centered on [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs), +where `Cli` currently serves 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`](/home/user/project/docs/users-guide.md), + [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md), and + [`docs/roadmap.md`](/home/user/project/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. +- Do not regress localized Clap help or localized runtime diagnostics. +- Use `ortho_config` as the primary merge mechanism rather than adding another + bespoke loader. +- 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. +- 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`](/home/user/project/src/cli/mod.rs) is already 398 + lines, so any additive work there will 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: 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. +- [ ] Approval received to implement. +- [ ] Stage A: split parser, config schema, and merge responsibilities. +- [ ] Stage B: introduce typed config groups and compatibility mapping. +- [ ] Stage C: wire global and subcommand merges through `CliConfig`. +- [ ] Stage D: add unit and behavioural coverage. +- [ ] 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 current repository already documents configuration discovery in + [`docs/users-guide.md`](/home/user/project/docs/users-guide.md), and already + has merge tests in + [`tests/cli_tests/merge.rs`](/home/user/project/tests/cli_tests/merge.rs). + The implementation must preserve these guarantees while changing the type + layout. +- `rstest-bdd` feature-file edits may require touching + [`tests/bdd_tests.rs`](/home/user/project/tests/bdd_tests.rs) to force Cargo + to rebuild generated scenarios. + +## 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) + +These decisions must be recorded in +[`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md) during +implementation if they remain unchanged after coding begins. + +## Outcomes & Retrospective + +Pending. This section must be replaced with the actual result, quality-gate +evidence, and lessons learned once implementation completes. + +## Context and orientation + +The current implementation is spread across the following files: + +- [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs): current `Cli` type, + Clap parser, OrthoConfig derive, validation parsers, and merge logic. +- [`src/main.rs`](/home/user/project/src/main.rs): startup parse/merge flow and + runtime localization bootstrap. +- [`src/output_mode.rs`](/home/user/project/src/output_mode.rs): accessible + versus standard output mode resolution. +- [`src/output_prefs.rs`](/home/user/project/src/output_prefs.rs): emoji-aware + semantic prefixes and current `no_emoji` handling. +- [`src/runner/mod.rs`](/home/user/project/src/runner/mod.rs): uses merged CLI + state to choose output mode, progress behaviour, and build targets. +- [`tests/cli_tests/merge.rs`](/home/user/project/tests/cli_tests/merge.rs): + current merge precedence coverage. +- [`tests/cli_tests/parsing.rs`](/home/user/project/tests/cli_tests/parsing.rs) + and + [`tests/features/cli.feature`](/home/user/project/tests/features/cli.feature): + current 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`. +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`](/home/user/project/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. + +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. 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. + +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`](/home/user/project/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. + +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 + +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`](/home/user/project/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`](/home/user/project/docs/users-guide.md) with + the new config schema, precedence rules, accepted values, and example TOML. +2. Update [`docs/netsuke-design.md`](/home/user/project/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`](/home/user/project/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 `/home/user/project`. + +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 +PATH="/root/.bun/bin:$PATH" 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. + +## 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`](/home/user/project/docs/users-guide.md), + [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md), and + [`docs/roadmap.md`](/home/user/project/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. From 3754d2c05cf4f60ac2bd992c0816ee79d0a93841 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 8 Mar 2026 17:45:01 +0000 Subject: [PATCH 02/19] feat(docs): upgrade ortho_config docs and embed v0.8.0 migration guidance - Updated docs/execplans/3-11-1-cli-config-struct.md to align with ortho_config 0.8.0 semantics and migration rules. - Replaced ortho-config-users-guide.md with upstream 0.8.0 version, adding detailed guidance on derive macro usage, dependency architecture, new attributes, typed Clap defaults with `cli_default_as_absent`, and documentation metadata generation. - Clarified dependency and macro usage with ortho_config crate aliasing support. - Introduced best practices for maintaining compatibility and upgrading from 0.7.x to 0.8.0. - Enhanced examples for default handling and integration with cargo-orthohelp documentation tooling. - Focused on improving reliability and usability of config schema derivation and CLI integration. These documentation enhancements provide a comprehensive reference for implementing the roadmap item 3.11.1 and safely upgrading ortho_config dependency while preserving existing CLI and config behavior. Refs #3.11.1 roadmap, ortho_config 0.8.0 migration Co-authored-by: devboxerhub[bot] --- docs/execplans/3-11-1-cli-config-struct.md | 63 +++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/execplans/3-11-1-cli-config-struct.md b/docs/execplans/3-11-1-cli-config-struct.md index 6ec7bfd4..9bf0c456 100644 --- a/docs/execplans/3-11-1-cli-config-struct.md +++ b/docs/execplans/3-11-1-cli-config-struct.md @@ -13,9 +13,13 @@ No `PLANS.md` file exists in this repository. 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. Today the repository already has partial layered configuration, -but it is centered on [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs), -where `Cli` currently serves three roles at once: +configuration. This plan now targets `ortho_config` `0.8.0` and uses the +repository copy of +[`docs/ortho-config-users-guide.md`](/home/user/project/docs/ortho-config-users-guide.md), + which has been replaced with the upstream `v0.8.0` guide. Today the repository +already has partial layered configuration, but it is centered on +[`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs), where `Cli` currently +serves three roles at once: 1. Clap parser. 2. OrthoConfig merge target. @@ -55,15 +59,28 @@ Observable success means: - 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 @@ -101,6 +118,12 @@ Observable success means: 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 @@ -129,6 +152,9 @@ Observable success means: - 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 current repository already documents configuration discovery in [`docs/users-guide.md`](/home/user/project/docs/users-guide.md), and already has merge tests in @@ -171,6 +197,13 @@ Observable success means: 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) + These decisions must be recorded in [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md) during implementation if they remain unchanged after coding begins. @@ -186,6 +219,10 @@ The current implementation is spread across the following files: - [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs): current `Cli` type, Clap parser, OrthoConfig derive, validation parsers, and merge logic. +- [`Cargo.toml`](/home/user/project/Cargo.toml): currently pins + `ortho_config = "0.7.0"` and `rust-version = "1.89.0"`. +- [`rust-toolchain.toml`](/home/user/project/rust-toolchain.toml): currently + pins toolchain `1.89.0`, which already satisfies the `0.8.0` minimum. - [`src/main.rs`](/home/user/project/src/main.rs): startup parse/merge flow and runtime localization bootstrap. - [`src/output_mode.rs`](/home/user/project/src/output_mode.rs): accessible @@ -214,7 +251,7 @@ 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`. + `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. @@ -267,14 +304,17 @@ compatibility aliases such as `no_emoji = true`. Start by reducing the blast radius in [`src/cli/mod.rs`](/home/user/project/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. +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. Keep existing parsing tests green before introducing new schema fields. +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: @@ -301,6 +341,8 @@ Concrete work in this stage: `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: @@ -326,6 +368,9 @@ Concrete work in this stage: 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: @@ -347,6 +392,8 @@ Unit coverage to add with `rstest`: `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: @@ -433,6 +480,10 @@ 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`](/home/user/project/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: From 42188083af9a4efb105697efbc410993010790e7 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 9 Mar 2026 11:03:28 +0000 Subject: [PATCH 03/19] feat(cli): introduce typed CliConfig schema for layered config merging - Added `CliConfig` as the authoritative OrthoConfig-derived schema for CLI configuration. - Split CLI module into parser, config, and merge submodules to separate parsing from merging. - Implement layered merging of defaults, configuration files, environment variables, and CLI overrides. - Added typed config fields for verbosity, locale, colour_policy, spinner_mode, output_format, theme, and build defaults. - Added validation for incompatible theme and spinner/progress settings. - Introduced `no_emoji` compatibility alias resolving to ASCII theme. - Provided default build command targets from config when CLI targets are absent. - Updated CLI parsing to preserve older `Cli` type for syntax and localized parsing. - Added extensive unit and BDD tests to cover merging behaviour and validation. - Updated documentation and roadmap for configuration preferences milestone 3.11.1. This refactor improves configuration schema clarity and layering, enabling unified merging from multiple sources while preserving CLI parsing ergonomics. Co-authored-by: devboxerhub[bot] --- Cargo.toml | 15 +- build.rs | 175 +++- docs/execplans/3-11-1-cli-config-struct.md | 73 +- docs/roadmap.md | 901 +++++++----------- docs/users-guide.md | 56 ++ src/cli/config.rs | 432 ++++----- src/cli/merge.rs | 256 +++++ src/cli/mod.rs | 363 +------ src/cli/parser.rs | 299 ++++++ src/cli/parsing.rs | 418 ++------ src/main.rs | 70 +- src/runner/mod.rs | 10 +- tests/bdd/fixtures/mod.rs | 148 +-- tests/bdd/steps/configuration_preferences.rs | 168 ++++ tests/bdd/steps/locale_resolution.rs | 10 +- tests/bdd/steps/mod.rs | 1 + tests/cli_tests/merge.rs | 318 ++++--- .../configuration_preferences.feature | 33 + 18 files changed, 1865 insertions(+), 1881 deletions(-) create mode 100644 src/cli/merge.rs create mode 100644 src/cli/parser.rs create mode 100644 tests/bdd/steps/configuration_preferences.rs create mode 100644 tests/features/configuration_preferences.feature diff --git a/Cargo.toml b/Cargo.toml index 145bd7d2..dd06398f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,15 +14,6 @@ include = [ 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 - [features] default = [] legacy-digests = ["sha1", "md5"] @@ -31,7 +22,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 +57,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] @@ -162,8 +155,6 @@ mockall = "0.11" camino = "1.2.0" test_support = { path = "test_support" } strip-ansi-escapes = "0.2" -toml = "0.8" -proptest = "1.11.0" # Target-specific dev-deps [target.'cfg(unix)'.dev-dependencies] diff --git a/build.rs b/build.rs index d47ab917..696f7eed 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,23 @@ //! 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"; #[path = "src/cli/mod.rs"] mod cli; @@ -22,12 +34,6 @@ mod host_pattern; #[path = "src/localization/mod.rs"] mod localization; -#[path = "src/output_mode.rs"] -mod output_mode; - -#[path = "src/theme.rs"] -mod theme; - mod build_l10n_audit; use host_pattern::{HostPattern, HostPatternError}; @@ -37,55 +43,130 @@ type LocalizedParseFn = fn( &Arc, ) -> Result<(cli::Cli, ArgMatches), clap::Error>; -type ResolveThemeFn = fn( - Option, - theme::ThemeContext, - 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) +} + +fn main() -> Result<(), Box> { + // 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 _: 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) -> bool = cli::Cli::resolved_progress; + const _: fn(&cli::Cli) -> Option = cli::Cli::no_emoji_override; + const _: fn(&cli::Cli) -> bool = cli::Cli::progress_enabled; const _: fn(&str) -> Result = HostPattern::parse; const _: fn(&HostPattern, host_pattern::HostCandidate<'_>) -> bool = HostPattern::matches; - 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 _: ResolveThemeFn = theme::resolve_theme; -} -/// Emits Cargo rerun directives for all inputs that affect the build output. -fn emit_rerun_directives() { + // Regenerate the manual page when the CLI or metadata changes. 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 main() -> Result<(), Box> { - assert_symbols_linked(); - emit_rerun_directives(); build_l10n_audit::audit_localization_keys()?; + + // Packagers expect man pages under target/generated-man//. + let out_dir = out_dir_for_target_profile(); + + // The top-level page documents the entire command interface. + 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(()) } diff --git a/docs/execplans/3-11-1-cli-config-struct.md b/docs/execplans/3-11-1-cli-config-struct.md index 9bf0c456..547750fa 100644 --- a/docs/execplans/3-11-1-cli-config-struct.md +++ b/docs/execplans/3-11-1-cli-config-struct.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: DRAFT +Status: COMPLETED No `PLANS.md` file exists in this repository. @@ -140,13 +140,16 @@ Observable success means: - [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. -- [ ] Approval received to implement. -- [ ] Stage A: split parser, config schema, and merge responsibilities. -- [ ] Stage B: introduce typed config groups and compatibility mapping. -- [ ] Stage C: wire global and subcommand merges through `CliConfig`. -- [ ] Stage D: add unit and behavioural coverage. -- [ ] Stage E: update user/design docs, mark roadmap item done, and run all - quality gates. +- [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 @@ -164,6 +167,10 @@ Observable success means: - `rstest-bdd` feature-file edits may require touching [`tests/bdd_tests.rs`](/home/user/project/tests/bdd_tests.rs) to force Cargo to rebuild generated scenarios. +- The new configuration-preferences BDD coverage initially flaked only in the + full suite because `NETSUKE_CONFIG_PATH` is process-global. Holding + [`EnvLock`](test_support/src/env_lock.rs) for the whole scenario fixed the + race without weakening the coverage. ## Decision Log @@ -204,14 +211,60 @@ Observable success means: 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`](/home/user/project/docs/netsuke-design.md) during implementation if they remain unchanged after coding begins. ## Outcomes & Retrospective -Pending. This section must be replaced with the actual result, quality-gate -evidence, and lessons learned once implementation completes. +Completed on 2026-03-09. + +Implemented results: + +- Added [`CliConfig`](/home/user/project/src/cli/config.rs) 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`](/home/user/project/tests/cli_tests/merge.rs) + plus behavioural coverage in + [`tests/features/configuration_preferences.feature`](/home/user/project/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` +- `PATH="/root/.bun/bin:$PATH" make markdownlint` +- `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. +- BDD coverage that touches process-wide environment variables must hold + `EnvLock` for the full scenario, not only for individual mutations. ## Context and orientation 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..7f9d74ca 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -681,6 +681,62 @@ 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"` +- `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..e860bf03 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,311 +1,245 @@ -//! 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 ortho_config::{OrthoConfig, OrthoError, OrthoResult, PostMergeContext, PostMergeHook}; use serde::{Deserialize, Serialize}; -use std::fmt; -use std::str::FromStr; +use std::path::PathBuf; +use std::sync::Arc; +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, 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 { - Self::Auto => write!(f, "auto"), - Self::Always => write!(f, "always"), - Self::Never => write!(f, "never"), - } - } -} - -impl FromStr for ColourPolicy { - type Err = ParseConfigEnumError; - - fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) - } -} - -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, 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::Enabled => write!(f, "enabled"), - Self::Disabled => write!(f, "disabled"), - } - } -} - -impl FromStr for SpinnerMode { - type Err = ParseConfigEnumError; - - fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) - } -} - -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, 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), +/// Presentation theme for semantic prefixes and glyph choices. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, 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, } } - - /// 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 { - Self::Human => write!(f, "human"), - Self::Json => write!(f, "json"), - } - } +/// 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, } -impl FromStr for OutputFormat { - type Err = ParseConfigEnumError; - - fn from_str(s: &str) -> Result { - Self::parse_raw(s).map_err(|valid_options| ParseConfigEnumError { - raw: s.into(), - valid_options, - }) - } +/// 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, } -impl<'de> Deserialize<'de> for OutputFormat { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserialize_config_enum(deserializer, "output format") - } -} +/// 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, -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}"))) -} + /// Set the number of parallel build jobs. + pub jobs: Option, -/// Preference-oriented configuration extracted from the top-level CLI surface. -#[derive(Debug, Clone, PartialEq, Eq, Args, Serialize, Deserialize, OrthoConfig, Default)] -pub struct CliConfig { /// 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")] - #[ortho_config(merge_strategy = "append")] - pub default_targets: Vec, + /// Preferred terminal theme. + #[ortho_config(skip_cli)] + pub theme: Option, + + /// Per-subcommand defaults. + #[ortho_config(skip_cli)] + #[serde(default)] + pub cmds: CommandConfigs, +} + +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, + cmds: CommandConfigs::default(), + } + } } -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 PostMergeHook for CliConfig { + fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> { + validate_theme_compatibility(self)?; + validate_spinner_mode_compatibility(self)?; + validate_output_format_support(self)?; + Ok(()) + } +} + +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(()), } +} - /// 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, - }, - } +fn validate_output_format_support(config: &CliConfig) -> OrthoResult<()> { + if matches!(config.output_format, Some(OutputFormat::Json)) { + return Err(validation_error( + "output_format", + "output_format = \"json\" is not supported yet; use \"human\" for this milestone", + )); } + Ok(()) } -#[cfg(test)] -#[path = "config_tests.rs"] -mod tests; +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/merge.rs b/src/cli/merge.rs new file mode 100644 index 00000000..f5e1d23d --- /dev/null +++ b/src/cli/merge.rs @@ -0,0 +1,256 @@ +//! 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, OrthoError, OrthoMergeExt, OrthoResult, sanitize_value, +}; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; +use std::sync::Arc; + +use super::config::{BuildConfig, CliConfig, Theme}; +use super::parser::{BuildArgs, Cli, Commands}; +const CONFIG_ENV_VAR: &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), + } + + let discovery = config_discovery(cli.directory.as_ref()); + let mut file_layers = discovery.compose_layers(); + errors.append(&mut file_layers.required_errors); + if file_layers.value.is_empty() { + errors.append(&mut file_layers.optional_errors); + } + for layer in file_layers.value { + composer.push_layer(layer); + } + + 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)?; + Ok(apply_config(cli, merged)) +} + +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); + if let Some(dir) = directory { + builder = builder.clear_project_roots().add_project_root(dir); + } + builder.build() +} + +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) +} + +/// 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 = CliConfig::default().diag_json; + + let discovery = config_discovery(cli.directory.as_ref()); + let file_layers = discovery.compose_layers(); + for layer in file_layers.value { + let layer_value = layer.into_value(); + if let Some(layer_diag_json) = diag_json_from_layer(&layer_value) { + diag_json = layer_diag_json; + } + } + + let env_provider = env_provider() + .map(|key| Uncased::new(key.as_str().to_ascii_uppercase())) + .split("__"); + if let Ok(value) = Figment::from(env_provider).extract::() + && let Some(env_diag_json) = diag_json_from_layer(&value) + { + diag_json = env_diag_json; + } + + if matches.value_source("diag_json") == Some(ValueSource::CommandLine) { + cli.diag_json + } else { + 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)?; + + 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_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 { + Cli { + file: config.file, + directory: parsed.directory.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), + command: Some(resolve_command(parsed.command.as_ref(), &config.cmds.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(), + }), + } +} + +const fn canonical_theme(theme: Option, no_emoji: Option) -> Option { + match (theme, no_emoji) { + (Some(value), _) => Some(value), + (None, Some(true)) => Some(Theme::Ascii), + _ => None, + } +} + +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/mod.rs b/src/cli/mod.rs index 7553f245..142168ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,351 +1,18 @@ -//! 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`. - -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 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; +//! 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. + +mod config; +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, +}; diff --git a/src/cli/parser.rs b/src/cli/parser.rs new file mode 100644 index 00000000..d2faaaaf --- /dev/null +++ b/src/cli/parser.rs @@ -0,0 +1,299 @@ +//! 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::parsing::{parse_host_pattern, parse_jobs, parse_locale, parse_scheme}; +use super::{ColourPolicy, OutputFormat, SpinnerMode, Theme}; +pub use crate::cli_l10n::{diag_json_hint_from_args, locale_hint_from_args}; +use crate::cli_l10n::localize_command; +use crate::host_pattern::HostPattern; + +#[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 = "Netsukefile")] + 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, + + /// 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, + + /// Resolved colour policy from layered configuration. + #[arg(skip)] + pub colour_policy: Option, + + /// Resolved spinner mode from layered configuration. + #[arg(skip)] + pub spinner_mode: Option, + + /// Resolved output format from layered configuration. + #[arg(skip)] + pub output_format: Option, + + /// Resolved presentation theme from layered configuration. + #[arg(skip)] + pub theme: Option, + + /// 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 { + if matches!(self.theme, Some(Theme::Ascii)) || matches!(self.no_emoji, Some(true)) { + Some(true) + } else { + self.no_emoji + } + } + + /// 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, + } + } +} + +impl Default for Cli { + fn default() -> Self { + Self { + file: PathBuf::from("Netsukefile"), + directory: 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, + 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); + + 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 +} +/// 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..fca65bce 100644 --- a/src/cli/parsing.rs +++ b/src/cli/parsing.rs @@ -3,184 +3,95 @@ use ortho_config::{LanguageIdentifier, LocalizationArgs, Localizer}; use std::str::FromStr; -use crate::cli::config::{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; -} - -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; -} - -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; -} - -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; -} - -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_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), + )) + } } -/// A localizer-bound parser for CLI values requiring localized validation. -pub(super) struct LocalizedParser<'a> { - localizer: &'a dyn Localizer, +/// 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<'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()) - } - - /// 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}'"), - ) - }) - } - - /// 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(|_| { +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(T::ARG_NAME, s.to_owned().into()); - super::validation_message( - self.localizer, - T::L10N_KEY, + args.insert("locale", trimmed.to_owned().into()); + super::parser::validation_message( + localizer, + keys::CLI_LOCALE_INVALID, Some(&args), - &format!("invalid {} '{s}'", T::LABEL), + &format!("invalid locale '{trimmed}'"), ) }) - } } /// Parse a host pattern supplied via CLI flags. @@ -188,174 +99,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..856ddd9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,9 +6,10 @@ use clap::ArgMatches; use clap::error::ErrorKind; use miette::Report; use netsuke::{ - cli, cli_localization, diagnostic_json, locale_resolution, localization, manifest, output_mode, - output_prefs, runner, theme::ThemeContext, + cli, cli_localization, diagnostic_json, locale_resolution, localization, manifest, + output_mode, output_prefs, runner, }; +use netsuke::theme::ThemeContext; use ortho_config::Localizer; use std::ffi::OsString; use std::io::{self, Write}; @@ -52,20 +53,17 @@ 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( - merged_cli.theme, + merged_cli.theme.map(Into::into), ThemeContext::new(merged_cli.no_emoji, merged_cli.colour_policy, output_mode), ); match runner::run(&merged_cli, prefs) { @@ -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/runner/mod.rs b/src/runner/mod.rs index f8814cbe..150c5289 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, }); @@ -202,11 +202,7 @@ fn handle_build( progress_enabled: bool, ) -> Result<()> { let ninja = generate_ninja(cli, reporter, Some(keys::STATUS_TOOL_BUILD.into()))?; - let targets = if args.targets.is_empty() { - BuildTargets::new(&cli.default_targets) - } else { - BuildTargets::new(&args.targets) - }; + let targets = BuildTargets::new(&args.targets); // Normalize the build file path and keep the temporary file alive for the // duration of the Ninja invocation. Borrow the emitted path when provided diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 7d211da5..61a252f2 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}; use test_support::env_lock::EnvLock; use test_support::http::HttpServer; @@ -149,73 +149,21 @@ pub struct TestWorld { // Environment state /// 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. - 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`) - pub fn track_env_var( - &self, - key: String, - previous: Option, - new_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); - } + /// Track an environment variable for later restoration. + pub fn track_env_var(&self, key: String, previous: Option) { + 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. - unsafe fn restore_environment_locked(&self) { + /// Restore any environment variables overridden during the scenario. + fn restore_environment(&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); - } + restore_many(vars); } } @@ -245,21 +193,8 @@ 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. - unsafe { self.restore_environment_locked() }; - } - - // Release env_lock after all mutations are complete self.env_lock.borrow_mut().take(); + self.restore_environment(); self.stdlib_text.clear(); } } @@ -326,68 +261,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..095ed675 --- /dev/null +++ b/tests/bdd/steps/configuration_preferences.rs @@ -0,0 +1,168 @@ +//! 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 netsuke::cli::{Cli, Commands, Theme}; +use netsuke::cli_localization; +use netsuke::output_prefs; +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"; + +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, OsStr::new(path.as_os_str())) }; + world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous); + 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); +} + +#[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 config file sets output format to {format:string}")] +fn config_sets_output_format(world: &TestWorld, format: &str) -> Result<()> { + write_config(world, &format!("output_format = \"{format}\"\n")) +} + +#[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") +} + +#[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<()> { + let theme = world + .cli + .with_ref(|cli| cli.theme) + .context("expected merged CLI to be available")?; + ensure!( + theme == Some(Theme::Ascii), + "expected merged theme to be ASCII, got {theme:?}", + ); + Ok(()) +} + +#[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(()) +} 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..35568201 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -17,6 +17,7 @@ mod accessibility_preferences; mod accessible_output; mod advanced_usage; mod cli; +mod configuration_preferences; mod cli_config; mod cli_parsing; mod conditional_manifest; diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 1b769a29..a2caa702 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -1,39 +1,88 @@ //! Configuration merge tests. //! -//! These tests validate `OrthoConfig` layer precedence (defaults, file, env, +//! 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::{Cli, CliConfig, OutputFormat, SpinnerMode, 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())?) + sanitize_value(&CliConfig::default()) } #[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 +99,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 +164,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 +172,149 @@ 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?; +fn cli_config_build_defaults_apply_when_cli_targets_are_absent() -> 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, + r#" +[cmds.build] +targets = ["all", "docs"] +"#, + ) + .context("write netsuke.toml")?; - // 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")?; + let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); + 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")?; + let merged = netsuke::cli::merge_with_config(&cli, &matches) + .context("merge CLI and configuration layers")?; - // 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 Some(netsuke::cli::Commands::Build(args)) = merged.command else { + anyhow::bail!("expected merged command to be build"); + }; + ensure!( + args.targets == vec![String::from("all"), String::from("docs")], + "configured build targets should be used when CLI targets are absent", + ); + Ok(()) +} +#[rstest] +fn cli_config_explicit_targets_override_configured_build_defaults() -> Result<()> { let _env_lock = EnvLock::acquire(); - let _cwd_guard = CwdGuard::acquire()?; - std::env::set_current_dir(&env.temp_project).context("change to project directory")?; + let temp_dir = tempdir().context("create temporary config directory")?; + let config_path = temp_dir.path().join("netsuke.toml"); + fs::write( + &config_path, + r#" +[cmds.build] +targets = ["all"] +"#, + ) + .context("write netsuke.toml")?; + let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); 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 (cli, matches) = + netsuke::cli::parse_with_localizer_from(["netsuke", "build", "lint"], &localizer) + .context("parse CLI args for merge")?; + let merged = netsuke::cli::merge_with_config(&cli, &matches) + .context("merge CLI and configuration layers")?; - let error = netsuke::cli::resolve_merged_diag_json(&cli, &matches) - .expect_err("malformed project config should surface before merge"); + let Some(netsuke::cli::Commands::Build(args)) = merged.command else { + anyhow::bail!("expected merged command to be build"); + }; ensure!( - format!("{error:?}").contains(".netsuke.toml"), - "error should mention the malformed project config" + args.targets == vec![String::from("lint")], + "explicit CLI targets should override configured defaults", ); + Ok(()) +} - let merge_error = netsuke::cli::merge_with_config(&cli, &matches) - .expect_err("merge_with_config should fail for malformed project config"); +#[rstest] +fn cli_config_validates_theme_alias_conflicts( + default_cli_json: Result, +) -> Result<()> { + let mut composer = MergeComposer::new(); + composer.push_defaults(default_cli_json?); + composer.push_file(json!({ + "theme": "unicode", + "no_emoji": true + }), None); + + let err = CliConfig::merge_from_layers(composer.layers()) + .expect_err("conflicting theme and alias should fail"); ensure!( - format!("{merge_error:?}").contains(".netsuke.toml"), - "merge error should mention the malformed project config" + err.to_string().contains("theme = \"unicode\" conflicts with no_emoji = true"), + "unexpected error text: {err}", ); - Ok(()) } -#[cfg(unix)] #[rstest] -fn resolve_merged_diag_json_does_not_discover_after_explicit_config_error( - unix_config_env: Result, +fn cli_config_validates_spinner_and_progress_conflicts( + default_cli_json: Result, ) -> Result<()> { - let env = unix_config_env?; + let mut composer = MergeComposer::new(); + composer.push_defaults(default_cli_json?); + composer.push_file(json!({ + "spinner_mode": "disabled", + "progress": true + }), None); - fs::write( - env.temp_project.path().join(".netsuke.toml"), - "output_format = \"json\"\n", - ) - .context("write project .netsuke.toml")?; + let err = CliConfig::merge_from_layers(composer.layers()) + .expect_err("conflicting spinner and progress settings should fail"); + ensure!( + err.to_string().contains("spinner_mode = \"disabled\" conflicts with progress = true"), + "unexpected error text: {err}", + ); + Ok(()) +} - let explicit_config = env.temp_project.path().join("broken.toml"); - fs::write(&explicit_config, "theme = \"ascii\n").context("write malformed explicit config")?; +#[rstest] +fn cli_config_rejects_unsupported_json_output_format( + default_cli_json: Result, +) -> Result<()> { + let mut composer = MergeComposer::new(); + composer.push_defaults(default_cli_json?); + composer.push_file(json!({ + "output_format": "json" + }), None); + let err = CliConfig::merge_from_layers(composer.layers()) + .expect_err("unsupported output format should fail"); + ensure!( + err.to_string().contains("output_format = \"json\" is not supported yet"), + "unexpected error text: {err}", + ); + Ok(()) +} + +#[rstest] +fn cli_runtime_canonicalizes_ascii_theme_from_no_emoji_alias() -> Result<()> { let _env_lock = EnvLock::acquire(); - let _cwd_guard = CwdGuard::acquire()?; - std::env::set_current_dir(&env.temp_project).context("change to project directory")?; + let temp_dir = tempdir().context("create temporary config directory")?; + let config_path = temp_dir.path().join("netsuke.toml"); + fs::write(&config_path, "no_emoji = true\n").context("write netsuke.toml")?; - let config_arg = explicit_config.to_string_lossy().into_owned(); + let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); 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 (cli, matches) = netsuke::cli::parse_with_localizer_from(["netsuke"], &localizer) + .context("parse CLI args for merge")?; + let merged = netsuke::cli::merge_with_config(&cli, &matches) + .context("merge CLI and configuration layers")?; - 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" + merged.theme == Some(Theme::Ascii), + "no_emoji compatibility alias should canonicalize to the ASCII theme", ); - - 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" + merged.no_emoji == Some(true), + "no_emoji alias should remain available in the runtime CLI", ); - Ok(()) } diff --git a/tests/features/configuration_preferences.feature b/tests/features/configuration_preferences.feature new file mode 100644 index 00000000..b798cb61 --- /dev/null +++ b/tests/features/configuration_preferences.feature @@ -0,0 +1,33 @@ +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" + 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 From a97e7958ca7d710d5f7d5607d166546515d9b613 Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 10 Mar 2026 19:02:15 +0000 Subject: [PATCH 04/19] test(cli): refactor CLI merge layer tests for reusability and clarity Refactor CLI merge layer tests by introducing utility functions `with_config_file` and `merge_defaults_with_file_layer` to handle configuration file setup and merging. Replace repetitive tempdir and environment variable management with these helpers to improve test clarity and reduce boilerplate. Updated various tests to use these helpers, keeping test behavior unchanged but improving maintainability. Co-authored-by: devboxerhub[bot] --- tests/cli_tests/merge.rs | 185 +++++++++++++++++++-------------------- 1 file changed, 88 insertions(+), 97 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index a2caa702..63cd8b0b 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -4,23 +4,48 @@ //! CLI) and list-value appending. use anyhow::{Context, Result, ensure}; -use netsuke::cli::{Cli, CliConfig, OutputFormat, SpinnerMode, Theme}; -use netsuke::cli_localization; +use netsuke::cli::{CliConfig, Theme}; 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}; +use test_support::EnvVarGuard; #[fixture] fn default_cli_json() -> Result { 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 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) +} + #[rstest] fn cli_merge_layers_respects_precedence_and_appends_lists( default_cli_json: Result, @@ -174,80 +199,58 @@ fn cli_merge_layers_prefers_cli_then_env_then_file_for_locale( #[rstest] fn cli_config_build_defaults_apply_when_cli_targets_are_absent() -> 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, + with_config_file( r#" [cmds.build] targets = ["all", "docs"] "#, + &["netsuke"], + |merged| { + let Some(netsuke::cli::Commands::Build(args)) = merged.command else { + anyhow::bail!("expected merged command to be build"); + }; + ensure!( + args.targets == vec![String::from("all"), String::from("docs")], + "configured build targets should be used when CLI targets are absent", + ); + Ok(()) + }, ) - .context("write netsuke.toml")?; - - let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); - 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")?; - let merged = netsuke::cli::merge_with_config(&cli, &matches) - .context("merge CLI and configuration layers")?; - - let Some(netsuke::cli::Commands::Build(args)) = merged.command else { - anyhow::bail!("expected merged command to be build"); - }; - ensure!( - args.targets == vec![String::from("all"), String::from("docs")], - "configured build targets should be used when CLI targets are absent", - ); - Ok(()) } #[rstest] fn cli_config_explicit_targets_override_configured_build_defaults() -> 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, + with_config_file( r#" [cmds.build] targets = ["all"] "#, + &["netsuke", "build", "lint"], + |merged| { + let Some(netsuke::cli::Commands::Build(args)) = merged.command else { + anyhow::bail!("expected merged command to be build"); + }; + ensure!( + args.targets == vec![String::from("lint")], + "explicit CLI targets should override configured defaults", + ); + Ok(()) + }, ) - .context("write netsuke.toml")?; - - let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); - let localizer = Arc::from(cli_localization::build_localizer(None)); - let (cli, matches) = - netsuke::cli::parse_with_localizer_from(["netsuke", "build", "lint"], &localizer) - .context("parse CLI args for merge")?; - let merged = netsuke::cli::merge_with_config(&cli, &matches) - .context("merge CLI and configuration layers")?; - - let Some(netsuke::cli::Commands::Build(args)) = merged.command else { - anyhow::bail!("expected merged command to be build"); - }; - ensure!( - args.targets == vec![String::from("lint")], - "explicit CLI targets should override configured defaults", - ); - Ok(()) } #[rstest] fn cli_config_validates_theme_alias_conflicts( default_cli_json: Result, ) -> Result<()> { - let mut composer = MergeComposer::new(); - composer.push_defaults(default_cli_json?); - composer.push_file(json!({ - "theme": "unicode", - "no_emoji": true - }), None); - - let err = CliConfig::merge_from_layers(composer.layers()) - .expect_err("conflicting theme and alias should fail"); + let err = merge_defaults_with_file_layer( + default_cli_json?, + json!({ + "theme": "unicode", + "no_emoji": true + }), + ) + .expect_err("conflicting theme and alias should fail"); ensure!( err.to_string().contains("theme = \"unicode\" conflicts with no_emoji = true"), "unexpected error text: {err}", @@ -259,15 +262,14 @@ fn cli_config_validates_theme_alias_conflicts( fn cli_config_validates_spinner_and_progress_conflicts( default_cli_json: Result, ) -> Result<()> { - let mut composer = MergeComposer::new(); - composer.push_defaults(default_cli_json?); - composer.push_file(json!({ - "spinner_mode": "disabled", - "progress": true - }), None); - - let err = CliConfig::merge_from_layers(composer.layers()) - .expect_err("conflicting spinner and progress settings should fail"); + let err = merge_defaults_with_file_layer( + default_cli_json?, + json!({ + "spinner_mode": "disabled", + "progress": true + }), + ) + .expect_err("conflicting spinner and progress settings should fail"); ensure!( err.to_string().contains("spinner_mode = \"disabled\" conflicts with progress = true"), "unexpected error text: {err}", @@ -279,14 +281,13 @@ fn cli_config_validates_spinner_and_progress_conflicts( fn cli_config_rejects_unsupported_json_output_format( default_cli_json: Result, ) -> Result<()> { - let mut composer = MergeComposer::new(); - composer.push_defaults(default_cli_json?); - composer.push_file(json!({ - "output_format": "json" - }), None); - - let err = CliConfig::merge_from_layers(composer.layers()) - .expect_err("unsupported output format should fail"); + let err = merge_defaults_with_file_layer( + default_cli_json?, + json!({ + "output_format": "json" + }), + ) + .expect_err("unsupported output format should fail"); ensure!( err.to_string().contains("output_format = \"json\" is not supported yet"), "unexpected error text: {err}", @@ -296,25 +297,15 @@ fn cli_config_rejects_unsupported_json_output_format( #[rstest] fn cli_runtime_canonicalizes_ascii_theme_from_no_emoji_alias() -> 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, "no_emoji = true\n").context("write netsuke.toml")?; - - let _config_guard = EnvVarGuard::set("NETSUKE_CONFIG_PATH", config_path.as_os_str()); - 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")?; - let merged = netsuke::cli::merge_with_config(&cli, &matches) - .context("merge CLI and configuration layers")?; - - ensure!( - merged.theme == Some(Theme::Ascii), - "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(()) + with_config_file("no_emoji = true\n", &["netsuke"], |merged| { + ensure!( + merged.theme == Some(Theme::Ascii), + "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(()) + }) } From b31734eb6c98b2c6647f3a16a90e7c9ec4efad41 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 11 Mar 2026 13:01:43 +0000 Subject: [PATCH 05/19] test(cli): refactor merge.rs tests with helper assertion functions - Introduced assert_build_targets helper to simplify build target checks. - Introduced assert_merge_rejects helper to validate expected merge errors. - Replaced repetitive inline assertions with helper functions in merge.rs tests. - Improved test readability and reduced duplication in cli merge tests. Co-authored-by: devboxerhub[bot] --- tests/cli_tests/merge.rs | 82 ++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 63cd8b0b..2cc5a273 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -5,13 +5,16 @@ use anyhow::{Context, Result, ensure}; use netsuke::cli::{CliConfig, Theme}; +use netsuke::cli_localization; 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 test_support::EnvVarGuard; +use std::sync::Arc; +use tempfile::tempdir; +use test_support::{EnvVarGuard, env_lock::EnvLock}; #[fixture] fn default_cli_json() -> Result { @@ -36,6 +39,21 @@ where f(merged) } +fn assert_build_targets( + toml_content: &str, + cli_args: &[&str], + expected_targets: &[String], + assertion_msg: &str, +) -> 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, "{}", assertion_msg); + Ok(()) + }) +} + fn merge_defaults_with_file_layer( defaults: serde_json::Value, file_layer: serde_json::Value, @@ -46,6 +64,20 @@ fn merge_defaults_with_file_layer( 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, + expect_err_hint: &str, + expected_msg: &str, +) -> anyhow::Result<()> { + let err = merge_defaults_with_file_layer(defaults, file_layer).expect_err(expect_err_hint); + ensure!( + err.to_string().contains(expected_msg), + "unexpected error text: {err}", + ); + Ok(()) +} + #[rstest] fn cli_merge_layers_respects_precedence_and_appends_lists( default_cli_json: Result, @@ -199,43 +231,27 @@ fn cli_merge_layers_prefers_cli_then_env_then_file_for_locale( #[rstest] fn cli_config_build_defaults_apply_when_cli_targets_are_absent() -> Result<()> { - with_config_file( + assert_build_targets( r#" [cmds.build] targets = ["all", "docs"] "#, &["netsuke"], - |merged| { - let Some(netsuke::cli::Commands::Build(args)) = merged.command else { - anyhow::bail!("expected merged command to be build"); - }; - ensure!( - args.targets == vec![String::from("all"), String::from("docs")], - "configured build targets should be used when CLI targets are absent", - ); - Ok(()) - }, + &[String::from("all"), String::from("docs")], + "configured build targets should be used when CLI targets are absent", ) } #[rstest] fn cli_config_explicit_targets_override_configured_build_defaults() -> Result<()> { - with_config_file( + assert_build_targets( r#" [cmds.build] targets = ["all"] "#, &["netsuke", "build", "lint"], - |merged| { - let Some(netsuke::cli::Commands::Build(args)) = merged.command else { - anyhow::bail!("expected merged command to be build"); - }; - ensure!( - args.targets == vec![String::from("lint")], - "explicit CLI targets should override configured defaults", - ); - Ok(()) - }, + &[String::from("lint")], + "explicit CLI targets should override configured defaults", ) } @@ -243,38 +259,30 @@ targets = ["all"] fn cli_config_validates_theme_alias_conflicts( default_cli_json: Result, ) -> Result<()> { - let err = merge_defaults_with_file_layer( + assert_merge_rejects( default_cli_json?, json!({ "theme": "unicode", "no_emoji": true }), + "conflicting theme and alias should fail", + "theme = \"unicode\" conflicts with no_emoji = true", ) - .expect_err("conflicting theme and alias should fail"); - ensure!( - err.to_string().contains("theme = \"unicode\" conflicts with no_emoji = true"), - "unexpected error text: {err}", - ); - Ok(()) } #[rstest] fn cli_config_validates_spinner_and_progress_conflicts( default_cli_json: Result, ) -> Result<()> { - let err = merge_defaults_with_file_layer( + assert_merge_rejects( default_cli_json?, json!({ "spinner_mode": "disabled", "progress": true }), + "conflicting spinner and progress settings should fail", + "spinner_mode = \"disabled\" conflicts with progress = true", ) - .expect_err("conflicting spinner and progress settings should fail"); - ensure!( - err.to_string().contains("spinner_mode = \"disabled\" conflicts with progress = true"), - "unexpected error text: {err}", - ); - Ok(()) } #[rstest] From 19c642af7c5f9e53aa5d83e88f3525a46b99bb76 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 11 Mar 2026 16:17:52 +0000 Subject: [PATCH 06/19] test(cli): use parameterized tests for conflict validation Refactor CLI config validation tests to use parameterized test cases with rstest's #[case] attributes. This reduces test code duplication and improves clarity when testing conflicting or unsupported CLI config settings. Co-authored-by: devboxerhub[bot] --- tests/cli_tests/merge.rs | 63 ++++++++++++---------------------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 2cc5a273..694cf9d7 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -67,10 +67,10 @@ fn merge_defaults_with_file_layer( fn assert_merge_rejects( defaults: serde_json::Value, file_layer: serde_json::Value, - expect_err_hint: &str, expected_msg: &str, ) -> anyhow::Result<()> { - let err = merge_defaults_with_file_layer(defaults, file_layer).expect_err(expect_err_hint); + let err = merge_defaults_with_file_layer(defaults, file_layer) + .expect_err("merge should have returned an error"); ensure!( err.to_string().contains(expected_msg), "unexpected error text: {err}", @@ -256,51 +256,24 @@ targets = ["all"] } #[rstest] -fn cli_config_validates_theme_alias_conflicts( +#[case( + json!({ "theme": "unicode", "no_emoji": true }), + "theme = \"unicode\" conflicts with no_emoji = true", +)] +#[case( + json!({ "spinner_mode": "disabled", "progress": true }), + "spinner_mode = \"disabled\" conflicts with progress = true", +)] +#[case( + json!({ "output_format": "json" }), + "output_format = \"json\" is not supported yet", +)] +fn cli_config_rejects_conflicting_or_unsupported_settings( default_cli_json: Result, + #[case] file_layer: serde_json::Value, + #[case] expected_msg: &str, ) -> Result<()> { - assert_merge_rejects( - default_cli_json?, - json!({ - "theme": "unicode", - "no_emoji": true - }), - "conflicting theme and alias should fail", - "theme = \"unicode\" conflicts with no_emoji = true", - ) -} - -#[rstest] -fn cli_config_validates_spinner_and_progress_conflicts( - default_cli_json: Result, -) -> Result<()> { - assert_merge_rejects( - default_cli_json?, - json!({ - "spinner_mode": "disabled", - "progress": true - }), - "conflicting spinner and progress settings should fail", - "spinner_mode = \"disabled\" conflicts with progress = true", - ) -} - -#[rstest] -fn cli_config_rejects_unsupported_json_output_format( - default_cli_json: Result, -) -> Result<()> { - let err = merge_defaults_with_file_layer( - default_cli_json?, - json!({ - "output_format": "json" - }), - ) - .expect_err("unsupported output format should fail"); - ensure!( - err.to_string().contains("output_format = \"json\" is not supported yet"), - "unexpected error text: {err}", - ); - Ok(()) + assert_merge_rejects(default_cli_json?, file_layer, expected_msg) } #[rstest] From e0d4432fa6e921ca43130d388e9b716b6aaa90d0 Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 12 Mar 2026 17:49:58 +0000 Subject: [PATCH 07/19] test(cli): improve merge tests with structured error expectations - Introduce ExpectedValidationError enum to represent specific merge error cases. - Refactor assert_merge_rejects to use ExpectedValidationError instead of raw strings. - Update test cases to use the enum, improving clarity and maintainability. - Enhance error assertion messages for better diagnostics in build target tests. Co-authored-by: devboxerhub[bot] --- tests/cli_tests/merge.rs | 47 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 694cf9d7..74b47df5 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -43,17 +43,44 @@ fn assert_build_targets( toml_content: &str, cli_args: &[&str], expected_targets: &[String], - assertion_msg: &str, ) -> 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, "{}", assertion_msg); + ensure!( + args.targets == expected_targets, + "build targets mismatch: got {:?}, expected {:?}", + args.targets, + expected_targets, + ); Ok(()) }) } +#[derive(Debug)] +enum ExpectedValidationError { + ThemeAliasConflict, + SpinnerProgressConflict, + UnsupportedOutputFormat, +} + +impl ExpectedValidationError { + fn expected_fragment(&self) -> &'static str { + match self { + Self::ThemeAliasConflict => { + "theme = \"unicode\" conflicts with no_emoji = true" + } + Self::SpinnerProgressConflict => { + "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, @@ -67,12 +94,12 @@ fn merge_defaults_with_file_layer( fn assert_merge_rejects( defaults: serde_json::Value, file_layer: serde_json::Value, - expected_msg: &str, + expected_error: ExpectedValidationError, ) -> anyhow::Result<()> { let err = merge_defaults_with_file_layer(defaults, file_layer) .expect_err("merge should have returned an error"); ensure!( - err.to_string().contains(expected_msg), + err.to_string().contains(expected_error.expected_fragment()), "unexpected error text: {err}", ); Ok(()) @@ -238,7 +265,6 @@ targets = ["all", "docs"] "#, &["netsuke"], &[String::from("all"), String::from("docs")], - "configured build targets should be used when CLI targets are absent", ) } @@ -251,29 +277,28 @@ targets = ["all"] "#, &["netsuke", "build", "lint"], &[String::from("lint")], - "explicit CLI targets should override configured defaults", ) } #[rstest] #[case( json!({ "theme": "unicode", "no_emoji": true }), - "theme = \"unicode\" conflicts with no_emoji = true", + ExpectedValidationError::ThemeAliasConflict, )] #[case( json!({ "spinner_mode": "disabled", "progress": true }), - "spinner_mode = \"disabled\" conflicts with progress = true", + ExpectedValidationError::SpinnerProgressConflict, )] #[case( json!({ "output_format": "json" }), - "output_format = \"json\" is not supported yet", + ExpectedValidationError::UnsupportedOutputFormat, )] fn cli_config_rejects_conflicting_or_unsupported_settings( default_cli_json: Result, #[case] file_layer: serde_json::Value, - #[case] expected_msg: &str, + #[case] expected_error: ExpectedValidationError, ) -> Result<()> { - assert_merge_rejects(default_cli_json?, file_layer, expected_msg) + assert_merge_rejects(default_cli_json?, file_layer, expected_error) } #[rstest] From 2412247590060d966e17e48c2dd9f84dc281e278 Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 12 Mar 2026 22:21:27 +0000 Subject: [PATCH 08/19] feat(cli): introduce CliConfig as central OrthoConfig-derived schema - Added `CliConfig` in `src/cli/config.rs` to encapsulate global CLI settings and subcommand defaults with OrthoConfig derive. - Split `src/cli/mod.rs` responsibilities into parser, config, and merge modules. - Upgraded ortho_config to v0.8.0 and migrated configuration layering accordingly. - Updated merge and parsing logic to use `CliConfig` as authoritative merge target. - Extended unit tests (`tests/cli_tests/merge.rs`), behavioural tests (`tests/features/configuration_preferences.feature`), and BDD steps. - Updated documentation (`docs/users-guide.md`, `docs/netsuke-design.md`, `docs/roadmap.md`) to reflect new config schema, precedence rules, and CLI defaults. - Improved handling for output preferences, locale overrides, and theme compatibility validation. This change modularizes CLI configuration management, clarifies schema separation, and sets foundation for safer future extensions while preserving legacy behavior. Co-authored-by: devboxerhub[bot] --- docs/execplans/3-11-1-cli-config-struct.md | 146 ++++++++---------- docs/ortho-config-users-guide.md | 14 +- docs/users-guide.md | 4 + src/cli/config.rs | 16 +- src/cli/merge.rs | 13 +- src/cli/mod.rs | 10 ++ src/cli/parser.rs | 17 +- tests/bdd/steps/configuration_preferences.rs | 15 ++ tests/cli_tests/merge.rs | 28 +++- tests/cli_tests/parsing.rs | 38 +++++ .../configuration_preferences.feature | 1 + 11 files changed, 183 insertions(+), 119 deletions(-) diff --git a/docs/execplans/3-11-1-cli-config-struct.md b/docs/execplans/3-11-1-cli-config-struct.md index 547750fa..b47b9d88 100644 --- a/docs/execplans/3-11-1-cli-config-struct.md +++ b/docs/execplans/3-11-1-cli-config-struct.md @@ -14,12 +14,10 @@ No `PLANS.md` file exists in this repository. 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`](/home/user/project/docs/ortho-config-users-guide.md), - which has been replaced with the upstream `v0.8.0` guide. Today the repository -already has partial layered configuration, but it is centered on -[`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs), where `Cli` currently -serves three roles at once: +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. @@ -50,10 +48,8 @@ Observable success means: 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`](/home/user/project/docs/users-guide.md), - [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md), and - [`docs/roadmap.md`](/home/user/project/docs/roadmap.md) reflect the final - behaviour. +5. `docs/users-guide.md`, `docs/netsuke-design.md`, and `docs/roadmap.md` + reflect the final behaviour. ## Constraints @@ -107,10 +103,10 @@ Observable success means: ## Risks -- Risk: [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs) is already 398 - lines, so any additive work there will 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: `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 @@ -158,18 +154,16 @@ Observable success means: - 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 current repository already documents configuration discovery in - [`docs/users-guide.md`](/home/user/project/docs/users-guide.md), and already - has merge tests in - [`tests/cli_tests/merge.rs`](/home/user/project/tests/cli_tests/merge.rs). - The implementation must preserve these guarantees while changing the type - layout. -- `rstest-bdd` feature-file edits may require touching - [`tests/bdd_tests.rs`](/home/user/project/tests/bdd_tests.rs) to force Cargo - to rebuild generated scenarios. -- The new configuration-preferences BDD coverage initially flaked only in the - full suite because `NETSUKE_CONFIG_PATH` is process-global. Holding - [`EnvLock`](test_support/src/env_lock.rs) for the whole scenario fixed the +- 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 @@ -222,8 +216,7 @@ Observable success means: 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`](/home/user/project/docs/netsuke-design.md) during +These decisions must be recorded in `docs/netsuke-design.md` during implementation if they remain unchanged after coding begins. ## Outcomes & Retrospective @@ -232,9 +225,9 @@ Completed on 2026-03-09. Implemented results: -- Added [`CliConfig`](/home/user/project/src/cli/config.rs) as the - authoritative OrthoConfig-derived schema and split the CLI module into - parser, config, and merge submodules. +- 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`. @@ -244,10 +237,8 @@ Implemented results: 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`](/home/user/project/tests/cli_tests/merge.rs) - plus behavioural coverage in - [`tests/features/configuration_preferences.feature`](/home/user/project/tests/features/configuration_preferences.feature). +- 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: @@ -255,7 +246,7 @@ Quality-gate evidence: - `make check-fmt` - `make lint` - `make test` -- `PATH="/root/.bun/bin:$PATH" make markdownlint` +- `make markdownlint` (with `markdownlint-cli2` available on `PATH`) - `make nixie` Lessons learned: @@ -263,33 +254,30 @@ 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. -- BDD coverage that touches process-wide environment variables must hold - `EnvLock` for the full scenario, not only for individual mutations. +- 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 -The current implementation is spread across the following files: - -- [`src/cli/mod.rs`](/home/user/project/src/cli/mod.rs): current `Cli` type, - Clap parser, OrthoConfig derive, validation parsers, and merge logic. -- [`Cargo.toml`](/home/user/project/Cargo.toml): currently pins - `ortho_config = "0.7.0"` and `rust-version = "1.89.0"`. -- [`rust-toolchain.toml`](/home/user/project/rust-toolchain.toml): currently - pins toolchain `1.89.0`, which already satisfies the `0.8.0` minimum. -- [`src/main.rs`](/home/user/project/src/main.rs): startup parse/merge flow and - runtime localization bootstrap. -- [`src/output_mode.rs`](/home/user/project/src/output_mode.rs): accessible - versus standard output mode resolution. -- [`src/output_prefs.rs`](/home/user/project/src/output_prefs.rs): emoji-aware - semantic prefixes and current `no_emoji` handling. -- [`src/runner/mod.rs`](/home/user/project/src/runner/mod.rs): uses merged CLI - state to choose output mode, progress behaviour, and build targets. -- [`tests/cli_tests/merge.rs`](/home/user/project/tests/cli_tests/merge.rs): - current merge precedence coverage. -- [`tests/cli_tests/parsing.rs`](/home/user/project/tests/cli_tests/parsing.rs) - and - [`tests/features/cli.feature`](/home/user/project/tests/features/cli.feature): - current parse-only coverage. +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: @@ -354,10 +342,9 @@ compatibility aliases such as `no_emoji = true`. ### Stage A: split responsibilities before adding new fields -Start by reducing the blast radius in -[`src/cli/mod.rs`](/home/user/project/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 +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: @@ -416,7 +403,7 @@ Concrete work in this stage: `[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`](/home/user/project/src/runner/mod.rs). + 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 @@ -456,9 +443,9 @@ Behavioural coverage to add with `rstest-bdd` v0.5.0: - compatibility alias (`no_emoji`) still produces ASCII-themed output Prefer a dedicated feature file such as -[`tests/features/configuration_preferences.feature`](/home/user/project/tests/features/configuration_preferences.feature) - plus matching step definitions, rather than overloading the existing -CLI-parsing feature with merge semantics. +`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 @@ -467,16 +454,14 @@ change. Documentation work: -1. Update [`docs/users-guide.md`](/home/user/project/docs/users-guide.md) with - the new config schema, precedence rules, accepted values, and example TOML. -2. Update [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md) - to record: +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`](/home/user/project/docs/roadmap.md). +3. Mark roadmap item `3.11.1` as done in `docs/roadmap.md`. Acceptance for Stage E: @@ -488,7 +473,7 @@ Acceptance for Stage E: ## Concrete steps -Run all commands from `/home/user/project`. +Run all commands from the repository root. Before editing feature files, remember the existing `rstest-bdd` gotcha: @@ -517,7 +502,7 @@ Because docs will change, also run: ```sh set -o pipefail -PATH="/root/.bun/bin:$PATH" make markdownlint 2>&1 | tee /tmp/netsuke-markdownlint.log +make markdownlint 2>&1 | tee /tmp/netsuke-markdownlint.log ``` ```sh @@ -534,8 +519,8 @@ 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`](/home/user/project/docs/ortho-config-users-guide.md). - Do not rely on older `0.7.x` examples when the two guides disagree. +`docs/ortho-config-users-guide.md`. Do not rely on older `0.7.x` examples when +the two guides disagree. ## Validation and acceptance @@ -551,9 +536,8 @@ The feature is complete only when all of the following are true: 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`](/home/user/project/docs/users-guide.md), - [`docs/netsuke-design.md`](/home/user/project/docs/netsuke-design.md), and - [`docs/roadmap.md`](/home/user/project/docs/roadmap.md) are updated. +7. `docs/users-guide.md`, `docs/netsuke-design.md`, and `docs/roadmap.md` are + updated. ## Idempotence and recovery 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/users-guide.md b/docs/users-guide.md index 7f9d74ca..6f1247b7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -688,6 +688,10 @@ top-level configuration keys: - `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"` diff --git a/src/cli/config.rs b/src/cli/config.rs index e860bf03..2324d066 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -4,11 +4,11 @@ //! and merging. It captures global CLI settings plus per-subcommand defaults //! under the `cmds` namespace. -use ortho_config::{OrthoConfig, OrthoError, OrthoResult, PostMergeContext, PostMergeHook}; +use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use std::sync::Arc; +use super::validation_error; use crate::host_pattern::HostPattern; use crate::theme::ThemePreference; @@ -186,6 +186,11 @@ impl Default for CliConfig { } } +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)?; @@ -236,10 +241,3 @@ fn validate_output_format_support(config: &CliConfig) -> OrthoResult<()> { } Ok(()) } - -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/merge.rs b/src/cli/merge.rs index f5e1d23d..99b84b9c 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -5,16 +5,14 @@ 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, OrthoError, OrthoMergeExt, OrthoResult, sanitize_value, -}; +use ortho_config::{ConfigDiscovery, MergeComposer, OrthoMergeExt, OrthoResult, sanitize_value}; use serde::Serialize; use serde_json::{Map, Value, json}; use std::path::PathBuf; -use std::sync::Arc; use super::config::{BuildConfig, CliConfig, Theme}; use super::parser::{BuildArgs, Cli, Commands}; +use super::validation_error; const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG_PATH"; const ENV_PREFIX: &str = "NETSUKE_"; @@ -247,10 +245,3 @@ const fn canonical_theme(theme: Option, no_emoji: Option) -> Option _ => None, } } - -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/mod.rs b/src/cli/mod.rs index 142168ee..4b37d7d8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -5,6 +5,9 @@ //! used to merge defaults, configuration files, environment variables, and CLI //! overrides into the runtime shape consumed by the runner. +use ortho_config::OrthoError; +use std::sync::Arc; + mod config; mod merge; mod parser; @@ -16,3 +19,10 @@ 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 index d2faaaaf..2d4469f6 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -10,6 +10,7 @@ use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; +use super::config::CliConfig; use super::parsing::{parse_host_pattern, parse_jobs, parse_locale, parse_scheme}; use super::{ColourPolicy, OutputFormat, SpinnerMode, Theme}; pub use crate::cli_l10n::{diag_json_hint_from_args, locale_hint_from_args}; @@ -160,10 +161,16 @@ impl Cli { /// Return the effective emoji override for output preference resolution. #[must_use] pub const fn no_emoji_override(&self) -> Option { - if matches!(self.theme, Some(Theme::Ascii)) || matches!(self.no_emoji, Some(true)) { - Some(true) - } else { - self.no_emoji + match self.theme { + Some(Theme::Ascii) => Some(true), + Some(Theme::Unicode) => Some(false), + _ => { + if matches!(self.no_emoji, Some(true)) { + Some(true) + } else { + None + } + } } } @@ -181,7 +188,7 @@ impl Cli { impl Default for Cli { fn default() -> Self { Self { - file: PathBuf::from("Netsukefile"), + file: CliConfig::default_manifest_path(), directory: None, jobs: None, verbose: false, diff --git a/tests/bdd/steps/configuration_preferences.rs b/tests/bdd/steps/configuration_preferences.rs index 095ed675..11d14fad 100644 --- a/tests/bdd/steps/configuration_preferences.rs +++ b/tests/bdd/steps/configuration_preferences.rs @@ -16,6 +16,7 @@ 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(); @@ -70,6 +71,20 @@ 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); + Ok(()) +} + #[given("the Netsuke config file sets output format to {format:string}")] fn config_sets_output_format(world: &TestWorld, format: &str) -> Result<()> { write_config(world, &format!("output_format = \"{format}\"\n")) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 74b47df5..c35402a9 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -60,20 +60,28 @@ fn assert_build_targets( #[derive(Debug)] enum ExpectedValidationError { - ThemeAliasConflict, - SpinnerProgressConflict, + ThemeUnicodeAliasConflict, + ThemeAsciiAliasConflict, + SpinnerDisabledConflict, + SpinnerEnabledConflict, UnsupportedOutputFormat, } impl ExpectedValidationError { fn expected_fragment(&self) -> &'static str { match self { - Self::ThemeAliasConflict => { + Self::ThemeUnicodeAliasConflict => { "theme = \"unicode\" conflicts with no_emoji = true" } - Self::SpinnerProgressConflict => { + Self::ThemeAsciiAliasConflict => { + "no_emoji = false conflicts with theme = \"ascii\"" + } + Self::SpinnerDisabledConflict => { "spinner_mode = \"disabled\" conflicts with progress = true" } + Self::SpinnerEnabledConflict => { + "progress = false conflicts with spinner_mode = \"enabled\"" + } Self::UnsupportedOutputFormat => { "output_format = \"json\" is not supported yet" } @@ -283,11 +291,19 @@ targets = ["all"] #[rstest] #[case( json!({ "theme": "unicode", "no_emoji": true }), - ExpectedValidationError::ThemeAliasConflict, + ExpectedValidationError::ThemeUnicodeAliasConflict, +)] +#[case( + json!({ "theme": "ascii", "no_emoji": false }), + ExpectedValidationError::ThemeAsciiAliasConflict, )] #[case( json!({ "spinner_mode": "disabled", "progress": true }), - ExpectedValidationError::SpinnerProgressConflict, + ExpectedValidationError::SpinnerDisabledConflict, +)] +#[case( + json!({ "spinner_mode": "enabled", "progress": false }), + ExpectedValidationError::SpinnerEnabledConflict, )] #[case( json!({ "output_format": "json" }), diff --git a/tests/cli_tests/parsing.rs b/tests/cli_tests/parsing.rs index 43cad615..174a27e6 100644 --- a/tests/cli_tests/parsing.rs +++ b/tests/cli_tests/parsing.rs @@ -3,9 +3,11 @@ use anyhow::{Context, Result, ensure}; use clap::error::ErrorKind; use netsuke::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; +use netsuke::cli::{BuildArgs, Cli, Commands, Theme}; use netsuke::cli::{BuildArgs, Commands}; use netsuke::cli_localization; use netsuke::host_pattern::HostPattern; +use netsuke::output_prefs; use netsuke::theme::ThemePreference; use rstest::rstest; use std::path::PathBuf; @@ -280,3 +282,39 @@ fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) ); Ok(()) } + +fn no_emoji_override_false_defers_to_environment_suppression() -> Result<()> { + let cli = Cli { + no_emoji: Some(false), + ..Cli::default() + }; + + let prefs = output_prefs::resolve_with(cli.no_emoji_override(), |key| match key { + "NETSUKE_NO_EMOJI" => Some(String::from("1")), + _ => None, + }); + + ensure!( + !prefs.emoji_allowed(), + "no_emoji = false should defer to environment suppression", + ); + Ok(()) +} + +fn no_emoji_override_honours_unicode_theme_over_environment_suppression() -> Result<()> { + let cli = Cli { + theme: Some(Theme::Unicode), + ..Cli::default() + }; + + let prefs = output_prefs::resolve_with(cli.no_emoji_override(), |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 index b798cb61..7bc656ad 100644 --- a/tests/features/configuration_preferences.feature +++ b/tests/features/configuration_preferences.feature @@ -10,6 +10,7 @@ Feature: Configuration preferences 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" From 6533f68a2466f6ad4c014964e876be38645fffb9 Mon Sep 17 00:00:00 2001 From: Leynos Date: Fri, 13 Mar 2026 18:10:15 +0000 Subject: [PATCH 09/19] docs(users-guide): fix comma usage in users guide for clarity Co-authored-by: devboxerhub[bot] --- docs/users-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 6f1247b7..717d1485 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -734,7 +734,7 @@ 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 +`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 From 456152c4ba72111a9e7496889a003a9e1593f6a4 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 14 Mar 2026 00:37:43 +0000 Subject: [PATCH 10/19] style: format rebased cli changes Apply formatter-required whitespace and import ordering changes\nafter rebasing the CLI config work onto origin/main.\n\nThis keeps the rebased branch passing the repository quality\ngates without introducing unrelated documentation churn. --- src/cli/parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/parser.rs b/src/cli/parser.rs index 2d4469f6..f230a851 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -13,8 +13,8 @@ use std::sync::Arc; use super::config::CliConfig; use super::parsing::{parse_host_pattern, parse_jobs, parse_locale, parse_scheme}; use super::{ColourPolicy, OutputFormat, SpinnerMode, Theme}; -pub use crate::cli_l10n::{diag_json_hint_from_args, locale_hint_from_args}; 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; #[derive(Clone)] From d3f248fb7d6ada7c8eb89c3a6d27620f8dc08fe0 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 14 Mar 2026 12:09:59 +0000 Subject: [PATCH 11/19] refactor(cli): split CLI parsing and config to separate modules Moved parser-facing `Cli` type to `src/cli/parser.rs` and layered configuration into a `CliConfig` struct in `src/cli/config.rs`. The top-level `src/cli/mod.rs` now re-exports the public CLI surface. This separation improves modularity by distinguishing parsing, configuration discovery, and runtime command selection while preserving existing command syntax. Also updated default manifest path handling in CLI arguments and enhanced test support environment isolation. Co-authored-by: devboxerhub[bot] --- docs/netsuke-design.md | 113 ++++++++++++++++++++++-------------- docs/users-guide.md | 2 +- src/cli/parser.rs | 8 ++- test_support/src/netsuke.rs | 5 ++ tests/cli_tests/merge.rs | 6 +- 5 files changed, 86 insertions(+), 48 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index bb21c4d8..8bb8a269 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -400,6 +400,22 @@ 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.5 Generated Targets and Actions with `foreach` Large sets of similar outputs or setup actions can clutter a manifest when @@ -2392,35 +2408,53 @@ 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 +2481,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 +2491,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/users-guide.md b/docs/users-guide.md index 717d1485..7ba2f61a 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -737,7 +737,7 @@ Likewise, `spinner_mode = "enabled"` conflicts with `progress = false`. `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 +`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. diff --git a/src/cli/parser.rs b/src/cli/parser.rs index f230a851..5d8ca121 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -65,7 +65,12 @@ pub(super) fn validation_message( #[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 = "Netsukefile")] + #[arg( + short, + long, + value_name = "FILE", + default_value_os_t = CliConfig::default_manifest_path() + )] pub file: PathBuf, /// Run as if started in this directory. @@ -302,5 +307,6 @@ fn configure_validation_parsers( }); command } + /// Maximum number of jobs accepted by the CLI. pub(super) const MAX_JOBS: usize = 64; 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/cli_tests/merge.rs b/tests/cli_tests/merge.rs index c35402a9..a0628250 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -104,8 +104,10 @@ fn assert_merge_rejects( file_layer: serde_json::Value, expected_error: ExpectedValidationError, ) -> anyhow::Result<()> { - let err = merge_defaults_with_file_layer(defaults, file_layer) - .expect_err("merge should have returned an error"); + let err = match merge_defaults_with_file_layer(defaults, file_layer) { + Ok(value) => anyhow::bail!("merge should have failed, got {value:#?}"), + Err(err) => err, + }; ensure!( err.to_string().contains(expected_error.expected_fragment()), "unexpected error text: {err}", From 528fa6be1bbcca592de2b3ac78e987db85451e4c Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 23 Mar 2026 00:01:59 +0000 Subject: [PATCH 12/19] Add explicit CLI overrides with typed enums; update tests/docs/build (#276) * refactor(tests): use EnvVarGuard for safer env var handling Refactored environment variable setting in BDD configuration preference tests by introducing EnvVarGuard to manage environment state more safely. This replaces unsafe direct std::env::set_var calls and improves test reliability. Additionally, minor spacing cleanup was done in documentation files. Co-authored-by: devboxerhub[bot] * feat(cli): add CLI options to override output appearance settings Add support for overriding colour policy, spinner mode, output format, and theme via CLI options. Derive ValueEnum for these enums to enable command line parsing integration. Update config merging logic to respect these new CLI flags. Refactor build script by splitting main function into smaller functions for clarity and maintainability. Co-authored-by: devboxerhub[bot] * refactor(tests): remove EnvVarGuard and directly set env vars with unsafe Replaced usage of the EnvVarGuard helper with direct calls to std::env::set_var inside unsafe blocks in BDD test steps. This simplifies environment variable management by manually tracking previous values without using the guard abstraction. Co-authored-by: devboxerhub[bot] * test(configuration): add BDD tests for theme, colour policy, and spinner mode precedence - Added various BDD step definitions for setting and verifying configuration preferences - Covered config file, environment variable, and CLI flag precedence for theme, colour policy, and spinner mode - Extended feature file with scenarios validating correct merge and override behavior among sources Co-authored-by: devboxerhub[bot] * refactor(tests/bdd): use typed enums for config and env var steps Replaced string-based configuration for theme, colour policy, spinner mode, and output format in BDD test steps with the corresponding typed enums from clap's ValueEnum trait. Added helper functions to write config values and set environment variables using enum canonical names, and unified assertion logic for merged CLI fields. This improves type safety, validation, and reduces code duplication in the BDD tests for configuration preference handling. Co-authored-by: devboxerhub[bot] --------- Co-authored-by: devboxerhub[bot] --- build.rs | 25 +-- docs/netsuke-design.md | 26 ++- src/cli/config.rs | 9 +- src/cli/merge.rs | 4 + src/cli/parser.rs | 16 +- tests/bdd/steps/configuration_preferences.rs | 176 ++++++++++++++++-- .../configuration_preferences.feature | 66 +++++++ 7 files changed, 281 insertions(+), 41 deletions(-) diff --git a/build.rs b/build.rs index 696f7eed..5e9307b2 100644 --- a/build.rs +++ b/build.rs @@ -88,7 +88,7 @@ fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result

Result<(), Box> { +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. @@ -107,8 +107,9 @@ fn main() -> Result<(), Box> { const _: fn(&cli::Cli) -> bool = cli::Cli::progress_enabled; const _: fn(&str) -> Result = HostPattern::parse; const _: fn(&HostPattern, host_pattern::HostCandidate<'_>) -> bool = HostPattern::matches; +} - // Regenerate the manual page when the CLI or metadata changes. +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"); @@ -125,13 +126,9 @@ fn main() -> Result<(), Box> { 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"); +} - build_l10n_audit::audit_localization_keys()?; - - // Packagers expect man pages under target/generated-man//. - let out_dir = out_dir_for_target_profile(); - - // The top-level page documents the entire command interface. +fn generate_man_page(out_dir: &Path) -> Result<(), Box> { let cmd = cli::Cli::command(); let name = cmd .get_bin_name() @@ -149,7 +146,6 @@ fn main() -> Result<(), Box> { 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}")) @@ -157,7 +153,7 @@ fn main() -> Result<(), Box> { let mut buf = Vec::new(); man.render(&mut buf)?; let page_name = format!("{cargo_bin}.1"); - write_man_page(&buf, &out_dir, &page_name)?; + 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) { @@ -167,6 +163,13 @@ fn main() -> Result<(), Box> { ); } } - Ok(()) } + +fn main() -> Result<(), Box> { + verify_public_api_symbols(); + emit_rerun_directives(); + build_l10n_audit::audit_localization_keys()?; + let out_dir = out_dir_for_target_profile(); + generate_man_page(&out_dir) +} diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 8bb8a269..189e5bcb 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -401,6 +401,21 @@ The cleaner model is: 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 @@ -2413,12 +2428,11 @@ 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. +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` diff --git a/src/cli/config.rs b/src/cli/config.rs index 2324d066..69dc87cd 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -4,6 +4,7 @@ //! and merging. It captures global CLI settings plus per-subcommand defaults //! under the `cmds` namespace. +use clap::ValueEnum; use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -13,7 +14,7 @@ use crate::host_pattern::HostPattern; use crate::theme::ThemePreference; /// Colour-output policy accepted by layered configuration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] pub enum ColourPolicy { /// Follow the host environment. @@ -26,7 +27,7 @@ pub enum ColourPolicy { } /// Spinner and progress rendering policy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] pub enum SpinnerMode { /// Follow Netsuke's default progress behaviour. @@ -39,7 +40,7 @@ pub enum SpinnerMode { } /// Top-level diagnostics and output format. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] pub enum OutputFormat { /// Human-readable terminal output. @@ -50,7 +51,7 @@ pub enum OutputFormat { } /// Presentation theme for semantic prefixes and glyph choices. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] pub enum Theme { /// Follow the host environment. diff --git a/src/cli/merge.rs b/src/cli/merge.rs index 99b84b9c..03105b80 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -155,6 +155,10 @@ fn cli_overrides_from_matches(cli: &Cli, matches: &ArgMatches) -> OrthoResult, - /// Resolved colour policy from layered configuration. - #[arg(skip)] + /// Override colour policy for terminal output. + #[arg(long, value_name = "POLICY")] pub colour_policy: Option, - /// Resolved spinner mode from layered configuration. - #[arg(skip)] + /// Override spinner animation mode. + #[arg(long, value_name = "MODE")] pub spinner_mode: Option, - /// Resolved output format from layered configuration. - #[arg(skip)] + /// Override output format style. + #[arg(long, value_name = "FORMAT")] pub output_format: Option, - /// Resolved presentation theme from layered configuration. - #[arg(skip)] + /// Override presentation theme. + #[arg(long, value_name = "THEME")] pub theme: Option, /// Optional subcommand to execute; defaults to `build` when omitted. diff --git a/tests/bdd/steps/configuration_preferences.rs b/tests/bdd/steps/configuration_preferences.rs index 11d14fad..a245c7c7 100644 --- a/tests/bdd/steps/configuration_preferences.rs +++ b/tests/bdd/steps/configuration_preferences.rs @@ -4,7 +4,8 @@ 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 netsuke::cli::{Cli, Commands, Theme}; +use clap::ValueEnum as _; +use netsuke::cli::{Cli, ColourPolicy, Commands, OutputFormat, SpinnerMode, Theme}; use netsuke::cli_localization; use netsuke::output_prefs; use rstest_bdd_macros::{given, then, when}; @@ -39,7 +40,7 @@ fn write_config(world: &TestWorld, contents: &str) -> Result<()> { 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, OsStr::new(path.as_os_str())) }; + unsafe { std::env::set_var(CONFIG_ENV_VAR, path.as_os_str()) }; world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous); Ok(()) } @@ -61,6 +62,93 @@ fn merge_cli(world: &TestWorld, args: &str) { 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); +} + +/// 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); +} + +/// 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); +} + +/// 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")) @@ -87,7 +175,9 @@ fn set_environment_locale_override(world: &TestWorld, locale: &str) -> Result<() #[given("the Netsuke config file sets output format to {format:string}")] fn config_sets_output_format(world: &TestWorld, format: &str) -> Result<()> { - write_config(world, &format!("output_format = \"{format}\"\n")) + 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")] @@ -95,6 +185,51 @@ 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" @@ -158,15 +293,7 @@ fn merged_verbose_enabled(world: &TestWorld) -> Result<()> { #[then("the merged theme is ascii")] fn merged_theme_is_ascii(world: &TestWorld) -> Result<()> { - let theme = world - .cli - .with_ref(|cli| cli.theme) - .context("expected merged CLI to be available")?; - ensure!( - theme == Some(Theme::Ascii), - "expected merged theme to be ASCII, got {theme:?}", - ); - Ok(()) + assert_merged_field(world, |cli| cli.theme, Theme::Ascii, "theme") } #[then("the merge error should contain {fragment:string}")] @@ -181,3 +308,28 @@ fn merge_error_contains(world: &TestWorld, fragment: &str) -> Result<()> { ); Ok(()) } + +#[then("the merged theme is unicode")] +fn merged_theme_is_unicode(world: &TestWorld) -> Result<()> { + assert_merged_field(world, |cli| cli.theme, Theme::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/features/configuration_preferences.feature b/tests/features/configuration_preferences.feature index 7bc656ad..187a7c37 100644 --- a/tests/features/configuration_preferences.feature +++ b/tests/features/configuration_preferences.feature @@ -32,3 +32,69 @@ Feature: Configuration preferences 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 From c52a6e6a919282af120d79535cf2027c837a3ffe Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 23:50:28 +0200 Subject: [PATCH 13/19] Reconcile CLI config after rebase Keep the layered `CliConfig` path compatible with the updated mainline release tooling and diagnostic output behaviour. Preserve project-scope configuration precedence, explicit config selection, JSON diagnostic preflight handling, and the runtime theme path while keeping Clippy and test fixtures aligned with the rebased code. --- Cargo.lock | 17 ++ Cargo.toml | 2 + build.rs | 30 +++ src/cli/config.rs | 84 +++++- src/cli/merge.rs | 204 +++++++++++++-- src/cli/mod.rs | 2 +- src/cli/parser.rs | 58 ++++- src/cli/parsing.rs | 53 ++++ src/main.rs | 8 +- src/output_mode.rs | 2 +- src/runner/mod.rs | 6 +- src/theme.rs | 2 +- tests/bdd/fixtures/mod.rs | 43 ++- tests/bdd/steps/configuration_preferences.rs | 15 +- tests/bdd/steps/mod.rs | 2 +- tests/cli_tests/helpers.rs | 259 ------------------- tests/cli_tests/merge.rs | 50 ++-- tests/cli_tests/parsing.rs | 32 ++- 18 files changed, 506 insertions(+), 363 deletions(-) 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 dd06398f..d8701cd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,8 @@ mockall = "0.11" camino = "1.2.0" test_support = { path = "test_support" } strip-ansi-escapes = "0.2" +toml = "0.8" +proptest = "1.11.0" # Target-specific dev-deps [target.'cfg(unix)'.dev-dependencies] diff --git a/build.rs b/build.rs index 5e9307b2..2a71f44d 100644 --- a/build.rs +++ b/build.rs @@ -19,6 +19,12 @@ 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; @@ -34,6 +40,12 @@ mod host_pattern; #[path = "src/localization/mod.rs"] mod localization; +#[path = "src/output_mode.rs"] +mod output_mode; + +#[path = "src/theme.rs"] +mod theme; + mod build_l10n_audit; use host_pattern::{HostPattern, HostPatternError}; @@ -43,6 +55,12 @@ type LocalizedParseFn = fn( &Arc, ) -> Result<(cli::Cli, ArgMatches), clap::Error>; +type ResolveThemeFn = fn( + Option, + theme::ThemeContext, + fn(&str) -> Option, +) -> theme::ResolvedTheme; + fn manual_date() -> String { let Ok(raw) = env::var("SOURCE_DATE_EPOCH") else { return FALLBACK_DATE.into(); @@ -95,6 +113,7 @@ const fn verify_public_api_symbols() { 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; @@ -105,8 +124,19 @@ const fn verify_public_api_symbols() { const _: LocalizedParseFn = cli::parse_with_localizer_from; 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 = + output_mode::resolve; + const _: OutputModeResolveWith = output_mode::resolve_with; + const _: fn( + Option, + Option, + output_mode::OutputMode, + ) -> theme::ThemeContext = theme::ThemeContext::new; + const _: ResolveThemeFn = theme::resolve_theme; } fn emit_rerun_directives() { diff --git a/src/cli/config.rs b/src/cli/config.rs index 69dc87cd..6e456b2f 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -7,7 +7,9 @@ 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; @@ -26,6 +28,24 @@ pub enum ColourPolicy { Never, } +impl fmt::Display for ColourPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Always => write!(f, "always"), + Self::Never => write!(f, "never"), + } + } +} + +impl FromStr for ColourPolicy { + type Err = String; + + fn from_str(s: &str) -> Result { + ::from_str(s, true).map_err(|_| format!("invalid colour policy '{s}'")) + } +} + /// Spinner and progress rendering policy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] @@ -39,6 +59,24 @@ pub enum SpinnerMode { Disabled, } +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"), + } + } +} + +impl FromStr for SpinnerMode { + type Err = String; + + fn from_str(s: &str) -> Result { + ::from_str(s, true).map_err(|_| format!("invalid spinner mode '{s}'")) + } +} + /// Top-level diagnostics and output format. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "kebab-case")] @@ -50,6 +88,23 @@ pub enum OutputFormat { Json, } +impl fmt::Display for OutputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Human => write!(f, "human"), + Self::Json => write!(f, "json"), + } + } +} + +impl FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + ::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")] @@ -73,6 +128,18 @@ impl From for ThemePreference { } } +impl PartialEq for Theme { + fn eq(&self, other: &ThemePreference) -> bool { + ThemePreference::from(*self) == *other + } +} + +impl PartialEq for ThemePreference { + fn eq(&self, other: &Theme) -> bool { + *self == Self::from(*other) + } +} + /// Layered defaults for the `build` subcommand. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct BuildConfig { @@ -157,6 +224,11 @@ pub struct CliConfig { #[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)] @@ -182,6 +254,7 @@ impl Default for CliConfig { spinner_mode: None, output_format: None, theme: None, + default_targets: Vec::new(), cmds: CommandConfigs::default(), } } @@ -196,7 +269,6 @@ impl PostMergeHook for CliConfig { fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> { validate_theme_compatibility(self)?; validate_spinner_mode_compatibility(self)?; - validate_output_format_support(self)?; Ok(()) } } @@ -232,13 +304,3 @@ fn validate_spinner_mode_compatibility(config: &CliConfig) -> OrthoResult<()> { _ => Ok(()), } } - -fn validate_output_format_support(config: &CliConfig) -> OrthoResult<()> { - if matches!(config.output_format, Some(OutputFormat::Json)) { - return Err(validation_error( - "output_format", - "output_format = \"json\" is not supported yet; use \"human\" for this milestone", - )); - } - Ok(()) -} diff --git a/src/cli/merge.rs b/src/cli/merge.rs index 03105b80..eb2c03d7 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -6,14 +6,21 @@ 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 std::path::PathBuf; use super::config::{BuildConfig, CliConfig, Theme}; use super::parser::{BuildArgs, Cli, Commands}; use super::validation_error; -const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG_PATH"; +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. @@ -31,15 +38,7 @@ pub fn merge_with_config(cli: &Cli, matches: &ArgMatches) -> OrthoResult { Err(err) => errors.push(err), } - let discovery = config_discovery(cli.directory.as_ref()); - let mut file_layers = discovery.compose_layers(); - errors.append(&mut file_layers.required_errors); - if file_layers.value.is_empty() { - errors.append(&mut file_layers.optional_errors); - } - for layer in file_layers.value { - composer.push_layer(layer); - } + push_file_layers(cli, &mut composer, &mut errors); let env_provider = env_provider() .map(|key| Uncased::new(key.as_str().to_ascii_uppercase())) @@ -60,21 +59,132 @@ pub fn merge_with_config(cli: &Cli, matches: &ArgMatches) -> OrthoResult { 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>, +) { + match explicit_config_path(cli) { + Some(path) => match load_layers_from_path(&path) { + Ok(layers) => { + for layer in layers { + composer.push_layer(layer); + } + } + Err(err) => errors.push(err), + }, + None => match collect_file_layers(cli.directory.as_deref()) { + 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); + 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()) } @@ -86,6 +196,18 @@ fn diag_json_from_layer(value: &Value) -> Option { .and_then(Value::as_bool) } +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 @@ -95,12 +217,16 @@ fn diag_json_from_layer(value: &Value) -> Option { pub fn resolve_merged_diag_json(cli: &Cli, matches: &ArgMatches) -> bool { let mut diag_json = CliConfig::default().diag_json; - let discovery = config_discovery(cli.directory.as_ref()); - let file_layers = discovery.compose_layers(); - for layer in file_layers.value { - let layer_value = layer.into_value(); - if let Some(layer_diag_json) = diag_json_from_layer(&layer_value) { - diag_json = layer_diag_json; + let file_layers = explicit_config_path(cli).map_or_else( + || collect_file_layers(cli.directory.as_deref()), + |path| load_layers_from_path(&path), + ); + if let Ok(layers) = file_layers { + for layer in layers { + let layer_value = layer.into_value(); + if let Some(layer_diag_json) = diag_json_from_layer(&layer_value) { + diag_json = layer_diag_json; + } } } @@ -113,7 +239,9 @@ pub fn resolve_merged_diag_json(cli: &Cli, matches: &ArgMatches) -> bool { diag_json = env_diag_json; } - if matches.value_source("diag_json") == Some(ValueSource::CommandLine) { + 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 { diag_json @@ -159,6 +287,7 @@ fn cli_overrides_from_matches(cli: &Cli, matches: &ArgMatches) -> OrthoResult OrthoResult, +) -> 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, @@ -202,9 +345,11 @@ where } 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, @@ -220,8 +365,21 @@ fn apply_config(parsed: &Cli, config: CliConfig) -> Cli { spinner_mode: config.spinner_mode, output_format: config.output_format, theme: canonical_theme(config.theme, config.no_emoji), - command: Some(resolve_command(parsed.command.as_ref(), &config.cmds.build)), + 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 { @@ -242,10 +400,10 @@ fn resolve_command(parsed: Option<&Commands>, build_defaults: &BuildConfig) -> C } } -const fn canonical_theme(theme: Option, no_emoji: Option) -> Option { +fn canonical_theme(theme: Option, no_emoji: Option) -> Option { match (theme, no_emoji) { - (Some(value), _) => Some(value), - (None, Some(true)) => Some(Theme::Ascii), + (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 4b37d7d8..df1248ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,7 +8,7 @@ use ortho_config::OrthoError; use std::sync::Arc; -mod config; +pub mod config; mod merge; mod parser; mod parsing; diff --git a/src/cli/parser.rs b/src/cli/parser.rs index 9a07b727..a67284d1 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -11,11 +11,15 @@ use std::path::PathBuf; use std::sync::Arc; use super::config::CliConfig; -use super::parsing::{parse_host_pattern, parse_jobs, parse_locale, parse_scheme}; -use super::{ColourPolicy, OutputFormat, SpinnerMode, Theme}; +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 { @@ -79,6 +83,11 @@ pub struct Cli { #[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. @@ -145,7 +154,11 @@ pub struct Cli { /// Override presentation theme. #[arg(long, value_name = "THEME")] - pub theme: Option, + 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)] @@ -167,8 +180,8 @@ impl Cli { #[must_use] pub const fn no_emoji_override(&self) -> Option { match self.theme { - Some(Theme::Ascii) => Some(true), - Some(Theme::Unicode) => Some(false), + Some(ThemePreference::Ascii) => Some(true), + Some(ThemePreference::Unicode) => Some(false), _ => { if matches!(self.no_emoji, Some(true)) { Some(true) @@ -188,6 +201,21 @@ impl Cli { _ => 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 { @@ -195,6 +223,7 @@ impl Default for Cli { Self { file: CliConfig::default_manifest_path(), directory: None, + config: None, jobs: None, verbose: false, locale: None, @@ -210,6 +239,7 @@ impl Default for Cli { spinner_mode: None, output_format: None, theme: None, + default_targets: Vec::new(), command: None, } .with_default_command() @@ -289,6 +319,12 @@ fn configure_validation_parsers( 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)) @@ -305,6 +341,18 @@ fn configure_validation_parsers( 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 } diff --git a/src/cli/parsing.rs b/src/cli/parsing.rs index fca65bce..3103dfee 100644 --- a/src/cli/parsing.rs +++ b/src/cli/parsing.rs @@ -1,10 +1,13 @@ //! CLI parsing helpers for clap value parsers. +use clap::ValueEnum; use ortho_config::{LanguageIdentifier, LocalizationArgs, Localizer}; use std::str::FromStr; +use super::{ColourPolicy, OutputFormat, SpinnerMode}; use crate::host_pattern::HostPattern; use crate::localization::keys; +use crate::theme::ThemePreference; pub(super) fn parse_jobs(localizer: &dyn Localizer, s: &str) -> Result { let value: usize = s.parse().map_err(|_| { @@ -94,6 +97,56 @@ pub(super) fn parse_locale(localizer: &dyn Localizer, s: &str) -> Result Result { + parse_value_enum(localizer, s, keys::CLI_COLOUR_POLICY_INVALID, "value") +} + +pub(super) fn parse_spinner_mode( + localizer: &dyn Localizer, + s: &str, +) -> Result { + parse_value_enum(localizer, s, keys::CLI_SPINNER_MODE_INVALID, "value") +} + +pub(super) fn parse_output_format( + localizer: &dyn Localizer, + s: &str, +) -> Result { + parse_value_enum(localizer, s, keys::CLI_OUTPUT_FORMAT_INVALID, "value") +} + +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}'"), + ) + }) +} + +fn parse_value_enum( + localizer: &dyn Localizer, + s: &str, + key: &'static str, + arg_name: &'static str, +) -> Result +where + T: ValueEnum, +{ + T::from_str(s, true).map_err(|_| { + let mut args = LocalizationArgs::default(); + args.insert(arg_name, s.to_owned().into()); + super::parser::validation_message(localizer, key, Some(&args), &format!("Invalid '{s}'")) + }) +} + /// Parse a host pattern supplied via CLI flags. /// /// The returned [`HostPattern`] retains both the wildcard flag and the diff --git a/src/main.rs b/src/main.rs index 856ddd9b..3da50cc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,11 @@ 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, + cli, cli_localization, diagnostic_json, locale_resolution, localization, manifest, output_mode, + output_prefs, runner, }; -use netsuke::theme::ThemeContext; use ortho_config::Localizer; use std::ffi::OsString; use std::io::{self, Write}; @@ -63,7 +63,7 @@ fn run_with_args( 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( - merged_cli.theme.map(Into::into), + merged_cli.theme, ThemeContext::new(merged_cli.no_emoji, merged_cli.colour_policy, output_mode), ); match runner::run(&merged_cli, prefs) { 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 150c5289..247a7d80 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -202,7 +202,11 @@ fn handle_build( progress_enabled: bool, ) -> Result<()> { let ninja = generate_ninja(cli, reporter, Some(keys::STATUS_TOOL_BUILD.into()))?; - let targets = BuildTargets::new(&args.targets); + let targets = if args.targets.is_empty() { + BuildTargets::new(&cli.default_targets) + } else { + BuildTargets::new(&args.targets) + }; // Normalize the build file path and keep the temporary file alive for the // duration of the Ninja invocation. Borrow the emitted path when provided 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/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 61a252f2..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, restore_many}; +use test_support::env::{NinjaEnvGuard, restore_many_locked}; use test_support::env_lock::EnvLock; use test_support::http::HttpServer; @@ -149,22 +149,49 @@ pub struct TestWorld { // Environment state /// 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. + pub env_vars_forward: RefCell>, /// Scenario-scoped guard that serialises environment mutations when needed. pub env_lock: RefCell>, + /// Original working directory before any scenario-level chdir. + pub original_cwd: RefCell>, } impl TestWorld { + /// 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) { + pub fn track_env_var( + &self, + key: String, + previous: Option, + forward_value: Option, + ) { + 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 any environment variables overridden during the scenario. - fn restore_environment(&self) { + unsafe fn restore_environment_locked(&self) { let vars = std::mem::take(&mut *self.env_vars.borrow_mut()); if !vars.is_empty() { - restore_many(vars); + // SAFETY: The caller holds EnvLock. + unsafe { restore_many_locked(vars) }; } + self.env_vars_forward.borrow_mut().clear(); } /// Shut down the active HTTP server fixture. @@ -193,8 +220,14 @@ impl Drop for TestWorld { self.ninja_env_guard.borrow_mut().take(); self.localization_guard.borrow_mut().take(); self.localization_lock.borrow_mut().take(); + if self.env_lock.borrow().is_some() { + if let Some(original_cwd) = self.original_cwd.borrow_mut().take() { + drop(std::env::set_current_dir(original_cwd)); + } + // SAFETY: EnvLock is still held. + unsafe { self.restore_environment_locked() }; + } self.env_lock.borrow_mut().take(); - self.restore_environment(); self.stdlib_text.clear(); } } diff --git a/tests/bdd/steps/configuration_preferences.rs b/tests/bdd/steps/configuration_preferences.rs index a245c7c7..9704ac0a 100644 --- a/tests/bdd/steps/configuration_preferences.rs +++ b/tests/bdd/steps/configuration_preferences.rs @@ -8,6 +8,7 @@ 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; @@ -41,7 +42,7 @@ fn write_config(world: &TestWorld, contents: &str) -> Result<()> { 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); + world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous, None); Ok(()) } @@ -109,7 +110,7 @@ fn set_env_theme(world: &TestWorld, theme: 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); + world.track_env_var("NETSUKE_THEME".to_owned(), previous, None); } /// Set the `NETSUKE_COLOUR_POLICY` environment variable. @@ -119,7 +120,7 @@ fn set_env_colour_policy(world: &TestWorld, policy: ColourPolicy) { 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); + world.track_env_var("NETSUKE_COLOUR_POLICY".to_owned(), previous, None); } /// Set the `NETSUKE_SPINNER_MODE` environment variable. @@ -129,7 +130,7 @@ fn set_env_spinner_mode(world: &TestWorld, mode: SpinnerMode) { 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); + world.track_env_var("NETSUKE_SPINNER_MODE".to_owned(), previous, None); } /// Assert a merged CLI field value matches the expected value. @@ -169,7 +170,7 @@ fn set_environment_locale_override(world: &TestWorld, locale: &str) -> Result<() 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); + world.track_env_var(LOCALE_ENV_VAR.to_owned(), previous, None); Ok(()) } @@ -293,7 +294,7 @@ fn merged_verbose_enabled(world: &TestWorld) -> Result<()> { #[then("the merged theme is ascii")] fn merged_theme_is_ascii(world: &TestWorld) -> Result<()> { - assert_merged_field(world, |cli| cli.theme, Theme::Ascii, "theme") + assert_merged_field(world, |cli| cli.theme, ThemePreference::Ascii, "theme") } #[then("the merge error should contain {fragment:string}")] @@ -311,7 +312,7 @@ fn merge_error_contains(world: &TestWorld, fragment: &str) -> Result<()> { #[then("the merged theme is unicode")] fn merged_theme_is_unicode(world: &TestWorld) -> Result<()> { - assert_merged_field(world, |cli| cli.theme, Theme::Unicode, "theme") + assert_merged_field(world, |cli| cli.theme, ThemePreference::Unicode, "theme") } #[then("the merged colour policy is always")] diff --git a/tests/bdd/steps/mod.rs b/tests/bdd/steps/mod.rs index 35568201..1d857492 100644 --- a/tests/bdd/steps/mod.rs +++ b/tests/bdd/steps/mod.rs @@ -17,11 +17,11 @@ mod accessibility_preferences; mod accessible_output; mod advanced_usage; mod cli; -mod configuration_preferences; 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 a0628250..9c914f02 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -1,6 +1,6 @@ //! Configuration merge tests. //! -//! These tests validate OrthoConfig layer precedence (defaults, file, env, +//! These tests validate `OrthoConfig` layer precedence (defaults, file, env, //! CLI) and list-value appending. use anyhow::{Context, Result, ensure}; @@ -18,7 +18,7 @@ use test_support::{EnvVarGuard, env_lock::EnvLock}; #[fixture] fn default_cli_json() -> Result { - sanitize_value(&CliConfig::default()) + Ok(sanitize_value(&CliConfig::default())?) } fn with_config_file(toml_content: &str, cli_args: &[&str], f: F) -> anyhow::Result @@ -58,33 +58,21 @@ fn assert_build_targets( }) } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] enum ExpectedValidationError { - ThemeUnicodeAliasConflict, - ThemeAsciiAliasConflict, - SpinnerDisabledConflict, - SpinnerEnabledConflict, - UnsupportedOutputFormat, + UnicodeThemeAlias, + AsciiThemeAlias, + DisabledSpinner, + EnabledSpinner, } impl ExpectedValidationError { - fn expected_fragment(&self) -> &'static str { + const fn expected_fragment(self) -> &'static str { match self { - Self::ThemeUnicodeAliasConflict => { - "theme = \"unicode\" conflicts with no_emoji = true" - } - Self::ThemeAsciiAliasConflict => { - "no_emoji = false conflicts with theme = \"ascii\"" - } - Self::SpinnerDisabledConflict => { - "spinner_mode = \"disabled\" conflicts with progress = true" - } - Self::SpinnerEnabledConflict => { - "progress = false conflicts with spinner_mode = \"enabled\"" - } - Self::UnsupportedOutputFormat => { - "output_format = \"json\" is not supported yet" - } + Self::UnicodeThemeAlias => "theme = \"unicode\" conflicts with no_emoji = true", + Self::AsciiThemeAlias => "no_emoji = false conflicts with theme = \"ascii\"", + Self::DisabledSpinner => "spinner_mode = \"disabled\" conflicts with progress = true", + Self::EnabledSpinner => "progress = false conflicts with spinner_mode = \"enabled\"", } } } @@ -293,23 +281,19 @@ targets = ["all"] #[rstest] #[case( json!({ "theme": "unicode", "no_emoji": true }), - ExpectedValidationError::ThemeUnicodeAliasConflict, + ExpectedValidationError::UnicodeThemeAlias, )] #[case( json!({ "theme": "ascii", "no_emoji": false }), - ExpectedValidationError::ThemeAsciiAliasConflict, + ExpectedValidationError::AsciiThemeAlias, )] #[case( json!({ "spinner_mode": "disabled", "progress": true }), - ExpectedValidationError::SpinnerDisabledConflict, + ExpectedValidationError::DisabledSpinner, )] #[case( json!({ "spinner_mode": "enabled", "progress": false }), - ExpectedValidationError::SpinnerEnabledConflict, -)] -#[case( - json!({ "output_format": "json" }), - ExpectedValidationError::UnsupportedOutputFormat, + ExpectedValidationError::EnabledSpinner, )] fn cli_config_rejects_conflicting_or_unsupported_settings( default_cli_json: Result, @@ -323,7 +307,7 @@ fn cli_config_rejects_conflicting_or_unsupported_settings( 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), + merged.theme == Some(Theme::Ascii.into()), "no_emoji compatibility alias should canonicalize to the ASCII theme", ); ensure!( diff --git a/tests/cli_tests/parsing.rs b/tests/cli_tests/parsing.rs index 174a27e6..c136a241 100644 --- a/tests/cli_tests/parsing.rs +++ b/tests/cli_tests/parsing.rs @@ -4,11 +4,11 @@ use anyhow::{Context, Result, ensure}; use clap::error::ErrorKind; use netsuke::cli::config::{ColourPolicy, OutputFormat, SpinnerMode}; use netsuke::cli::{BuildArgs, Cli, Commands, Theme}; -use netsuke::cli::{BuildArgs, Commands}; use netsuke::cli_localization; use netsuke::host_pattern::HostPattern; +use netsuke::output_mode::OutputMode; use netsuke::output_prefs; -use netsuke::theme::ThemePreference; +use netsuke::theme::{ThemeContext, ThemePreference}; use rstest::rstest; use std::path::PathBuf; use std::sync::Arc; @@ -283,16 +283,21 @@ 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_with(cli.no_emoji_override(), |key| match key { - "NETSUKE_NO_EMOJI" => Some(String::from("1")), - _ => None, - }); + 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(), @@ -301,16 +306,21 @@ fn no_emoji_override_false_defers_to_environment_suppression() -> Result<()> { Ok(()) } +#[test] fn no_emoji_override_honours_unicode_theme_over_environment_suppression() -> Result<()> { let cli = Cli { - theme: Some(Theme::Unicode), + theme: Some(Theme::Unicode.into()), ..Cli::default() }; - let prefs = output_prefs::resolve_with(cli.no_emoji_override(), |key| match key { - "NETSUKE_NO_EMOJI" => Some(String::from("1")), - _ => None, - }); + 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(), From e7a065f9253371def31ca2484cd95cd35bb219da Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:28:01 +0200 Subject: [PATCH 14/19] Deduplicate CLI merge validation tests Replace the enum-backed rejection helper and four-case matrix with a three-case rstest table and string expectations. Route JSON output-format rejection through the runtime merge path because validation now lives in merge_with_config rather than merge_from_layers. Co-authored-by: Cursor --- tests/cli_tests/merge.rs | 59 ++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 9c914f02..654fec16 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -58,25 +58,6 @@ fn assert_build_targets( }) } -#[derive(Debug, Clone, Copy)] -enum ExpectedValidationError { - UnicodeThemeAlias, - AsciiThemeAlias, - DisabledSpinner, - EnabledSpinner, -} - -impl ExpectedValidationError { - const fn expected_fragment(self) -> &'static str { - match self { - Self::UnicodeThemeAlias => "theme = \"unicode\" conflicts with no_emoji = true", - Self::AsciiThemeAlias => "no_emoji = false conflicts with theme = \"ascii\"", - Self::DisabledSpinner => "spinner_mode = \"disabled\" conflicts with progress = true", - Self::EnabledSpinner => "progress = false conflicts with spinner_mode = \"enabled\"", - } - } -} - fn merge_defaults_with_file_layer( defaults: serde_json::Value, file_layer: serde_json::Value, @@ -90,15 +71,27 @@ fn merge_defaults_with_file_layer( fn assert_merge_rejects( defaults: serde_json::Value, file_layer: serde_json::Value, - expected_error: ExpectedValidationError, + expected_msg: &str, ) -> anyhow::Result<()> { - let err = match merge_defaults_with_file_layer(defaults, file_layer) { - Ok(value) => anyhow::bail!("merge should have failed, got {value:#?}"), - Err(err) => err, + let err = if file_layer + .get("output_format") + .and_then(serde_json::Value::as_str) + .is_some_and(|format| format == "json") + { + 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.to_string().contains(expected_error.expected_fragment()), - "unexpected error text: {err}", + err.chain() + .any(|cause| cause.to_string().contains(expected_msg)), + "unexpected error text: {err:#}", ); Ok(()) } @@ -281,26 +274,22 @@ targets = ["all"] #[rstest] #[case( json!({ "theme": "unicode", "no_emoji": true }), - ExpectedValidationError::UnicodeThemeAlias, -)] -#[case( - json!({ "theme": "ascii", "no_emoji": false }), - ExpectedValidationError::AsciiThemeAlias, + "theme = \"unicode\" conflicts with no_emoji = true", )] #[case( json!({ "spinner_mode": "disabled", "progress": true }), - ExpectedValidationError::DisabledSpinner, + "spinner_mode = \"disabled\" conflicts with progress = true", )] #[case( - json!({ "spinner_mode": "enabled", "progress": false }), - ExpectedValidationError::EnabledSpinner, + json!({ "output_format": "json" }), + "output_format = \"json\" is not supported yet", )] fn cli_config_rejects_conflicting_or_unsupported_settings( default_cli_json: Result, #[case] file_layer: serde_json::Value, - #[case] expected_error: ExpectedValidationError, + #[case] expected_msg: &str, ) -> Result<()> { - assert_merge_rejects(default_cli_json?, file_layer, expected_error) + assert_merge_rejects(default_cli_json?, file_layer, expected_msg) } #[rstest] From bb051e09b6dd3cae68fc71854adeaa2378bbe14e Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:31:32 +0200 Subject: [PATCH 15/19] Bundle enum parser localisation metadata in ParseEnumSpec Replace the two string parameters on parse_value_enum with a single spec struct so call sites pass one grouped argument and CodeScene's string-heavy argument metric drops below threshold. Co-authored-by: Cursor --- src/cli/parsing.rs | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cli/parsing.rs b/src/cli/parsing.rs index 3103dfee..7719f184 100644 --- a/src/cli/parsing.rs +++ b/src/cli/parsing.rs @@ -101,21 +101,42 @@ pub(super) fn parse_colour_policy( localizer: &dyn Localizer, s: &str, ) -> Result { - parse_value_enum(localizer, s, keys::CLI_COLOUR_POLICY_INVALID, "value") + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_COLOUR_POLICY_INVALID, + arg_name: "value", + }, + ) } pub(super) fn parse_spinner_mode( localizer: &dyn Localizer, s: &str, ) -> Result { - parse_value_enum(localizer, s, keys::CLI_SPINNER_MODE_INVALID, "value") + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_SPINNER_MODE_INVALID, + arg_name: "value", + }, + ) } pub(super) fn parse_output_format( localizer: &dyn Localizer, s: &str, ) -> Result { - parse_value_enum(localizer, s, keys::CLI_OUTPUT_FORMAT_INVALID, "value") + parse_value_enum( + localizer, + s, + ParseEnumSpec { + key: keys::CLI_OUTPUT_FORMAT_INVALID, + arg_name: "value", + }, + ) } pub(super) fn parse_theme(localizer: &dyn Localizer, s: &str) -> Result { @@ -131,19 +152,26 @@ pub(super) fn parse_theme(localizer: &dyn Localizer, s: &str) -> Result( - localizer: &dyn Localizer, - s: &str, +/// Bundles the static localisation metadata needed by [`parse_value_enum`]. +#[derive(Copy, Clone)] +struct ParseEnumSpec { key: &'static str, arg_name: &'static str, -) -> Result +} + +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(arg_name, s.to_owned().into()); - super::parser::validation_message(localizer, key, Some(&args), &format!("Invalid '{s}'")) + args.insert(spec.arg_name, s.to_owned().into()); + super::parser::validation_message( + localizer, + spec.key, + Some(&args), + &format!("Invalid '{s}'"), + ) }) } From 56397fcabc50ee0b07a1b97c032965eb66e500c0 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:33:54 +0200 Subject: [PATCH 16/19] Extract diag_json resolution helpers in merge.rs Move file-layer and environment branching out of resolve_merged_diag_json into diag_json_from_file_layers and diag_json_from_env so the public entry point delegates through three focused steps and cyclomatic complexity drops. Co-authored-by: Cursor --- src/cli/merge.rs | 75 ++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/src/cli/merge.rs b/src/cli/merge.rs index eb2c03d7..075f3859 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -196,6 +196,46 @@ fn diag_json_from_layer(value: &Value) -> Option { .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) @@ -215,37 +255,10 @@ fn validate_output_format_source(config: &CliConfig, matches: &ArgMatches) -> Or /// environment. #[must_use] pub fn resolve_merged_diag_json(cli: &Cli, matches: &ArgMatches) -> bool { - let mut diag_json = CliConfig::default().diag_json; - - let file_layers = explicit_config_path(cli).map_or_else( - || collect_file_layers(cli.directory.as_deref()), - |path| load_layers_from_path(&path), - ); - if let Ok(layers) = file_layers { - for layer in layers { - let layer_value = layer.into_value(); - if let Some(layer_diag_json) = diag_json_from_layer(&layer_value) { - diag_json = layer_diag_json; - } - } - } - - let env_provider = env_provider() - .map(|key| Uncased::new(key.as_str().to_ascii_uppercase())) - .split("__"); - if let Ok(value) = Figment::from(env_provider).extract::() - && let Some(env_diag_json) = diag_json_from_layer(&value) - { - diag_json = env_diag_json; - } - - 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 { - diag_json - } + 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 { From 151c0cb426bbe36113ac8d9f498e9e8e164394f2 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:36:21 +0200 Subject: [PATCH 17/19] Flatten push_file_layers layer loading match Resolve explicit and discovered config paths through map_or_else so a single Ok/Err match pushes layers or records one error, removing the duplicated nested match arms. Co-authored-by: Cursor --- src/cli/merge.rs | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/cli/merge.rs b/src/cli/merge.rs index 075f3859..7781db46 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -68,23 +68,17 @@ fn push_file_layers( composer: &mut MergeComposer, errors: &mut Vec>, ) { - match explicit_config_path(cli) { - Some(path) => match load_layers_from_path(&path) { - Ok(layers) => { - for layer in layers { - composer.push_layer(layer); - } - } - Err(err) => errors.push(err), - }, - None => match collect_file_layers(cli.directory.as_deref()) { - Ok(layers) => { - for layer in layers { - composer.push_layer(layer); - } + 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), - }, + } + Err(err) => errors.push(err), } } From cedb2b2584268c7835ca5361916c6b7057cbe553 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:37:53 +0200 Subject: [PATCH 18/19] Declare OrthoConfig root type for cargo-orthohelp Add package metadata pointing cargo-orthohelp at CliConfig and the supported locale list so release help generation can resolve the schema. Co-authored-by: Cursor --- Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d8701cd6..4363f43f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ include = [ license = "ISC" description = "A YAML-powered Ninja/Jinja hybrid build system." +[package.metadata.ortho_config] +root_type = "netsuke::cli::CliConfig" +locales = ["en-US", "es-ES"] + [features] default = [] legacy-digests = ["sha1", "md5"] From ffbf353d6e4e2718d00b9fcb4e616c23b73a1cbd Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 27 May 2026 11:47:37 +0200 Subject: [PATCH 19/19] Type merge rejection expectations in CLI tests Replace string expected_msg parameters with ExpectedValidationError so assert_merge_rejects and its rstest cases carry typed expectations and CodeScene's string-argument ratio drops below threshold. Co-authored-by: Cursor --- tests/cli_tests/merge.rs | 45 ++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/tests/cli_tests/merge.rs b/tests/cli_tests/merge.rs index 654fec16..64037b2e 100644 --- a/tests/cli_tests/merge.rs +++ b/tests/cli_tests/merge.rs @@ -58,6 +58,25 @@ fn assert_build_targets( }) } +#[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, @@ -71,13 +90,12 @@ fn merge_defaults_with_file_layer( fn assert_merge_rejects( defaults: serde_json::Value, file_layer: serde_json::Value, - expected_msg: &str, + expected_error: ExpectedValidationError, ) -> anyhow::Result<()> { - let err = if file_layer - .get("output_format") - .and_then(serde_json::Value::as_str) - .is_some_and(|format| format == "json") - { + 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, @@ -89,8 +107,9 @@ fn assert_merge_rejects( } }; ensure!( - err.chain() - .any(|cause| cause.to_string().contains(expected_msg)), + err.chain().any(|cause| cause + .to_string() + .contains(expected_error.expected_fragment())), "unexpected error text: {err:#}", ); Ok(()) @@ -274,22 +293,22 @@ targets = ["all"] #[rstest] #[case( json!({ "theme": "unicode", "no_emoji": true }), - "theme = \"unicode\" conflicts with no_emoji = true", + ExpectedValidationError::ThemeUnicodeWithNoEmoji, )] #[case( json!({ "spinner_mode": "disabled", "progress": true }), - "spinner_mode = \"disabled\" conflicts with progress = true", + ExpectedValidationError::SpinnerDisabledWithProgress, )] #[case( json!({ "output_format": "json" }), - "output_format = \"json\" is not supported yet", + ExpectedValidationError::UnsupportedOutputFormat, )] fn cli_config_rejects_conflicting_or_unsupported_settings( default_cli_json: Result, #[case] file_layer: serde_json::Value, - #[case] expected_msg: &str, + #[case] expected_error: ExpectedValidationError, ) -> Result<()> { - assert_merge_rejects(default_cli_json?, file_layer, expected_msg) + assert_merge_rejects(default_cli_json?, file_layer, expected_error) } #[rstest]