diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index 309e248a8..d9673a6ac 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -79,13 +79,13 @@ jobs: python-version: ${{ inputs['python-version'] }} - name: Install cargo-orthohelp - run: cargo install cargo-orthohelp --version 0.8.0 --locked + run: cargo install cargo-orthohelp --version 0.9.0 --locked - name: Validate cargo-orthohelp version shell: bash run: | set -euo pipefail - cargo-orthohelp --version | grep '0\.8\.0' + cargo-orthohelp --version | grep -Eq '(^|[[:space:]])0\.9\.0([[:space:]]|$)' - name: Build release binary uses: leynos/shared-actions/.github/actions/rust-build-release@8add2d99854a5b77548eae98cca59202e68fefc8 diff --git a/Cargo.lock b/Cargo.lock index 3f9249bb2..e5215dd8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,9 @@ name = "camino" version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] [[package]] name = "cap-primitives" @@ -251,8 +254,8 @@ checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a" dependencies = [ "ambient-authority", "fs-set-times", - "io-extras", - "io-lifetimes", + "io-extras 0.18.4", + "io-lifetimes 2.0.4", "ipnet", "maybe-owned", "rustix", @@ -261,6 +264,24 @@ dependencies = [ "winx", ] +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras 0.19.0", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + [[package]] name = "cap-std" version = "3.4.4" @@ -268,9 +289,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189" dependencies = [ "camino", - "cap-primitives", - "io-extras", - "io-lifetimes", + "cap-primitives 3.4.4", + "io-extras 0.18.4", + "io-lifetimes 2.0.4", + "rustix", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "camino", + "cap-primitives 4.0.2", + "io-extras 0.19.0", + "io-lifetimes 3.0.1", "rustix", ] @@ -309,7 +343,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -333,7 +367,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -459,7 +493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -482,9 +516,15 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.119", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "difflib" version = "0.4.0" @@ -562,7 +602,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -760,7 +800,7 @@ version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ - "io-lifetimes", + "io-lifetimes 2.0.4", "rustix", "windows-sys 0.59.0", ] @@ -790,7 +830,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -863,7 +903,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.104", + "syn 2.0.119", "textwrap", "thiserror 1.0.69", "typed-builder", @@ -881,6 +921,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "googletest" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94" +dependencies = [ + "googletest_macro", + "num-traits", + "regex", + "rustversion", +] + +[[package]] +name = "googletest_macro" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -986,7 +1049,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -1176,7 +1239,17 @@ version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" dependencies = [ - "io-lifetimes", + "io-lifetimes 2.0.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", "windows-sys 0.59.0", ] @@ -1186,6 +1259,12 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "io-uring" version = "0.7.8" @@ -1394,7 +1473,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -1478,13 +1557,14 @@ dependencies = [ "anyhow", "assert_cmd", "camino", - "cap-primitives", - "cap-std", + "cap-primitives 3.4.4", + "cap-std 3.4.4", "clap", "clap_mangen", "digest 0.11.3", "fluent-bundle", "glob", + "googletest", "hashbrown 0.17.1", "indexmap", "indicatif", @@ -1502,6 +1582,7 @@ dependencies = [ "monotony", "ortho_config", "predicates 3.1.3", + "pretty_assertions", "proptest", "rstest", "rstest-bdd", @@ -1623,12 +1704,12 @@ dependencies = [ [[package]] name = "ortho_config" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9314c7a4be184287f3f9a66fcbcec054ac215d4975152df86b7aadeae8d5e019" +checksum = "e1e1275151857af166ec59cab125a6fec5d2d0ddceda70a8c50c83c45456b953" dependencies = [ "camino", - "cap-std", + "cap-std 4.0.2", "clap", "clap-dispatch", "directories", @@ -1641,7 +1722,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "toml 0.9.10+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "tracing", "uncased", "unic-langid", @@ -1650,14 +1731,14 @@ dependencies = [ [[package]] name = "ortho_config_macros" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3941ab7c592a0b93aadf12ce30e0ef3d01af87b46c8f363d8157dc33273ba83" +checksum = "8ca0f4ba149c15111da8c4d3b6180af452d44c36049b25c22b0902d2383a1da9" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -1709,7 +1790,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -1831,6 +1912,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1872,9 +1963,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1887,7 +1978,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", "version_check", "yansi", ] @@ -1934,9 +2025,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2150,7 +2241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe104196f61dc8911a8da1b10005e9401e7c2e14ad9302e2de6688311c0beec7" dependencies = [ "camino", - "cap-std", + "cap-std 3.4.4", "cfg-if", "convert_case 0.6.0", "gherkin", @@ -2162,7 +2253,7 @@ dependencies = [ "regex", "rstest-bdd-patterns", "rstest-bdd-policy", - "syn 2.0.104", + "syn 2.0.119", "thiserror 1.0.69", "walkdir", ] @@ -2198,7 +2289,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.104", + "syn 2.0.119", "unicode-ident", ] @@ -2222,7 +2313,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn 2.0.119", "walkdir", ] @@ -2317,9 +2408,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -2510,7 +2601,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -2676,9 +2767,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2704,7 +2795,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -2752,7 +2843,7 @@ dependencies = [ "anyhow", "assert_cmd", "camino", - "cap-std", + "cap-std 3.4.4", "mockable", "netsuke-build", "proptest", @@ -2801,7 +2892,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -2812,7 +2903,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -2888,7 +2979,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -2914,17 +3005,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow 1.0.3", ] [[package]] @@ -2945,6 +3036,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2988,9 +3088,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -3011,7 +3111,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -3075,7 +3175,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -3139,7 +3239,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn 2.0.104", + "syn 2.0.119", "unic-langid-impl", ] @@ -3318,7 +3418,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -3635,7 +3735,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", "synstructure", ] @@ -3656,7 +3756,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -3676,7 +3776,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", "synstructure", ] @@ -3716,7 +3816,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f327091b3..2e39d0774 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,13 +130,14 @@ time = { version = "0.3.44", features = ["formatting", "macros", "parsing", "ser ureq = { version = "2.10.5" } wait-timeout = "0.2" url = "^2.5.0" -ortho_config = { version = "0.8.0", features = ["serde_json"] } +ortho_config = { version = "0.9.0", features = ["serde_json"] } sys-locale = "0.3.2" [build-dependencies] +cap-std = "3.4.4" clap = { version = "4.5.0", features = ["derive"] } clap_mangen = "0.3.0" -ortho_config = { version = "0.8.0", features = ["serde_json"] } +ortho_config = { version = "0.9.0", features = ["serde_json"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } thiserror = "1" @@ -163,6 +164,8 @@ strip-ansi-escapes = "0.2" toml = "0.8" serde_yaml = "0.9" proptest = "1.11.0" +googletest = "0.14.3" +pretty_assertions = "1.4.1" # Plural-selection tests must pass numeric arguments to Fluent. `ortho_config` # exposes `LocalizationArgs` as a map of `FluentValue`, but does not re-export # the value type, so the tests need `fluent-bundle` directly. Constrained to diff --git a/build.rs b/build.rs index 7f063f9a8..1ddc40615 100644 --- a/build.rs +++ b/build.rs @@ -6,30 +6,29 @@ //! - 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 cap_std::{ambient_authority, fs::Dir}; use clap::CommandFactory; use clap_mangen::Man; use std::{ - env, fs, + env, path::{Path, PathBuf}, }; use time::{OffsetDateTime, format_description::well_known::Iso8601}; const FALLBACK_DATE: &str = "1970-01-01"; -// The build script recompiles these library modules as its own crate so that -// `cli::Cli::command()` (used for man-page generation) can be constructed. Only -// a small slice of each module's public API is reachable from this binary, so -// the compiler reports the remainder as unused. Those items are not dead: their -// real call sites live in the library crate and are covered by its tests, where -// dead-code and unused-import analysis applies normally. Each shared module -// therefore carries an `#[expect]` for exactly the lints it triggers here, in -// preference to anchoring the symbols with artificial references. +// The build script recompiles the parser subset needed to construct +// `cli::Cli::command()` for man-page generation. Runtime discovery is excluded: +// the build script does not perform discovery, and compiling it here would pull +// its ambient canonicalization boundary into this separate compilation unit. +// The parser subset exposes more library API than this binary reaches, so the +// compiler reports unused items that the library crate and its tests exercise. #[expect( dead_code, unused_imports, reason = "shared library source; the unreached API is exercised by the library crate" )] -#[path = "src/cli/mod.rs"] +#[path = "src/cli/build_support.rs"] mod cli; #[path = "src/cli_localization.rs"] @@ -127,21 +126,24 @@ fn out_dir_for_target_profile() -> PathBuf { } 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) + let man_dir = if dir.is_relative() { + let working_dir = Dir::open_ambient_dir(".", ambient_authority())?; + working_dir.create_dir_all(dir)?; + working_dir.open_dir(dir)? + } else { + // Cargo creates `OUT_DIR` before the build script runs, so this boundary + // can narrow the build process's ambient authority to that directory. + Dir::open_ambient_dir(dir, ambient_authority())? + }; + let temporary_name = format!("{page_name}.tmp"); + man_dir.write(&temporary_name, data)?; + man_dir.rename(&temporary_name, &man_dir, page_name)?; + Ok(dir.join(page_name)) } fn emit_rerun_directives() { - println!("cargo:rerun-if-changed=src/cli/mod.rs"); + println!("cargo:rerun-if-changed=src/cli/build_support.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"); diff --git a/build_l10n_audit/mod.rs b/build_l10n_audit/mod.rs index b1b0ed671..428bbcc1d 100644 --- a/build_l10n_audit/mod.rs +++ b/build_l10n_audit/mod.rs @@ -16,11 +16,12 @@ mod keys; mod metadata; use crate::locale_catalogues::{LocaleCatalogue, SOURCE_LOCALE, SUPPORTED_LOCALES}; +use cap_std::{ambient_authority, fs::Dir}; use compare::{audit_catalogue, build_error_message}; use ftl::MessageVariables; use metadata::parse_metadata_locales; -use std::error::Error; use std::path::{Path, PathBuf}; +use std::{error::Error, io}; const KEYS_PATH: &str = "src/localization/keys.rs"; const CARGO_MANIFEST: &str = "Cargo.toml"; @@ -58,11 +59,24 @@ fn audit_cargo_metadata(root: &Path) -> Result<(), Box> { /// Read `path`, naming it in the error. /// -/// Every filesystem read the audit performs goes through here. The parsers -/// below it take `&str`, so this module is the only one holding an ambient -/// path, and they stay testable without staging files. +/// Every filesystem read the audit performs goes through a directory capability +/// here. The parsers below it take `&str`, so this module keeps the path-shaped +/// test seam while limiting each read to its parent directory. fn read_source(path: &Path) -> Result> { - std::fs::read_to_string(path) + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("cannot read directory as source: {}", path.display()), + ) + })?; + let directory = Dir::open_ambient_dir(parent, ambient_authority()) + .map_err(|err| format!("failed to open {}: {err}", parent.display()))?; + directory + .read_to_string(name) .map_err(|err| format!("failed to read {}: {err}", path.display()).into()) } diff --git a/docs/adr-004-explicit-config-selection-outside-orthoconfig.md b/docs/adr-004-explicit-config-selection-outside-orthoconfig.md index f63bc7e9f..b7f4c961e 100644 --- a/docs/adr-004-explicit-config-selection-outside-orthoconfig.md +++ b/docs/adr-004-explicit-config-selection-outside-orthoconfig.md @@ -80,12 +80,15 @@ environment selection, diagnostics, and automatic discovery are combined. Netsuke resolves explicit configuration paths in `src/cli/discovery.rs`. -- `explicit_config_path` applies `--config` > `NETSUKE_CONFIG`, ignoring empty - environment values. -- `env_config_path(var_name)` reads one environment variable with - `std::env::var_os`, so precedence tests use current-process values. -- `push_file_layers` drains successful layer loads into the merge composer, or - records the load error for final diagnostics. +- `resolve_config_selector` applies `--config` > `NETSUKE_CONFIG`, ignoring + empty environment values. +- `env_config_path(env, var_name)` reads through Netsuke's injected + `EnvProvider` port. Production supplies `StdEnvProvider`; tests use a + map-backed provider without mutating process-global state. +- `push_file_layers_with_sources` drains successful layer loads into the merge + composer, or records the load error for final diagnostics. Its private + `DiscoverySources` input pairs that port with the selected OrthoConfig + discovery adapter. - Automatic discovery remains the fallback only when no explicit selector is present. @@ -94,6 +97,10 @@ Netsuke resolves explicit configuration paths in `src/cli/discovery.rs`. - The CLI adapter has a small amount of Netsuke-specific orchestration logic, but the rules are visible and testable where the public contract is defined. - OrthoConfig does not gain Netsuke-specific configuration selector semantics. +- Ambient composition uses OrthoConfig `ProcessEnv`; injected composition uses + a closed `MapEnv` containing only documented discovery keys. This keeps + automatic discovery hermetic in tests while retaining platform home fallback + for users. - Explicit selected files fail closed. A missing or invalid file reports the selected-file error instead of silently inheriting a discovered file. - Future changes to selector precedence must update `discovery.rs`, the @@ -102,6 +109,7 @@ Netsuke resolves explicit configuration paths in `src/cli/discovery.rs`. ## Related documents - [`docs/developers-guide.md`](developers-guide.md) +- [`docs/execplans/adopt-ortho-config-v0-9-0.md`](execplans/adopt-ortho-config-v0-9-0.md) - [`docs/execplans/3-11-3-expose-config-path-and-netsuke-config.md`][execplan] - [`docs/netsuke-design.md`](netsuke-design.md) diff --git a/docs/contents.md b/docs/contents.md index f3315a18f..9261ccb42 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -38,6 +38,8 @@ operator, user, and contributor references are easier to find. Kani IR harness bound and Proptest hand-off decision record. - [adr-004-graph-subcommand-in-process-rendering.md](adr-004-graph-subcommand-in-process-rendering.md): Graph rendering architecture decision record. +- [adr-004-explicit-config-selection-outside-orthoconfig.md](adr-004-explicit-config-selection-outside-orthoconfig.md): + Explicit configuration selector ownership decision record. - [adr-005-typed-which-resolve-error.md](adr-005-typed-which-resolve-error.md): Typed executable resolver error decision record for `which` and `command_available`. @@ -70,6 +72,8 @@ operator, user, and contributor references are easier to find. template standard-library reference with executable YAML and Jinja examples. - [ortho-config-users-guide.md](ortho-config-users-guide.md): Configuration system guide and precedence reference. +- [ortho-config-v0-9-0-migration-guide.md](ortho-config-v0-9-0-migration-guide.md): + Migration guidance for the OrthoConfig v0.9.0 release. - [translators-guide.md](translators-guide.md): Localization workflow, translation guidance, the locale registry that owns the supported-tag list, and the fallback policy that keeps regional and script variants distinct. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2da36302e..4ff6789f6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -510,19 +510,24 @@ deviation from upstream" callout, and record Netsuke-specific policy here and in Prefer `excluded_paths` over `excluded_crates`: a path entry exempts one module and its descendants, whereas a crate entry exempts a whole compilation unit. -The application crate is scoped this way — only +The application crate's module-scoped exemptions include `netsuke::stdlib::which::lookup` (executable discovery through `PATH` and cross-directory symlink canonicalization, which `cap_std` cannot express) and `netsuke::runner::process::file_io::ambient_sync` (temporary-file synchronization, scoped to the submodule holding only that `sync_all` so the -rest of `file_io` keeps writing through `cap_std` handles), and -`netsuke::cli::discovery::paths` (canonicalizing an ambient `--directory` to -match OrthoConfig's layer paths) are exempt; the rest of `netsuke` stays under -the capability policy. The behavioural step definitions, CLI integration tests, -and shared workflow-reading helper that stage fixtures ambiently are scoped the -same way. A crate-level entry is justified only when the ambient access lives -in the crate root itself, where a path entry would be no narrower — that covers -the Cargo build script and the enumerated integration-test crates. The +rest of `file_io` keeps writing through `cap_std` handles). Configuration +discovery otherwise uses capability-scoped canonicalization. Its small, +dedicated path-normalization module, `netsuke::cli::discovery::paths`, remains +narrowly excluded because `std::fs::canonicalize` preserves the absolute +comparison keys and cross-directory symlink behaviour that `cap_std` rejects. +For man-page generation, the build script compiles the `cli::build_support` +parser subset and deliberately omits runtime discovery. The broader +`netsuke::cli::discovery` module remains under the capability policy; no +`build_script_build` exception is required. The behavioural step definitions, +CLI integration tests, and shared workflow-reading helper that stage fixtures +ambiently are scoped the same way. A crate-level entry is justified only when +the ambient access lives in the crate root itself, where a path entry would be +no narrower — that covers the enumerated integration-test crates. The `test_support` crate uses capability-backed fixture helpers and remains linted by Whitaker under its own narrow policy. @@ -541,12 +546,10 @@ configuration names only `test_support::fs` in `excluded_paths`. The root crate remains subject to the filesystem policy. Permanent exceptions belong in `dylint.toml`, scoped as narrowly as the lint -allows. The lint does honour in-source lint attributes, but this repository -denies `clippy::allow_attributes`, so `#[allow(no_std_fs_operations)]` will not -compile here; an in-source exemption must be a *temporary*, item-level -`#[expect(no_std_fs_operations, reason = "…")]` that states the reason and the -route back to compliance. Prefer migrating to `cap_std` over any of these; -reach for an exclusion only when the operation is irreducibly ambient. +allows. Do not use Rust `#[allow]` or `#[expect]` for `no_std_fs_operations`: +this Dylint lint is not known to `rustc`, so its exclusions must be configured +there. Prefer migrating to `cap_std` over any of these; reach for an exclusion +only when the operation is irreducibly ambient. To confirm the exclusions have not silently widened, add a temporary `std::fs::metadata` call to an unexcluded module — for example @@ -785,7 +788,7 @@ rather than from `build.rs`. The build script remains responsible for the localization key audit only. Release automation installs the pinned tool with: ```bash -cargo install cargo-orthohelp --version 0.8.0 --locked +cargo install cargo-orthohelp --version 0.9.0 --locked ``` The workflow then calls: @@ -794,6 +797,10 @@ The workflow then calls: scripts/generate-release-help.sh ``` +The script invokes `cargo-orthohelp orthohelp`; v0.9.0 reserves direct +generator options for that subcommand. Keep its `rstest` script contract and +the real Unix and Windows generation smoke aligned with this invocation. + The script writes manual pages under `target/orthohelp//release/man/man1/` and, for Windows targets, PowerShell external help under @@ -809,6 +816,10 @@ unit tests, and `rstest-bdd` release-help scenarios. `src/cli/config_path_precedence_tests.rs` is the canonical exhaustive state-enumeration example. +Use `googletest` matchers for structural or diagnostic assertions and +`pretty_assertions` for ordered collection equality where its diff is useful. +Do not rewrite established tests only to introduce either library. + ## Local build acceleration Debug builds and tests can optionally use the [`mold`] linker and the Cranelift @@ -2376,7 +2387,7 @@ Because `MergeComposer` uses last-wins semantics, pushing the project layers after user layers gives them higher precedence. Early JSON resolution reuses this logic through -`collect_diag_file_layers_with_env`, before full configuration merging. +`collect_diag_file_layers_with_sources`, before full configuration merging. ### Layer precedence @@ -2394,10 +2405,10 @@ Private helper functions for config discovery and JSON-output resolution. Configuration merge helpers: -- `config_discovery(directory: Option<&PathBuf>) -> ConfigDiscovery` builds - the single-pass OrthoConfig discovery scanner with an optional project-root - anchor. -- `project_scope_file_str(directory: Option<&Path>) -> Option` +- `config_discovery(directory, env_source) -> ConfigDiscovery` builds the + single-pass OrthoConfig discovery scanner with an optional project-root + anchor and the environment adapter selected at the composition root. +- `project_scope_file(directory: Option<&Path>) -> Option` resolves the expected project `.netsuke.toml` path for project-layer detection. - `project_scope_layers(directory)` loads the project-scope config directly, @@ -2408,17 +2419,15 @@ Configuration merge helpers: `PathBuf`. - `explicit_config_path_with_env(cli, env) -> Option` resolves explicit config selection from `--config` and `NETSUKE_CONFIG`. -- `push_file_layers_with_env(cli, composer, errors, env) -> ()` pushes explicit - or discovered file layers onto a `MergeComposer`. The injected `env` - parameter follows the environment mandate: it supplies environment access - without requiring callers to mutate the process environment. Explicit load +- `push_file_layers_with_sources(cli, composer, errors, sources) -> ()` pushes + explicit or discovered file layers onto a `MergeComposer`. Explicit load errors are pushed into `errors`, and automatic discovery is not attempted after an explicit selector fails. -- `collect_diag_file_layers_with_env(cli, env)` reuses the same file-layer - precedence for early JSON resolution. -- `collect_file_layers(directory)` builds the fallback discovery layer chain, - applies the project-layer second pass, and returns - `OrthoResult>>`. +- `collect_diag_file_layers_with_sources(cli, sources)` reuses the same + file-layer precedence for early JSON resolution. +- `collect_file_layers_with_env_source(directory, env_source)` builds the + fallback discovery layer chain, applies the project-layer second pass, and + returns `OrthoResult>>`. - `is_empty_value(value: &serde_json::Value) -> bool` detects an empty CLI override object. - `json_from_layer(value: &serde_json::Value) -> Option` extracts `json` @@ -2453,6 +2462,15 @@ so discovery and value merging observe one environment. Keep this port scoped to CLI configuration; runner, manifest, locale, and stdlib environment seams remain separate because their input and lifetime contracts differ. +`DiscoverySources` is a crate-private composition input owned by +`src/cli/discovery.rs`. Only full merge and early JSON resolution may construct +it. Ambient entry points pair `ConfigStdEnvProvider` with OrthoConfig +`ProcessEnv`; injected entry points project the same `ConfigEnvProvider` into a +closed `MapEnv` containing only `NETSUKE_CONFIG`, `HOME`, `USERPROFILE`, +`XDG_CONFIG_HOME`, `XDG_CONFIG_DIRS`, `APPDATA`, and `LOCALAPPDATA`. Do not +reuse this fixed-key projection as a general environment-copy helper; +`EnvironmentLayer` alone enumerates the full `NETSUKE_*` value environment. + `explicit_config_path_with_env` is the crate-internal seam for explicit config-file selection. It evaluates the precedence chain in this order: @@ -2486,12 +2504,11 @@ The `cli` module re-exports this trait publicly as `ConfigEnvProvider` (and the unrelated `LocaleEnvProvider` in `locale_resolution`; crate-internal code uses the bare `EnvProvider` name. -Discovery tests that exercise OrthoConfig's `ConfigDiscovery` must run the -ambient adapter in an isolated child configured with `env_clear()` followed by -`Command::env`. Tests for Netsuke's own environment port should inject a -provider directly. `EnvLock` is reserved for tests that change the process -working directory alongside `CwdGuard`; it does not justify environment -mutation. +Tests for injected configuration discovery should provide a map-backed +`ConfigEnvProvider`. End-to-end tests of the ambient `ProcessEnv` adapter must +run in an isolated child configured with `env_clear()` followed by +`Command::env`. `EnvLock` is reserved for tests that change the process working +directory alongside `CwdGuard`; it does not justify environment mutation. Unit tests that only need to verify explicit config path precedence should test `explicit_config_path_with_env` with an injected provider instead of mutating @@ -2751,8 +2768,8 @@ split diagnostics, path comparison, and tests out of the main discovery flow: (`--config` versus `NETSUKE_CONFIG`), the removed legacy `NETSUKE_CONFIG_PATH` alias, and event-schema snapshots for both selection and explicit load failures. -- `discovery_layer_tests.rs` — tests which branch - `collect_diag_file_layers_with_env` takes (explicit path versus automatic +- `discovery_layer_tests.rs` — tests the test-only + `collect_diag_file_layers_with_env` wrapper (explicit path versus automatic discovery) and the project-scope second pass in `collect_file_layers`. Both test modules import `capture_events`, `find_event`, and `EventAssertion` diff --git a/docs/execplans/adopt-ortho-config-v0-9-0.md b/docs/execplans/adopt-ortho-config-v0-9-0.md new file mode 100644 index 000000000..1e80e3e30 --- /dev/null +++ b/docs/execplans/adopt-ortho-config-v0-9-0.md @@ -0,0 +1,1494 @@ +# Adopt OrthoConfig v0.9.0 without weakening Netsuke's boundaries + +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: COMPLETE + +## Purpose / big picture + +Netsuke currently uses `ortho_config` v0.8.0 at runtime and build time, and its +release workflow installs `cargo-orthohelp` v0.8.0. This migration moves those +three consumers to the compatible v0.9.0 release family while retaining +Netsuke's established command-line interface (CLI), configuration precedence, +localized errors, two-pass project discovery, and release-help paths. + +After the change, a user can keep using the same configuration files, flags, and +`NETSUKE_*` variables. An absent optional configuration still falls back to +defaults, whereas a candidate that exists but cannot be loaded is reported as +an error. Release builds generate their man page and PowerShell help with +`cargo-orthohelp` v0.9.0. Tests that inject environment values control +automatic discovery as well as configuration-value merging, without reading or +mutating the test process's environment. + +Observable success means all of the following are true: + +1. `Cargo.toml` and `Cargo.lock` resolve both direct `ortho_config` + requirements to v0.9.0, and the release workflow installs and validates + `cargo-orthohelp` v0.9.0. +2. Existing configuration precedence remains defaults, discovered files, + `NETSUKE_*` environment values, then explicit CLI values; `--config` still + outranks `NETSUKE_CONFIG` and bypasses automatic discovery. +3. Automatic discovery distinguishes no candidate from candidates that all + fail, and injected discovery tests cannot fall through to the host's home or + platform configuration directories. +4. Netsuke's customized localized value parsers still produce localized help + and localized parse failures through OrthoConfig v0.9.0's combined parsing + entry point. +5. The existing man-page and PowerShell generation workflow succeeds with the + v0.9.0 documentation metadata. +6. `make check-fmt`, `make typecheck`, `make lint`, and `make test` pass after + every major implementation milestone. Documentation gates pass before the + migration is complete. + +This draft is a plan only. Per the `execplans` skill's approval gate, do not +implement it until the user explicitly approves the draft. + +## Constraints + +- Preserve the current public CLI and configuration contract unless a verified + v0.9.0 incompatibility makes that impossible. In particular, preserve + `--config`, `NETSUKE_CONFIG`, `-C/--directory`, `NETSUKE_*` value merging, + default-command resolution, and the documented precedence order. +- Preserve the accepted decision in + `docs/adr-004-explicit-config-selection-outside-orthoconfig.md`: Netsuke's + CLI adapter, not OrthoConfig attributes, owns explicit selector precedence, + early diagnostic selection, and fail-closed selected-file handling. +- Keep `CliConfig` in `src/cli/config.rs` as the configuration policy model. + It may derive OrthoConfig traits, but it must not acquire filesystem, + process-environment, tracing-subscriber, or command-execution concerns. +- Keep process-backed environment access at composition roots. Domain and + policy queries receive data through the existing `ConfigEnvProvider` port; + tests must not call `std::env::set_var` or `std::env::remove_var`. +- Treat OrthoConfig discovery and file loading as driven adapters. Netsuke's + CLI orchestration may compose them, but domain and manifest modules must not + depend on OrthoConfig discovery types. +- Do not transplant a canonical hexagonal directory tree into Netsuke. Apply + the `$hexagonal-architecture` dependency rule to the existing feature-based + modules: policy points inward; `ProcessEnv`, `MapEnv`, Figment, files, clap, + and `cargo-orthohelp` remain adapters. +- Preserve the two-pass project discovery required by `--directory` and the + append merge strategy. Do not replace it with the v0.9.0 `discovery(...)` + derive attribute unless tests prove identical layer order, inherited-layer + handling, selector precedence, and diagnostics. +- Preserve Netsuke's custom clap value-parser configuration. Do not adopt + `LocalizedParse` directly because it cannot insert + `configure_validation_parsers`; use `parse_localized_command` only after + supplying the already localized and configured command. +- Keep `ortho_config`'s `yaml` feature disabled. Netsuke configuration is TOML; + Netsuke manifest YAML is a separate `serde-saphyr` boundary and is not part + of this dependency's YAML 1.2 migration. +- Retain direct dependencies that Netsuke source genuinely imports. In + particular, do not remove `serde-saphyr`, `serde_json`, or `fluent-bundle` + merely because OrthoConfig re-exports implementation dependencies. +- Use caret requirements for every new or changed dependency. The required + versions are `ortho_config = "0.9.0"`, `googletest = "0.14.3"`, and + `pretty_assertions = "1.4.1"`; Cargo's plain version syntax supplies caret + semantics. +- Follow Red-Green-Refactor. A focused test must fail for the expected reason + before each behavioural production change, then pass after the smallest + implementation, and remain passing after cleanup. +- Use `rstest` for new parameterized unit and integration coverage, + `rstest-bdd` for user-visible acceptance behaviour, `googletest` matchers for + structural and error assertions, and `pretty_assertions` for collection or + metadata equality where a diff aids diagnosis. +- Preserve current `insta` snapshots unless output intentionally changes. + Review new snapshots before accepting them; never use automatic snapshot + acceptance as the oracle. +- Keep all Rust source files below 400 lines. Extract only cohesive helpers, + first sweeping the repository for an equivalent. Document any new helper's + ownership, permitted call sites, and composition rules in + `docs/developers-guide.md`. +- Use the dated nightly in `rust-toolchain.toml` and Polonius. Do not add clones + or NLL-era lookup workarounds to make the migration compile. +- Use en-GB-oxendict prose, wrap Markdown prose at 80 columns, and follow + `docs/documentation-style-guide.md`. +- Update `docs/netsuke-design.md` for the migration decisions, + `docs/developers-guide.md` for internal seams and release tooling, and + `docs/users-guide.md` for any observable error or discovery clarification. If + implementation requires a new public architectural commitment, stop and add + an architectural decision record (ADR) in the style required by + `docs/documentation-style-guide.md`, then reference it from the design. +- Do not commit a milestone unless all gates assigned to it pass. Keep + functional changes and any later discretionary refactor in separate atomic + commits. + +## Tolerances (exception triggers) + +- Scope: if the migration needs more than 18 changed files excluding + `Cargo.lock`, snapshots, and this ExecPlan, or more than 900 net lines, stop + and split or re-scope the work with the user. +- Public interface: if an exported Netsuke type or function must change, stop + and present the compatibility options. Adding a private adapter helper does + not trigger this threshold. +- Dependencies: if production needs any new dependency other than the requested + `ortho_config` update, stop. The requested test-only assertion dependencies + do not trigger this threshold. +- Discovery: if v0.9.0 cannot preserve current project/user layer order or + `extends` append semantics through `compose_layers`, stop rather than + approximating the old behaviour. +- Localization: if `parse_localized_command` changes any established help or + error snapshot beyond an expected v0.9.0 correction, stop and compare keeping + the current manual path against adopting the combined path. +- Release help: if a real v0.9.0 generation smoke changes a staged path, module + layout, or public help content, stop before updating packaging and present + the user-visible difference. +- Test iterations: if a focused red test does not turn green after three + implementation attempts, record the evidence in `Decision Log` and stop. +- Full gates: if the same full gate fails after three focused correction + cycles, stop and report the exact command and remaining failures. +- Milestone duration: if one milestone takes more than four hours without a + passing focused checkpoint, record the partial state and stop. +- Ambiguity: if two valid interpretations materially change configuration or + release behaviour, stop and present both options rather than choosing + silently. + +## Risks + +- Risk: v0.9.0's accumulated discovery errors expose a malformed optional file + that v0.8.0 effectively ignored. Severity: high. Likelihood: medium. + Mitigation: specify absent, malformed, unreadable, and successful candidate + cases before the upgrade; accept the new error only where it matches the + migration guide, and document it for users. + +- Risk: passing a test `MapEnv` to automatic discovery while leaving value + merging on another environment source produces split-brain tests. Severity: + high. Likelihood: medium. Mitigation: make the private merge orchestration + accept both capabilities and have the injected public entry point derive the + discovery `MapEnv` from the same `ConfigEnvProvider` fixture. The ambient + entry point uses `ProcessEnv`, preserving platform home fallback in + production. + +- Risk: adapting all environment entries into OrthoConfig's `EnvSource` would + grant discovery an unnecessary enumeration capability or lose non-Unicode + values. Severity: medium. Likelihood: medium. Mitigation: snapshot only + OrthoConfig's documented discovery keys (`NETSUKE_CONFIG`, `HOME`, + `USERPROFILE`, `XDG_CONFIG_HOME`, `XDG_CONFIG_DIRS`, `APPDATA`, and + `LOCALAPPDATA`) into `MapEnv`; retain the existing entry enumeration solely + for Netsuke's `NETSUKE_*` merge adapter. + +- Risk: `parse_localized_command` uses `FromArgMatches` rather than Netsuke's + current `from_arg_matches_mut` call and could alter custom-parser behaviour. + Severity: medium. Likelihood: low. Mitigation: add focused happy and unhappy + parser tests before replacing the glue, retain the configured command, and + compare localized snapshots. + +- Risk: v0.9.0 documentation metadata or `cargo-orthohelp` changes generated + man-page or PowerShell output. Severity: high. Likelihood: medium. + Mitigation: pin a compact metadata snapshot, update workflow contract tests + first, then run the real generator for Unix and Windows formats before + changing any packaging expectation. + +- Risk: adding two assertion libraries creates inconsistent test idioms. + Severity: low. Likelihood: medium. Mitigation: use `googletest` for + matcher-oriented structure and error inspection, `pretty_assertions` only for + equality with useful diffs, and document these narrow roles rather than + mechanically rewriting existing tests. + +- Risk: the generic migration guide's YAML warning is mistaken for a Netsuke + manifest migration. Severity: medium. Likelihood: low. Mitigation: keep the + OrthoConfig `yaml` feature off and state explicitly in design and developer + documentation that `serde-saphyr` continues to own Netsukefile parsing. + +## Progress + +- [x] (2026-08-12 16:03Z) Read the OrthoConfig v0.9.0 migration and user + guides, Netsuke design and contributor guidance, current configuration code, + tests, release workflow contracts, and repository layout. +- [x] (2026-08-12 16:03Z) Confirm `ortho_config` and `cargo-orthohelp` v0.9.0 + exist and require Rust 1.89.0; the repository's dated 2026 nightly is new + enough to satisfy that declared minimum. +- [x] (2026-08-12 16:03Z) Draft this ExecPlan with architecture boundaries, + Red-Green-Refactor milestones, coverage choices, and exception thresholds. +- [x] (2026-08-12 16:35Z) Obtain explicit approval for the draft before + implementation. +- [x] (2026-08-12 16:35Z) Start Milestone 1 with a clean working tree and + record the v0.8.0 baseline: `make check-fmt`, `make typecheck`, `make lint`, + and `make test` all passed. The non-doctest suite reported 1,917 passing + tests and one skip. +- [x] (2026-08-12 17:08Z) Milestone 1: captured the baseline and added the + red workflow-pin and injected-XDG-discovery tests. The first failed because + the workflow still named v0.8.0; the second returned no injected XDG layer + before `MapEnv` was wired into discovery. +- [x] (2026-08-12 17:08Z) Milestone 2: upgraded both `ortho_config` + requirements and the release-tool workflow pin to v0.9.0, added the required + assertion dependencies, and updated the resolved lockfile. +- [x] (2026-08-12 17:08Z) Milestone 3: made injected automatic discovery + hermetic, retained ambient `ProcessEnv` in production, and added unit, + behavioural, and closed-environment end-to-end coverage for absent, valid, + malformed, and missing-parent candidate outcomes. +- [x] (2026-08-12 17:08Z) Milestone 4: adopted `parse_localized_command` + after the existing parser happy- and unhappy-path suites preserved Netsuke's + configured localized value-parser contract. +- [x] (2026-08-12 17:08Z) CodeRabbit reviewed Milestones 1–4 at `eba40a18` + after browser OAuth authentication completed. `coderabbit review --agent` + reported zero high, medium, or low findings. +- [x] (2026-08-12 17:08Z) Milestone 5: installed `cargo-orthohelp` v0.9.0, + changed the release helper to invoke its required `orthohelp` subcommand, and + generated the expected Unix and Windows artefact layouts. The compact + metadata snapshot and workflow/release-helper contracts are green. +- [x] (2026-08-12 18:31Z) Milestone 6: updated the design, explicit-selector + ADR, developer conventions, user guide, and decision-record index. The + documentation records the v0.9.0 discovery adapter, failure distinction, + release-helper invocation, and deliberate metadata follow-up boundary. +- [x] (2026-08-12 18:42Z) Milestone 7: reran the complete deterministic gate + suite and reviewed the documentation diff with CodeRabbit. All checks and + reviews are green; the outcomes and deferred metadata follow-up are recorded + below. +- [x] (2026-08-14) Verified the follow-up review findings against the rebased + tree. One stale documentation signature was corrected in + `docs/developers-guide.md`, changing + `project_scope_file_str(...) -> Option` to + `project_scope_file(...) -> Option`; all other findings were already + fixed. +- [x] (2026-08-14) Replayed the initial rebase's `Cargo.lock` conflict by + taking the `origin/main` baseline, then applying the narrow + `cargo update -p ortho_config --precise 0.9.0` update. This avoided the + unrelated churn from `cargo generate-lockfile`; the resulting change was + committed as `642bac70`. +- [x] (2026-08-14) Rebased cleanly again onto `origin/main` at `69286cdf`. + Mainline's fixture no-clobber changes had no pertinent overlap with this + branch, so no additional plan or implementation change was required. The + final branch head is `c12ce72a`. +- [x] (2026-08-14) Ran the exact post-rebase gates: `make check-fmt`, + `make test` (1,992 non-doctests and all doctests), `make typecheck`, and + `make lint`; all passed. Published PR #558 at the final branch head. +- [x] (2026-08-15) Addressed the post-turn Whitaker + `no_std_fs_operations` finding: `build.rs` and + `build_l10n_audit::read_source` now use capability-scoped directory reads, + while discovery retains only the dedicated path-normalization module + exclusion at `netsuke::cli::discovery::paths`, needed for OrthoConfig- + compatible absolute comparison keys and cross-directory symlinks. The + focused `normalized_path_key_follows_cross_directory_symlinks` test proves + that compatibility. The copied build-script path exception and the broad + discovery-module and build-script crate exclusions were later rejected as + unnecessary; the final gate evidence is recorded below. +- [x] (2026-08-15) Completed the post-remediation gates. The initial + `make lint` run found only three Clippy `doc_markdown` backtick omissions in + `discovery_paths` Rustdoc; after correction, `make check-fmt`, `make test` + (1,993 nextest tests, one skipped; 100 doctests passed, 28 ignored), + `make typecheck`, `make lint` (docs, Clippy, and Whitaker), + `make markdownlint` (81 files, 0 errors), `make nixie`, and + `git diff --check` all passed. +- [x] (2026-08-16) Addressed the follow-up review findings. Man-page + replacement now delegates directly to `Dir::rename`, avoiding a + pre-delete gap so a failed replacement preserves the existing page. The + then-current `dylint.toml` comment accurately described the module-scoped + `build_script_build::cli::discovery::paths` exception rather than claiming + that the whole `build_script_build` crate was excluded; that provisional + exception was superseded when the build script stopped compiling discovery. +- [x] (2026-08-16) Rebased the branch onto `origin/main` at `6b6e9e64` with a + clean Weave replay. The replay incorporated mainline's command-list public + API, `Recipe::Command { command: StringOrList }`, and its direct-rustc + external fixture at `tests/ui/command_list_public_api_pass.rs`, wired by + `tests/command_env_ui_tests.rs`. +- [x] (2026-08-16) Rechecked the compile-time API review warning against the + rebased tree. The upstream direct-rustc fixture now covers the intended + public `Recipe::Command` and `StringOrList` contract, so no duplicate local + fixture was added. +- [x] (2026-08-16) Re-ran the post-rebase Whitaker check and found that the + build script compiled runtime discovery despite not needing it for man-page + generation. Replaced its full CLI import with `cli::build_support`, the + parser/configuration subset that provides `Cli::command()` but omits + discovery. The initial rerun also reported 11 existing excluded + `std::fs` sites because the staged suite came from stale checkout `692f654`, + which predates `excluded_paths`. Installed the CI-pinned + `whitaker-installer` 0.2.7, switched its source checkout to updated main + `6cb2f483`, and restaged the prebuilt lint suite. This repairs the validation + environment rather than expanding source-policy exceptions. After + restaging, `make check-fmt`, `make test` (2,063 passed, one skipped; + doctests passed), `make typecheck`, `make lint`, `make markdownlint` + (34 tests, 0 errors), `make nixie`, and `git diff --check` all passed. + +## Surprises & discoveries + +- Observation: Netsuke does not call `ConfigDiscovery::load_first`; it calls + `compose_layers` so each inherited file remains a separate merge layer. + Evidence: `src/cli/discovery_layers.rs` builds a discovery scanner and reads + `DiscoveryLayersOutcome::{value,required_errors,optional_errors}`. Impact: + the v0.9.0 `load_first` change is not a source-level break, but the same + absent-versus-failed outcome must be pinned around `compose_layers`. + +- Observation: injected `ConfigEnvProvider` values currently select explicit + files and feed the `NETSUKE_*` merge layer, but automatic `ConfigDiscovery` + still uses its process-backed default. Evidence: + `collect_file_layers_with_env` calls `collect_file_layers`, whose + `config_discovery` builder does not call `env_source`. Impact: v0.9.0's + `MapEnv` can close a real hermeticity gap without replacing Netsuke's port. + +- Observation: Netsuke needs a configured clap command before parsing because + localized enum validators are installed by `configure_validation_parsers`. + Evidence: `parse_with_localizer_from` localizes `Cli::command`, configures + the parsers, parses matches, and localizes both failure paths. Impact: use + `parse_localized_command`, not `LocalizedParse`, and preserve the + preprocessing sequence. + +- Observation: the OrthoConfig `yaml` feature is not enabled. Netsuke's direct + `serde-saphyr` dependency parses build manifests, not Netsuke configuration. + Evidence: both `ortho_config` requirements enable only `serde_json`, while + `serde-saphyr` is a separate normal dependency. Impact: YAML 1.2 Boolean and + duplicate-key migration work is out of scope for configuration, and manifest + parsing must not be changed accidentally. + +- Observation: the release workflow and its Rust contract tests hard-code + `cargo-orthohelp` v0.8.0 independently from Cargo's runtime and build + dependencies. Evidence: `.github/workflows/build-and-package.yml` and + `tests/workflow_build_and_package.rs` both assert v0.8.0. Impact: the tool + pin and its tests are a required part of the atomic version migration. + +- Observation: the accepted explicit-selection ADR still describes + `env_config_path` as reading `std::env::var_os`, although the current code + has already replaced that ambient read with `ConfigEnvProvider` injection. + Evidence: `docs/adr-004-explicit-config-selection-outside-orthoconfig.md` + names the old implementation, while `src/cli/discovery.rs` takes + `&impl EnvProvider`. Impact: update the existing ADR's implementation + consequences during the documentation milestone; do not create a competing + ADR for the v0.9.0 adapter refinement. + +- Observation: neither `googletest` nor `pretty_assertions` is currently a + dependency. Evidence: no match exists in `Cargo.toml` or `Cargo.lock`; + crates.io reports current compatible releases 0.14.3 and 1.4.1 respectively. + Impact: add them as test-only caret requirements and document their distinct + use rather than rewriting unrelated tests. + +- Observation: `CliConfig::get_doc_metadata()` exposes field sources and + precedence but not merge strategy. Evidence: OrthoConfig v0.9.0's + `FieldMetadata` has CLI, environment, and file metadata but no merge-policy + member. Impact: the new compact snapshot derives Netsuke's four append fields + from the configuration policy and marks the remaining fields as replace, + avoiding a full upstream-structure snapshot. + +- Observation: the initial adapter hand-off created six arguments in + `push_file_layers_with_sources`, which Clippy rejects. Evidence: the first + full lint reported `clippy::too_many_arguments`. Impact: `DiscoverySources` + now groups the Netsuke `EnvProvider` port with the narrow OrthoConfig + discovery adapter, documenting a real composition boundary instead of + suppressing the lint. + +- Observation: Whitaker rejects direct `std::fs` operations in the new E2E + target. Evidence: `make lint` reported `no_std_fs_operations` for three + fixture writes. Impact: the test uses the established `test_support::fs` + fixture boundary and leaves production capability policy intact. + +- Observation: the initial CodeRabbit Milestones 1–4 review required browser + OAuth authentication. Evidence: `coderabbit review --agent` initially emitted + `awaiting_browser_auth`, and the rerun completed after authentication with + zero findings. Impact: this was an external authentication prerequisite, not + a code concern or rate limit; every completed milestone has a clean + CodeRabbit review. + +- Observation: real v0.9.0 release-help generation requires + `cargo-orthohelp orthohelp`, whereas the previously pinned command accepted + generator options directly. Evidence: direct invocation rejected `--format` + and instructed callers to use the subcommand; both Unix and Windows runs + succeeded after the helper added it. Impact: the script and a red-green + helper-contract assertion now pin the new invocation shape. + +- Observation: generated help contains parser-only schema omissions rather + than a v0.9.0 output-path failure. Evidence: the generated man page has no + `--config` flag or subcommands and renders missing Fluent IDs because + `CliConfig` has no parser-only selector field or `OrthoConfigSubcommandDocs` + metadata. Impact: retain the generated artefacts and record configuration and + parser metadata convergence as follow-up work; do not expand this version + migration beyond its approved boundary. + +- Observation: the follow-up review had one still-valid documentation finding; + the implementation findings were already fixed. Evidence: + `docs/developers-guide.md` described `project_scope_file_str(...)` returning + `Option`, while the current API is + `project_scope_file(...) -> Option`. Impact: correct that stale + signature only; no runtime change was required. + +- Observation: the initial rebase exposed a `Cargo.lock` conflict, and a + whole-lockfile regeneration introduced unrelated dependency churn. Evidence: + the conflict was resolved with the mainline lockfile as the baseline, then a + precise `cargo update -p ortho_config --precise 0.9.0` restored the intended + package update. Impact: preserve the mainline lockfile and avoid + `cargo generate-lockfile` for this narrow migration. + +- Observation: a later mainline change did not overlap this migration's + relevant files. Evidence: the clean rebase onto `origin/main` at `69286cdf` + showed no pertinent overlap with the fixture no-clobber changes. Impact: no + additional conflict resolution or plan amendment was needed. + +- Observation: the post-turn Whitaker lint initially found valid ambient + filesystem I/O in the build script, the localization audit's `read_source`, + and the discovery path normalizer. Evidence: the `no_std_fs_operations` + report identified those operations after the earlier migration gates. Impact: + build-script and audit reads now open directory capabilities with `cap_std`, + and the library path normalizer remains a dedicated module-scoped exclusion. + The build script now omits discovery through `cli::build_support`, so no + copied build-script exception is needed. + +- Observation (superseded): capability-scoped canonicalization cannot replace + the discovery normalizer completely. Evidence: the focused Unix test + `normalized_path_key_follows_cross_directory_symlinks` requires an alias in + one directory to resolve to its target in another, which `cap_std` rejects + when the symlink leaves the capability root. The earlier proposed copied + build-script exception was unnecessary once man-page generation stopped + compiling discovery. The active impact is limited to retaining absolute + canonical comparison keys and this cross-directory symlink behaviour through + `std::fs::canonicalize` in the library's dedicated path-normalization module. + +- Observation: the first post-remediation gate set found only documentation + lint omissions. Evidence: `make check-fmt`, `make test` (1,993 nextest tests + and doctests), `make typecheck`, `make markdownlint`, `make nixie`, and + `git diff --check` passed, while `make lint` reported three Clippy + `doc_markdown` backtick omissions in `discovery_paths` Rustdoc. Impact: the + three omissions were corrected, and the final lint rerun passed with the + documentation, Clippy, and Whitaker checks green. + +- Observation (superseded): Whitaker `excluded_paths` matches module + boundaries rather than + arbitrary nested implementation scopes. Evidence: the attempted inner + `ambient` submodule exclusion did not match the reported path, while the + dedicated library `discovery::paths` module did, but the equivalent copied + build-script path still reported. Impact: retain the library module + exclusion. The copied build-script path was removed from the build graph by + the parser-only composition root, so neither a module nor crate exception is + active; the post-rebase full gate remains pending. + +- Observation: the man-page replacement path must not remove the existing page + before installing the temporary output. Evidence: the review identified the + `metadata`/`remove_file` pair immediately before `Dir::rename` in + `build.rs`. Impact: direct `Dir::rename` leaves the current page in place if + replacement fails, preserving the atomic replacement boundary. + +- Observation (superseded): the `dylint.toml` explanatory comment had drifted + from the configured scope. Evidence: `build_script_build` is not present in + `excluded_crates`; the then-configured + `build_script_build::cli::discovery::paths` entry was excluded by module + path. Impact: revise the comment to describe the actual narrow exception and + avoid implying a broader capability-policy bypass. The entry was later + removed when the build script stopped compiling discovery. + +- Observation: the compile-time API review warning was resolved by an + upstream fixture incorporated during the 2026-08-16 rebase. Evidence: + `tests/ui/command_list_public_api_pass.rs` exercises the public + `Recipe::Command { command: StringOrList }` contract through direct rustc, + and `tests/command_env_ui_tests.rs` wires it into the UI test harness. + Impact: retain the upstream fixture as the single contract test and avoid + duplicate coverage in this branch. + +## Decision Log + +- Decision: preserve the current feature-based module layout and apply + hexagonal dependency direction within it. Rationale: the migration changes + infrastructure integrations, not Netsuke's bounded context. A + directory-pattern transplant would create churn without protecting an + additional boundary. Date/Author: 2026-08-12 / Codex. + +- Decision: retain `ConfigEnvProvider` as Netsuke's environment port and adapt + injected values to OrthoConfig's `MapEnv` at the discovery boundary. + Rationale: domain-facing code should not depend on an upstream discovery + trait, while the adapter can use v0.9.0's hermetic source without process + mutation. Date/Author: 2026-08-12 / Codex. + +- Decision: retain manual two-pass discovery rather than adopting the new + `#[ortho_config(discovery(...))]` attribute. Rationale: Netsuke adds a + `--directory`-anchored project pass, de-duplicates canonicalized paths, + preserves inherited append layers, and deliberately bypasses discovery for + explicit selectors. The derive attribute does not express that entire + application policy. Date/Author: 2026-08-12 / Codex. + +- Decision: treat ADR 004 as controlling the explicit-selection boundary and + update its stale environment-access detail during migration. Rationale: the + accepted ownership decision remains correct, but leaving its + direct-process-read description unchanged would contradict the current + injected port and the v0.9.0 discovery adapter. Date/Author: 2026-08-12 / + Codex. + +- Decision: plan adoption of `parse_localized_command`, but make it a go/no-go + milestone rather than an unconditional rewrite. Rationale: it removes v0.8.0 + glue and guarantees the whole parse path is localized, while focused tests + must first prove compatibility with Netsuke's customized value parsers. + Date/Author: 2026-08-12 / Codex. + +- Decision: do not derive `OrthoConfigSubcommandDocs` during this migration. + Rationale: Netsuke's parser-facing `Commands` enum and configuration-facing + `CliConfig` are intentionally separate. Making every subcommand an + OrthoConfig schema is a larger public metadata design decision, not a version + compatibility fix. Record it as follow-up work if the v0.9.0 metadata smoke + demonstrates an actual release-help gap. Date/Author: 2026-08-12 / Codex. + +- Decision: do not enable OrthoConfig metrics or agent context. + Rationale: both are optional v0.9.0 capabilities and this migration has no + new recorder, `context --json` contract, or consumer. Generic capability + adoption belongs in separately approved product work. Date/Author: 2026-08-12 + / Codex. + +- Decision: use existing Proptest selector coverage and do not add Kani or + Verus work. Rationale: the migration introduces no new mathematical business + rule, state machine, unsafe boundary, or lemma. The relevant range invariant + is already expressed by `resolve_config_path_obeys_precedence_invariant`; + example, behavioural, and end-to-end tests are the proportionate tools for + adapter compatibility. Date/Author: 2026-08-12 / Codex. + +- Decision: record migration choices in `docs/netsuke-design.md`, not a new + ADR, unless implementation crosses an exception threshold. Rationale: version + alignment, a private environment adapter, and use of a new upstream helper + refine the existing configuration architecture without establishing a + hard-to-reverse system-wide direction. Date/Author: 2026-08-12 / Codex. + +- Decision: make `DiscoverySources` a crate-private composition input owned by + `src/cli/discovery.rs`. Rationale: only CLI merge and early diagnostic + resolution may pair a Netsuke environment port with either `ProcessEnv` or a + fixed-key `MapEnv`; no other module may use it as a general environment + copying utility. Date/Author: 2026-08-12 / Codex. + +- Decision: snapshot an application-owned metadata projection rather than + OrthoConfig's complete IR. Rationale: Netsuke must pin field order, sources, + its append/replace policy, precedence, discovery declaration, and subcommand + count, but upstream headings and prose fields are not an application + contract. Date/Author: 2026-08-12 / Codex. + +- Decision: adopt `cargo-orthohelp orthohelp` in the release helper. + Rationale: it is the v0.9.0 executable's documented direct invocation form, + preserves Cargo's `cargo orthohelp` user form, and is verified by both a + helper contract and real Unix/Windows generation. Date/Author: 2026-08-12 / + Codex. + +- Decision: defer parser/config documentation metadata convergence. Rationale: + exposing `--config`, subcommands, and localized prose in generated help + requires merging the intentionally separate parser and configuration schema, + which is a larger design decision than a compatible library upgrade. + Date/Author: 2026-08-12 / Codex. + +- Decision: fix only the stale `project_scope_file` documentation signature and + retain the other reviewed changes as-is. Rationale: the signature mismatch + was still present in the current tree, while the remaining review findings + were already resolved; a broader edit would add unsupported churn. + Date/Author: 2026-08-14 / Codex. + +- Decision: resolve the initial lockfile rebase conflict from the mainline + baseline, then apply a precise OrthoConfig update. Rationale: this preserves + dependencies introduced on `origin/main` and limits the migration to the + required package versions; `cargo generate-lockfile` needlessly refreshed + unrelated packages. Date/Author: 2026-08-14 / Codex. + +- Decision: accept the clean rebase onto `origin/main` at `69286cdf` without + changes for the fixture no-clobber work. Rationale: inspection found no + pertinent overlap with the migration's discovery or release-help changes. + Date/Author: 2026-08-14 / Codex. + +- Decision: publish PR #558 at `c12ce72a` after the exact post-rebase gates + passed. Rationale: `make check-fmt`, `make test`, `make typecheck`, and + `make lint` all passed, including 1,992 non-doctest tests and the doctests. + Date/Author: 2026-08-14 / Codex. + +- Decision: replace the build script's and localization audit's ambient file + reads with `cap_std` directory capabilities. Rationale: those reads have + stable parent-directory boundaries and do not require the ambient authority + that the post-turn Whitaker finding exposed. Date/Author: 2026-08-15 / Codex. + +- Decision: retain a narrowly scoped Whitaker exclusion for the library + path-normalization module at `netsuke::cli::discovery::paths`. Rationale: + `std::fs::canonicalize` preserves the absolute comparison keys and + cross-directory symlink behaviour required to match OrthoConfig, while + `cap_std` rejects that symlink case. The focused existing path test is the + compatibility evidence; no Rust `#[expect]` is used for this Dylint lint. + Date/Author: 2026-08-15 / Codex. + +- Decision: record the final post-remediation gate result in this plan. + Rationale: the focused compatibility evidence and the final gate set both + passed after correcting three `doc_markdown` findings. The earlier + post-rebase gate results remain historical evidence, while this later run is + the final all-green result for the remediation. Date/Author: 2026-08-15 / + Codex. + +- Decision: replace an existing man page with a direct `Dir::rename` from the + temporary output, without a metadata check or pre-emptive removal. + Rationale: the capability API can perform the replacement while preserving + the current page when the rename fails, avoiding a window with no page. + Date/Author: 2026-08-16 / Codex. + +- Decision (superseded): describe the build-script filesystem exception in + `dylint.toml` as module-scoped. Rationale: the configured entry was + `build_script_build::cli::discovery::paths`. Date/Author: 2026-08-16 / Codex. + +- Decision (superseded): use a crate-level Whitaker exception for + `build_script_build` while + compiling the CLI for man-page generation. Rationale: the copied path + normalizer must preserve OrthoConfig-compatible cross-directory symlink + canonicalization, and Whitaker still reports it despite the exact module + entry. Keep the library normalizer module-scoped; rerun the gates before + treating this decision as validated. Date/Author: 2026-08-16 / Codex. + +- Decision: compile `cli::build_support` rather than the full CLI module in the + man-page build script. Rationale: `Cli::command()` needs parser, + configuration, and validation definitions but not runtime discovery. The + narrower composition root removes the copied canonicalization boundary, so + the build script needs no Whitaker exception. Date/Author: 2026-08-16 / Codex. + +- Decision: use the rebased upstream direct-rustc fixture as the compile-time + contract for the command-list public API. Rationale: mainline now provides + `tests/ui/command_list_public_api_pass.rs`, wired through + `tests/command_env_ui_tests.rs`, and it directly checks + `Recipe::Command { command: StringOrList }` without adding duplicate local + coverage. Date/Author: 2026-08-16 / Codex. + +## Outcomes & retrospective + +The migration is complete. Runtime and build-time dependencies now resolve +`ortho_config` v0.9.0, and release automation installs `cargo-orthohelp` +v0.9.0. The release helper invokes its required `orthohelp` subcommand and a +real Unix and Windows generation smoke produced the expected man page and +PowerShell help layouts. + +The existing `ConfigEnvProvider` remains Netsuke's port. Production composes +OrthoConfig `ProcessEnv`; injected paths project only the documented discovery +keys into `MapEnv`. This protects the discovery adapter boundary without +changing the user-facing configuration precedence. Unit, behavioural, and +closed-environment end-to-end tests now cover absent, valid, malformed, and +missing-parent discovery outcomes. The parser uses v0.9.0's combined localized +entry point after Netsuke applies its validation parser configuration. + +The new compact metadata snapshot tests Netsuke's stable configuration +projection, while `googletest` and `pretty_assertions` make new diagnostic and +collection assertions clear. Existing Proptest coverage remains sufficient for +the selector precedence invariant; no new Kani or Verus obligation was +introduced. The user guide, design, ADR 004, developer guidance, and document +index now describe the resulting behaviour and ownership conventions. + +`make check-fmt`, `make typecheck`, `make lint`, `make test`, +`make markdownlint`, and `make nixie` passed at the 2026-08-14 final milestone, +before the post-turn capability remediation. CodeRabbit reported zero findings +for Milestones 1–4, 5, and 6. The only deferred item is configuration and +parser metadata convergence: generated release help still cannot represent +parser-only `--config` and subcommand metadata. That is a separate public +metadata design decision, not a v0.9.0 compatibility defect. + +The follow-up review found one stale documentation signature, which was +corrected in `docs/developers-guide.md`; all other findings were already fixed. +The initial rebase's lockfile conflict was resolved by retaining the mainline +baseline and applying a narrow OrthoConfig update, rather than regenerating the +whole lockfile. A subsequent clean rebase onto `origin/main` at `69286cdf` +confirmed no pertinent overlap with the fixture no-clobber changes. The exact +post-rebase gates (`make check-fmt`, `make test`, `make typecheck`, and +`make lint`) passed, with 1,992 non-doctest tests and all doctests succeeding. +PR #558 was published at final head `c12ce72a`. + +The post-turn Whitaker remediation is recorded above: build-script and +localization-audit reads use `cap_std` directory capabilities, and the library +discovery normalizer retains its focused `discovery::paths` module exclusion. +The unsupported inner `ambient` scope was replaced after Whitaker path-match +evidence. The post-rebase run revealed that man-page generation compiled that +normalizer unnecessarily, so the build script now uses the parser-only +`cli::build_support` composition root. A later lint rerun exposed a stale +staged Whitaker checkout rather than a source-policy gap: checkout `692f654` +did not contain `excluded_paths`. The CI-pinned installer 0.2.7 was installed, +its source was switched to updated main `6cb2f483`, and the prebuilt suite was +restaged. Full validation remains pending. + +The initial Whitaker run reported the 11 existing exclusions from stale +checkout `692f654`; the restaged Whitaker run passed without any source-policy +expansion. The final gate set passed: `make check-fmt`, `make test` (2,063 +passed, one skipped; doctests passed), `make typecheck`, `make lint`, +`make markdownlint` (34 tests, 0 errors), `make nixie`, and +`git diff --check`. + +The pre-rebase post-remediation lint run found only three Clippy `doc_markdown` +backtick omissions in `discovery_paths` Rustdoc. After those corrections, the +pre-rebase gates passed: `make check-fmt`; `make test` with 1,993 nextest +tests, one skipped test, and 100 doctests passed with 28 ignored; +`make typecheck`; +`make lint` including docs, Clippy, and Whitaker; `make markdownlint` over 81 +files with 0 errors; `make nixie`; and `git diff --check`. + +Those results are historical evidence only after the 2026-08-16 rebase. The +post-rebase Whitaker rerun found that the build script imported the unused +discovery normalizer; the parser-only composition root now avoids it. After +restaging the CI-pinned Whitaker suite, the final gate results recorded above +are green. + +The 2026-08-16 rebase onto `origin/main` at `6b6e9e64` replayed cleanly with +Weave and incorporated the upstream command-list public API and its direct- +rustc compile-time fixture. That fixture, wired through +`tests/command_env_ui_tests.rs`, resolves the review warning about coverage +for `Recipe::Command { command: StringOrList }` without introducing a second +fixture in this branch. + +## Context and orientation + +The repository is a Rust workspace whose package is `netsuke-build`, while its +library and binary targets are both named `netsuke`. Run every command in this +plan from the repository root: + +```plaintext +/data/leynos/Projects/netsuke.worktrees/adopt-ortho-config-v0-9-0 +``` + +The relevant production flow is: + +```plaintext +src/main.rs + -> src/cli/parser.rs parses a localized Cli and retains clap ArgMatches + -> src/cli/discovery.rs selects explicit or automatic file discovery + -> src/cli/discovery_layers.rs adapts ConfigDiscovery into MergeLayer values + -> src/cli/environment.rs adapts NETSUKE_* entries into a Figment provider + -> src/cli/merge.rs composes defaults, files, environment, and CLI values + -> src/cli/config.rs validates the CliConfig policy model + -> src/cli/merge.rs maps the policy model back to the runtime Cli +``` + +In hexagonal terms, `CliConfig`, the policy enums, selector precedence, and +post-merge validation are inward-facing policy. `ConfigEnvProvider` is a driven +port: it describes the environment capability Netsuke needs. `StdEnvProvider`, +OrthoConfig `ProcessEnv` and `MapEnv`, Figment providers, clap parsing, +configuration files, and `cargo-orthohelp` are adapters. `src/main.rs` and the +public merge/parse wrappers are composition roots that select production or +test adapters. + +The dependency and release-tool pins live in: + +- `Cargo.toml`: runtime and build-time `ortho_config = "0.8.0"` requirements; +- `Cargo.lock`: resolved runtime, macro, and transitive packages; +- `.github/workflows/build-and-package.yml`: installs and validates + `cargo-orthohelp = 0.8.0`; +- `tests/workflow_build_and_package.rs`: contract tests for that workflow; and +- `docs/developers-guide.md` and `docs/netsuke-design.md`: documented tool pin. + +The existing test surfaces to extend are: + +- source-adjacent `rstest` modules under `src/cli/` for discovery and parsing; +- `tests/cli_tests/` for configuration selection and merge integration; +- `tests/features/configuration_discovery.feature` and + `tests/bdd/steps/configuration_discovery.rs` for acceptance behaviour; +- a dedicated `assert_cmd` integration target for the real binary's + configuration-failure contract; +- `src/snapshots/` or `tests/snapshots/` for reviewed `insta` output; and +- `tests/workflow_build_and_package.rs` plus + `tests/release_help_script_tests.rs` for release tooling. + +Before editing, read and use these repository sources: + +- `docs/contents.md` and `docs/repository-layout.md` for ownership; +- `docs/ortho-config-v0-9-0-migration-guide.md` for required and recommended + version work; +- `docs/ortho-config-users-guide.md` for v0.9.0 APIs and precedence; +- `docs/netsuke-design.md`, especially section 8.4 and configuration discovery; +- `docs/adr-004-explicit-config-selection-outside-orthoconfig.md` for the + accepted ownership of explicit selector policy; +- `docs/developers-guide.md`, especially quality gates, test suite map, + environment seams, configuration merge architecture, and release help; +- `docs/users-guide.md`, especially "Configure Netsuke"; +- `docs/rust-testing-with-rstest-fixtures.md` for fixtures and parameterized + cases; +- `docs/rstest-bdd-users-guide.md` for feature binding and step isolation; +- `docs/rust-doctest-dry-guide.md` for public examples and doctest selection; +- `docs/reliable-testing-in-rust-via-dependency-injection.md` for injected + environment adapters; and +- `docs/documentation-style-guide.md` for design and ADR decisions. + +The implementing agent must use and re-read these skills before acting: + +- `$execplans` at `/home/leynos/.codex/skills/execplans/SKILL.md` governs the + living sections, approval gate, Red-Green-Refactor evidence, and revision + notes. +- `$hexagonal-architecture` at + `/home/leynos/.codex/skills/hexagonal-architecture/SKILL.md` governs inward + dependencies, port ownership, adapter isolation, and layer-specific tests. +- `$leta` at `/home/leynos/.codex/skills/leta/SKILL.md` governs semantic Rust + navigation; use `leta show`, `leta refs`, and `leta calls` before text + searches for known symbols. +- `$rust-router` at `/home/leynos/.codex/skills/rust-router/SKILL.md` must route + implementation questions to the smallest applicable Rust skill. +- `$rust-unit-testing` at + `/home/leynos/.codex/skills/rust-unit-testing/SKILL.md` applies when adding + the `rstest` coverage. +- `$commit-message` at + `/home/leynos/.codex/skills/commit-message/SKILL.md` applies only when the + user authorizes commits. + +## Plan of work + +### Milestone 1: capture the baseline and specify v0.9.0 compatibility + +First update `Progress` with the start timestamp, confirm the branch and clean +worktree, and run the four required baseline gates. Save concise transcripts in +`Artefacts and notes`; do not change dependencies until their v0.8.0 results +are known. + +Add the smallest red tests for the behaviours that v0.9.0 can affect: + +1. In a cohesive source-adjacent discovery test module, add `#[rstest]` cases + for no candidate, a valid candidate, a malformed sole candidate, and a + missing `extends` parent. Use an injected environment and temporary + capability-scoped directories. The malformed and missing-parent cases must + assert an error rather than an empty layer list. Use `googletest` matchers + to inspect variants and diagnostic fragments. +2. Add a parameterized test proving that an injected `HOME`, + `XDG_CONFIG_HOME`, or `APPDATA` determines the candidates without consulting + the host. Compare candidate or layer vectors with `pretty_assertions`. +3. Add focused parser cases showing successful custom policy parsing and a + localized invalid policy error. These tests pin the contract before adopting + `parse_localized_command`. +4. Add `tests/ortho_config_metadata_snapshot_tests.rs` with an + `insta::assert_yaml_snapshot!` of a compact, application-owned projection of + `CliConfig::get_doc_metadata()`: schema/IR version if exposed, ordered field + names, sources, merge strategies, discovery metadata, and subcommand count. + Do not snapshot debug output or the entire upstream structure. +5. Add or extend workflow contract cases so v0.8.0 is demonstrably the old pin + and changing the workflow without its test fails. + +Run each focused test before production edits and record the expected red +failure. Tests that merely characterize already-correct v0.8.0 behaviour may +pass; label them characterization evidence rather than claiming a false red. At +least the dependency/tool pin assertion and the injected automatic-discovery +test must fail before their implementation. + +End the milestone by running: + +```bash +make check-fmt +make typecheck +make lint +make test +``` + +The full suite may be red only for the deliberately introduced migration tests. +Record each expected failure by exact test name and reason. Unexpected failures +block Milestone 2. + +### Milestone 2: align dependencies and restore compilation + +In `Cargo.toml`, update both runtime and build-time requirements to: + +```toml +ortho_config = { version = "0.9.0", features = ["serde_json"] } +``` + +Add the requested assertion libraries under `[dev-dependencies]`: + +```toml +googletest = "0.14.3" +pretty_assertions = "1.4.1" +``` + +Do not add `ortho_config_macros`; v0.9.0 continues to re-export the derive +macros. Keep the existing direct dependencies that source imports. Update the +lockfile narrowly with: + +```bash +cargo update -p ortho_config --precise 0.9.0 +cargo update -p ortho_config_macros --precise 0.9.0 +``` + +If Cargo updates unrelated packages beyond what the new resolution requires, +inspect `git diff -- Cargo.lock` and regenerate with the narrowest supported +command; do not hand-edit the lockfile. + +In `.github/workflows/build-and-package.yml`, change both the installation and +version validation to `cargo-orthohelp` v0.9.0. Update the corresponding +`rstest` assertions in `tests/workflow_build_and_package.rs` so a mixed 0.8/0.9 +toolchain fails clearly. Do not yet change help paths or generated-content +expectations. + +Compile first, then fix only verified v0.9.0 API changes. Run: + +```bash +cargo check --workspace --all-targets --all-features +cargo test --test workflow_build_and_package +make check-fmt +make typecheck +make lint +make test +``` + +All commands must pass before Milestone 3. If a v0.9.0 compile error suggests a +public API redesign, trigger the interface tolerance instead of hiding it with +a clone, wrapper exposed outside `cli`, or lint suppression. + +### Milestone 3: make discovery hermetic and pin failure behaviour + +Before adding a helper, use semantic and text searches to confirm there is no +existing adapter from `ConfigEnvProvider` to OrthoConfig `EnvSource` or +`MapEnv`. Record the sweep in `Surprises & Discoveries`. + +Refactor only the CLI discovery composition: + +1. Let the internal discovery-layer functions accept an OrthoConfig + `SharedEnvSource` and pass it to `ConfigDiscoveryBuilder::env_source`. +2. Keep the ambient public wrappers (`merge_with_config` and + `resolve_merged_json`) bound to OrthoConfig `ProcessEnv`, preserving the + production home-directory fallback. +3. Have the injected wrappers (`merge_with_config_and_env` and + `resolve_merged_json_with_env`) build a closed `MapEnv` from only the seven + documented discovery keys listed in `Risks`, using the same + `ConfigEnvProvider` that supplies selector and `NETSUKE_*` values. +4. Keep complete environment enumeration confined to `EnvironmentLayer`; do + not add enumeration to OrthoConfig's name-only `EnvSource` capability. +5. Keep explicit selector policy in `resolve_config_selector`. Automatic + discovery must not become responsible for deciding whether `--config` or + `NETSUKE_CONFIG` wins. + +This helper is owned by `src/cli/discovery.rs` or a colocated discovery adapter +module, may be called only by CLI configuration composition, and may contain +only the fixed discovery-key projection. It is not a general environment-copy +utility. Document those ownership, call-site, and composition rules in +`docs/developers-guide.md` in Milestone 6. + +Turn the discovery red tests green, then add the user-visible behavioural and +system coverage. Extend `tests/features/configuration_discovery.feature` with +this synchronized specification: + +```gherkin +Feature: Configuration file discovery and precedence + Netsuke reports broken discovered configuration and keeps absent + configuration distinct from failed configuration. + + Scenario: No configuration file uses built-in defaults + Given a temporary workspace + And no Netsuke configuration file exists + When the CLI is parsed with no additional arguments + Then parsing succeeds + And the default configuration is used + + Scenario: A malformed discovered configuration is an error + Given a temporary workspace + And a malformed project config file ".netsuke.toml" + When the CLI is parsed with no additional arguments + Then an error should be returned + And the merge error identifies the configuration load failure +``` + +Use existing world fixtures and steps where possible. New `Given` steps may +write only inside the scenario workspace; the `When` step must exercise the +same public parse-and-merge path as existing discovery scenarios. Keep the +`Then` wording user-observable and avoid asserting internal adapter names. + +Add a dedicated `assert_cmd` end-to-end test target if no current target drives +this exact binary path. It must run `netsuke` with `env_clear`, an explicit +child environment, a temporary workspace, and a malformed automatically +discovered `.netsuke.toml`; assert non-zero exit and the stable application +diagnostic category, not the whole upstream error sentence. Pair it with a +happy-path run that has no configuration and reaches normal manifest handling. + +Run focused then full validation: + +```bash +cargo nextest run --lib 'cli::discovery' +cargo nextest run --test cli_tests +cargo nextest run --test bdd_tests +cargo nextest run --test config_discovery_e2e_tests +make check-fmt +make typecheck +make lint +make test +``` + +Use the actual integration-test target name if the test is colocated in an +existing target. Update this plan with that name. All commands must pass before +Milestone 4. + +### Milestone 4: consolidate the localized parse adapter + +With the parser compatibility tests already green on v0.9.0, replace the manual +match parsing and error relocalization in `parse_with_localizer_from` with +`ortho_config::parse_localized_command`. Build and localize `Cli::command`, run +`configure_validation_parsers`, then pass that configured command, the argument +iterator, and the same localizer to the combined helper. Preserve the +`(Cli, ArgMatches)` return type and every public caller. + +Run the focused happy and unhappy tests after the smallest edit. Then run the +existing localized help snapshots and BDD invalid-policy scenarios. If any +unexpected snapshot changes, stop under the localization tolerance; do not +accept them automatically. + +```bash +cargo nextest run --lib 'cli::parser' +cargo nextest run --test cli_tests +cargo nextest run --test bdd_tests +cargo nextest run --test novice_flow_smoke_tests +make check-fmt +make typecheck +make lint +make test +``` + +If compatibility cannot be proved, retain the current manual parser code, +record the reason in `Decision Log`, and continue the version migration. The +v0.9.0 combined helper is recommended, not required. + +### Milestone 5: verify metadata and release-help adapters + +Turn the compact metadata snapshot green and review it. It should prove the +application-owned metadata contract without binding Netsuke to every private +field or formatting choice in OrthoConfig. + +Install and validate the exact tool used by continuous integration (CI), then +run the generator for both supported output families: + +```bash +cargo install cargo-orthohelp --version 0.9.0 --locked +cargo-orthohelp --version +scripts/generate-release-help.sh \ + x86_64-unknown-linux-gnu netsuke \ + target/orthohelp/x86_64-unknown-linux-gnu/release Netsuke +scripts/generate-release-help.sh \ + x86_64-pc-windows-msvc netsuke \ + target/orthohelp/x86_64-pc-windows-msvc/release Netsuke +``` + +Expect the reported version to contain `0.9.0`, the Unix run to produce +`man/man1/netsuke.1`, and the Windows run additionally to produce the existing +PowerShell module, manifest, locale help, and about-help paths. Inspect the +generated artefacts for the public flags and subcommands already guaranteed by +the v0.8.0 workflow. Do not commit `target/` output. + +Run: + +```bash +cargo nextest run --test ortho_config_metadata_snapshot_tests +cargo nextest run --test workflow_build_and_package +cargo nextest run --test release_help_script_tests +make check-fmt +make typecheck +make lint +make test +``` + +If v0.9.0 exposes a genuine missing-subcommand documentation problem, record it +as a follow-up design task. Do not fold parser/config-schema convergence into +this migration. + +### Milestone 6: synchronize documentation + +Update documentation only after behaviour and generated artefacts are known: + +- In `docs/netsuke-design.md`, record v0.9.0 as the configuration runtime, + explain the absent-versus-failed discovery contract, preserve the manual + two-pass design, and state why discovery attributes and subcommand docs were + not transplanted. +- In `docs/adr-004-explicit-config-selection-outside-orthoconfig.md`, preserve + the accepted selector-ownership decision while replacing the stale direct + `std::env::var_os` description with the current injected `ConfigEnvProvider` + port and the v0.9.0 automatic-discovery adapter boundary. +- In `docs/developers-guide.md`, update the `cargo-orthohelp` install command, + document the `ProcessEnv`/`MapEnv` composition roots, the fixed-key + projection helper's ownership and permitted callers, and the narrow roles of + `googletest` and `pretty_assertions`. +- In `docs/users-guide.md`, update only user-visible behaviour: malformed or + unreadable discovered candidates are errors when no candidate can load, and + absence still uses defaults. Retain selector precedence and privacy wording. +- In `docs/contents.md`, add the existing explicit-selection ADR to the + decision-record index if it is still missing. No new guide or ADR is planned, + and the existing `docs/execplans/` entry already indexes this plan's + directory. +- Do not rewrite the imported `docs/ortho-config-users-guide.md`; it already + documents v0.9.0 and serves as the upstream reference. + +If no public behaviour changed beyond making the failure distinction explicit, +say that rather than inventing a migration burden. State that Netsukefile YAML +continues to use `serde-saphyr` and is unaffected by OrthoConfig's optional +YAML provider. + +Run: + +```bash +make fmt +make check-fmt +make typecheck +make lint +make test +make markdownlint +make nixie +``` + +Every command must pass before completion. + +### Milestone 7: final review and atomic delivery + +Review `git diff --check`, `git diff --stat`, dependency diffs, new snapshots, +all changed tests, and the surrounding discovery/parser code. Verify no source +file exceeds 400 lines and no in-process environment mutation was introduced. +Use the repository's refactoring heuristics. If a refactor is truly needed, +complete the functional migration first, pass all gates, and perform the +refactor as a separate atomic change with the same gates. + +Update every living section of this plan, including exact test names, gate +transcripts, final file count, decisions, and retrospective. Change status to +`COMPLETE` only when no required work remains. Commit only if the user has +authorized it, using the `$commit-message` skill and intended-file staging. + +## Concrete steps + +From the repository root, establish the baseline: + +```bash +git status --short --branch +make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-ortho-v0-9-0-baseline.out +make typecheck 2>&1 | tee /tmp/typecheck-netsuke-ortho-v0-9-0-baseline.out +make lint 2>&1 | tee /tmp/lint-netsuke-ortho-v0-9-0-baseline.out +make test 2>&1 | tee /tmp/test-netsuke-ortho-v0-9-0-baseline.out +``` + +Expected summary: + +```plaintext +git status: current branch, no unexpected changes +check-fmt: exit 0 +typecheck: exit 0 +lint: exit 0 +test: nextest and doctests exit 0 +``` + +After adding the red tests, run them individually and paste concise failure +evidence into `Artefacts and notes`. The exact filters must be updated after +the test names are chosen. A representative sequence is: + +```bash +cargo nextest run --lib 'cli::discovery::*injected*' +cargo nextest run --lib 'cli::discovery::*malformed*' +cargo nextest run --lib 'cli::parser::*localized*' +cargo nextest run --test workflow_build_and_package +``` + +After the version edit, update only the intended packages and inspect them: + +```bash +cargo update -p ortho_config --precise 0.9.0 +cargo update -p ortho_config_macros --precise 0.9.0 +cargo tree -i ortho_config +cargo tree -i ortho_config_macros +git diff -- Cargo.toml Cargo.lock +``` + +Expected dependency evidence: + +```plaintext +ortho_config v0.9.0 +ortho_config_macros v0.9.0 +no direct ortho_config_macros requirement in Cargo.toml +``` + +After each major milestone, use a milestone-specific suffix and run: + +```bash +make check-fmt +make typecheck +make lint +make test +``` + +After documentation changes and at final completion, run: + +```bash +make fmt +make check-fmt +make typecheck +make lint +make test +make markdownlint +make nixie +git diff --check +git status --short +``` + +Do not treat a missing local tool as a passing gate. Install the repository pin +where documented, or record the exact unavailable gate and stop before commit. + +## Validation and acceptance + +Acceptance is behaviour, not the presence of new types: + +- With no selected or discovered Netsuke configuration, parsing and merging + succeeds with built-in defaults. +- With a valid `.netsuke.toml`, its values load and remain below environment + and explicit CLI values in precedence. +- With only a malformed or unreadable discovered candidate, Netsuke returns a + configuration error; it does not silently use defaults. +- With a missing `extends` parent, the diagnostic identifies the resolved + missing path and referencing file without tests parsing a complete unstable + human sentence. +- `--config` remains above `NETSUKE_CONFIG`, and either explicit selector + bypasses automatic discovery. +- An injected environment controls automatic discovery and value merging; a + deliberately conflicting host home or XDG directory cannot change the test. +- `--color always` and the other customized policy parsers still succeed, while + invalid values still produce localized failures. +- Release workflow tests require `cargo-orthohelp` v0.9.0, and real generation + produces every existing staged Unix and Windows help artefact. +- The compact metadata snapshot is reviewed and stable across its relevant + variants. +- `make check-fmt`, `make typecheck`, `make lint`, and `make test` pass after + each major milestone; `make markdownlint` and `make nixie` pass at the end. + +Record Red-Green-Refactor evidence as follows: + +- Red: name the focused command, failing test, and why its failure proves the + missing v0.9.0 or hermetic behaviour. Do not count dependency compile errors + as behavioural red evidence when a smaller assertion is possible. +- Green: rerun the identical focused command after the smallest dependency or + adapter change and record exit 0. +- Refactor: rerun the focused tests plus the four full gates after cleanup. + No snapshot may remain unreviewed. + +The test-tool choice is deliberate: + +- `rstest` covers finite source/outcome matrices and reusable temporary + fixtures. +- `rstest-bdd` covers the user-observable absent-versus-broken configuration + distinction. +- `assert_cmd` covers the real process boundary, exit status, and streams. +- `googletest` makes typed errors, variants, and nested metadata readable. +- `pretty_assertions` gives useful diffs for ordered layers and projected + metadata. +- `insta` pins compact multivariant metadata and any intentionally changed + localized or release-help output. +- Existing Proptest covers selector precedence over generated optional paths. + No new broad input invariant warrants a second property suite. +- Kani and Verus are not acceptance requirements because this migration adds + neither a bounded state transition nor contractual business lemma. + +## Idempotence and recovery + +All test, format, lint, metadata, and help-generation commands are safe to +rerun. `cargo update --precise` is deterministic relative to the manifest and +registry index. Never hand-edit `Cargo.lock`; if an update is interrupted, +rerun the precise commands and inspect the resulting diff. + +Test fixtures must live in `TempDir` values and clean up on drop. Child-process +tests use `env_clear` and explicit `Command::env`; they do not need process +environment restoration. The `target/orthohelp/` smoke output is disposable and +ignored. Do not delete broad target or workspace directories to recover from a +failed test. + +If a red test fails for the wrong reason, revert only that uncommitted test +edit with a targeted patch, correct the fixture, and rerun it. Do not weaken +the assertion. If a milestone gate fails, leave the working tree intact, record +the exact failure in this plan, and resume from the focused failing command. +Existing user changes are never reset or overwritten. + +## Artefacts and notes + +Retain concise evidence here rather than pasting full logs into the plan: + +```plaintext +Baseline: +- check-fmt: passed at the 2026-08-12 baseline +- typecheck: passed at the 2026-08-12 baseline +- lint: passed at the 2026-08-12 baseline +- test: passed at the 2026-08-12 baseline (1,917 non-doctests and one skip) + +Red evidence: +- injected automatic discovery: red test recorded before the `MapEnv` wiring +- malformed discovered candidate: red test recorded before the discovery fix +- localized combined parse: compatibility characterization recorded before + adopting `parse_localized_command` +- release tool pin: red workflow assertion recorded before the v0.9.0 pin + +Green/refactor evidence: +- focused discovery tests: passed after the hermetic adapter change +- focused parser tests: passed after the combined localized parser change +- BDD and E2E tests: passed for absent, valid, malformed, and missing-parent + discovery outcomes +- metadata and release-help tests: passed, including Unix and Windows smoke + layouts +- prior final gates: passed after the final rebase; `make test` reported 1,992 + non-doctests and all doctests, before the post-turn remediation + +Post-turn Whitaker remediation (pre-rebase evidence): +- `build.rs` and `build_l10n_audit::read_source` use `cap_std` directory + capabilities for their stable parent-directory reads +- the stale broad discovery-module exclusion was removed +- `netsuke::cli::discovery::paths` remains module-scoped, preserving + `std::fs::canonicalize`'s absolute comparison keys and cross-directory + symlink behaviour; the attempted inner `ambient` scope was replaced because + Whitaker matches module boundaries +- `normalized_path_key_follows_cross_directory_symlinks` passed as focused + compatibility evidence +- initial `make lint` failed only on three Clippy `doc_markdown` backtick + omissions in `discovery_paths` Rustdoc; the omissions were corrected +- pre-rebase post-remediation gates: `make check-fmt` passed; `make test` + passed with 1,993 nextest tests, one skipped test, and 100 doctests passed + with 28 ignored; `make typecheck` passed; `make lint` passed including docs, + Clippy, and Whitaker; `make markdownlint` passed over 81 files with 0 errors; and + `make nixie` plus `git diff --check` passed +- these pre-rebase results do not close the current validation: the post-rebase + Whitaker rerun found the build script compiling unused discovery code +- current decision: use `cli::build_support` for man-page generation while the + library normalizer remains module-scoped; the initial rerun's 11 existing + excluded `std::fs` reports were caused by stale staged checkout `692f654`, + not by a source-policy expansion. After installing the CI-pinned + `whitaker-installer` 0.2.7, switching its source to `6cb2f483`, and + restaging, all final gates passed: `make check-fmt`, `make test` (2,063 + passed, one skipped; doctests passed), `make typecheck`, `make lint`, + `make markdownlint` (34 tests, 0 errors), `make nixie`, and + `git diff --check` + +Review/rebase/publication evidence: +- review correction: `project_scope_file(...) -> Option` documented in + place of the stale `project_scope_file_str(...) -> Option` signature; + other findings were already fixed +- initial rebase: mainline `Cargo.lock` baseline plus + `cargo update -p ortho_config --precise 0.9.0`; no + `cargo generate-lockfile`; commit `642bac70` +- subsequent rebase: clean onto `origin/main` `69286cdf`, with no pertinent + overlap with fixture no-clobber changes; final head `c12ce72a` +- publication: PR #558 published after `check-fmt`, `test`, `typecheck`, and + `lint` passed +``` + +Store long command output under `/tmp` with the +`netsuke-ortho-v0-9-0-` naming convention. These logs are diagnostic +scratch artefacts, not repository deliverables. + +## Interfaces and dependencies + +The final implementation should preserve these public interfaces: + +```rust +pub trait ConfigEnvProvider { + fn get(&self, key: &str) -> Option; + fn entries(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)>; +} + +pub fn merge_with_config( + cli: &Cli, + matches: &clap::ArgMatches, +) -> ortho_config::OrthoResult; + +pub fn merge_with_config_and_env( + cli: &Cli, + matches: &clap::ArgMatches, + env: &impl ConfigEnvProvider, +) -> ortho_config::OrthoResult; + +pub fn parse_with_localizer_from( + iter: I, + localizer: &std::sync::Arc, +) -> Result<(Cli, clap::ArgMatches), clap::Error> +where + I: IntoIterator, + T: Into + Clone; +``` + +Private orchestration may add a function whose responsibility is explicit in +its signature, for example: + +```rust +fn merge_with_config_sources( + cli: &Cli, + matches: &clap::ArgMatches, + value_env: &impl EnvProvider, + discovery_env: ortho_config::SharedEnvSource, +) -> ortho_config::OrthoResult; +``` + +The exact private name may follow existing module vocabulary, but it must not +be exported. The ambient wrapper supplies `Arc::new(ProcessEnv)`; the injected +wrapper supplies `Arc::new(MapEnv)` projected from the same fixture. Apply the +same composition rule to early JSON resolution so early diagnostics and the +full merge cannot select different files. + +The final dependency intent is: + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["serde_json"] } + +[build-dependencies] +ortho_config = { version = "0.9.0", features = ["serde_json"] } + +[dev-dependencies] +googletest = "0.14.3" +pretty_assertions = "1.4.1" +``` + +No `ortho_config_macros`, OrthoConfig `yaml`, OrthoConfig `metrics`, or agent +context dependency is required. + +## Revision note + +Initial draft, 2026-08-12: created the self-contained migration plan from the +v0.9.0 migration guide, current Netsuke configuration and release-help +architecture, repository testing policy, and the `execplans` and +`hexagonal-architecture` skills. The draft deliberately preserves Netsuke's +policy/adapter boundaries, adds hermetic discovery coverage, and defers +optional v0.9.0 product features. Implementation remains pending explicit +approval. + +Revised, 2026-08-12: reconciled the draft with the accepted explicit config +selection ADR discovered during validation. The plan now treats that ADR as +controlling, schedules its stale environment-access detail for correction, and +adds its missing contents-index entry without changing the migration's +implementation boundary. + +Completed, 2026-08-12: recorded the v0.9.0 implementation, the hermetic +discovery adapter, the release-help subcommand correction, validation evidence, +and the parser-metadata follow-up. The plan is now a completion record as well +as the approved migration guide. + +Revised, 2026-08-14: recorded the review correction for the +`project_scope_file` signature, the already-fixed status of the remaining +findings, the conflict-aware narrow lockfile rebase, the clean rebase onto +`origin/main` at `69286cdf`, the final gate results, and publication of PR #558 +at `c12ce72a`. + +Revised, 2026-08-15: recorded the post-turn Whitaker ambient-I/O findings and +the capability-scoped conversions in `build.rs` and +`build_l10n_audit::read_source`. The broad discovery-module and build-script +crate exclusions were removed, while the focused library path-normalization +module exclusion remains because the existing cross-directory symlink test +proves that `std::fs::canonicalize` is required for OrthoConfig-compatible +absolute comparison keys. Earlier gate results are marked as pre-remediation; +the final all-green gate result is recorded below. + +Revised, 2026-08-15: recorded the post-remediation gate set, including 1,993 +nextest tests, one skipped test, 100 doctests passed with 28 ignored, +`markdownlint` over 81 files with 0 errors, and the three Clippy `doc_markdown` +backtick omissions found and corrected before the final lint rerun. + +Revised, 2026-08-15 (superseded): corrected the provisional Whitaker scope +record after path-match evidence showed that `excluded_paths` applies at module +boundaries. The unsupported inner `ambient` scope was replaced with dedicated +library and copied build-script `discovery::paths` module exclusions. The +copied build-script path was later removed from the build graph, superseding +that exception. The final post-remediation gates then passed, including docs, +Clippy, Whitaker, Markdown, Mermaid, and diff checks. + +Revised, 2026-08-15: recorded the final all-green post-remediation gates: +`make check-fmt`, `make test`, `make typecheck`, `make lint`, +`make markdownlint`, `make nixie`, and `git diff --check`, with the exact test +and documentation counts captured in `Progress`, `Outcomes & retrospective`, and +`Artefacts and notes`. + +Revised, 2026-08-16 (superseded): recorded the review correction to use direct +`Dir::rename` for man-page replacement, preserving the current page if the +replacement fails, and corrected the then-current `dylint.toml` comment to +describe only the module-scoped +`build_script_build::cli::discovery::paths` exception. That provisional +exception was later removed when the build script stopped compiling discovery. + +Revised, 2026-08-16: recorded the clean Weave rebase onto `origin/main` at +`6b6e9e64` and the resulting upstream direct-rustc coverage for the public +command-list API. The existing mainline fixture resolves the review warning, +so no duplicate compile-time fixture was added. + +Revised, 2026-08-16 (superseded): recorded the post-rebase Whitaker failure for +the copied build-script path normalizer. The library path normalizer remains a +module-scoped exception, but the exact copied build-script module entry was not +honoured; a crate-level `build_script_build` exception was considered for +man-page generation. Full validation was pending at that point. + +Revised, 2026-08-16: replaced the provisional build-script exception after +verifying that neither the module nor crate configuration was honoured. The +man-page build now compiles `cli::build_support`, which contains only the +parser subset required for `Cli::command()` and omits discovery. The library +normalizer remains the sole documented `std::fs::canonicalize` boundary; full +validation is pending. The subsequent 11-site Whitaker report was traced to +stale staged checkout `692f654`; installing `whitaker-installer` 0.2.7, +switching its source to `6cb2f483`, and restaging repaired the validation +environment without changing the source policy. + +Revised, 2026-08-16: recorded the repaired Whitaker validation environment +and the final green gate set. The stale staged checkout `692f654` lacked +`excluded_paths`; installing the CI-pinned `whitaker-installer` 0.2.7, +switching its source to `6cb2f483`, and restaging made the configured existing +exclusions effective. `make check-fmt`, `make test` (2,063 passed, one skipped; +doctests passed), `make typecheck`, `make lint` (the stale-suite finding was +followed by a passing restaged run), `make markdownlint` (34 tests, 0 errors), +`make nixie`, and `git diff --check` all passed. No source-policy expansion was +made. diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 2b3c0c2fa..d3be16862 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2774,10 +2774,10 @@ flowchart LR ``` Netsuke configuration discovery is implemented in `src/cli/discovery.rs`. -Explicit file selection is handled by `explicit_config_path_with_env(...)`, -which applies the precedence `--config` > `NETSUKE_CONFIG`. Layer loading and -automatic discovery are handled by `push_file_layers(...)`, which also applies -the `-C/--directory` flag as the project-discovery root. +Explicit file selection is handled by `resolve_config_selector(...)`, which +applies the precedence `--config` > `NETSUKE_CONFIG`. Layer loading and +automatic discovery are handled by `push_file_layers_with_sources(...)`, which +also applies the `-C/--directory` flag as the project-discovery root. **Figure: Explicit Config Selector Resolution** — This diagram shows how Netsuke chooses the configuration file before automatic discovery. Netsuke @@ -2887,18 +2887,24 @@ manual flag repetition. project, and user file layers, merges them with defaults, adds environment variables via Figment, and finally applies CLI overrides extracted from `ArgMatches`. -- The `config_discovery()` function uses OrthoConfig's builder API without - further customization beyond the application name and environment variable - override, relying on OrthoConfig's platform-specific defaults for standard - directory resolution. +- The `config_discovery()` function uses OrthoConfig's builder API with the + application name, environment selector, and an environment source selected at + the CLI composition root. Ambient runs use `ProcessEnv`; injected runs use a + closed `MapEnv` projected from Netsuke's environment port, preventing tests + from falling through to host directories. +- A missing optional candidate means no configuration layer and therefore + built-in defaults. A candidate that exists but cannot load is retained as an + error when no candidate succeeds, so malformed configuration and a missing + `extends` parent are never mistaken for absence. - Netsuke-owned environment reads for explicit config selection and early JSON resolution go through the `EnvProvider` port in `src/cli/discovery.rs`. Production code uses `StdEnvProvider`; tests can inject a map-backed provider - instead of mutating the process environment. OrthoConfig discovery remains an - external boundary and may still read platform environment variables directly. -- Configuration files use TOML format by default. JSON5 (`.json`, `.json5`) and - YAML (`.yaml`, `.yml`) formats are supported when the corresponding Cargo - features are enabled. + instead of mutating the process environment. The v0.9.0 adapter projects only + the documented discovery keys into OrthoConfig, while `EnvironmentLayer` + retains the complete `NETSUKE_*` value merge boundary. +- Configuration files use TOML. OrthoConfig's optional YAML provider remains + disabled; Netsukefile YAML continues to be parsed by the separate + `serde-saphyr` manifest boundary. - Explicit config selection is handled outside OrthoConfig's built-in discovery override surface so Netsuke keeps its custom two-pass project-over-user merge behaviour for automatic discovery. If an explicit selector is set, the @@ -2935,9 +2941,10 @@ Release engineering is delegated to GitHub Actions workflows built on the SHAs so release automation remains reproducible. The tagging workflow first verifies that the Git ref matches `Cargo.toml` and records the crate's binary name once so all subsequent jobs operate on consistent metadata. Each build job -installs `cargo-orthohelp = 0.8.0`, invokes the `rust-build-release` composite +installs `cargo-orthohelp = 0.9.0`, invokes the `rust-build-release` composite action, and then runs `scripts/generate-release-help.sh` before staging or -packaging. +packaging. The pinned v0.9.0 executable is invoked by the helper as +`cargo-orthohelp orthohelp`. Linux builds cross-compile for `x86_64` and `aarch64`, stage the binary and generated manual page through `.github/release-staging.toml`, and pass the diff --git a/docs/ortho-config-users-guide.md b/docs/ortho-config-users-guide.md index 88280304f..6cc93e45a 100644 --- a/docs/ortho-config-users-guide.md +++ b/docs/ortho-config-users-guide.md @@ -1,1300 +1,575 @@ # OrthoConfig user's guide -`OrthoConfig` is a Rust library that unifies command‑line arguments, -environment variables and configuration files into a single, strongly typed -configuration struct. It is inspired by tools such as `esbuild` and is designed -to minimize boiler‑plate. The library uses `serde` for deserialization and -`clap` for argument parsing, while `figment` provides layered configuration -management. This guide covers the functionality currently implemented in the -repository. - -## Core concepts and motivation - -Rust projects often wire together `clap` for CLI parsing, `serde` for -de/serialization, and ad‑hoc code for loading `*.toml` files or reading -environment variables. Mapping between different naming conventions (kebab‑case -flags, `UPPER_SNAKE_CASE` environment variables, and `snake_case` struct -fields) can be tedious. `OrthoConfig` addresses these problems by letting -developers describe their configuration once and then automatically loading -values from multiple sources. The core features are: - -- **Layered configuration** – Configuration values can come from application - defaults, configuration files, environment variables and command‑line - arguments. Later sources override earlier ones. Command‑line arguments have - the highest precedence and defaults the lowest. - -- **Orthographic naming** – A single field in a Rust struct is automatically - mapped to a CLI flag (kebab‑case), an environment variable (upper snake case - with a prefix), and a file key (snake case). This removes the need for manual - aliasing. - -- **Type‑safe deserialization** – Values are deserialized into strongly typed - Rust structs using `serde`. - -- **Easy adoption** – A procedural macro `#[derive(OrthoConfig)]` adds the - necessary code. Developers only need to derive `serde` traits on their - configuration struct and call a generated method to load the configuration. - -- **Customizable behaviour** – Attributes such as `default`, `cli_long`, - `cli_short`, and `merge_strategy` provide fine‑grained control over naming - and merging behaviour. -- **Declarative merge tooling** – Every configuration struct exposes a - `merge_from_layers` helper along with `MergeComposer`, making it simple to - compose defaults, files, environment captures, and CLI values in unit tests - or bespoke loaders without instantiating the CLI parser. Vector fields honour - the append strategy by default, so defaults flow through alongside - environment and CLI additions. - -The workspace bundles an executable Hello World example under -`examples/hello_world`. It layers defaults, environment variables, and CLI -flags via the derive macro; see its [README](../examples/hello_world/README.md) -for a step-by-step walkthrough and the `rstest-bdd` (Behaviour-Driven -Development) scenarios that validate behaviour end-to-end. - -Run `make test` to execute the example’s coverage. The unit suite uses `rstest` -fixtures to exercise parsing, validation, and command planning across -parameterized edge-cases (conflicting delivery modes, blank salutations, and -custom punctuation). Behavioural coverage comes from the `rstest-bdd` -integration test under `tests/rstest_bdd`, which spawns the compiled binary -inside a temporary working directory, layers `.hello_world.toml` defaults via -`cap-std`, and sets `HELLO_WORLD_*` environment variables per scenario to -demonstrate precedence: configuration files < environment variables < CLI -arguments. Scenarios tagged `@requires.yaml` are gated by compile-time tag -filters, so non-`yaml` builds skip them automatically. - -`ConfigDiscovery` exposes the same search order used by the example so -applications can replace bespoke path juggling with a single call. By default -the helper honours `HELLO_WORLD_CONFIG_PATH`, then searches -`$XDG_CONFIG_HOME/hello_world`, each entry in `$XDG_CONFIG_DIRS` (falling back -to `/etc/xdg` on Unix-like targets), Windows application data directories, -`$HOME/.config/hello_world`, `$HOME/.hello_world.toml`, and finally the project -root. Candidates are deduplicated in precedence order (case-insensitively on -Windows). Call `utf8_candidates()` to receive a `Vec` -without manual conversions: - -```rust,no_run -use ortho_config::ConfigDiscovery; - -# fn load() -> ortho_config::OrthoResult<()> { -let discovery = ConfigDiscovery::builder("hello_world") - .env_var("HELLO_WORLD_CONFIG_PATH") - .build(); - -if let Some(figment) = discovery.load_first()? { - // Extract your configuration struct from the figment here. - println!( - "Loaded configuration from {:?}", - discovery.candidates().first() - ); -} else { - // Fall back to defaults when no configuration files exist. -} -# Ok(()) -# } -``` - -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 - -The derive macro now emits helpers for composing configuration layers without -going through Figment directly. `MergeComposer` collects `MergeLayer` instances -for defaults, files, environment, and CLI input; once constructed, pass the -layers to `YourConfig::merge_from_layers` to build the final struct: - -```rust -use ortho_config::{MergeComposer, OrthoConfig}; -use serde::Deserialize; -use serde_json::json; - -#[derive(Debug, Deserialize, OrthoConfig)] -struct AppConfig { - recipient: String, - salutations: Vec, -} - -let mut composer = MergeComposer::new(); -composer.push_defaults(json!({"recipient": "Defaults", "salutations": ["Hi"] })); -composer.push_environment(json!({"salutations": ["Env"] })); -composer.push_cli(json!({"recipient": "Cli" })); - -let merged = AppConfig::merge_from_layers(composer.layers())?; -assert_eq!(merged.recipient, "Cli"); -assert_eq!( - merged.salutations, - vec![String::from("Hi"), String::from("Env")] -); -``` - -This API surfaces the same precedence as the generated `load()` method while -making it trivial to drive unit and behavioural tests with hand-crafted layers. -`Vec<_>` fields accumulate values from each layer in order, so defaults can -coexist with environment or CLI extensions. The Hello World example’s -behavioural suite includes a dedicated scenario that parses JSON descriptors -into `MergeLayer` values and asserts the merged configuration via these -helpers. Unit tests can mirror this approach with `rstest` fixtures: define -fixtures for default payloads, then enumerate cases for file, environment, and -CLI layers. This validates every precedence permutation without copy-pasting -setup. - -Every derived configuration also exposes `compose_layers()` and -`compose_layers_from_iter(...)`. These helpers discover configuration files, -serialize environment variables, and capture CLI input as a `LayerComposition`, -keeping discovery separate from merging. The returned composition includes both -the ordered layers and any collected errors, letting callers push additional -layers or aggregate errors before invoking `merge_from_layers`. - -### Post-merge hooks - -Some configuration structs require custom adjustments after the standard merge -pipeline completes. The `PostMergeHook` trait provides an opt-in hook that the -library invokes automatically when the `#[ortho_config(post_merge_hook)]` -attribute is present. - -```rust -use ortho_config::{OrthoConfig, OrthoResult, PostMergeContext, PostMergeHook}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Default, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP_", post_merge_hook)] -struct GreetArgs { - #[ortho_config(default = String::from("!"))] - punctuation: String, - preamble: Option, -} - -impl PostMergeHook for GreetArgs { - fn post_merge(&mut self, _ctx: &PostMergeContext) -> OrthoResult<()> { - // Normalize whitespace-only preambles to None - if self.preamble.as_ref().is_some_and(|p| p.trim().is_empty()) { - self.preamble = None; - } - Ok(()) - } -} -``` - -The `PostMergeContext` provides metadata about the merge process: - -- `prefix()` – the environment variable prefix used during loading -- `loaded_files()` – paths of configuration files that contributed to the merge -- `has_cli_input()` – whether CLI arguments were present in the merge +Configuration should not be the hardest part of writing a command-line +application. OrthoConfig describes settings as a Rust struct, then loads that +struct from defaults, a configuration file, environment variables, and +command-line arguments. -Use post-merge hooks sparingly. Most configuration needs are satisfied by the -standard merge pipeline combined with field-level attributes like -`cli_default_as_absent` and `merge_strategy`. Hooks are best suited for: +This guide starts with a small working CLI and grows it one practical task at a +time. Stop as soon as the application has what it needs. -- Normalizing values after all layers have been applied -- Performing validation that depends on multiple fields being merged -- Conditional transformations based on which sources contributed +## Install OrthoConfig -The Hello World example demonstrates this pattern with `GreetCommand`, which -uses a post-merge hook to clean up whitespace-only preambles. - -### Localizing CLI copy - -`ortho_config` exposes a `Localizer` trait, so applications can swap the text -`clap` displays without abandoning sensible defaults. Each implementation is -`Send + Sync` and returns owned `String` instances, making it cheap to cache -resolved messages or fall back to the stock help text. The helper type -`LocalizationArgs<'a> = HashMap<&'a str, FluentValue<'a>>` mirrors Fluent’s -placeholder model, keeping argument-aware lookups ergonomic. - -The crate now ships a Fluent-backed implementation. `FluentLocalizer` embeds an -English catalogue at `locales/en-US/messages.ftl`, layers any consumer bundles -over those defaults, logs formatting errors with `tracing`, and falls back to -the next bundle when a lookup fails: - -```rust -use ortho_config::{langid, FluentLocalizer, LocalizationArgs, Localizer}; - -static APP_EN: &str = include_str!("../locales/en-US/app.ftl"); - -let localizer = FluentLocalizer::builder(langid!("en-US")) - .with_consumer_resources([APP_EN]) - .try_build() - .expect("embedded locales load successfully"); - -let mut args: LocalizationArgs<'_> = LocalizationArgs::default(); -args.insert("binary", "demo".into()); -assert_eq!( -localizer - .lookup("cli.usage", Some(&args)) - .expect("usage copy exists"), - "Usage: demo [OPTIONS] " -); -``` - -Applications can inject a custom logger with `with_error_reporter` when they -need to capture Fluent formatting errors alongside command parsing failures. - -The Hello World example ships `hello_world::localizer::DemoLocalizer`, which -builds a `FluentLocalizer` from `examples/hello_world/locales/en-US` and drives -`CommandLine::command().localize(&localizer)` and -`CommandLine::try_parse_localized_env`. If the localization setup ever fails, -the example falls back to `NoOpLocalizer`, preserving the stock `clap` strings -until translations are fixed. - -Errors surfaced by `clap` can be localized as well. Use -`localize_clap_error_with_command` to map each `ErrorKind` to a Fluent -identifier of the form `clap-error-`, forwarding argument context -such as the missing flag or the offending value. Supplying the command enables -the helper to populate missing context (for example, the available subcommands -when `clap` emits `DisplayHelpOnMissingArgumentOrSubcommand`). When no -translation exists, the helper returns the original `clap` error unchanged: - -```rust -use clap::CommandFactory; -use ortho_config::{localize_clap_error_with_command, Localizer}; - -# #[derive(clap::Parser)] -# struct Cli {} -fn parse(localizer: &dyn Localizer) -> Result { - let mut command = Cli::command().localize(localizer); - let mut matches = command - .try_get_matches() - .map_err(|err| { - localize_clap_error_with_command(err, localizer, Some(&command)) - })?; - - Cli::from_arg_matches_mut(&mut matches).map_err(|err| { - let err = err.with_cmd(&command); - localize_clap_error_with_command(err, localizer, Some(&command)) - }) -} -``` - -## Installation and dependencies - -Add `ortho_config` as a dependency in `Cargo.toml` along with `serde`: +OrthoConfig needs Serde to turn merged values into the application's +configuration type. Add `clap` when the application defines its own command or +subcommand parser: + ```toml [dependencies] -ortho_config = "0.8.0" # replace with the latest version +clap = { version = "4.5", features = ["derive"] } +ortho_config = "0.9.0" serde = { version = "1.0", features = ["derive"] } -clap = { version = "4", features = ["derive"] } # required for CLI support ``` -By default, only TOML configuration files are supported. To enable JSON5 ( -`.json` and `.json5`) and YAML (`.yaml` and `.yml`) support, enable the -corresponding cargo features: - -```toml -[dependencies] -ortho_config = { version = "0.8.0", features = ["json5", "yaml"] } -# Enabling these features expands file formats; precedence stays: defaults < file < env < CLI. -``` - -Enabling the `json5` feature causes both `.json` and `.json5` files to be -parsed using the JSON5 format. Without this feature, these files are ignored -during discovery and do not cause errors if present. The `yaml` feature -similarly enables `.yaml` and `.yml` files; without it, such files are skipped -during discovery and do not cause errors if present. - -`ortho_config` re-exports its parsing dependencies, so consumers do not need to -declare them directly. Access `figment`, `uncased`, `xdg` (on Unix-like and -Redox targets), and the optional parsers (`figment_json5`, `json5`, -`serde_saphyr`, `toml`) via `ortho_config::` paths. The `serde_json` re-export -is enabled by default because the crate relies on it internally; disable -default features only when explicitly opting back into `serde_json`. - -### Dependency architecture for derive macro users - -The `#[derive(OrthoConfig)]` macro emits fully qualified paths rooted at -`ortho_config`. For example, generated code references -`ortho_config::figment::Figment` and `ortho_config::uncased::Uncased` rather -than `figment::...` or `uncased::...`. Those paths resolve because -`ortho_config` re-exports these crates. - -For screen readers: The following diagram shows that generated code references -re-exported crates through `ortho_config`, so consumer crates can rely on the -runtime crate dependency. - -```mermaid -flowchart TD - A[Consumer crate] -->|depends on| B[ortho_config] - C[derive OrthoConfig] -->|generates| D[ortho_config::figment::...] - C -->|generates| E[ortho_config::uncased::...] - B -->|re-exports| F[figment] - B -->|re-exports| G[uncased] - B -->|re-exports on Unix/Redox| H[xdg] -``` +The default features support TOML and the JSON-backed merge machinery used by +the derive. Optional `json5`, `yaml`, and `metrics` features are covered later. -_Figure 1: Derive output resolves parser crates through `ortho_config`._ - -In the common case, `Cargo.toml` does not need direct `figment`, `uncased`, or -`xdg` dependencies: - -```toml -[dependencies] -ortho_config = "0.8.0" -serde = { version = "1.0", features = ["derive"] } -clap = { version = "4", features = ["derive"] } -``` +## Build the first layered CLI -### Troubleshooting dependency errors - -- If source code imports `figment`, `uncased`, or `xdg` directly, either switch - imports to `ortho_config::figment` / `ortho_config::uncased` / - `ortho_config::xdg`, or keep explicit dependencies for that direct usage. -- If derive output fails with unresolved `ortho_config::...` paths, ensure the - dependency key is named `ortho_config` in `Cargo.toml` or use the - `#[ortho_config(crate = "...")]` attribute to specify the alias. -- **Dependency aliasing** is supported via the `crate` attribute. When - renaming the dependency in `Cargo.toml` (for example, - `my_cfg = { package = "ortho_config", ... }`), add - `#[ortho_config(crate = "my_cfg")]` to the struct so generated code - references the correct crate path. -- If dependency resolution reports conflicts, inspect duplicates with - `cargo tree -d` and prefer the versions selected through `ortho_config` - unless direct usage requires something else. - -### FAQ: should `figment`, `uncased`, or `xdg` be direct dependencies? - -No for derive-generated code. Yes, only when application code directly imports -those crates without going through the `ortho_config::` re-exports. - -YAML parsing is handled by the pure-Rust `serde-saphyr` crate. It adheres to -the YAML 1.2 specification, so unquoted scalars such as `yes`, `on`, and `off` -remain strings. The provider enables `Options::strict_booleans`, ensuring only -`true` and `false` deserialize as booleans, while legacy YAML 1.1 literals are -treated as plain strings. Duplicate mapping keys surface as parsing errors -instead of silently accepting the last entry, helping catch typos early. - -## Migrating from earlier versions - -Projects using a pre‑0.5 release can upgrade with the following steps: - -- `#[derive(OrthoConfig)]` remains the correct way to annotate configuration - structs. No additional derives are required. -- Remove any `load_with_reference_fallback` helpers. The merge logic inside - `load_and_merge_subcommand_for` supersedes this workaround. -- Replace calls to deprecated helpers such as `load_subcommand_config_for` with - `ortho_config::subcommand::load_and_merge_subcommand_for` or import - `ortho_config::SubcmdConfigMerge` to call `load_and_merge` directly. - -Import it with: - -```rust -use ortho_config::SubcmdConfigMerge; -``` - -Subcommand structs can leverage the `SubcmdConfigMerge` trait to expose a -`load_and_merge` method automatically: +Start with one struct. The `prefix` is used for environment variables and for +the default file-discovery names. A trailing underscore is conventional and +keeps names such as `ACME_PORT` easy to read. + ```rust use ortho_config::{OrthoConfig, OrthoResult}; -use ortho_config::SubcmdConfigMerge; -use serde::Deserialize; - -#[derive(Deserialize, OrthoConfig)] -struct PrArgs { - reference: String, -} - -# fn demo(pr_args: &PrArgs) -> OrthoResult<()> { -let merged = pr_args.load_and_merge()?; -# let _ = merged; -# Ok(()) -# } -``` - -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. - -## Defining configuration structures - -A configuration is represented by a plain Rust struct. To take advantage of -`OrthoConfig`, derive the following traits: - -- `serde::Deserialize` and `serde::Serialize` – required for deserializing - values and merging overrides. - -- The derive macro generates a hidden `clap::Parser` implementation, so - manual `clap` annotations are not required in typical use. CLI customization - is performed using `ortho_config` attributes such as `cli_short`, or - `cli_long`. - -- `OrthoConfig` – provided by the library. This derive macro generates the code - to load and merge configuration from multiple sources. - -Optionally, the struct can include a `#[ortho_config(prefix = "PREFIX")]` -attribute. The prefix sets a common string for environment variables and -configuration file names. When the attribute omits a trailing underscore, -`ortho_config` appends one automatically so environment variables consistently -use `_`. Trailing underscores are trimmed and the prefix is lower‑cased -when used to form file names. For example, a prefix of `APP` results in -environment variables like `APP_PORT` and file names such as `.app.toml`. - -### Field-level attributes - -Field attributes modify how a field is sourced or merged: - -| Attribute | Behaviour | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `default = expr` | Supplies a default value when no source provides one. The expression can be a literal or a function path. | -| `cli_long = "name"` | Overrides the automatically generated long CLI flag (kebab-case). | -| `cli_short = 'c'` | Adds a single-letter short flag for the field. | -| `merge_strategy = "append"` | For `Vec` fields, specifies that values from different sources should be concatenated. This is currently the only supported strategy and is the default for vector fields. | -| `cli_default_as_absent` | Treats typed clap defaults (`default_value_t`, `default_values_t`) as absent during configuration merging. File and environment values take precedence, while explicit CLI overrides still win. | - -Unrecognized keys are ignored by the derive macro for forwards compatibility. -Unknown keys will therefore silently do nothing. Developers who require -stricter validation may add manual `compile_error!` guards. - -Vector append buffers operate on raw JSON values, so element types only need to -implement `serde::Deserialize`. Deriving `serde::Serialize` remains useful when -applications serialize configuration back out (for example, to emit defaults), -but it is no longer required merely to opt into the append strategy. - -By default, each field receives a long flag derived from its name in kebab‑case -and a short flag. The macro chooses the short flag using these rules: - -- Use the field's first ASCII alphanumeric character. -- If that character is already taken or reserved, try its uppercase form. -- If both are unavailable, no short flag is assigned; specify `cli_short` to - resolve the collision. - -| Scenario | Result | -| --------------------------------- | ---------------------- | -| First letter free | `-p` | -| Lowercase taken; uppercase free | `-P` | -| Both cases taken | none (set `cli_short`) | -| Explicit override via `cli_short` | `-r` | - -Collisions are evaluated against short flags already assigned within the same -parser, and reserved characters such as clap's `-h` and `-V`. A character is -considered taken if it matches either set. - -The macro does not scan other characters in the field name when deriving the -short flag. Short flags must be single ASCII alphanumeric characters and may -not use clap's global `-h` or `-V` options. Long flags must contain only ASCII -alphanumeric characters or hyphens, must not start with `-`, cannot be named -`help` or `version`, and the macro rejects underscores. - -For example, when multiple fields begin with the same character, `cli_short` -can disambiguate the final field: - -```rust -#[derive(OrthoConfig)] -struct Options { - port: u16, // -p - path: String, // -P - #[ortho_config(cli_short = 'r')] - peer: String, // -r via override -} -``` - -### Example configuration struct - -The following example illustrates many of these features: - -```rust - use ortho_config::{OrthoConfig, OrthoError}; - use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; - #[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] - // env vars use APP_ (the macro adds the underscore automatically) - #[ortho_config(prefix = "APP")] - struct AppConfig { - /// Logging verbosity - log_level: String, +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, - /// Port to bind on – defaults to 8080 when unspecified - #[ortho_config(default = 8080)] + #[ortho_config(default = 8080, cli_short = 'p')] port: u16, - /// Optional list of features. Values from files, environment and CLI are appended. - #[ortho_config(merge_strategy = "append")] - features: Vec, - - /// Nested configuration for the database. A separate prefix is used to avoid ambiguity. - #[serde(flatten)] - database: DatabaseConfig, - - /// Enable verbose output; also available as -v via cli_short - #[ortho_config(cli_short = 'v')] - verbose: bool, - } - -#[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "DB")] // used in conjunction with APP_ prefix to form APP_DB_URL -struct DatabaseConfig { - url: String, - - #[ortho_config(default = 5)] - pool_size: Option, + #[ortho_config(default = String::from("info"))] + log_level: String, } -fn main() -> Result<(), OrthoError> { - // Parse CLI arguments and merge with defaults, file and environment - let config = AppConfig::load()?; - println!("Final config: {:#?}", config); +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!( + "host={} port={} log_level={}", + config.host, config.port, config.log_level + ); Ok(()) } ``` -`clap` attributes are not required in general; flags are derived from field -names and `ortho_config` attributes. In this example, the `AppConfig` struct -uses a prefix of `APP`. The `DatabaseConfig` struct declares a prefix `DB`, -resulting in environment variables such as `APP_DB_URL`. The `features` field -is a `Vec` and accumulates values from multiple sources rather than -overwriting them. +That one definition provides three spellings for each field: -### Customizing configuration discovery +| Rust field | Command line | Environment | TOML | +| ----------- | ---------------- | ---------------- | ----------- | +| `host` | `--host` | `ACME_HOST` | `host` | +| `port` | `--port` or `-p` | `ACME_PORT` | `port` | +| `log_level` | `--log-level` | `ACME_LOG_LEVEL` | `log_level` | -Configuration discovery can be tailored per struct using the `discovery(...)` -attribute. The keys recognized today include: +_Table 1: Rust fields and their command-line, environment, and TOML names._ -- `app_name`: directory name used under XDG and application data folders. -- `env_var`: override for the environment variable consulted before discovery - runs (defaults to `CONFIG_PATH`). -- `config_file_name`: primary filename searched in platform-specific - configuration directories (defaults to `config.toml`). -- `dotfile_name`: dotfile name consulted in the current working directory and - the user's home directory. -- `project_file_name`: filename searched within project roots (defaults to the - dotfile name). -- `config_cli_long` / `config_cli_short`: rename the CLI flag used to provide an - explicit configuration path. -- `config_cli_visible`: when `true`, the generated CLI flag appears in help - output instead of remaining hidden. +Values are merged from lowest to highest precedence: -Supplying only the keys you need lets you rename the CLI flag without altering -file discovery, or vice versa. When the attribute is omitted, the defaults -described in [Config path override](#config-path-override) continue to apply. +1. `#[ortho_config(default = ...)]` values; +2. configuration files; +3. environment variables; and +4. command-line arguments. -## Loading configuration and precedence rules +This means a checked-in file can provide team defaults, an environment variable +can adapt them for a deployment, and a one-off CLI option can override both. -### How loading works +## See the configuration surface -The `load_from_iter` method (used by the convenience `load`) performs the -following steps: +Different sources suit different moments. Start with durable team settings in +`.acme.toml`: -1. Builds a `figment` configuration profile. A defaults provider constructed - from the `#[ortho_config(default = …)]` attributes is added first. + +```toml +host = "0.0.0.0" +port = 9000 +log_level = "debug" +``` -2. Attempts to load a configuration file. Candidate file paths are searched in - the following order: +At deployment time, an environment variable can change the host without +rewriting the file. For a one-off run, a CLI option can change the port again: - 1. If provided, a path supplied via the CLI flag generated by the - `discovery(...)` attribute (which defaults to a hidden `--config-path`) - or the `CONFIG_PATH` environment variable (for example, - `APP_CONFIG_PATH` or `CONFIG_PATH`) takes precedence; see - [Config path override](#config-path-override). + +```console +$ ACME_HOST=api.internal cargo run -- --port 3000 +host=api.internal port=3000 log_level=debug +``` - 2. A dotfile named `..toml` in the current working directory. +The result shows all three surfaces working together: `log_level` comes from +TOML, `ACME_HOST` supplies `host`, and `--port` wins for `port`. The command +uses POSIX shell syntax; in PowerShell, set `$env:ACME_HOST = "api.internal"` +before running the same Cargo command. - 3. A dotfile of the same name in the user's home directory. +TOML is available by default. Enable the `yaml` or `json5` crate feature when +those formats are a better fit for application users; the +[file-format section](#enable-another-file-format) covers the details. - 4. On Unix‑like systems, the XDG configuration directory (e.g. - `~/.config/app/config.toml`) is searched using the `xdg` crate; on - Windows, the `%APPDATA%` and `%LOCALAPPDATA%` directories are checked. +By default, discovery checks an explicit `--config-path`, the +`ACME_CONFIG_PATH` environment variable, project and home dotfiles, and the +platform configuration directory. Explicitly requested files are required: a +missing `--config-path` is an error rather than a silent fallback. - 5. If the `json5` or `yaml` features are enabled, files with `.json`, - `.json5`, `.yaml`, or `.yml` extensions are also considered in these - locations. +TOML naturally handles lists and nested values. For example, an application +could add `workers: Vec` and `labels: BTreeMap` to its +configuration struct, then use: -3. Adds an environment provider using the prefix specified on the struct. Keys - are upper‑cased and nested fields use double underscores (`__`) to separate - components. + +```toml +[[workers]] +name = "queue-a" +concurrency = 4 + +[[workers]] +name = "queue-b" +concurrency = 2 -4. Adds a provider containing the CLI values (captured as `Option` fields) - as the final layer. +[labels] +region = "eu-west" +tier = "worker" +``` -5. Merges vector fields according to the `merge_strategy` (currently only - `append`) so that lists of values from lower precedence sources are extended - with values from higher precedence ones. +For vectors, `merge_strategy = "append"` appends higher-precedence values; +`merge_strategy = "replace"` replaces the collection. Use +`merge_strategy = "keyed"` for keyed collection merging. Choose the policy +deliberately when operators may combine file, environment, and CLI values. -6. Attempts to extract the merged configuration into the concrete struct. On - success it returns the completed configuration; otherwise an `OrthoError` is - returned. +Configuration files can also contain `extends` entries. Relative paths are +resolved from the file that declares them, and parent layers are merged before +the child. OrthoConfig reports a missing parent with its absolute path and the +referencing file so the failure is actionable. -### Config path override +## Make discovery match the application -The derive macro always recognizes a configuration override flag and the -associated environment variables even when you do not declare a field -explicitly. By default a hidden `--config-path` flag is accepted alongside -`CONFIG_PATH` and the unprefixed `CONFIG_PATH`. Applying the -struct-level `discovery(...)` attribute customizes this behaviour, allowing you -to rename or expose the CLI flag and adjust the filenames searched during -discovery: +Application names do not need to bend around OrthoConfig's defaults. Put the +discovery contract beside the struct when the public flag or filenames are part +of the CLI design: + ```rust -#[derive(Debug, Deserialize, ortho_config::OrthoConfig)] +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] #[ortho_config( - prefix = "APP_", + prefix = "ACME_", discovery( - app_name = "demo", - env_var = "DEMO_CONFIG_PATH", - config_file_name = "demo.toml", - dotfile_name = ".demo.toml", - project_file_name = ".demo.toml", + app_name = "acme-server", + config_file_name = "server.toml", + dotfile_name = ".acme-server.toml", + project_file_name = ".acme-server.toml", config_cli_long = "config", config_cli_short = 'c', - config_cli_visible = true, + config_cli_visible = true ) )] -struct CliArgs { +struct Config { #[ortho_config(default = 8080)] port: u16, } -``` - -The snippet above exposes a visible `--config`/`-c` flag, renames the -environment override to `DEMO_CONFIG_PATH`, and instructs discovery to search -for `demo.toml` (and `.demo.toml`) within the standard directories. Omitting -`config_cli_visible` keeps the flag hidden while still parsing it, and leaving -`config_cli_short` unset skips the short alias. When the `discovery(...)` -attribute is absent, the defaults—hidden `--config-path`, `CONFIG_PATH` -and `CONFIG_PATH`, and the automatically derived dotfile names—remain in effect. - -### Source precedence - -Values are loaded from each layer in a specific order. Later layers override -earlier ones. The precedence, from lowest to highest, is: - -1. **Application‑defined defaults** – values provided via `default` attributes - or `Option` fields are considered defaults. - -2. **Configuration file** – values from a TOML (or JSON5/YAML) file loaded from - one of the paths listed above. - -3. **Environment variables** – variables prefixed with the struct's `prefix` - (e.g. `APP_PORT`, `APP_DATABASE__URL`) override file values. - -4. **Command‑line arguments** – values parsed by `clap` override all other - sources. - -Nested structs are flattened in the environment namespace by joining field -names with double underscores. For example, if `AppConfig` has a nested -`database` field and the prefix is `APP`, then `APP_DATABASE__URL` sets the -`database.url` field. If a nested struct has its own prefix attribute, that -prefix is used for its fields (e.g. `APP_DB_URL`). - -When `clap`'s `flatten` attribute is employed to compose argument groups, the -flattened struct is initialized even if no CLI flags within the group are -specified. During merging, `ortho_config` discards these empty groups so that -values from configuration files or the environment remain in place unless a -field is explicitly supplied on the command line. - -### Using defaults and optional fields - -Fields of type `Option` are treated as optional values. If no source -provides a value for an `Option` field then it remains `None`. To provide a -default value for a non‑`Option` field or for an `Option` field that should -have an initial value, specify `#[ortho_config(default = expr)]`. This default -acts as the lowest‑precedence source and is overridden by file, environment or -CLI values. - -### Environment variable naming - -Environment variables are upper‑cased and use underscores. The struct‑level -prefix (if supplied) is prepended without any separator, and nested fields are -separated by double underscores. For the `AppConfig` and `DatabaseConfig` -example above, valid environment variables include `APP_LOG_LEVEL`, `APP_PORT`, -`APP_DATABASE__URL` and `APP_DATABASE__POOL_SIZE`. If the nested struct has its -own prefix (`DB`), then the environment variable becomes `APP_DB_URL`. - -Comma-separated values such as `DDLINT_RULES=A,B,C` are parsed as lists. The -loader converts these strings into arrays before merging, so array fields -behave the same across environment variables, CLI arguments and configuration -files. Values containing literal commas must be wrapped in quotes or brackets -to disable list parsing. - -## Configuration inheritance - -A configuration file may specify an `extends` key pointing to another file. The -referenced file is loaded first and the current file's values override it. The -path is resolved relative to the file containing the `extends` directive. -Missing files raise a not-found error that includes both the resolved absolute -path and the file that declared `extends`, making it clear what needs to be -created. Precedence across all sources becomes base file → extending file → -environment variables → CLI flags. Cycles are detected and reported via a -`CyclicExtends` error. Prefix handling and subcommand namespaces work as normal -when inheritance is in use. - -## Dynamic rule tables - -Map fields such as `BTreeMap` allow configuration files to -declare arbitrary rule keys. Any table nested under `rules.` is -deserialized into the map without prior knowledge of the key names. This -enables use cases like: - -```toml -[rules.consistent-casing] -enabled = true -[rules.no-tabs] -enabled = false -``` - -Each entry becomes a map key with its associated struct value. - -## Ignore patterns -Lists of files or directories to exclude can be specified via comma-separated -environment variables and CLI flags. Values are merged using the `append` -strategy, so that configuration defaults are extended by environment variables -and finally by the CLI. Whitespace around entries is trimmed and duplicates are -preserved. For example: - -```bash -DDLINT_IGNORE_PATTERNS=".git/,build/" -mytool --ignore-patterns target/ +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) +} ``` -results in `ignore_patterns = [".git/", "build/", "target/"]`. - -By default, the ignore-pattern list includes `[".git/", "build/", "target/"]`. -These defaults are extended (not replaced) by environment variables and CLI -flags via the `append` merge strategy. - -## Subcommand configuration - -Many CLI applications use `clap` subcommands to perform different operations. -`OrthoConfig` supports per‑subcommand defaults via a dedicated `cmds` -namespace. The helper function `load_and_merge_subcommand_for` loads defaults -for a specific subcommand and merges them beneath the CLI values. The merged -struct is returned as a new instance; the original `cli` struct remains -unchanged. CLI fields left unset (`None`) do not override environment or file -defaults, avoiding accidental loss of configuration. - -### How it works +This application accepts `--config` and `-c`, reads `ACME_CONFIG_PATH`, looks +for `.acme-server.toml` in project locations, and uses `server.toml` in +platform configuration directories. Keeping these choices in the derive also +exposes them through `OrthoConfigDocs`. -When a struct derives `OrthoConfig`, it also implements the associated -`prefix()` method. This method returns the configured prefix string. -`load_and_merge_subcommand_for(prefix, cli_struct)` uses this prefix to build a -`cmds.` section name for the configuration file and an -`PREFIX_CMDS_SUBCOMMAND_` prefix for environment variables. Configuration is -loaded in the same order as global configuration (defaults → file → environment -→ CLI), but only values in the `[cmds.]` section or environment -variables beginning with `PREFIX_CMDS__` are considered. +### Handle every `load_first` outcome -### Example - -Suppose an application has a `pr` subcommand that accepts a `reference` -argument and a `repo` global option. With `OrthoConfig` the argument structures -might be defined as follows: +`ConfigDiscovery::load_first` distinguishes three outcomes. `Ok(Some(...))` +contains the first successfully parsed candidate. `Ok(None)` means discovery +had no candidates to try. `Err(...)` means candidates existed but none loaded +successfully; surface or map that error rather than treating it as absence: + ```rust -use clap::Parser; -use ortho_config::OrthoConfig; -use ortho_config::SubcmdConfigMerge; -use serde::{Deserialize, Serialize}; - -#[derive(Parser, Deserialize, Serialize, Debug, OrthoConfig, Clone, Default)] -#[ortho_config(prefix = "VK")] // all variables start with VK -pub struct GlobalArgs { - pub repo: Option, -} - -#[derive(Parser, Deserialize, Serialize, Debug, OrthoConfig, Clone, Default)] -#[ortho_config(prefix = "VK")] // subcommands share the same prefix -pub struct PrArgs { - #[arg(required = true)] - pub reference: Option, // optional for merging defaults but required on the CLI +use ortho_config::{ConfigDiscovery, OrthoResult}; + +fn load_discovered_config(discovery: &ConfigDiscovery) -> OrthoResult<()> { + match discovery.load_first() { + Ok(Some(_config)) => { + // Merge or deserialize the discovered Figment value. + println!("discovery=loaded"); + Ok(()) + } + Ok(None) => { + // Continue with application defaults. + println!("discovery=absent"); + Ok(()) + } + Err(error) => Err(error), + } } -fn main() -> Result<(), ortho_config::OrthoError> { - let cli_pr = PrArgs::parse(); - // Merge defaults from [cmds.pr] and VK_CMDS_PR_* over CLI - let merged_pr = cli_pr.load_and_merge()?; - println!("PrArgs after merging: {:#?}", merged_pr); - Ok(()) +fn main() -> OrthoResult<()> { + let discovery = ConfigDiscovery::builder("acme").build(); + load_discovered_config(&discovery) } ``` -A configuration file might include: +## Test discovery without changing the process environment -```toml -[cmds.pr] -reference = "https://github.com/leynos/mxd/pull/31" +Tests that mutate environment variables interfere with one another. Build a +`ConfigDiscovery` with `MapEnv` instead. Each test owns its values and can run +in parallel: -[cmds.issue] -reference = "https://github.com/leynos/mxd/issues/7" -``` - -and environment variables could override these defaults: + +```rust +use ortho_config::{ConfigDiscovery, MapEnv}; +use std::sync::Arc; -```bash -VK_CMDS_PR_REFERENCE=https://github.com/owner/repo/pull/42 -VK_CMDS_ISSUE_REFERENCE=https://github.com/owner/repo/issues/101 -``` +fn main() { + let environment = Arc::new( + MapEnv::new() + .with_var("ACME_CONFIG", "/srv/acme/server.toml") + .with_var("HOME", "/home/tester"), + ); -Within the `vk` example repository, the global `--repo` option is provided via -the `GlobalArgs` struct. A developer can set this globally using the -environment variable `VK_REPO` without passing `--repo` on every invocation. -Subcommands `pr` and `issue` load their defaults from the `cmds` namespace and -environment variables. If the `reference` field is missing in the defaults, the -tool continues using the CLI value instead of exiting with an error. + let discovery = ConfigDiscovery::builder("acme") + .env_var("ACME_CONFIG") + .env_source(environment) + .clear_project_roots() + .build(); -### Merging a selected subcommand enum + assert_eq!( + discovery.candidates().first().map(|path| path.as_path()), + Some(std::path::Path::new("/srv/acme/server.toml")) + ); + println!("candidate=/srv/acme/server.toml"); +} +``` -When the root CLI parses into a `Commands` enum, it is possible to derive -`ortho_config_macros::SelectedSubcommandMerge` and import the -`SelectedSubcommandMerge` trait from `ortho_config` to merge the selected -variant in one call, instead of matching only to call `load_and_merge()` per -branch. +`ProcessEnv` remains the default, so production applications do not need to +change. `EnvSource` deliberately supports lookup by name but not enumeration; +discovery cannot accidentally scan or log unrelated environment values. -Variants that rely on `cli_default_as_absent` (because they use -`default_value_t`) should be annotated with `#[ortho_subcommand(with_matches)]` -so the merge can consult `ArgMatches` and treat clap defaults as absent. +## Give each subcommand its own settings -To load the global configuration and merge the selected subcommand in one -expression, use `load_globals_and_merge_selected_subcommand` and supply a -global loader as a closure. +Many CLIs have global options plus commands with different configuration. Derive +`OrthoConfig` for each subcommand's argument struct and merge only the +selected command: + ```rust -use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; -use ortho_config::{SelectedSubcommandMerge, load_globals_and_merge_selected_subcommand}; +use clap::{Args, Parser, Subcommand}; +use ortho_config::{OrthoConfig, OrthoResult, SubcmdConfigMerge}; +use serde::{Deserialize, Serialize}; -#[derive(Parser)] +#[derive(Debug, Parser)] +#[command(name = "acme")] struct Cli { #[command(subcommand)] - command: Commands, + command: Command, } -#[derive(Subcommand, ortho_config_macros::SelectedSubcommandMerge)] -enum Commands { - #[ortho_subcommand(with_matches)] - Greet(GreetArgs), - Run(RunArgs), +#[derive(Debug, Subcommand)] +enum Command { + Serve(ServeConfig), } -// Placeholder types for the example; real subcommands define fields and derive -// `OrthoConfig`. -struct GreetArgs; -struct RunArgs; - -fn main() -> Result<(), Box> { -let mut cmd = Cli::command(); -let matches = cmd.get_matches(); -let cli = Cli::from_arg_matches(&matches)?; -let (_globals, _merged) = load_globals_and_merge_selected_subcommand( - &matches, - cli.command, - || Ok::<_, std::io::Error>(()), -)?; -Ok(()) +#[derive(Debug, Default, Args, Deserialize, Serialize, OrthoConfig)] +#[command(name = "serve")] +#[ortho_config(prefix = "ACME_SERVE_")] +struct ServeConfig { + #[arg(long)] + port: Option, } -``` - -### Hello world walkthrough - - - -The `hello_world` example crate demonstrates these patterns in a compact -setting. Global options such as `--recipient` or `--salutation` are resolved by -`load_global_config`, which now reuses -`HelloWorldCli::compose_layers_from_iter` to collect defaults, discovered files -and environment variables before applying CLI overrides. When callers pass -`-s/--salutation`, the helper clears earlier vector contributions, so CLI input -replaces file or environment values. The `greet` subcommand adds optional -behaviour like a preamble (`--preamble "Good morning"`) or custom punctuation -while reusing the merged global configuration. The `take-leave` subcommand -combines switches and optional arguments (`--wave`, `--gift`, `--channel email`, -`--remind-in 15`) alongside greeting adjustments ( -`--preamble "Until next time"`, `--punctuation ?`) to describe how the farewell -should unfold. Each subcommand struct derives `OrthoConfig` so defaults from -`[cmds.greet]` or `[cmds.take-leave]` merge automatically when -`load_and_merge_selected()` is invoked on the derived `Commands` enum. - -Behavioural tests in `examples/hello_world/tests` exercise scenarios such as -`hello_world greet --preamble "Good morning"` and running -`hello_world --is-excited take-leave` with `--gift biscuits`, `--remind-in 15`, -`--channel email`, and `--wave`. These end-to-end checks verify that CLI -arguments override configuration files and that validation errors surface -cleanly when callers provide blank strings or conflicting switches. - -Sample configuration files live in `examples/hello_world/config`. The -`baseline.toml` defaults underpin both the automated tests and the demo -scripts, while `overrides.toml` extends the baseline to demonstrate inheritance -by adjusting the recipient and salutation. The paired `scripts/demo.sh` and -`scripts/demo.cmd` helpers copy these files into a temporary directory before -running `cargo run -p hello_world`, illustrating how file defaults, environment -variables, and CLI arguments override one another without mutating the working -tree. - -### Treating clap defaults as absent - -Non‑`Option` fields annotated with `#[arg(default_value_t = ...)]` normally -override configuration files and environment variables because `clap` always -populates them. The `cli_default_as_absent` attribute changes this behaviour: -when the user does not explicitly provide a value on the command line, the -field is excluded from the CLI layer so that file and environment values take -precedence. - -Add `cli_default_as_absent` and define the default in clap. The derive macro -now infers the struct default from clap's default metadata, so the default only -needs to be declared once: -```rust -#[derive(Parser, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP_")] -struct GreetArgs { - #[arg(long, default_value_t = String::from("!"))] - #[ortho_config(cli_default_as_absent)] - punctuation: String, +fn main() -> OrthoResult<()> { + match Cli::parse().command { + Command::Serve(cli) => { + let config = cli.load_and_merge()?; + println!("port={:?}", config.port); + } + } + Ok(()) } ``` -`default_value_t` and `default_values_t` are supported for inferred defaults. -`default_value` inference is intentionally unsupported for now; use -`default_value_t` or add an explicit `#[ortho_config(default = ...)]` to avoid -string-parser mismatches. Parser-faithful `default_value` inference is planned -as a day-2 follow-up. - -If `#[ortho_config(default = ...)]` is still provided, that explicit value -remains available for generated defaults/documentation metadata. +For an enum with many variants, derive `SelectedSubcommandMerge` and use +`load_globals_and_merge_selected_subcommand`. The generated match keeps the +entry point small. Add `#[ortho_config(cli_default_as_absent)]` to a field when +a `clap` default should not override a value supplied by a file or environment +variable. -**Precedence with the attribute (lowest to highest):** +## Handle errors at the application boundary -1. Struct default (`#[ortho_config(default = ...)]` or inferred from clap) -2. Configuration file -3. Environment variable -4. Explicit CLI override (e.g. `--punctuation "?"`) - -Without `cli_default_as_absent`, the clap default would always beat the file -and environment layers. With the attribute, calling `greet` without -`--punctuation` allows a `[cmds.greet] punctuation = "?"` file entry or -`APP_CMDS_GREET_PUNCTUATION=?` environment variable to win. - -When using this attribute, pass the `ArgMatches` so the crate can inspect -`value_source()`: +Library APIs return `OrthoResult`, whose error is an `Arc`. +Propagate it while loading, then render or map it where the application owns +the user experience: + ```rust -let matches = GreetArgs::command().get_matches(); -let cli = GreetArgs::from_arg_matches(&matches)?; -let merged = cli.load_and_merge_with_matches(&matches)?; -``` - -Clap's `value_source()` uses argument IDs (the field identifier unless -`#[arg(id = "...")]` overrides it). This behaviour requires the `serde_json` -feature (enabled by default). - -### Dispatching with `clap‑dispatch` - -The `clap‑dispatch` crate can be combined with `OrthoConfig` to simplify -subcommand execution. Each subcommand struct implements a trait defining the -action to perform. An enum of subcommands is annotated with -`#[clap_dispatch(fn run(...))]`, and the `load_and_merge_subcommand_for` -function can be called on each variant before dispatching. See the -`Subcommand Configuration` section of the `OrthoConfig` [README](../README.md) -for a complete example. - -## Error handling - -`load` and `load_and_merge_subcommand_for` return `OrthoResult`, an alias for -`Result>`. `OrthoError` wraps errors from `clap`, file I/O -and `figment`. Failures during the final merge of CLI values over configuration -sources surface as the `Merge` variant, providing clearer diagnostics when the -combined data is invalid. When multiple sources fail, the errors are collected -into the `Aggregate` variant, so callers can inspect each individual failure. -Consumers should handle these errors appropriately, for example by printing -them to stderr and exiting. If required fields are missing after merging, the -crate returns `OrthoError::MissingRequiredValues` with a user‑friendly list of -missing paths and hints on how to provide them. For example: - -```plaintext -Missing required values: - sample_value (use --sample-value, SAMPLE_VALUE, or file entry) -``` - -### Preserving `clap` display exits +use ortho_config::{OrthoConfig, OrthoError}; +use serde::Deserialize; -When a user passes `--help` or `--version`, `clap` surfaces specialized -`ErrorKind::DisplayHelp` / `DisplayVersion` errors so applications can print -usage text and exit successfully. Deriving `OrthoConfig` often goes hand in -hand with `Cli::try_parse()` so applications can map errors into their own -types. Before performing that conversion, call -`ortho_config::is_display_request` to detect these cases and delegate to -`err.exit()`: +#[derive(Debug, Deserialize, OrthoConfig)] +struct Config { + port: u16, +} -```rust -use clap::Parser; -use ortho_config::{is_display_request, OrthoConfig}; - -fn parse_cli() -> Result { - match MyCli::try_parse() { - Ok(cli) => Ok(cli), - Err(mut err) => { - if is_display_request(&err) { - err.exit(); - } - Err(CliError::ArgumentParsing(err.into())) - } +fn main() { + match Config::load_from_iter(["acme", "--port", "not-a-number"]) { + Ok(config) => println!("port={}", config.port), + Err(error) => match error.as_ref() { + OrthoError::CliParsing(clap_error) => eprintln!("{clap_error}"), + other => eprintln!("configuration error: {other}"), + }, } } ``` -The `examples/hello_world` crate applies this pattern in `main.rs`. Behavioural -tests assert that both `--help` and `--version` exit with code 0 so regressions -are caught automatically. +Preserve `clap`'s display-only exits for `--help` and `--version`; use +`is_display_request` when a wider application error layer needs to distinguish +them. `OrthoError::try_aggregate` combines independent validation failures +without inventing an error for an empty collection. The result extension traits +`OrthoResultExt`, `OrthoMergeExt`, and `ResultIntoFigment` keep conversions +explicit at integration boundaries. -### Aggregating multiple errors +## Localize help and parse failures together -To return multiple errors in one go, use `OrthoError::aggregate`. It accepts -any iterator of items that can be converted into `Arc` so both -owned and shared errors are supported. If the list might be empty, -`OrthoError::try_aggregate` returns `Option` instead of panicking: +Localization is most reliable when the command metadata is translated before +parsing and any resulting error goes through the same localizer. +`LocalizedParse` provides that path for the common case: + ```rust -use std::sync::Arc; -use ortho_config::OrthoError; - -// From bare errors -let err = OrthoError::aggregate(vec![ - OrthoError::Validation { key: "port".into(), message: "must be positive".into() }, - OrthoError::gathering(figment::Error::from("invalid")), -]); - -// From shared errors -let err = OrthoError::aggregate(vec![ - Arc::new(OrthoError::Validation { key: "x".into(), message: "bad".into() }), - OrthoError::gathering_arc(figment::Error::from("boom")), -]); -``` - -### Gathering vs Merge errors - -`OrthoConfig` distinguishes between two phases of configuration loading: - -- **Gathering** (`OrthoError::Gathering`): Errors that occur while reading - configuration sources (files, environment variables). These indicate problems - with the source data itself, such as malformed TOML or invalid JSON. +use clap::Parser; +use ortho_config::{LocalizedParse, NoOpLocalizer}; -- **Merge** (`OrthoError::Merge`): Errors that occur while combining layers and - deserializing the final configuration. These indicate incompatibilities - between the merged data and the target struct, such as type mismatches or - invalid field values. +#[derive(Debug, Parser)] +#[command(name = "acme", bin_name = "acme")] +struct Cli { + #[arg(long)] + verbose: bool, +} -When deserializing the final merged configuration fails (for example, because a -field has an invalid type after all layers are combined), the error is reported -as `Merge`. This distinction helps diagnose whether an issue lies with a -specific source file (Gathering) or with the combined result of all layers -(Merge). +fn main() -> Result<(), clap::Error> { + let localizer = NoOpLocalizer::new(); + let cli = Cli::try_parse_localized_from( + ["acme", "--verbose"], + &localizer, + )?; + assert!(cli.verbose); + println!("verbose={}", cli.verbose); + Ok(()) +} +``` -### Mapping errors ergonomically +Use `FluentLocalizer` for translated catalogues. Use `LocalizeCmd::with_base` +with `parse_localized_command` when catalogue identifiers must use an explicit +root rather than the binary name. Missing translations fall back to the original +`clap` text and emit a warning event, so users still receive a useful error. -To reduce boiler‑plate when converting between error types, the crate exposes -small extension traits: +## Add production diagnostics -- `OrthoResultExt::into_ortho()` converts `Result` into - `OrthoResult` when `E: Into` (e.g., `serde_json::Error`). -- `OrthoMergeExt::into_ortho_merge()` converts `Result` - into `OrthoResult` as `OrthoError::Merge`. -- `OrthoJsonMergeExt::into_ortho_merge_json()` converts - `Result` into `OrthoResult` as `OrthoError::Merge`, - preserving location information from the JSON parser. -- `IntoFigmentError::into_figment()` converts `Arc` (or - `&Arc`) into `figment::Error` for interop in tests or adapters, - cloning the inner error to preserve structured details where possible. -- `ResultIntoFigment::to_figment()` converts `OrthoResult` into - `Result`. +OrthoConfig emits structured `tracing` events for discovery attempts, selected +files, skips, and failures. The library does not install a subscriber; the +binary should do that once during start-up. Add the subscriber with its +environment-filter support: -Examples: + +```toml +[dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +``` + ```rust -use ortho_config::{OrthoMergeExt, OrthoResultExt, ResultIntoFigment}; +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::Deserialize; -fn sanitize(v: &T) -> ortho_config::OrthoResult { - serde_json::to_value(v).into_ortho() +#[derive(Deserialize, OrthoConfig)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, } -fn extract(fig: figment::Figment) -> ortho_config::OrthoResult { - fig.extract::().into_ortho_merge() -} +fn main() -> OrthoResult<()> { + tracing_subscriber::fmt() + .with_env_filter("ortho_config=debug") + .with_writer(std::io::stderr) + .try_init() + .ok(); -fn interop(r: ortho_config::OrthoResult) -> Result { - r.to_figment() + let config = Config::load()?; + println!("port={}", config.port); + Ok(()) } ``` -## Documentation metadata (OrthoConfigDocs) +Do not log configuration values or candidate paths around these events. The +crate's own diagnostics avoid path and value fields because configuration +locations and values may be sensitive. -The derive macro now emits an `OrthoConfigDocs` implementation alongside the -runtime loader. This lets tooling such as `cargo-orthohelp` serialize a stable, -clap-agnostic intermediate representation (IR) for man pages and PowerShell -help. +Metrics are a low-cost opt-in when the application already has a `metrics` +recorder: -```rust -use ortho_config::docs::OrthoConfigDocs; + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["metrics"] } +``` -#[derive(serde::Deserialize, serde::Serialize, ortho_config::OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct AppConfig { - #[ortho_config(default = 8080)] - port: u16, -} +The feature emits bounded counters such as discovery attempts, outcomes, and +failures. OrthoConfig never installs a recorder, and enabling the feature does +nothing visible until the application installs one. -let ir = AppConfig::get_doc_metadata(); -let json = ortho_config::serde_json::to_string_pretty(&ir)?; -println!("{json}"); -``` +## Generate help from the same metadata -When IDs are not supplied, the macro generates deterministic defaults such as -`{app}.about` for the CLI overview and `{app}.fields.{field}.help` for field -descriptions. Field-level metadata can be refined with `help_id`, -`long_help_id`, `value(type = "...")`, `deprecated(note_id = "...")`, -`env(name = "...")`, and `file(key_path = "...")`. These documentation -attributes affect only the emitted IR; they do not change runtime naming or -loading behaviour. +`#[derive(OrthoConfig)]` also implements `OrthoConfigDocs`. The metadata +records fields, source names, precedence, discovery, defaults, and nested +subcommands. Derive `OrthoConfigSubcommandDocs` on a `clap::Subcommand` enum so +the generated tree includes every variant. -### Generating IR with cargo-orthohelp +Inspect the metadata in code: -`cargo-orthohelp` compiles a tiny bridge binary that calls -`OrthoConfigDocs::get_doc_metadata()`, resolves Fluent messages per locale, and -writes localized IR JSON into the chosen output directory. Add metadata to the -package `Cargo.toml` so the tool knows which config type to load: + +```rust +use ortho_config::{OrthoConfig, OrthoConfigDocs}; +use serde::{Deserialize, Serialize}; -```toml -[package.metadata.ortho_config] -root_type = "hello_world::cli::HelloWorldCli" -locales = ["en-US", "ja"] +#[derive(Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Config { + /// Address on which the service listens. + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, +} + +fn main() { + let metadata = Config::get_doc_metadata(); + assert_eq!(metadata.fields.len(), 1); + assert_eq!(metadata.fields[0].name, "host"); + println!("field={}", metadata.fields[0].name); +} ``` -Run the tool from the project root: +Or use `cargo-orthohelp` to emit intermediate representation (IR), Unix man +pages, PowerShell help, compact agent context, or all formats: -```bash -cargo-orthohelp --out-dir target/orthohelp --locale en-US + +```console +cargo orthohelp --package hello_world --format agent-context ``` -`--cache` reuses any previously generated IR cached under -`target/orthohelp//ir.json`, while `--no-build` skips the bridge build -and fails if the cache is missing. The generated per-locale JSON lives under -`/ir/.json` and is ready for downstream generators. +The tool builds a small bridge against the selected package. Keep the root +configuration type public and ensure its documentation metadata is available +from the selected library or binary target. `--format all` includes agent +context as well as IR, man pages, and PowerShell output. -### Generating man pages +## Offer a compact contract to automation -`cargo-orthohelp` can generate roff-formatted man pages from the localized IR. -Use `--format man` to produce `man/man/.` files suitable for -installation via `make install` or packaging: +Agent context complements human help with a small, stable JSON description of +commands, inputs, output modes, interaction, and mutation boundaries. A common +application convention is `context --json`; `cargo-orthohelp` uses +`--format agent-context` for generation. -```bash -cargo-orthohelp --format man --out-dir target/man --locale en-US +The smallest valid context created by `AgentContext::new("acme")` serializes to +this shape: + + +```json +{ + "schema_version": "1", + "kind": "acme.agent_context", + "package": "acme", + "commands": [], + "profiles": { "supported": false }, + "feedback": { "supported": false }, + "policy": { "agent_native": "warn" }, + "skill_manifests": [] +} ``` -The generator produces standard man page sections in the canonical order: +Fill `AgentCommand` entries only with claims the executable honours. +`SkillManifest` and `SkillCommandRef` link skills to real commands. They do not +replace command validation or grant an agent capabilities that the CLI does not +have. -1. **NAME** – binary name and one-line description -2. **SYNOPSIS** – usage pattern with flags -3. **DESCRIPTION** – expanded about text -4. **OPTIONS** – CLI flags with types, defaults, and possible values -5. **ENVIRONMENT** – environment variables mapped to fields -6. **FILES** – configuration file paths and discovery locations -7. **PRECEDENCE** – source priority order (defaults → file → env → CLI) -8. **EXAMPLES** – usage examples from the IR -9. **SEE ALSO** – related commands and documentation links -10. **EXIT STATUS** – standard exit codes +## Use an aliased dependency -Additional options: +Cargo permits dependency aliases. In v0.9.0 the derive macros can generate +paths through that alias, which is useful in workspaces that reserve the +canonical crate name: -- `--man-section ` – man page section number (default: 1) -- `--man-date ` – override the date shown in the footer -- `--man-split-subcommands` – generate separate man pages for each subcommand + +```toml +[dependencies] +config_layer = { package = "ortho_config", version = "0.9.0" } +serde = { version = "1.0", features = ["derive"] } +``` -Text is automatically escaped for roff: backslashes are doubled, and leading -dashes, periods, and single quotes are escaped to prevent macro interpretation. -Enum fields list their possible values in the OPTIONS description. +Name the alias on every type that derives an OrthoConfig macro: -### Generating PowerShell help + +```rust +use config_layer::{OrthoConfig, OrthoResult}; +use serde::Deserialize; -`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/`: +#[derive(Deserialize, OrthoConfig)] +#[ortho_config(crate = "config_layer", prefix = "ACME_")] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} -```bash -cargo-orthohelp --format ps --out-dir target/orthohelp --locale en-US +fn main() -> OrthoResult<()> { + let config = Config::load_from_iter(["acme"])?; + assert_eq!(config.port, 8080); + println!("port={}", config.port); + Ok(()) +} ``` -The generator produces: +The same attribute is supported by `SelectedSubcommandMerge`. OrthoConfig +re-exports the dependencies used by generated code, so a derive-only consumer +does not need direct `figment`, `uncased`, `xdg`, or format-parser dependencies. + +## Enable another file format -- `powershell//.psm1` – wrapper module. -- `powershell//.psd1` – module manifest. -- `powershell///-help.xml` – MAML help. -- `powershell///about_.help.txt` – about topic. +TOML is enabled by default. Enable `json5` or `yaml` when users already work in +that format. YAML uses YAML 1.2 semantics in v0.9.0: legacy words such as `yes` +and `on` remain strings, and duplicate mapping keys are rejected. -`en-US` help is always generated. If only other locales are rendered, the -generator copies the first locale into `en-US` unless fallback generation is -disabled with `--ensure-en-us false`. + +```yaml +enabled: yes +mode: on +port: 8080 +``` -PowerShell options: +Enable YAML with `features = ["yaml"]`; this also requires the `serde_json` +feature, which is part of the default feature set. If defaults are disabled, +enable both explicitly. Treat a change from v0.8.0 YAML parsing as a data +migration and run representative production files through v0.9.0 before +deploying. -- `--ps-module-name ` – override the module name (defaults to the binary - name). -- `--ps-split-subcommands ` – emit wrapper functions for subcommands. -- `--ps-include-common-parameters ` – include CommonParameters in MAML. -- `--ps-help-info-uri ` – set `HelpInfoUri` for Update-Help payloads. -- `--ensure-en-us ` – control the `en-US` fallback behaviour. +## A practical path from here -To set defaults in `Cargo.toml`, use the Windows metadata table: +For a new CLI, begin with the first layered struct and add only the required +sections. A typical progression is: -```toml -[package.metadata.ortho_config.windows] -module_name = "MyModule" -include_common_parameters = true -split_subcommands_into_functions = false -help_info_uri = "https://example.com/help/MyModule" -``` +1. choose stable CLI and environment names; +2. add a project file for durable settings; +3. customize discovery if the defaults are not part of the public interface; +4. split independent commands into subcommand configurations; +5. localize help and initialize tracing at the application boundary; and +6. generate human and agent documentation once the command surface stabilizes. -## Additional notes - -- **Vector merging** – For `Vec` fields the default merge strategy is - `append`, meaning that values from the configuration file appear first, then - environment variables and finally CLI arguments. Use - `merge_strategy = "append"` explicitly for clarity. When overrides should - discard earlier layers entirely (for example, to replace a default list with - a CLI-provided value) apply `merge_strategy = "replace"` instead. -- **Map merging** – Map fields (such as `BTreeMap`) default to keyed - merges, where later layers update only the entries they define. Apply - `merge_strategy = "replace"` when later layers must replace the entire map. - The hello_world example exposes a `greeting_templates` map that uses this - strategy, so declarative configuration files can swap the full template set - at once. - -- **Option<T> fields** – Fields of type `Option` are not treated as - required. They default to `None` and can be set via any source. Required CLI - arguments can be represented as `Option` to allow configuration defaults - while still requiring the CLI to provide a value when defaults are absent; - see the `vk` example above. - -- **Changing naming conventions** – Runtime naming continues to use the - default snake/hyphenated (underscores → hyphens)/upper snake mappings. For - documentation output, use `env(name = "...")` and `file(key_path = "...")` to - override IR metadata without altering runtime behaviour. - -- **Testing** – Because the CLI and environment variables are merged at - runtime, integration tests should set environment variables and construct CLI - argument vectors to exercise the merge logic. The `figment` crate makes it - easy to inject additional providers when writing unit tests. - -- **Sanitized providers** – The `sanitized_provider` helper returns a `Figment` - provider with `None` fields removed. It aids manual layering when bypassing - the derive macro. For example: - - ```rust - use figment::{Figment, providers::Serialized}; - use ortho_config::sanitized_provider; - - let fig = Figment::from(Serialized::defaults(&Defaults::default())) - .merge(sanitized_provider(&cli)?); - let cfg: Defaults = fig.extract()?; - ``` - -## Conclusion - -`OrthoConfig` streamlines configuration management in Rust applications. By -defining a single struct and annotating it with a small number of attributes, -developers obtain a full configuration parser that respects CLI arguments, -environment variables and configuration files with predictable precedence. -Subcommand support and integration with `clap‑dispatch` further reduce -boiler‑plate in complex CLI tools. The example `vk` repository demonstrates how -a real application can adopt `OrthoConfig` to handle global options and -subcommand defaults. Contributions to the project are welcome, and the design -documents outline planned improvements such as richer error messages and -support for additional naming strategies. +The [Hello World application](../examples/hello_world/) demonstrates these +pieces in a larger layout. The +[v0.9.0 migration guide](ortho-config-v0-9-0-migration-guide.md) explains compatibility +changes for existing v0.8.0 users, and the +[API documentation](https://docs.rs/ortho_config) is the source for complete +type and method signatures. diff --git a/docs/ortho-config-v0-9-0-migration-guide.md b/docs/ortho-config-v0-9-0-migration-guide.md new file mode 100644 index 000000000..c11d452d0 --- /dev/null +++ b/docs/ortho-config-v0-9-0-migration-guide.md @@ -0,0 +1,428 @@ +# Migration guide: v0.8.0 to v0.9.0 + +## Who should read this + +Read this guide before upgrading an application or library from OrthoConfig +v0.8.0 to v0.9.0. Most existing derives continue to compile unchanged, but two +runtime contracts need review: + +- `ConfigDiscovery::load_first` now reports accumulated candidate failures; and +- YAML files use YAML 1.2 parsing and reject duplicate keys. + +Everything else is additive or removes integration work. The sections below +separate required changes from improvements that can be adopted when useful. + +## Impact at a glance + +| Priority | Area | What to do | +| ---------------------- | --------------------------------- | ---------------------------------------------------------------------------------- | +| **Required** | Dependency versions | Update every direct `ortho_config` and `ortho_config_macros` requirement together. | +| **Required if called** | `ConfigDiscovery::load_first` | Handle `Err` when candidates exist but all readable candidates fail. | +| **Review if enabled** | YAML | Test representative files against YAML 1.2 booleans and duplicate-key rejection. | +| **Recommended** | Discovery tests | Replace process-environment mutation with `MapEnv`. | +| **Recommended** | Custom discovery | Move bespoke flag and filename wiring into `discovery(...)`. | +| **Recommended** | Localized CLIs | Parse through `LocalizedParse` or `parse_localized_command`. | +| **Recommended** | Subcommand documentation | Derive `OrthoConfigSubcommandDocs` so generated metadata is complete. | +| **Optional, low cost** | Dependency aliases and re-exports | Point derives at an alias and remove direct implementation-only dependencies. | +| **Optional, low cost** | Tracing and metrics | Observe discovery decisions; enable counters only when wanted. | +| **Optional** | Agent context | Publish a compact machine-readable command contract. | + +_Table 1: Application-facing work when moving from v0.8.0 to v0.9.0._ + +## 1. Update dependency versions + +Update the runtime and macro crates as one unit if both are direct dependencies. + +Before: + +```toml +[dependencies] +ortho_config = { version = "0.8.0", features = ["yaml"] } +ortho_config_macros = "0.8.0" +``` + +After: + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["yaml"] } +ortho_config_macros = "0.9.0" +``` + +Most applications only need `ortho_config`; it re-exports the derive macros. If +application source does not import `ortho_config_macros` directly, remove that +dependency rather than carrying two version requirements. + +Format features now flow from `ortho_config` to `ortho_config_macros`, so keep +`toml`, `json5`, and `yaml` on the runtime dependency. Equivalent feature +selections no longer need coordination on a direct macro dependency. + +## 2. Handle failed discovery explicitly + +### What breaks + +In v0.8.0, a caller could receive `Ok(None)` after discovery found candidates +but failed to load all of them. In v0.9.0, `ConfigDiscovery::load_first` +returns: + +- `Ok(Some(figment))` when a candidate loads; +- `Ok(None)` only when no candidate exists and discovery records no error; or +- `Err(error)` when every candidate fails and at least one failure was + recorded. + +This prevents a malformed, unreadable, or otherwise broken configuration from +being mistaken for an absent configuration. + +### Migration + +Do not collapse `Err` and `Ok(None)` into the same fallback: + +```rust +use ortho_config::{ConfigDiscovery, OrthoResult}; + +fn load_optional() -> OrthoResult<()> { + let discovery = ConfigDiscovery::builder("acme").build(); + + match discovery.load_first()? { + Some(figment) => { + let _loaded = figment; + println!("configuration loaded"); + } + None => println!("no configuration file found; using defaults"), + } + + Ok(()) +} +``` + +If v0.8.0 code intentionally ignored malformed optional files, reproduce that +policy explicitly at the application boundary and log it. Do not convert every +error into `None`; doing so reinstates the ambiguity this change removes. + +## 3. Review YAML files and feature selection + +### What can break + +The `yaml` feature now uses the `SaphyrYaml` provider backed by `serde-saphyr`. +It follows YAML 1.2 semantics: + +- unquoted `yes`, `no`, `on`, and `off` are strings rather than booleans; and +- duplicate mapping keys are errors rather than silently overwriting an + earlier value. + +A v0.8.0 file that relied on YAML 1.1 boolean spellings may fail to deserialize +into a Boolean field. A file containing duplicate keys now fails early. + +Before, where `yes` could be interpreted as a Boolean: + +```yaml +enabled: yes +``` + +After, use an unambiguous YAML 1.2 Boolean: + +```yaml +enabled: true +``` + +Remove duplicate keys and decide which value should survive. Run production +samples through v0.9.0 as part of the upgrade rather than waiting for the first +deployment load. + +The `yaml` feature requires `serde_json`, which is enabled by default. A +consumer using `default-features = false` must select both: + +```toml +ortho_config = { + version = "0.9.0", + default-features = false, + features = ["serde_json", "yaml"] +} +``` + +The old transitive `figment/yaml` integration is gone. Application code that +directly named its provider should use `ortho_config::serde_saphyr` or +OrthoConfig's file-loading APIs. + +## 4. Expect clearer inheritance errors + +Missing `extends` targets now report the resolved absolute path and the file +that referenced it. This changes error text, not the success path. + +Update snapshot or approval tests that assert the v0.8.0 message. Replace any +parsing of human-readable error strings with matching on the public error type +or with an application-owned error mapping. The more precise message is +intended for people and is not a stable machine protocol. + +## 5. Adopt hermetic discovery tests + +### Why change an existing pattern + +Production discovery still reads the live process environment by default, so no +application change is required. Tests can now inject `MapEnv` through +`ConfigDiscoveryBuilder::env_source`, avoiding global environment mutation and +serialization locks: + +```rust +use ortho_config::{ConfigDiscovery, MapEnv}; +use std::sync::Arc; + +let environment = Arc::new( + MapEnv::new().with_var("ACME_CONFIG", "/etc/acme/config.toml"), +); +let discovery = ConfigDiscovery::builder("acme") + .env_var("ACME_CONFIG") + .env_source(environment) + .build(); + +assert_eq!( + discovery.candidates().first().map(|path| path.as_path()), + Some(std::path::Path::new("/etc/acme/config.toml")) +); +``` + +`MapEnv` supports `with_var`, `insert`, `remove`, and `FromIterator`. A custom +source can implement the object-safe `EnvSource` trait. + +Two boundaries matter: + +- injection controls discovery inputs: the explicit selector, XDG or Windows + base directories, and home-directory resolution; +- it does not yet replace the `APP_*` configuration-value merge layer, which + still uses the process environment. + +`MapEnv::home_fallback` returns `None`, preventing an injected test from +silently using the host's home directory. `ProcessEnv`, the production default, +preserves the v0.8.0 platform fallback. + +## 6. Declare discovery beside the configuration + +v0.8.0 applications often assembled `ConfigDiscovery` manually to rename the +configuration option or searched files. v0.9.0 can generate that wiring from +the derive: + +```rust +use ortho_config::OrthoConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, OrthoConfig)] +#[ortho_config( + prefix = "ACME_", + discovery( + app_name = "acme-server", + config_file_name = "server.toml", + dotfile_name = ".acme-server.toml", + project_file_name = ".acme-server.toml", + config_cli_long = "config", + config_cli_short = 'c', + config_cli_visible = true + ) +)] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} +``` + +This is recommended when those names are part of the public CLI contract. It +keeps loading and generated documentation in agreement. Existing manual +`ConfigDiscovery` code remains supported and need not change if it performs +application-specific work that the attribute does not express. + +## 7. Complete documentation for subcommands + +`OrthoConfigDocs` metadata now supports recursive `DocMetadata.subcommands`. +Derive `OrthoConfigSubcommandDocs` on the enum stored in a +`#[command(subcommand)]` field: + +```rust +use clap::{Args, Parser, Subcommand}; +use ortho_config::{OrthoConfig, OrthoConfigSubcommandDocs}; +use serde::{Deserialize, Serialize}; + +#[derive(Parser, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_")] +struct Cli { + #[serde(skip)] + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, OrthoConfigSubcommandDocs)] +enum Commands { + Serve(ServeConfig), +} + +impl Default for Commands { + fn default() -> Self { + Self::Serve(ServeConfig::default()) + } +} + +#[derive(Default, Args, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "ACME_SERVE_")] +struct ServeConfig { + #[arg(long)] + port: Option, +} +``` + +Loading behaviour is unchanged. Adopt the derive to let `cargo-orthohelp` +produce complete nested IR, man pages, PowerShell help, and agent context. +Renderer integrations that deserialize `DocMetadata` should tolerate the new +recursive field and use `ORTHO_DOCS_IR_VERSION` rather than assuming the v0.8.0 +shape. + +Generated support structs now carry doc comments. Crates with strict +`missing_docs` should be able to remove workarounds that suppressed warnings +originating in the derive. + +## 8. Localize the whole parse path + +v0.8.0 exposed helpers for translating command metadata and errors separately. +It was easy for an application to localize `--help` but return an untranslated +parse error. v0.9.0 adds two public entry points: + +- `LocalizedParse` builds, localizes, parses, and localizes failures for any + `clap::Parser`; +- `parse_localized_command` does the same when the application has already + built a command or uses `LocalizeCmd::with_base`. + +For the common case: + +```rust +use clap::Parser; +use ortho_config::{LocalizedParse, NoOpLocalizer}; + +#[derive(Parser)] +#[command(name = "acme", bin_name = "acme")] +struct Cli { + #[arg(long)] + verbose: bool, +} + +let localizer = NoOpLocalizer::new(); +let cli = Cli::try_parse_localized_from(["acme", "--verbose"], &localizer)?; +assert!(cli.verbose); +# Ok::<(), clap::Error>(()) +``` + +Existing calls to `LocalizeCmd`, `localize_clap_error`, and +`localize_clap_error_with_command` remain available. Move to the combined path +when possible so future command changes cannot bypass localization. + +## 9. Use dependency aliases and runtime re-exports + +### Aliases + +Derive-generated paths previously assumed that the dependency was named +`ortho_config`. If a workspace aliases it, tell the derive which path to use: + +```toml +[dependencies] +config_layer = { package = "ortho_config", version = "0.9.0" } +``` + +```rust +use config_layer::OrthoConfig; +use serde::Deserialize; + +#[derive(Deserialize, OrthoConfig)] +#[ortho_config(crate = "config_layer", prefix = "ACME_")] +struct Config { + #[ortho_config(default = 8080)] + port: u16, +} +``` + +Apply the same `crate = "..."` attribute to enums deriving +`SelectedSubcommandMerge`. Canonically named dependencies need no attribute. + +### Re-exports + +The runtime now re-exports `figment`, `uncased`, `xdg` on supported platforms, +and enabled format parsers (`figment_json5`, `json5`, `serde_saphyr`, and +`toml`). Generated code uses those paths, so derive-only consumers can remove +direct dependencies that existed solely to satisfy macro expansion. + +Keep a direct dependency when application source imports that crate itself or +needs a different feature set. This cleanup is optional; retaining a compatible +direct dependency does not change behaviour. + +## 10. Observe discovery safely + +v0.9.0 emits structured `tracing` events for discovery source selection, +selector resolution, platform-directory resolution, attempts, candidate +outcomes, and the terminal load outcome. A successful load identifies the +bounded source category that won. + +No feature is required for tracing. The library installs no subscriber, so +existing applications remain quiet unless their subscriber enables these +events. Use `ortho_config=debug` while diagnosing discovery. + +Event fields come from a closed vocabulary and exclude environment values, +resolved paths, and file contents. `Debug` implementations for `MapEnv` and +`ConfigDiscoveryBuilder` follow the same rule. Subscriber-added span fields +remain the application's responsibility. + +Applications with a metrics recorder can also enable: + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["metrics"] } +``` + +This emits: + +- `ortho_config.discovery.attempts`, labelled by operation; +- `ortho_config.discovery.outcomes`, labelled by operation and outcome; and +- `ortho_config.discovery.candidate_failures`, labelled by operation, bounded + source, and error category. + +The feature is off by default and never installs a recorder. It is therefore a +small opt-in for applications that already export `metrics` data. + +## 11. Add agent-facing context when useful + +v0.9.0 introduces a machine-readable agent-context model alongside the human +documentation IR. Public types include `AgentContext`, `AgentCommand`, +`AgentInput`, `AgentExample`, policy and effect enums, `SkillManifest`, and +`SkillCommandRef`. + +Adoption is optional. A CLI can expose a downstream `context --json` command, +and `cargo-orthohelp --format agent-context` generates the same class of +compact contract. `--format all` now includes agent context. Consumers should +use the `schema_version` and `kind` fields when validating the document. + +`AgentContext.skill_manifests` contains structured descriptors, not bare paths, +and defaults to an empty list during deserialization. Applications with an +earlier design draft using `skill_manifest_paths` should rename that field and +map entries to `SkillManifest`. That design name was not a v0.8.0 runtime API, +so published v0.8.0 users have no required code change. + +## 12. Do not depend on proposed errors + +`OrthoError::MissingRequiredValues` is not part of v0.9.0. It remains proposed +future work. Continue matching the error variants actually exported by the +crate, and keep a wildcard arm because `OrthoError` is non-exhaustive. + +## Upgrade checklist + +- [ ] Update every OrthoConfig crate requirement to v0.9.0. +- [ ] Audit every `ConfigDiscovery::load_first` call for distinct absent and + failed paths. +- [ ] If YAML is enabled, test real files for YAML 1.2 booleans and duplicate + keys. +- [ ] Update snapshots that assert missing-`extends` error text. +- [ ] Run tests with the feature combinations shipped by the application. +- [ ] Prefer `MapEnv` in discovery tests that currently mutate global state. +- [ ] Adopt `discovery(...)`, combined localization, and subcommand docs where + they replace application glue. +- [ ] Remove implementation-only direct dependencies only after confirming + application source does not import them. +- [ ] Enable metrics or agent context only when the application has a consumer + for them. + +The [user's guide](ortho-config-users-guide.md) contains complete worked +examples for each new pattern. The [changelog](../CHANGELOG.md) remains the +concise release inventory. diff --git a/docs/users-guide.md b/docs/users-guide.md index 49c15d959..d24d61b36 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -851,6 +851,11 @@ this order: An explicit file that is missing or invalid causes an error; Netsuke does not fall back to discovery. +When automatic discovery finds no configuration file, Netsuke uses its built-in +defaults. When it finds a candidate that cannot be loaded, such as malformed +TOML or a file whose `extends` parent is missing, Netsuke reports the load +error. A broken discovered configuration is therefore not treated as absent. + ### Diagnose configuration selection Pass `--verbose` to see how Netsuke selected its configuration. Structured diff --git a/dylint.toml b/dylint.toml index 7ff5443d7..d5a067c10 100644 --- a/dylint.toml +++ b/dylint.toml @@ -24,12 +24,12 @@ excluded_paths = [ # handles — stays under the policy. "netsuke::runner::process::file_io::ambient_sync", # Canonicalises a caller-supplied `--directory` so it can be compared with - # the layer paths `ortho_config` records, which that crate canonicalises - # through `std::fs`. The comparison has to mirror it exactly, and the - # directory is ambient input that may be relative or symlinked, which - # `cap_std` refuses to resolve across directory boundaries. + # the layer paths `ortho_config` records. The comparison must mirror + # OrthoConfig exactly: `cap_std` rejects aliases that cross a directory + # boundary, while standard canonicalization resolves them. Whitaker scopes + # filesystem policy at module boundaries, so this small path-normalization + # module is the narrowest available exception. "netsuke::cli::discovery::paths", - # Behavioural step definitions that stage scenario fixtures — manifests, # workspaces, and captured output — through ambient tempdir access. "bdd_tests::bdd::steps::advanced_usage", @@ -53,8 +53,9 @@ excluded_paths = [ # The build script's parsers, included by path so `cargo test` can reach # them (build scripts are not test targets). Each reads a file Cargo hands - # it through `build_script_build`, which is excluded below for the same - # reason; these entries cover the copies compiled into the test crates. + # it through the build script; these entries cover the copies compiled into + # the test crates. The build script compiles only the parser subset, so it + # does not include the discovery path normalizer. "build_l10n_keys_tests::keys", "build_l10n_parser_tests::ftl", "build_l10n_audit_rules_tests::ftl", @@ -62,9 +63,6 @@ excluded_paths = [ # Whole crates whose ambient filesystem access lives in the crate root, so a # path entry would be no narrower than a crate entry: -# - build_script_build: the Cargo build script writes generated man pages and -# audit artefacts to ambient paths supplied by Cargo (`OUT_DIR`, `target/`), -# where capability-based handles offer no benefit. # - integration test crates (tests/*.rs): fixture management writes manifests, # workspaces, and captured output with ambient tempdir access, which the # Whitaker user's guide lists as a sanctioned exclusion. @@ -77,7 +75,6 @@ excluded_paths = [ # loaded by the crate-scoped `make lint-whitaker` pass. That configuration # narrows the exemption to `test_support::fs` rather than exempting the crate. excluded_crates = [ - "build_script_build", # Stages the audit's inputs into a TempDir and runs the real audit over # them and over CARGO_MANIFEST_DIR. Both roots are ambient by # construction, and the audit modules it includes by path read files the diff --git a/scripts/generate-release-help.sh b/scripts/generate-release-help.sh index 910adede5..ae2128281 100755 --- a/scripts/generate-release-help.sh +++ b/scripts/generate-release-help.sh @@ -130,7 +130,7 @@ run_cargo_orthohelp() { echo "::notice title=cargo-orthohelp invocation::$(annotation_context "$format")" >&2 local output - if ! output="$(cargo-orthohelp "$@" 2>&1)"; then + if ! output="$(cargo-orthohelp orthohelp "$@" 2>&1)"; then if [[ -n "$output" ]]; then echo "$output" >&2 fi diff --git a/src/cli/build_support.rs b/src/cli/build_support.rs new file mode 100644 index 000000000..c523eefe1 --- /dev/null +++ b/src/cli/build_support.rs @@ -0,0 +1,25 @@ +//! Build-script composition root for the CLI parser. +//! +//! The release manual needs Clap metadata from [`Cli`], but not runtime +//! configuration discovery or command merging. Keeping this subset separate +//! prevents the build script from compiling those runtime-only boundaries. + +use ortho_config::OrthoError; +use std::sync::Arc; + +mod config; +mod parser; +mod parsing; + +pub use config::{AccessibilityPolicy, CliConfig, ColourPolicy, EmojiPolicy, ProgressPolicy}; +pub use parser::Cli; + +/// Maximum number of jobs accepted by the CLI. +pub(super) const MAX_JOBS: usize = 64; + +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/diag.rs b/src/cli/diag.rs index 497c88693..fff7c9288 100644 --- a/src/cli/diag.rs +++ b/src/cli/diag.rs @@ -11,7 +11,10 @@ use ortho_config::{OrthoError, OrthoResult}; use serde_json::Value; use std::sync::Arc; -use super::discovery::{EnvProvider, StdEnvProvider, collect_diag_file_layers_with_env}; +use super::discovery::{ + DiscoverySources, EnvProvider, StdEnvProvider, collect_diag_file_layers_with_sources, + discovery_env_source, +}; use super::parser::Cli; const JSON_ENV_VAR: &str = "NETSUKE_JSON"; @@ -26,7 +29,12 @@ const JSON_ENV_VAR: &str = "NETSUKE_JSON"; /// Returns an [`ortho_config::OrthoError`] when a selected config file cannot /// be loaded, or when `NETSUKE_JSON` contains an invalid boolean. pub fn resolve_merged_json(cli: &Cli, matches: &ArgMatches) -> OrthoResult { - resolve_merged_json_with_env(cli, matches, &StdEnvProvider) + resolve_merged_json_with_sources( + cli, + matches, + &StdEnvProvider, + Arc::new(ortho_config::ProcessEnv), + ) } /// Resolve the JSON preference using an injected environment provider. @@ -43,7 +51,18 @@ pub fn resolve_merged_json_with_env( matches: &ArgMatches, env: &impl EnvProvider, ) -> OrthoResult { - let mut json = json_from_file_layers(cli, env)?; + resolve_merged_json_with_sources(cli, matches, env, discovery_env_source(env)) +} + +/// Resolve JSON using the same selector and discovery adapters as merging. +fn resolve_merged_json_with_sources( + cli: &Cli, + matches: &ArgMatches, + env: &impl EnvProvider, + discovery_env: ortho_config::SharedEnvSource, +) -> OrthoResult { + let sources = DiscoverySources::new(env, discovery_env); + let mut json = json_from_file_layers(cli, &sources)?; if !has_cli_json_override(matches) && let Some(env_json) = json_from_env(env)? { @@ -74,9 +93,12 @@ fn has_cli_json_override(matches: &ArgMatches) -> bool { } /// Resolve the last valid JSON preference from the selected config layers. -fn json_from_file_layers(cli: &Cli, env: &impl EnvProvider) -> OrthoResult { +fn json_from_file_layers( + cli: &Cli, + sources: &DiscoverySources<'_, impl EnvProvider>, +) -> OrthoResult { let default = Cli::default().json; - let layers = collect_diag_file_layers_with_env(cli, env)?; + let layers = collect_diag_file_layers_with_sources(cli, sources)?; let mut json = default; for layer in layers { if let Some(layer_json) = json_from_layer(&layer.into_value()) { diff --git a/src/cli/discovery.rs b/src/cli/discovery.rs index 5455335e4..0bc3ed9aa 100644 --- a/src/cli/discovery.rs +++ b/src/cli/discovery.rs @@ -4,7 +4,9 @@ //! through [`ConfigDiscovery`], handling explicit paths from CLI flags and //! environment variables, and loading TOML chains into [`MergeLayer`] values. -use ortho_config::{MergeComposer, MergeLayer, OrthoResult, load_config_file_as_chain}; +use ortho_config::{ + MapEnv, MergeComposer, MergeLayer, OrthoResult, SharedEnvSource, load_config_file_as_chain, +}; use std::borrow::Cow; use std::ffi::OsString; use std::io; @@ -26,9 +28,18 @@ use diagnostics::{ ConfigLoadFailureKind, debug_config_path, path_hash, trace_config_path_variable, warn_explicit_config_load_failed, }; -use layers::collect_file_layers; +use layers::collect_file_layers_with_env_source; const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG"; +const DISCOVERY_ENV_KEYS: [&str; 7] = [ + CONFIG_ENV_VAR, + "HOME", + "USERPROFILE", + "XDG_CONFIG_HOME", + "XDG_CONFIG_DIRS", + "APPDATA", + "LOCALAPPDATA", +]; /// Provides access to environment variables used during config discovery. /// @@ -66,17 +77,32 @@ impl EnvProvider for StdEnvProvider { } } -/// Load configuration layers with environment access supplied by `env`. +/// Environment adapters consumed by configuration file discovery. /// -/// Loading errors are appended to `errors`, matching the normal merge path -/// without requiring callers to mutate the process environment. -pub(crate) fn push_file_layers_with_env( +/// The value adapter remains the Netsuke-owned [`EnvProvider`] port, whereas +/// `discovery_env` is the deliberately narrow `OrthoConfig` adapter. Keeping +/// them together makes every composition root choose both dependencies +/// explicitly and prevents a test-only environment from leaking ambient reads. +pub(crate) struct DiscoverySources<'a, E: EnvProvider + ?Sized> { + env: &'a E, + discovery_env: SharedEnvSource, +} + +impl<'a, E: EnvProvider + ?Sized> DiscoverySources<'a, E> { + /// Construct the discovery adapters selected by one composition root. + pub(crate) fn new(env: &'a E, discovery_env: SharedEnvSource) -> Self { + Self { env, discovery_env } + } +} + +/// Load configuration layers with explicit selector and discovery adapters. +pub(crate) fn push_file_layers_with_sources( cli: &Cli, composer: &mut MergeComposer, errors: &mut Vec>, - env: &impl EnvProvider, + sources: &DiscoverySources<'_, impl EnvProvider>, ) { - match collect_file_layers_with_env(cli, env) { + match collect_file_layers_with_env(cli, sources) { Ok(layers) => { for layer in layers { composer.push_layer(layer); @@ -92,14 +118,17 @@ pub(crate) fn push_file_layers_with_env( /// select the same file layers while retaining their own error handling. fn collect_file_layers_with_env( cli: &Cli, - env: &impl EnvProvider, + sources: &DiscoverySources<'_, impl EnvProvider>, ) -> OrthoResult>> { - let resolution = resolve_config_selector(cli.config.clone(), env); + let resolution = resolve_config_selector(cli.config.clone(), sources.env); trace_config_path_resolution(&resolution); resolution.path.map_or_else( || { debug!("using config discovery"); - collect_file_layers(cli.directory.as_deref()) + collect_file_layers_with_env_source( + cli.directory.as_deref(), + Arc::clone(&sources.discovery_env), + ) }, |path| { debug_config_path("using explicit config path", &path); @@ -108,6 +137,21 @@ fn collect_file_layers_with_env( ) } +/// Project the fixed discovery inputs from Netsuke's environment port. +/// +/// This adapter is private to CLI configuration composition. It intentionally +/// exposes only discovery's documented lookup keys: `EnvironmentLayer` remains +/// the sole owner of complete `NETSUKE_*` enumeration for value merging. +pub(crate) fn discovery_env_source(env: &impl EnvProvider) -> SharedEnvSource { + let mut source = MapEnv::new(); + for key in DISCOVERY_ENV_KEYS { + if let Some(value) = env.get(key) { + source.insert(key, value); + } + } + Arc::new(source) +} + /// Select an explicit config path, giving `--config` precedence over `env`. /// /// A thin wrapper over [`resolve_config_selector`] for callers that need only @@ -215,15 +259,22 @@ pub(crate) fn load_layers_from_path( } } -/// Load file layers for early JSON resolution using injected environment access. -/// -/// This delegates to the same precedence boundary as the normal merge path. -pub(crate) fn collect_diag_file_layers_with_env( +/// Load diagnostic file layers with the same discovery adapter as merging. +pub(crate) fn collect_diag_file_layers_with_sources( cli: &Cli, - env: &impl EnvProvider, + sources: &DiscoverySources<'_, impl EnvProvider>, ) -> OrthoResult>> { let _span = debug_span!("collect_diag_file_layers").entered(); - collect_file_layers_with_env(cli, env) + collect_file_layers_with_env(cli, sources) +} + +#[cfg(test)] +fn collect_diag_file_layers_with_env( + cli: &Cli, + env: &impl EnvProvider, +) -> OrthoResult>> { + let sources = DiscoverySources::new(env, discovery_env_source(env)); + collect_diag_file_layers_with_sources(cli, &sources) } #[cfg(test)] diff --git a/src/cli/discovery_helper_proptests.rs b/src/cli/discovery_helper_proptests.rs index 95bc8269f..8b93539b7 100644 --- a/src/cli/discovery_helper_proptests.rs +++ b/src/cli/discovery_helper_proptests.rs @@ -126,6 +126,35 @@ fn normalized_path_key_resolves_non_canonical_forms( Ok(()) } +/// Normalization follows a project alias into a different directory. +/// +/// Discovery accepts user-supplied paths, so its capability root must cover +/// the full absolute path rather than rejecting a symlink that leaves the +/// alias's parent directory. +#[cfg(unix)] +#[test] +fn normalized_path_key_follows_cross_directory_symlinks() -> Result<()> { + let temp = tempdir().context("create temp dir")?; + let target = temp.path().join("project"); + let aliases = temp.path().join("aliases"); + test_support::fs::create_dir(&target).context("create project dir")?; + test_support::fs::create_dir(&aliases).context("create aliases dir")?; + + let alias = aliases.join("project-link"); + test_support::fs::symlink(&target, &alias).context("create project alias")?; + + let normalized = normalized_path_key(&FsPathNormalizer, &alias.to_string_lossy()) + .context("normalize project alias")?; + let expected = normalized_path_key(&FsPathNormalizer, &target.to_string_lossy()) + .context("normalize project path")?; + + ensure!( + normalized == expected, + "project alias {alias:?} should normalize to {expected:?}, got {normalized:?}" + ); + Ok(()) +} + /// `normalized_path_key` propagates the normalizer's error unchanged. #[test] fn normalized_path_key_propagates_normalizer_failure() -> Result<()> { diff --git a/src/cli/discovery_layer_tests.rs b/src/cli/discovery_layer_tests.rs index e315ce44b..936c777fb 100644 --- a/src/cli/discovery_layer_tests.rs +++ b/src/cli/discovery_layer_tests.rs @@ -8,11 +8,13 @@ use super::*; use crate::cli::test_support::TestEnv; use anyhow::{Context, Result, ensure}; +use googletest::prelude::*; +use pretty_assertions::assert_eq; use rstest::rstest; use tempfile::{TempDir, tempdir}; use super::event_assertions::{EventAssertion, capture_events, find_event}; -use super::layers::collect_file_layers_with_normalizer; +use super::layers::{collect_file_layers, collect_file_layers_with_normalizer}; use super::paths::FailingPathNormalizer; #[derive(Debug, Clone, Copy)] @@ -82,6 +84,69 @@ fn collect_diag_file_layers_logs_selected_branch( Ok(()) } +/// Automatic discovery must use the injected XDG directory, not the host. +#[test] +fn injected_automatic_discovery_uses_xdg_config_home() -> Result<()> { + let temp = tempdir().context("create temp dir")?; + let xdg_config_home = temp.path().join("xdg-config"); + let config_path = xdg_config_home.join("netsuke/config.toml"); + test_support::fs::create_dir(&xdg_config_home).context("create injected XDG directory")?; + test_support::fs::create_dir(config_path.parent().context("config parent")?) + .context("create injected config directory")?; + test_support::fs::write(&config_path, "json = true\n").context("write injected config")?; + + let env = TestEnv::default().with_var("XDG_CONFIG_HOME", xdg_config_home.as_os_str()); + let sources = DiscoverySources::new(&env, discovery_env_source(&env)); + let layers = collect_file_layers_with_env(&Cli::default(), &sources)?; + let paths = layers + .iter() + .filter_map(|layer| layer.path().map(|path| path.as_str().to_owned())) + .collect::>(); + + assert_eq!(paths, vec![config_path.to_string_lossy().into_owned()]); + Ok(()) +} + +/// Discovered configuration candidates retain the outcome that their content +/// warrants; an unreadable candidate is never mistaken for an absent one. +#[rstest] +#[case::no_candidate(None, None, 0)] +#[case::valid_candidate(Some("emoji = \"always\"\n"), None, 1)] +#[case::malformed_candidate(Some("emoji = \"always\n"), Some(".netsuke.toml"), 0)] +#[case::missing_parent( + Some("extends = \"missing-parent.toml\"\n"), + Some("missing-parent.toml"), + 0 +)] +fn discovered_project_config_retains_load_outcome( + #[case] contents: Option<&str>, + #[case] expected_error_fragment: Option<&str>, + #[case] expected_layer_count: usize, +) -> Result<()> { + let temp = tempdir().context("create temp dir")?; + if let Some(config_contents) = contents { + test_support::fs::write(temp.path().join(".netsuke.toml"), config_contents) + .context("write project config")?; + } + + let cli = Cli { + directory: Some(temp.path().to_path_buf()), + ..Cli::default() + }; + let env = TestEnv::default(); + let sources = DiscoverySources::new(&env, discovery_env_source(&env)); + let result = collect_file_layers_with_env(&cli, &sources); + + if let Some(fragment) = expected_error_fragment { + let error = result.expect_err("invalid discovered config must fail"); + assert_that!(error.to_string(), contains_substring(fragment)); + } else { + let layers = result.context("valid discovered config must load")?; + assert_eq!(layers.len(), expected_layer_count); + } + Ok(()) +} + /// A project-scope layer already found by discovery is not appended again. /// /// `OrthoConfig` records canonicalised layer paths, so a non-canonical diff --git a/src/cli/discovery_layers.rs b/src/cli/discovery_layers.rs index 19816edf5..6b4cb8f56 100644 --- a/src/cli/discovery_layers.rs +++ b/src/cli/discovery_layers.rs @@ -4,9 +4,13 @@ //! project `.netsuke.toml` outranks user-scope files. Path comparison and its //! fallback policy live here because that policy is a discovery decision. -use ortho_config::{ConfigDiscovery, MergeLayer, OrthoResult, load_config_file_as_chain}; +use ortho_config::{ + ConfigDiscovery, MergeLayer, OrthoResult, SharedEnvSource, load_config_file_as_chain, +}; use std::borrow::Cow; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::Arc; use super::CONFIG_ENV_VAR; use super::diagnostics::debug_optional_config_path; @@ -16,18 +20,29 @@ use super::paths::{FsPathNormalizer, PathNormalizer, normalized_path_key}; /// /// Anchors the project root to `directory` when supplied; otherwise the default /// project roots apply. `NETSUKE_CONFIG` is registered as the discovery env var. -fn config_discovery(directory: Option<&PathBuf>) -> ConfigDiscovery { - let mut builder = ConfigDiscovery::builder("netsuke").env_var(CONFIG_ENV_VAR); +fn config_discovery(directory: Option<&PathBuf>, env_source: SharedEnvSource) -> ConfigDiscovery { + let mut builder = ConfigDiscovery::builder("netsuke") + .env_var(CONFIG_ENV_VAR) + .env_source(env_source); if let Some(dir) = directory { builder = builder.clear_project_roots().add_project_root(dir); } builder.build() } +#[cfg(test)] pub(crate) fn collect_file_layers( directory: Option<&Path>, ) -> OrthoResult>> { - collect_file_layers_with_normalizer(directory, &FsPathNormalizer) + collect_file_layers_with_env_source(directory, Arc::new(ortho_config::ProcessEnv)) +} + +/// Collect layers with the environment source chosen at the composition root. +pub(super) fn collect_file_layers_with_env_source( + directory: Option<&Path>, + env_source: SharedEnvSource, +) -> OrthoResult>> { + collect_file_layers_with_normalizer_and_env_source(directory, &FsPathNormalizer, env_source) } /// Return the key used to compare `path` against the expected project file. @@ -43,11 +58,25 @@ fn comparison_key(normalizer: &impl PathNormalizer, path: &str) -> PathBuf { } /// Build the discovery layer chain, resolving paths through `normalizer`. +#[cfg(test)] pub(super) fn collect_file_layers_with_normalizer( directory: Option<&Path>, normalizer: &impl PathNormalizer, ) -> OrthoResult>> { - let discovery = config_discovery(directory.map(PathBuf::from).as_ref()); + collect_file_layers_with_normalizer_and_env_source( + directory, + normalizer, + Arc::new(ortho_config::ProcessEnv), + ) +} + +/// Build the discovery layer chain with an injected environment source. +fn collect_file_layers_with_normalizer_and_env_source( + directory: Option<&Path>, + normalizer: &impl PathNormalizer, + env_source: SharedEnvSource, +) -> OrthoResult>> { + let discovery = config_discovery(directory.map(PathBuf::from).as_ref(), env_source); let mut file_layers = discovery.compose_layers(); let mut errors = file_layers.required_errors; if file_layers.value.is_empty() { diff --git a/src/cli/discovery_paths.rs b/src/cli/discovery_paths.rs index dfc9a411d..5726beb10 100644 --- a/src/cli/discovery_paths.rs +++ b/src/cli/discovery_paths.rs @@ -12,7 +12,7 @@ pub(super) trait PathNormalizer { fn normalize(&self, path: &Path) -> io::Result; } -/// Production normalizer backed by [`std::fs::canonicalize`]. +/// Production normalizer that exactly mirrors `OrthoConfig`'s canonical paths. #[derive(Debug, Default, Clone, Copy)] pub(super) struct FsPathNormalizer; diff --git a/src/cli/merge.rs b/src/cli/merge.rs index cc7344a19..6af1a3413 100644 --- a/src/cli/merge.rs +++ b/src/cli/merge.rs @@ -26,11 +26,15 @@ use ortho_config::declarative::LayerComposition; use ortho_config::figment::Figment; use ortho_config::{MergeComposer, OrthoMergeExt, OrthoResult, sanitize_value}; use serde::Serialize; +use std::sync::Arc; use serde_json::{Map, Value, json}; use super::config::{BuildConfig, CliConfig}; -use super::discovery::{EnvProvider, StdEnvProvider, push_file_layers_with_env}; +use super::discovery::{ + DiscoverySources, EnvProvider, StdEnvProvider, discovery_env_source, + push_file_layers_with_sources, +}; use super::environment::EnvironmentLayer; use super::parser::{BuildArgs, Cli, Commands}; use super::validation_error; @@ -42,7 +46,12 @@ use super::validation_error; /// Returns an [`ortho_config::OrthoError`] if layer composition or merging /// fails. pub fn merge_with_config(cli: &Cli, matches: &ArgMatches) -> OrthoResult { - merge_with_config_and_env(cli, matches, &StdEnvProvider) + merge_with_config_sources( + cli, + matches, + &StdEnvProvider, + Arc::new(ortho_config::ProcessEnv), + ) } /// Merge configuration layers using an explicit environment provider. @@ -59,6 +68,16 @@ pub fn merge_with_config_and_env( cli: &Cli, matches: &ArgMatches, env: &impl EnvProvider, +) -> OrthoResult { + merge_with_config_sources(cli, matches, env, discovery_env_source(env)) +} + +/// Merge configuration using distinct value and discovery adapters. +fn merge_with_config_sources( + cli: &Cli, + matches: &ArgMatches, + env: &impl EnvProvider, + discovery_env: ortho_config::SharedEnvSource, ) -> OrthoResult { let mut errors = Vec::new(); let mut composer = MergeComposer::with_capacity(4); @@ -68,7 +87,8 @@ pub fn merge_with_config_and_env( Err(err) => errors.push(err), } - push_file_layers_with_env(cli, &mut composer, &mut errors, env); + let discovery_sources = DiscoverySources::new(env, discovery_env); + push_file_layers_with_sources(cli, &mut composer, &mut errors, &discovery_sources); match Figment::from(EnvironmentLayer::new(env.entries())) .extract::() diff --git a/src/cli/parser.rs b/src/cli/parser.rs index 260eac883..b1e10970c 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -16,9 +16,8 @@ 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 clap::{ArgMatches, Args, CommandFactory, Parser, Subcommand}; +use ortho_config::{LocalizationArgs, Localizer, parse_localized_command}; use serde::{Deserialize, Serialize}; use std::ffi::OsString; use std::path::PathBuf; @@ -316,18 +315,11 @@ 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)) + let command = configure_validation_parsers( + localize_command(Cli::command(), localizer.as_ref()), + localizer, + ); + parse_localized_command(command, iter, localizer.as_ref()) } fn configure_validation_parsers( diff --git a/tests/bdd/steps/configuration_discovery.rs b/tests/bdd/steps/configuration_discovery.rs index 1059a31b7..4e7dbd571 100644 --- a/tests/bdd/steps/configuration_discovery.rs +++ b/tests/bdd/steps/configuration_discovery.rs @@ -62,6 +62,11 @@ jobs = {jobs} write_config_file(world, file_name.as_str(), &content, true) } +#[given("a malformed project config file {file_name:string}")] +fn malformed_project_config(world: &TestWorld, file_name: FileName) -> Result<()> { + write_config_file(world, file_name.as_str(), "emoji = \"always\n", true) +} + /// Returns the TOML snippet for a config file that sets only `emoji`. fn emoji_config_content(emoji: EmojiPolicy) -> String { format!("\nemoji = \"{emoji}\"\n") diff --git a/tests/config_discovery_e2e_tests.rs b/tests/config_discovery_e2e_tests.rs new file mode 100644 index 000000000..0f334dc73 --- /dev/null +++ b/tests/config_discovery_e2e_tests.rs @@ -0,0 +1,55 @@ +//! End-to-end configuration discovery failure coverage. +//! +//! These tests run the real binary in a child process with a closed +//! environment, proving that missing configuration still permits the normal +//! workflow while a malformed discovered file fails before manifest handling. + +use anyhow::{Context, Result, ensure}; +use assert_cmd::cargo::cargo_bin_cmd; +use tempfile::{TempDir, tempdir}; +use test_support::fs as test_fs; + +fn workspace(context: &str) -> Result { + let temp = tempdir().with_context(|| format!("create workspace for {context}"))?; + test_fs::copy("tests/data/minimal.yml", temp.path().join("Netsukefile")) + .with_context(|| format!("write manifest for {context}"))?; + Ok(temp) +} + +fn isolated_netsuke_command(current_dir: &std::path::Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("netsuke"); + command.current_dir(current_dir).env_clear(); + command +} + +#[test] +fn no_discovered_config_allows_the_graph_workflow() -> Result<()> { + let temp = workspace("no discovered configuration")?; + let output = isolated_netsuke_command(temp.path()) + .arg("graph") + .output() + .context("run graph without configuration")?; + + ensure!(output.status.success(), "graph should use defaults"); + Ok(()) +} + +#[test] +fn malformed_discovered_config_fails_the_binary_workflow() -> Result<()> { + let temp = workspace("malformed discovered configuration")?; + test_fs::write(temp.path().join(".netsuke.toml"), "emoji = \"always\n") + .context("write malformed discovered config")?; + + let output = isolated_netsuke_command(temp.path()) + .arg("graph") + .output() + .context("run graph with malformed configuration")?; + let stderr = String::from_utf8_lossy(&output.stderr); + + ensure!(!output.status.success(), "malformed config must fail"); + ensure!( + stderr.contains(".netsuke.toml"), + "configuration failure should identify the discovered file: {stderr}", + ); + Ok(()) +} diff --git a/tests/features/configuration_discovery.feature b/tests/features/configuration_discovery.feature index 71e35ecca..2961d2d31 100644 --- a/tests/features/configuration_discovery.feature +++ b/tests/features/configuration_discovery.feature @@ -61,3 +61,16 @@ Feature: Configuration file discovery and precedence When the CLI is parsed with "--config cli.toml" Then parsing succeeds And the emoji policy is "always" + + Scenario: No configuration file uses built-in defaults + Given a temporary workspace + When the CLI is parsed with no additional arguments + Then parsing succeeds + And the emoji policy is "auto" + + Scenario: A malformed discovered configuration is an error + Given a temporary workspace + And a malformed project config file ".netsuke.toml" + When the CLI is parsed with no additional arguments + Then an error should be returned + And the error message should contain ".netsuke.toml" diff --git a/tests/ortho_config_metadata_snapshot_tests.rs b/tests/ortho_config_metadata_snapshot_tests.rs new file mode 100644 index 000000000..37b57fefa --- /dev/null +++ b/tests/ortho_config_metadata_snapshot_tests.rs @@ -0,0 +1,123 @@ +//! Contract test for the `OrthoConfig` documentation metadata Netsuke emits. +//! +//! `cargo-orthohelp` consumes this serialised IR. The snapshot projects only +//! Netsuke's schema choices, keeping release-help compatibility visible without +//! coupling the application to unrelated upstream IR fields. + +use insta::assert_yaml_snapshot; +use netsuke::cli::CliConfig; +use ortho_config::OrthoConfigDocs; +use serde::Serialize; + +const APPEND_MERGE_FIELDS: [&str; 4] = [ + "fetch_allow_scheme", + "fetch_allow_host", + "fetch_block_host", + "default_targets", +]; + +#[derive(Serialize)] +struct MetadataSnapshot { + ir_version: String, + precedence: Vec, + discovery: Option, + fields: Vec, + subcommand_count: usize, +} + +#[derive(Serialize)] +struct DiscoverySnapshot { + formats: Vec, + search_paths: Vec, + override_flag: Option, + override_env: Option, + xdg_compliant: bool, +} + +#[derive(Serialize)] +struct FieldSnapshot { + name: String, + cli: Option, + environment: Option, + file: Option, + merge_strategy: &'static str, +} + +#[derive(Serialize)] +struct CliSourceSnapshot { + long: Option, + short: Option, + multiple: bool, + possible_values: Vec, +} + +fn merge_strategy(field_name: &str) -> &'static str { + if APPEND_MERGE_FIELDS.contains(&field_name) { + "append" + } else { + "replace" + } +} + +fn metadata_snapshot() -> MetadataSnapshot { + let metadata = CliConfig::get_doc_metadata(); + let precedence = metadata + .sections + .precedence + .as_ref() + .map_or_else(Vec::new, |precedence| { + precedence + .order + .iter() + .map(|source| format!("{source:?}")) + .collect() + }); + let discovery = metadata + .sections + .discovery + .as_ref() + .map(|discovery| DiscoverySnapshot { + formats: discovery + .formats + .iter() + .map(|format| format!("{format:?}")) + .collect(), + search_paths: discovery + .search_paths + .iter() + .map(|path| path.pattern.clone()) + .collect(), + override_flag: discovery.override_flag_long.clone(), + override_env: discovery.override_env.clone(), + xdg_compliant: discovery.xdg_compliant, + }); + let fields = metadata + .fields + .iter() + .map(|field| FieldSnapshot { + name: field.name.clone(), + cli: field.cli.as_ref().map(|cli| CliSourceSnapshot { + long: cli.long.clone(), + short: cli.short, + multiple: cli.multiple, + possible_values: cli.possible_values.clone(), + }), + environment: field.env.as_ref().map(|env| env.var_name.clone()), + file: field.file.as_ref().map(|file| file.key_path.clone()), + merge_strategy: merge_strategy(&field.name), + }) + .collect(); + + MetadataSnapshot { + ir_version: metadata.ir_version, + precedence, + discovery, + fields, + subcommand_count: metadata.subcommands.len(), + } +} + +#[test] +fn cli_config_documentation_metadata_is_stable() { + assert_yaml_snapshot!(metadata_snapshot()); +} diff --git a/tests/release_help_script_tests.rs b/tests/release_help_script_tests.rs index ec8ee90e0..d563e8bd0 100644 --- a/tests/release_help_script_tests.rs +++ b/tests/release_help_script_tests.rs @@ -44,6 +44,11 @@ fn generates_manual_page_for_non_windows_target( log.contains("--format man"), "expected man generation command, got {log}" ); + ensure!( + log.lines() + .all(|invocation| invocation.starts_with("orthohelp ")), + "v0.9.0 invocations should select the cargo-orthohelp subcommand, got {log}" + ); ensure!( log.contains("--man-date 1970-01-01"), "expected reproducible date from SOURCE_DATE_EPOCH, got {log}" diff --git a/tests/snapshots/ortho_config_metadata_snapshot_tests__cli_config_documentation_metadata_is_stable.snap b/tests/snapshots/ortho_config_metadata_snapshot_tests__cli_config_documentation_metadata_is_stable.snap new file mode 100644 index 000000000..1d01f161b --- /dev/null +++ b/tests/snapshots/ortho_config_metadata_snapshot_tests__cli_config_documentation_metadata_is_stable.snap @@ -0,0 +1,133 @@ +--- +source: tests/ortho_config_metadata_snapshot_tests.rs +expression: metadata_snapshot() +--- +ir_version: "1.1" +precedence: + - Defaults + - File + - Env + - Cli +discovery: ~ +fields: + - name: file + cli: + long: file + short: f + multiple: false + possible_values: [] + environment: NETSUKE_FILE + file: file + merge_strategy: replace + - name: jobs + cli: + long: jobs + short: j + multiple: false + possible_values: [] + environment: NETSUKE_JOBS + file: jobs + merge_strategy: replace + - name: verbose + cli: + long: verbose + short: v + multiple: false + possible_values: [] + environment: NETSUKE_VERBOSE + file: verbose + merge_strategy: replace + - name: locale + cli: + long: locale + short: l + multiple: false + possible_values: [] + environment: NETSUKE_LOCALE + file: locale + merge_strategy: replace + - name: fetch_allow_scheme + cli: + long: fetch-allow-scheme + short: F + multiple: true + possible_values: [] + environment: NETSUKE_FETCH_ALLOW_SCHEME + file: fetch_allow_scheme + merge_strategy: append + - name: fetch_allow_host + cli: + long: fetch-allow-host + short: e + multiple: true + possible_values: [] + environment: NETSUKE_FETCH_ALLOW_HOST + file: fetch_allow_host + merge_strategy: append + - name: fetch_block_host + cli: + long: fetch-block-host + short: E + multiple: true + possible_values: [] + environment: NETSUKE_FETCH_BLOCK_HOST + file: fetch_block_host + merge_strategy: append + - name: fetch_default_deny + cli: + long: fetch-default-deny + short: t + multiple: false + possible_values: [] + environment: NETSUKE_FETCH_DEFAULT_DENY + file: fetch_default_deny + merge_strategy: replace + - name: json + cli: + long: json + short: J + multiple: false + possible_values: [] + environment: NETSUKE_JSON + file: json + merge_strategy: replace + - name: no_input + cli: ~ + environment: NETSUKE_NO_INPUT + file: no_input + merge_strategy: replace + - name: color + cli: ~ + environment: NETSUKE_COLOR + file: color + merge_strategy: replace + - name: emoji + cli: ~ + environment: NETSUKE_EMOJI + file: emoji + merge_strategy: replace + - name: progress + cli: ~ + environment: NETSUKE_PROGRESS + file: progress + merge_strategy: replace + - name: accessibility + cli: ~ + environment: NETSUKE_ACCESSIBILITY + file: accessibility + merge_strategy: replace + - name: default_targets + cli: + long: default-targets + short: d + multiple: true + possible_values: [] + environment: NETSUKE_DEFAULT_TARGETS + file: default_targets + merge_strategy: append + - name: cmds + cli: ~ + environment: NETSUKE_CMDS + file: cmds + merge_strategy: replace +subcommand_count: 0 diff --git a/tests/workflow_build_and_package.rs b/tests/workflow_build_and_package.rs index 4d498f82b..eabf50a76 100644 --- a/tests/workflow_build_and_package.rs +++ b/tests/workflow_build_and_package.rs @@ -144,7 +144,7 @@ fn behavioural_build_and_package_generates_release_help_with_orthohelp() { .expect("build-and-package workflow should be readable"); assert!( - contents.contains("cargo install cargo-orthohelp --version 0.8.0 --locked"), + contents.contains("cargo install cargo-orthohelp --version 0.9.0 --locked"), "workflow should install the pinned cargo-orthohelp release tool" ); assert!( @@ -195,7 +195,9 @@ fn behavioural_build_and_package_validates_release_help_tooling() { .expect("build-and-package workflow should be readable"); assert!( - contents.contains("cargo-orthohelp --version | grep '0\\.8\\.0'"), + contents.contains( + "cargo-orthohelp --version | grep -Eq '(^|[[:space:]])0\\.9\\.0([[:space:]]|$)'" + ), "workflow should validate the installed cargo-orthohelp version" ); assert!(