Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ fn write_man_page(data: &[u8], dir: &Path, page_name: &str) -> std::io::Result<P
Ok(destination)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
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.
Expand All @@ -107,8 +107,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
const _: fn(&cli::Cli) -> bool = cli::Cli::progress_enabled;
const _: fn(&str) -> Result<HostPattern, HostPatternError> = 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");
Expand All @@ -125,13 +126,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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/<target>/<profile>.
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<dyn std::error::Error>> {
let cmd = cli::Cli::command();
let name = cmd
.get_bin_name()
Expand All @@ -149,15 +146,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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)?;
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) {
Expand All @@ -167,6 +163,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
}
}

Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
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)
}
9 changes: 3 additions & 6 deletions docs/execplans/3-10-1-guarantee-status-message-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,21 +276,18 @@ model provides the necessary ordering guarantees.
`NINJA_STDERR_MARKER`) to avoid coupling to localized UI strings.

2. **Status messages do not contaminate stdout in standard mode**: Verifies
stream
routing in non-accessible mode using the same stable markers.
stream routing in non-accessible mode using the same stable markers.

3. **Build artifacts can be captured via stdout redirection**: Verifies that
`netsuke manifest -` output goes to stdout without status contamination.

Supporting infrastructure added:

- `FakeNinjaConfig` struct in `tests/bdd/steps/progress_output.rs` for
configurable
fixture generation with optional stderr markers.
configurable fixture generation with optional stderr markers.
- `install_fake_ninja_with_config()` function for flexible fixture setup.
- Updated `fake_ninja_emits_stdout_output` fixture to emit both stdout and
stderr
markers for comprehensive stream routing verification.
stderr markers for comprehensive stream routing verification.

### Stage E: Documentation (completed)

Expand Down
11 changes: 5 additions & 6 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2034,12 +2034,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`
Expand Down
9 changes: 5 additions & 4 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,7 +13,7 @@ use super::validation_error;
use crate::host_pattern::HostPattern;

/// 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.
Expand All @@ -25,7 +26,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.
Expand All @@ -38,7 +39,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.
Expand All @@ -49,7 +50,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.
Expand Down
4 changes: 4 additions & 0 deletions src/cli/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ fn cli_overrides_from_matches(cli: &Cli, matches: &ArgMatches) -> OrthoResult<Va
maybe_insert_explicit(matches, "progress", &cli.progress, &mut root)?;
maybe_insert_explicit(matches, "no_emoji", &cli.no_emoji, &mut root)?;
maybe_insert_explicit(matches, "diag_json", &cli.diag_json, &mut root)?;
maybe_insert_explicit(matches, "colour_policy", &cli.colour_policy, &mut root)?;
maybe_insert_explicit(matches, "spinner_mode", &cli.spinner_mode, &mut root)?;
maybe_insert_explicit(matches, "output_format", &cli.output_format, &mut root)?;
maybe_insert_explicit(matches, "theme", &cli.theme, &mut root)?;

if let Some(Commands::Build(args)) = cli.command.as_ref()
&& let Some(build_matches) = matches.subcommand_matches("build")
Expand Down
16 changes: 8 additions & 8 deletions src/cli/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,20 @@ pub struct Cli {
#[arg(long)]
pub progress: Option<bool>,

/// Resolved colour policy from layered configuration.
#[arg(skip)]
/// Override colour policy for terminal output.
#[arg(long, value_name = "POLICY")]
pub colour_policy: Option<ColourPolicy>,

/// Resolved spinner mode from layered configuration.
#[arg(skip)]
/// Override spinner animation mode.
#[arg(long, value_name = "MODE")]
pub spinner_mode: Option<SpinnerMode>,

/// Resolved output format from layered configuration.
#[arg(skip)]
/// Override output format style.
#[arg(long, value_name = "FORMAT")]
pub output_format: Option<OutputFormat>,

/// Resolved presentation theme from layered configuration.
#[arg(skip)]
/// Override presentation theme.
#[arg(long, value_name = "THEME")]
pub theme: Option<Theme>,

/// Optional subcommand to execute; defaults to `build` when omitted.
Expand Down
100 changes: 98 additions & 2 deletions tests/bdd/steps/configuration_preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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::{Cli, ColourPolicy, Commands, SpinnerMode, Theme};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: String Heavy Function Arguments
In this module, 39.0% of all arguments to its 25 functions are strings. The threshold for string arguments is 39.0%

Suppress

use netsuke::cli_localization;
use netsuke::output_prefs;
use rstest_bdd_macros::{given, then, when};
Expand Down Expand Up @@ -39,7 +39,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()) };
Comment on lines 41 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add BDD coverage for the new explicit CLI override flags and their interaction with configuration and environment variables.

This file already exercises configuration and env-var wiring, so it’s a good place to assert the new CLI override behavior. Please add or extend BDD scenarios to:

  • Run the CLI with each new flag and assert the merged configuration uses the CLI value.
  • Assert precedence when both config and CLI specify a value (CLI must win).
  • Where applicable, cover env + config + CLI together, checking that CLI still wins and that env isolation holds between scenarios.

This coverage will more firmly validate the new merge/precedence rules and protect against regressions for these enum-based flags.

Suggested implementation:

    fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?;
    let previous = std::env::var_os(CONFIG_ENV_VAR);
    // SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario.
    unsafe { std::env::set_var(CONFIG_ENV_VAR, path.as_os_str()) };
    world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous);
    Ok(())
}

///
/// BDD step definitions to validate CLI override flags and their precedence
/// against configuration files and environment variables.
///
/// These steps are intended to be reused across scenarios that:
/// - set configuration values via config file
/// - optionally set environment variables
/// - provide explicit CLI override flags
/// and then assert that the merged configuration prefers CLI over env and config.
///

/// Record a CLI override flag value in the world before resolving configuration.
///
/// Example Gherkin:
///   Given the CLI is run with the "mode" override flag set to "fast"
#[given(regex = r#"^the CLI is run with the "([^"]+)" override flag set to "([^"]+)"$"#)]
async fn given_cli_override_flag(world: &mut ConfigurationWorld, flag: String, value: String) {
    world.cli_overrides.insert(flag, value);
}

/// Trigger configuration resolution using the currently configured config file,
/// environment variables, and any registered CLI overrides.
///
/// Example Gherkin:
///   When the configuration is resolved with CLI, config file, and environment variables
#[when("the configuration is resolved with CLI, config file, and environment variables")]
async fn when_configuration_is_resolved(world: &mut ConfigurationWorld) {
    // This method should:
    // - apply config file values
    // - overlay environment variables
    // - finally overlay explicit CLI overrides so that CLI wins
    world
        .resolve_configuration()
        .await
        .expect("configuration resolution failed");
}

/// Assert the final effective configuration value for a given key, ensuring
/// that CLI override precedence is honored over env and config.
///
/// Example Gherkin:
///   Then the effective configuration value for "mode" is "fast"
#[then(regex = r#"^the effective configuration value for "([^"]+)" is "([^"]+)"$"#)]
async fn then_effective_configuration_uses_cli_value(
    world: &mut ConfigurationWorld,
    key: String,
    expected: String,
) {
    let actual = world
        .effective_configuration
        .get(&key)
        .unwrap_or_else(|| panic!("missing configuration key {key}"));
    assert_eq!(
        actual, &expected,
        "expected effective configuration[{key}] = {expected}, got {actual}"
    );
}

To fully implement the requested BDD coverage, the following additional work is likely required:

  1. Ensure ConfigurationWorld:

    • Is the actual world type used in this file (if it has a different name, update the step signatures accordingly).
    • Exposes a cli_overrides: HashMap<String, String> or similar field; initialize it in the world constructor/reset logic.
    • Exposes an effective_configuration: HashMap<String, String> (or appropriate type) populated by resolve_configuration.
    • Provides an async resolve_configuration(&mut self) method that merges configuration as:
      config file < environment variables < explicit CLI overrides.
      If a differently named method already exists that performs this merge, adapt the call accordingly.
  2. Wire these steps into your existing BDD setup:

    • If you are using a steps! or similar registration macro/module, add these three step functions there so they are discoverable.
    • Make sure the #[given], #[when], and #[then] attribute macros and ConfigurationWorld are imported at the top of this file, matching your existing conventions.
  3. Add/extend .feature files to use these steps:

    • Scenarios that set only config + CLI and assert CLI wins.
    • Scenarios that set env + config + CLI and assert CLI still wins and env isolation holds between scenarios.
    • For each new explicit CLI override flag (enum-based flags), add scenarios that:
      • set the flag only via CLI and assert the effective configuration.
      • set conflicting values via config and CLI and assert the CLI value is used.
      • optionally combine env + config + CLI for that flag to validate full precedence.

world.track_env_var(CONFIG_ENV_VAR.to_owned(), previous);
Ok(())
}
Expand Down Expand Up @@ -95,6 +95,63 @@ 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<()> {
write_config(world, &format!("theme = \"{theme}\"\n"))
}

#[given("the Netsuke config file sets colour policy to {policy:string}")]
fn config_sets_colour_policy(world: &TestWorld, policy: &str) -> Result<()> {
write_config(world, &format!("colour_policy = \"{policy}\"\n"))
}

#[given("the Netsuke config file sets spinner mode to {mode:string}")]
fn config_sets_spinner_mode(world: &TestWorld, mode: &str) -> Result<()> {
write_config(world, &format!("spinner_mode = \"{mode}\"\n"))
}

#[given("the NETSUKE_THEME environment variable is {theme:string}")]
#[expect(
clippy::unnecessary_wraps,
reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381"
)]
fn set_environment_theme_override(world: &TestWorld, theme: &str) -> Result<()> {
ensure_env_lock(world);
let previous = std::env::var_os("NETSUKE_THEME");
// SAFETY: `EnvLock` is held in `world.env_lock` for the lifetime of the scenario.
unsafe { std::env::set_var("NETSUKE_THEME", OsStr::new(theme)) };
world.track_env_var("NETSUKE_THEME".to_owned(), previous);
Ok(())
}

#[given("the NETSUKE_COLOUR_POLICY environment variable is {policy:string}")]
#[expect(
clippy::unnecessary_wraps,
reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381"
)]
fn set_environment_colour_policy_override(world: &TestWorld, policy: &str) -> Result<()> {
ensure_env_lock(world);
let previous = std::env::var_os("NETSUKE_COLOUR_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(policy)) };
world.track_env_var("NETSUKE_COLOUR_POLICY".to_owned(), previous);
Ok(())
}

#[given("the NETSUKE_SPINNER_MODE environment variable is {mode:string}")]
#[expect(
clippy::unnecessary_wraps,
reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381"
)]
fn set_environment_spinner_mode_override(world: &TestWorld, mode: &str) -> Result<()> {
ensure_env_lock(world);
let previous = std::env::var_os("NETSUKE_SPINNER_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(mode)) };
world.track_env_var("NETSUKE_SPINNER_MODE".to_owned(), previous);
Ok(())
}

#[expect(
clippy::unnecessary_wraps,
reason = "rstest-bdd macro generates Result wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381"
Expand Down Expand Up @@ -181,3 +238,42 @@ fn merge_error_contains(world: &TestWorld, fragment: &str) -> Result<()> {
);
Ok(())
}

#[then("the merged theme is unicode")]
fn merged_theme_is_unicode(world: &TestWorld) -> Result<()> {
let theme = world
.cli
.with_ref(|cli| cli.theme)
.context("expected merged CLI to be available")?;
ensure!(
theme == Some(Theme::Unicode),
"expected merged theme to be Unicode, got {theme:?}",
);
Ok(())
}

#[then("the merged colour policy is always")]
fn merged_colour_policy_is_always(world: &TestWorld) -> Result<()> {
let policy = world
.cli
.with_ref(|cli| cli.colour_policy)
.context("expected merged CLI to be available")?;
ensure!(
policy == Some(ColourPolicy::Always),
"expected merged colour policy to be Always, got {policy:?}",
);
Ok(())
}

#[then("the merged spinner mode is enabled")]
fn merged_spinner_mode_is_enabled(world: &TestWorld) -> Result<()> {
let mode = world
.cli
.with_ref(|cli| cli.spinner_mode)
.context("expected merged CLI to be available")?;
ensure!(
mode == Some(SpinnerMode::Enabled),
"expected merged spinner mode to be Enabled, got {mode:?}",
);
Ok(())
}
Comment on lines +269 to +279

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Code Duplication
The module contains 4 functions with similar structure: merged_colour_policy_is_always,merged_spinner_mode_is_enabled,merged_theme_is_ascii,merged_theme_is_unicode

Suppress

66 changes: 66 additions & 0 deletions tests/features/configuration_preferences.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading