diff --git a/Cargo.lock b/Cargo.lock index a9b97553..7eb462e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "trybuild", "uncased", "unic-langid", + "wait-timeout", "xdg", ] diff --git a/README.md b/README.md index 9690ffcb..197c0a12 100644 --- a/README.md +++ b/README.md @@ -1,530 +1,176 @@ # OrthoConfig -[![Ask DeepWiki][dw]][dw-url] [![Crates.io Version][cr]][cr-url] +*One Rust struct keeps every configuration source on the straight and narrow.* -[cr]: https://img.shields.io/crates/v/ortho_config "crates.io package" -[cr-url]: https://crates.io/crates/ortho_config -[dw]: https://deepwiki.com/badge.svg -[dw-url]: https://deepwiki.com/leynos/ortho-config - -**OrthoConfig** is a Rust configuration management library designed for -simplicity and power, inspired by the flexible configuration mechanisms found -in tools like `esbuild`. This enables an application to seamlessly load -configuration from command-line arguments, environment variables, and -configuration files, all with a clear order of precedence and minimal -boilerplate. - -The core principle is **orthographic option naming**: a single field in a Rust -configuration struct can be set through idiomatic naming conventions from -various sources (e.g., `--my-option` for CLI, `MY_APP_MY_OPTION` for -environment variables, `my_option` in a TOML file) without requiring extensive -manual aliasing. - -## Core Features - -- **Layered Configuration:** Sources configuration from multiple places with a - well-defined precedence: - 1. Command-Line Arguments (Highest) - 2. Environment Variables - 3. Configuration File (e.g., `config.toml`) - 4. Application-Defined Defaults (Lowest) -- **Orthographic Option Naming:** Automatically maps diverse external naming - conventions (kebab-case, UPPER_SNAKE_CASE, etc.) to a Rust struct's - snake_case fields. -- **Type-Safe Deserialization:** Uses `serde` to deserialize configuration into - strongly typed Rust structs. -- **Easy to Use:** A simple `#[derive(OrthoConfig)]` macro enables a quick - start. -- **Customizable:** Field-level attributes allow fine-grained control over - naming, defaults, and merging behaviour. -- **Config discovery attributes:** Use `#[ortho_config(discovery(...))]` to - rename the generated config override flag, adjust environment variables, and - customize the filenames searched for configuration files without bespoke glue - code. -- **Localized CLI parsing:** Use `LocalizedParse` or - `parse_localized_command` to translate `clap` help text and parse errors - through the same Fluent catalogue used by the rest of the application. -- **Nested Configuration:** Naturally supports nested structs for organized - configuration. -- **Sensible Defaults:** Aims for intuitive behaviour out-of-the-box. - -## Quick Start - - -1. **Add `OrthoConfig` to the project `Cargo.toml`:** - -```toml -[dependencies] -ortho_config = "0.8.0" # Replace with the latest version -serde = { version = "1.0", features = ["derive"] } -``` - -`ortho_config` re-exports its parsing dependencies, so applications can import -`figment`, `uncased`, `xdg` (on Unix-like and Redox targets), and the optional -format parsers (`figment_json5`, `json5`, `serde_saphyr`, `toml`) without -declaring them directly. The `OrthoConfig` derive macro emits paths like -`ortho_config::figment::Figment`, so direct dependencies are only needed when -application code imports those crates independently. - -1. **Define the configuration struct:** - -```rust -use ortho_config::{OrthoConfig, OrthoResult}; -use serde::{Deserialize, Serialize}; // Required for OrthoConfig derive - -#[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "DB")] // Nested prefix: e.g., APP_DB_URL -struct DatabaseConfig { - // Automatically maps to: - // CLI: --database-url (if clap flattens) or via file/env - // Env: APP_DB_URL= - // File: [database] url = - url: String, - - #[ortho_config(default = 5)] - pool_size: Option, // Optional value, defaults to `Some(5)` -} - -#[derive(Debug, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] // Prefix for environment variables (e.g., APP_LOG_LEVEL) -struct AppConfig { - log_level: String, - - // Automatically maps to: - // CLI: --port - // Env: APP_PORT= - // File: port = - #[ortho_config(default = 8080)] - port: u16, +[![Ask DeepWiki][dw]][dw-url] [![Crates.io Version][cr]][cr-url] - #[ortho_config(merge_strategy = "append")] // Default for Vec is append - features: Vec, +> **TL;DR:** Derive `OrthoConfig`, call `load()`, and let your users choose +> defaults, configuration files, environment variables, or command-line +> options. OrthoConfig handles the naming, discovery, and precedence. - // Nested configuration - database: DatabaseConfig, +______________________________________________________________________ - #[ortho_config(cli_short = 'v')] // Enable a short flag: -v - verbose: bool, // Defaults to false if not specified -} +## Why OrthoConfig? -fn main() -> OrthoResult<()> { - let config = AppConfig::load()?; // Load configuration +Configuration plumbing starts small, then quietly takes over the kitchen. +Every new setting needs a CLI flag, an environment variable, a file key, merge +rules, and useful errors when something goes wrong. - println!("Loaded configuration: {:#?}", config); +OrthoConfig lets you describe that setting once, in the Rust struct your +application already needs. From there it gives you: - if config.verbose { - println!("Verbose mode enabled!"); - } - println!("Log level: {}", config.log_level); - println!("Listening on port: {}", config.port); - println!("Enabled features: {:?}", config.features); - println!("Database URL: {}", config.database.url); - println!("Database pool size: {:?}", config.database.pool_size); +- **less glue:** derive the interface instead of hand-wiring `clap`, Serde, and + file discovery; +- **familiar choices:** users can reach for a flag, an environment variable, or + a configuration file; +- **unsurprising overrides:** command-line values beat environment values, + which beat files and defaults; and +- **one source of truth:** the same metadata can generate human help, agent + context, man pages, and Windows PowerShell help. - Ok(()) -} -``` +You get to spend more time on what your application does—and less time teaching +four configuration systems to agree. -2. **Running the application**: +______________________________________________________________________ -- With CLI arguments: - `cargo run -- --log-level debug --port 3000 -v --features extra_cli_feature` -- With environment variables: - `APP_LOG_LEVEL=warn APP_PORT=4000` - `APP_DB_URL="postgres://localhost/mydb"` - `APP_FEATURES="env_feat1,env_feat2" cargo run` -- With a `.app.toml` file (assuming `#[ortho_config(prefix = "APP_")]`; - adjust for the chosen prefix): +## Quick start - -- With a `.app.toml` file (assuming `#[ortho_config(prefix = "APP_")]`; adjust - for your prefix): +From an empty Rust binary to a layered CLI takes one derive and one call. -```toml -# .app.toml -log_level = "file_level" -port = 5000 -features = ["file_feat_a", "file_feat_b"] - -[database] -url = "mysql://localhost/prod_db" -pool_size = 10 -``` +### Installation -## Configuration Sources and Precedence - -OrthoConfig loads configuration from the following sources, with later sources -overriding earlier ones: - -1. **Application-Defined Defaults:** Specified using - `#[ortho_config(default =…)]` or `Option` fields (which default to - `None`). -2. **Configuration File:** Resolved in this order: - 1. `--config-path` CLI option (renameable through the - `discovery(...)` attribute) - 2. `[PREFIX]CONFIG_PATH` environment variable - 3. `..toml` in the current directory - 4. `..toml` in the user's home directory - (where `` comes from `#[ortho_config(prefix = "…")]` and defaults - to `config`). JSON5 and YAML support are feature gated. -3. **Environment Variables:** Variables prefixed with the string specified in - `#[ortho_config(prefix = "...")]` (e.g., `APP_`). Nested struct fields are - typically accessed using double underscores (e.g., `APP_DATABASE__URL` if - `prefix = "APP"` on `AppConfig` and no prefix on `DatabaseConfig`, or - `APP_DB_URL` with `#` on `DatabaseConfig`). -4. **Command-Line Arguments:** Parsed using `clap` conventions. Long flags are - derived from field names (e.g., `my_field` becomes `--my-field`). - -### File Format Support - -TOML parsing is enabled by default. Enable the `json5` and `yaml` features to -support additional formats: +Add OrthoConfig and Serde to `Cargo.toml`: + ```toml [dependencies] -ortho_config = { version = "0.8.0", features = ["json5", "yaml"] } +ortho_config = "0.9.0" +serde = { version = "1.0", features = ["derive"] } ``` -When the `yaml` feature is enabled, configuration files are parsed with -`serde-saphyr` configured for YAML 1.2 semantics. `Options::strict_booleans` -keeps legacy literals such as `yes` or `on` as plain strings, and duplicate -mapping keys raise errors instead of being silently overwritten. - -### Error interop helpers - -`OrthoConfig` includes small extensions to simplify error conversions: +### Basic usage -- `OrthoResultExt::into_ortho()` maps external errors into `OrthoResult`. -- `OrthoMergeExt::into_ortho_merge()` maps `figment::Error` into - `OrthoError::Merge` within `OrthoResult`. -- `ResultIntoFigment::to_figment()` converts `OrthoResult` into - `Result` for integrations that prefer Figment’s type. - -These keep examples and adapters concise while maintaining explicit semantics. - -To return multiple failures at once, `OrthoError::aggregate` builds an -aggregate error from either owned or shared errors. When the collection might -be empty, `OrthoError::try_aggregate` returns `Option`: +Define the settings your application needs and call `load()`: + ```rust -use ortho_config::OrthoError; - -let agg = OrthoError::aggregate(vec![ - OrthoError::validation("port", "must be positive"), // or explicit variant - OrthoError::gathering_arc(figment::Error::from("boom")), -]); - -assert!( - OrthoError::try_aggregate(std::iter::empty::()).is_none() -); -``` - -The file loader selects the parser based on the extension (`.toml`, `.json`, -`.json5`, `.yaml`, `.yml`). When the `json5` feature is active, both `.json` and -`.json5` files are parsed using the JSON5 format. Standard JSON is valid -JSON5, so existing `.json` files continue to work. Without this feature -enabled, attempting to load a `.json` or `.json5` file will result in an error. -When the `yaml` feature is enabled, `.yaml` and `.yml` files are also -discovered and parsed. Without this feature, those extensions are ignored -during path discovery. - -JSON5 extends JSON with conveniences such as comments, trailing commas, -single-quoted strings, and unquoted keys. - -## Orthographic Naming - -A key goal of OrthoConfig is to make configuration natural from any source. A -field like `max_connections: u32` in a Rust struct will, by default, be -configurable via: - -- CLI: `--max-connections ` -- Environment (assuming `#[ortho_config(prefix = "MYAPP")]`): - `MYAPP_MAX_CONNECTIONS=` -- TOML file: `max_connections = ` -- JSON5 file: `max_connections` or `maxConnections` (configurable) - -You can customize these mappings using `#[ortho_config(…)]` attributes. - -## Field Attributes `#[ortho_config(…)]` - -Customize behaviour for each field: - -- `#[ortho_config(default =…)]`: Sets a default value. Can be a literal (e.g., - `"debug"`, `123`, `true`) or a path to a function (e.g., - `default = "my_default_fn"`). -- `#[ortho_config(cli_long = "custom-name")]`: Specifies a custom long CLI flag - (e.g., `--custom-name`). -- `#[ortho_config(cli_short = 'c')]`: Specifies a short CLI flag (e.g., `-c`). -- `#`: Specifies a custom environment variable suffix (appended to the - struct-level prefix). -- `#[ortho_config(file_key = "customKey")]`: Specifies a custom key name for - configuration files. -- `#[ortho_config(merge_strategy = "append")]`: For `Vec` fields, defines how - values from different sources are combined. Defaults to `"append"`. -- `#[ortho_config(flatten)]`: Similar to `serde(flatten)`, useful for inlining - fields from a nested struct into the parent's namespace for CLI or - environment variables. - -## Subcommand Configuration - -Applications using `clap` subcommands can keep per-command defaults in a -dedicated `cmds` namespace. The helper `load_and_merge_subcommand_for` or the -`SubcmdConfigMerge` trait reads these values from configuration files and -environment variables using the struct’s `prefix()` value. When no prefix is -set, environment variables use no prefix, whilst file discovery still defaults -to `.config.toml`. These values are then merged beneath the CLI arguments. +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; -```rust -use clap::{Args, Parser}; -use serde::Deserialize; -use ortho_config::OrthoConfig; -use ortho_config::SubcmdConfigMerge; - -#[derive(Debug, Deserialize, Args, OrthoConfig)] -#[ortho_config(prefix = "APP_")] -pub struct AddUserArgs { - username: Option, - admin: Option, -} +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "HELLO_")] +struct Config { + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, -#[derive(Parser)] -struct Cli { - #[command(flatten)] - args: AddUserArgs, + #[ortho_config(default = 8080)] + port: u16, } -fn main() -> Result<(), Box> { - let cli = Cli::parse(); - - // Reads `[cmds.add-user]` sections and `APP_CMDS_ADD_USER_*` variables - // then merges with CLI values - let args = cli.args.load_and_merge()?; - - println!("Final args: {args:?}"); +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!("Listening on {}:{}", config.host, config.port); Ok(()) } ``` -Configuration file example: +The same fields are now available as CLI options and `HELLO_HOST` or +`HELLO_PORT` environment variables. Command-line values take precedence: -```toml -[cmds.add-user] -username = "file_user" -admin = true + +```console +$ cargo run -- --host 0.0.0.0 --port 3000 +Listening on 0.0.0.0:3000 ``` -Environment variables override file values using the pattern -`CMDS__`: +That is the whole integration. Your application can grow into files, +subcommands, localization, and generated help when it needs them; it does not +have to start there. -```bash -APP_CMDS_ADD_USER_USERNAME=env_user -APP_CMDS_ADD_USER_ADMIN=false -``` +______________________________________________________________________ -### Dispatching Subcommands +## Features -Subcommands can be executed with defaults applied using -[`clap-dispatch`](https://docs.rs/clap-dispatch): +- **Layered configuration:** combine typed defaults, configuration files, + environment variables, and command-line arguments predictably. +- **Convention without confinement:** get idiomatic names such as + `--log-level`, `APP_LOG_LEVEL`, and `log_level`, then customize the public + names that matter. +- **Practical file support:** discover configuration across platforms, extend + base files, and choose how collections merge. +- **CLI-shaped configuration:** support subcommands, localized help, and rich + source-aware errors without building a second settings model. +- **Documentation from code:** generate man pages, Windows PowerShell help, + human documentation, and compact agent context with `cargo-orthohelp`. +- **Production-friendly instrumentation:** opt into structured tracing and + low-cardinality metrics while keeping global setup in the application. -```rust -use clap::{Args, Parser}; -use clap_dispatch::clap_dispatch; -use serde::Deserialize; -use ortho_config::{load_and_merge_subcommand_for, OrthoConfig}; - -#[derive(Debug, Deserialize, Args, OrthoConfig)] -#[ortho_config(prefix = "APP_")] -pub struct AddUserArgs { - username: Option, - admin: Option, -} +______________________________________________________________________ -#[derive(Debug, Deserialize, Args, OrthoConfig)] -pub struct ListItemsArgs { - category: Option, - all: Option, -} +## Now and next -trait Run { - fn run(&self, db_url: &str) -> Result<(), String>; -} +**Now:** the configuration foundation is in place, from layered loading and +file inheritance to subcommands, Fluent localization, generated help, recursive +command metadata, and compact agent context. -impl Run for AddUserArgs { /* application logic here */ } -impl Run for ListItemsArgs { /* application logic here */ } +**Next:** make agent-driven CLIs harder to misunderstand and safer to operate. +Skill manifests will be checked against the real command tree, while opt-in +policy flags inconsistent vocabulary, missing machine-readable results, unsafe +mutation surfaces, and unbounded list commands. `cargo-orthohelp` will dogfood +those contracts with structured results, actionable errors, and atomic output; +later metadata will describe profiles, delivery targets, and long-running jobs +so agents can reuse predictable workflows instead of inventing integration +glue. -#[derive(Parser)] -#[command(name = "registry-ctl")] -#[clap_dispatch(fn run(self, db_url: &str) -> Result<(), String>)] -enum Commands { - AddUser(AddUserArgs), - ListItems(ListItemsArgs), -} +See the [completed v0.8.0 roadmap][archived-roadmap] for the foundation and the +[active roadmap][roadmap] for the detailed sequence. -fn main() -> Result<(), String> { - let cli = Commands::parse(); - let db_url = "postgres://user:pass@localhost/registry"; - - // merge per-command defaults - let cmd = match cli { - Commands::AddUser(args) => { - Commands::AddUser(load_and_merge_subcommand_for::(&args)?) - } - Commands::ListItems(args) => { - Commands::ListItems(load_and_merge_subcommand_for::(&args)?) - } - }; - - cmd.run(db_url) -} -``` +______________________________________________________________________ -## Why OrthoConfig? +## Learn more -- **Reduced Boilerplate:** Define the configuration schema once and let - OrthoConfig handle multi-source loading and mapping. -- **Developer Ergonomics:** Intuitive mapping from external sources to Rust - code. -- **Flexibility:** Users of the application can configure it in the way that - best suits their environment (CLI for quick overrides, env vars for CI/CD, - files for persistent settings). -- **Clear Precedence:** Predictable configuration resolution. - -## Migration notes for v0.9.0 - -Use these notes when upgrading from v0.8.x to v0.9.0. For full examples and -background, see the [v0.9.0 migration guide](docs/v0-9-0-migration-guide.md). - -- Update every `ortho_config` and `ortho_config_macros` dependency to - `0.9.0`. No other changes are required for existing `ConfigDiscovery` - usage. -- Configuration discovery now reads the environment through an injectable - `EnvSource` rather than ambient `std::env` calls. The default, - `process_env_source()`, preserves existing behaviour exactly. -- Inject `MapEnv` via `ConfigDiscoveryBuilder::env_source(...)` for hermetic, - lock-free discovery tests, instead of mutating the process environment. -- Discovery now emits `tracing` events at `DEBUG` level describing each - decision (never the underlying paths or values); enable the optional - `metrics` feature for `attempts`, `outcomes`, and `candidate_failures` - counters via the `metrics` facade. - -## Migration notes for v0.8.0 - -Use these notes when upgrading from v0.7.x to v0.8.0: - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.8.0`, - and ensure your toolchain is Rust `1.88` or newer. -- If you alias the runtime crate in `Cargo.toml` (for example, - `my_cfg = { package = "ortho_config", ... }`), add - `#[ortho_config(crate = "my_cfg")]` so derive-generated paths resolve. The - same attribute also applies to `SelectedSubcommandMerge`. -- If you use `cli_default_as_absent`, prefer typed clap defaults - (`default_value_t` / `default_values_t`). Inference from `default_value` is - rejected, and mixed clap default overrides on the same field now fail fast. -- YAML parsing now uses `serde-saphyr` with YAML 1.2 behaviour. Quote legacy - literals like `yes`, `on`, and `off` when they should remain strings, and - remove duplicate mapping keys that older parsers may have tolerated. -- For derive-generated code, use dependency re-exports from - `ortho_config::figment`, `ortho_config::uncased`, and `ortho_config::xdg` - unless your own application source imports those crates directly. -- If you generate documentation artefacts, wire in - `[package.metadata.ortho_config]` (`root_type`, `locales`) and optional - `[package.metadata.ortho_config.windows]` overrides, then run - `cargo orthohelp` (`--format man` / `--format ps`) against the emitted - `OrthoConfigDocs` metadata. -- Generate compact agent metadata with `cargo orthohelp --format agent-context`. - `--format all` now writes `agent-context.json` alongside localized IR, man - pages, and PowerShell help. -- Treat `schema_version` as the agent-context compatibility boundary and ignore - unknown object fields only for that exact supported version. Enum wire - strings remain exact contract values. -- Update agent-context parsers and golden fixtures that expected the - `MutationEffect::ReadOnly` wire value `read-only`; the locked schema-v1 value - is now `read_only` after enum strings were standardized on `snake_case`. - -## Migration notes for v0.7.0 - -Use these notes when upgrading from v0.6.x to v0.7.0. For full examples and -background, see the [v0.7.0 migration guide](docs/v0-7-0-migration-guide.md). - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.7.0` - and keep feature flags (`toml`, `json5`, `yaml`) on `ortho_config`. -- If you disable default features, enable `serde_json` explicitly before using - selected-subcommand merge helpers or `cli_default_as_absent`. -- Adopt `compose_layers()` / `compose_layers_from_iter(...)` when you need to - inspect, amend, or aggregate layers before merging. -- Add `#[ortho_config(post_merge_hook)]` plus `PostMergeHook` only when - cross-field normalization or validation must run after merge resolution. -- For localized CLI copy and errors, use `FluentLocalizer` and - `localize_clap_error_with_command`. -- For `cli_default_as_absent`, pass `ArgMatches` into merge flows (and annotate - subcommand variants with `#[ortho_subcommand(with_matches)]` when needed) so - clap defaults do not override file/env values unless explicitly provided. - -## Migrating from 0.5 to 0.6 - -Version v0.6.0 streamlines dependency management, discovery, and YAML parsing. -For a full walkthrough see the -[v0.6.0 migration guide](docs/v0-6-0-migration-guide.md); the highlights are: - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.6.0`. - Feature flags now flow from the runtime crate to the macros, so you can drop - duplicated feature declarations on the derive crate. -- Use the crates re-exported via `ortho_config::figment` (and friends) instead - of keeping direct dependencies on Figment, `uncased`, or `xdg`. -- Prefer the `#[ortho_config(discovery(...))]` attribute to configure search - paths declaratively and bubble up errors from `ConfigDiscovery::load_first`, - which now returns `Err` whenever every candidate failed to load. -- Switch to the new `SaphyrYaml` provider (behind the existing `yaml` - feature) wherever Figment's YAML provider was used to benefit from YAML 1.2 - semantics and duplicate-key validation. - -## Migrating from 0.4 to 0.5 - -Version v0.5.0 introduces a small API refinement: - -- In v0.5.0 the helper `load_subcommand_config_for` was removed. Use - [`load_and_merge_subcommand_for`](#subcommand-configuration) to load defaults - and merge them with CLI arguments. -- Types deriving `OrthoConfig` expose an associated `prefix()` function. Use - this if you need the configured prefix directly. - -Update the `Cargo.toml` to depend on `ortho_config = "0.5.0"` and adjust code -to call `load_and_merge_subcommand_for` instead of manually merging defaults. - -## Version management - -- The `scripts/bump_version.py` helper keeps the workspace and member crates in - version sync. -- It requires [`uv`](https://docs.astral.sh/uv/) on the `PATH` as the shebang - uses `uv` for dependency resolution. -- Run it with the desired semantic version: - -```sh -./scripts/bump_version.py 1.2.3 -``` +- [User's guide][users-guide] — build a real CLI one practical task at a time, + with worked examples. +- [v0.9.0 migration guide][migration-guide] — separate required migrations + from improvements that can be adopted when useful. +- [Hello World application][hello-world] — explore a complete, + multi-module example with localization and generated help. +- [API documentation](https://docs.rs/ortho_config) — look up individual + traits, attributes, and types. +- [Developer's guide][developers-guide] — build, test, and contribute to + OrthoConfig. +- [Changelog][changelog] — review released features, fixes, and compatibility + notes. +- [Design document][design] — understand the architecture and guiding + decisions. +- [Roadmap][roadmap] — see what has landed and what comes next. -## Publish checks +______________________________________________________________________ -Run `make publish-check` before releasing to execute the `lading` publish -pre-flight validations with the repository's helper scripts on the `PATH`. The -target is parameterized via `PUBLISH_CHECK_FLAGS`, which now defaults to an -empty value, so the command enforces a clean working tree. Developers who want -the previous convenience may opt in explicitly: +## Licence -```sh -PUBLISH_CHECK_FLAGS="--allow-dirty" make publish-check -``` +OrthoConfig is distributed under the [ISC licence][licence]. -Run `make publish-check` in release validation workflows (for example, -`workflow_dispatch`) where the target versions are already published. +______________________________________________________________________ ## Contributing -Contributions are welcome! Please feel free to submit issues, fork the -repository, and send pull requests. - -## License - -OrthoConfig is distributed under the terms of both the ISC license. +Found a rough edge, a missing example, or an idea that would make configuration +less of a chore? Contributions are welcome. Start with the +[developer's guide][developers-guide] and the repository's +[contributor guidance][contributor-guidance]. -See LICENSE for details. +[archived-roadmap]: https://github.com/leynos/ortho-config/blob/main/docs/archive/v0-8-0-roadmap.md +[changelog]: https://github.com/leynos/ortho-config/blob/main/CHANGELOG.md +[contributor-guidance]: https://github.com/leynos/ortho-config/blob/main/AGENTS.md +[cr]: https://img.shields.io/crates/v/ortho_config "crates.io package" +[cr-url]: https://crates.io/crates/ortho_config +[design]: https://github.com/leynos/ortho-config/blob/main/docs/design.md +[developers-guide]: https://github.com/leynos/ortho-config/blob/main/docs/developers-guide.md +[dw]: https://deepwiki.com/badge.svg +[dw-url]: https://deepwiki.com/leynos/ortho-config +[hello-world]: https://github.com/leynos/ortho-config/tree/main/examples/hello_world +[licence]: https://github.com/leynos/ortho-config/blob/main/LICENSE +[migration-guide]: https://github.com/leynos/ortho-config/blob/main/docs/v0-9-0-migration-guide.md +[roadmap]: https://github.com/leynos/ortho-config/blob/main/docs/roadmap.md +[users-guide]: https://github.com/leynos/ortho-config/blob/main/docs/users-guide.md diff --git a/docs/contents.md b/docs/contents.md index 7cddc708..6e4498ae 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -83,6 +83,8 @@ v0.6.0 changes. - [v0.7.0 migration guide](v0-7-0-migration-guide.md): migrate through the v0.7.0 changes. +- [v0.9.0 migration guide](v0-9-0-migration-guide.md): identify required, + recommended, and optional changes when upgrading from v0.8.0. ## Testing and documentation references @@ -152,5 +154,7 @@ model downstream skill manifests. - [Adopt rstest-bdd v0.5.0](execplans/adopt-rstest-bdd-v0-5-0.md): plan for the behavioural testing migration. + - [Prepare the v0.9.0 user documentation](execplans/prepare-v0-9-0-user-documentation.md): + plan for the public guides and executable documentation-example contract. - [Ortho agent CLI roadmap](execplans/ortho-agent-cli-roadmap.md): plan for the agent-native documentation and roadmap overhaul. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b1167370..bb6c9ac7 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -289,6 +289,51 @@ Keep richer fixture families isolated. For example, `NestedDocsConfig` and fixture-specific `tests/rstest_bdd/behaviour/steps/nested_docs_steps.rs` module rather than expanding unrelated step files. +### Public documentation examples + +The root README and `docs/users-guide.md` are executable documentation. Every +fenced block in either file must have a unique `tested-example` marker on the +immediately preceding line. `ortho_config/tests/documentation_examples/mod.rs` +owns parsing and lookup for these examples. It is test infrastructure only; +production code, other crates, and examples must not depend on it. + +The loader is shared by the documentation integration-test targets. Each target +loads and parses the documents once, then borrows examples from its cached +registry. The cache is immutable and has no reset operation within an +integration-test process. Tests that need fresh input should call the pure +parser with owned text or use a separate integration-test target. Keep the +loader's scope limited to loading exact fence bodies, rejecting unmarked or +malformed fences, and querying stable identifiers. Put scenario policy in the +test target that consumes it: compile and run Rust, parse data formats, execute +documented commands, and compare observable output. Do not copy a fence into a +fixture because the copied text can pass after the published example has +drifted. Identifiers use a closed filename-safe grammar: start with a lowercase +ASCII letter, then use lowercase ASCII letters, digits, or single hyphens. The +parser and temporary workspace both enforce this boundary before constructing +paths. + +`documentation_examples/workspace.rs` owns temporary Cargo package assembly for +Rust examples. Reuse it only from documentation tests that compile an exact +fence body. A workspace has one owner: operations that add, build, run, or +write require an exclusive mutable borrow. Concurrent scenarios must create an +independent workspace per thread rather than share one workspace. The helper +may add the dependencies needed to compile a published example, but it must not +rewrite that source. Keep the expected identifier registry closed so adding an +example without choosing its behavioural contract fails a test. +`documentation_examples/cargo_runner.rs` owns isolated Cargo process setup for +these documentation tests; reuse it only when executing a documented Cargo +workflow with a caller-owned temporary state directory. Run Cargo and child +binaries with cleared environments. Cargo receives only the toolchain and +platform paths it needs; binaries receive only the non-sensitive Windows +runtime variables named by the workspace allow-list and deliberate scenario +overrides. Keep fallible host-tool discovery and environment preparation at the +runner boundary; command construction consumes the prepared values without +starting subprocesses or writing files. The sibling `process_runner.rs` owns +bounded execution for these documentation tests only: route Cargo, host-tool, +and documented-binary commands through it, while keeping command construction +and exit-status policy with their existing owners. Run-file paths must contain +normal relative components only. + ## Snapshot tests Use `insta` for renderer golden coverage that would be noisy as handwritten @@ -468,13 +513,12 @@ environment. `ProcessEnv` is the default and preserves the historical behaviour; process. - **Owned returns, deliberately.** An `impl Iterator` return would be return-position `impl Trait` in traits (RPITIT) and make the trait unusable - behind a trait object, forcing `ConfigDiscovery` - generics to leak through the derive-generated `load()` and every call site. - Configuration resolves once per process, so the allocation is immaterial. + behind a trait object, forcing `ConfigDiscovery` generics to leak through + the derive-generated `load()` and every call site. Configuration resolves + once per process, so the allocation is immaterial. - **`home_fallback` defaults to `None`.** `ProcessEnv` overrides it by - default, and custom sources may too. See - the users' guide for why an injected source must be able to suppress the - platform lookup. + default, and custom sources may too. See the users' guide for why an injected + source must be able to suppress the platform lookup. ## Dependency management diff --git a/docs/execplans/prepare-v0-9-0-user-documentation.md b/docs/execplans/prepare-v0-9-0-user-documentation.md new file mode 100644 index 00000000..23cbde60 --- /dev/null +++ b/docs/execplans/prepare-v0-9-0-user-documentation.md @@ -0,0 +1,414 @@ +# Prepare the v0.9.0 user documentation + +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 + +Someone evaluating OrthoConfig should be able to reach a working, layered Rust +command-line application from the repository README within one screen. Someone +already using v0.8.0 should be able to identify every v0.9.0 change that +affects their code or runtime behaviour, distinguish required migrations from +useful opt-ins, and follow a tested example for each adoption path. + +The completed change rewrites `README.md`, `docs/users-guide.md`, and +`docs/v0-9-0-migration-guide.md`. Every fenced example in the README and user's +guide is loaded from the Markdown by Rust integration tests through the same +marker-and-registry pattern used by Netsuke. Scenario tests then compile, +execute, parse, or otherwise exercise the exact extracted text. The result is +observable by running the focused documentation-example tests and the +repository's formatting, lint, test, spelling, Markdown, and Mermaid gates. + +## Constraints + +- Do not change OrthoConfig's public API or runtime behaviour. This work + documents the v0.8.0-to-v0.9.0 delta and tests published examples. +- Treat tag `v0.8.0`, current `HEAD`, `CHANGELOG.md`, public Rust APIs, and + existing integration tests as the release evidence. Do not infer a migration + claim solely from roadmap or design prose. +- Keep `README.md` a short df12-style entry point. It must explain benefits, + present a copyable quick start, and signpost deeper material instead of + restating the user's guide. +- Keep all public prose in en-GB Oxford English and wrap Markdown prose at 80 + columns. Preserve upstream API spellings inside code and identifiers. +- Every fenced code block in `README.md` and `docs/users-guide.md` must have a + unique `` marker immediately before it. The loader + must reject unmarked, duplicate, malformed, or unterminated examples. +- Tests must use the exact Markdown body loaded at runtime; copied fixture text + does not satisfy the documentation contract. +- Behavioural checks must be proportional to the fence language: compile or run + Rust programs, parse Cargo/TOML/JSON data, and execute documented CLI flows. + `console` output may be checked against the command that produces it. +- Do not introduce a new production dependency. Existing `ortho_config` + development dependencies may support the test harness. +- Keep each Rust source file at or below 400 lines and begin every new module + with a `//!` comment. +- Document the new test helper's ownership and reuse policy in + `docs/developers-guide.md`, because it is a repository abstraction shared by + multiple documentation-example test binaries. +- Run Rust formatting, Clippy, Whitaker, tests, Markdown formatting and lint, + spelling, Mermaid validation, and Makefile validation before committing. +- Use a scrutineer agent for the final deterministic gate run and summary. +- Create or update a draft pull request after a clean commit and push, following + the `pr-creation` skill. + +## Tolerances (exception triggers) + +- Scope: stop and escalate if the implementation needs more than 12 tracked + files, more than 2,500 net new lines, or changes outside documentation, + documentation-example tests, test-only manifest configuration, and the + documentation index. +- Interface: stop and escalate if an example cannot be made correct without a + public API or runtime behaviour change. +- Dependencies: stop and escalate before adding any external dependency. +- Precedent: stop and escalate if faithfully adapting Netsuke's marker loader + requires weakening the rule that every fence is registered. +- Iterations: stop and escalate if the focused documentation-example suite + still fails after five distinct repair attempts for the same failure class. +- Full gates: stop and report if the same full-gate failure persists across + three attempts after focused checks pass. +- Ambiguity: stop and present options if evidence supports two materially + different descriptions of a public compatibility contract. + +## Risks + +- Risk: current documentation contains many overlapping and stale examples, so + preserving all of them would make behavioural coverage unwieldy. Severity: + high. Likelihood: high. Mitigation: rewrite around a smaller set of complete + worked journeys, while covering every supported common task in prose and + linking specialist tools. + +- Risk: examples that spawn nested Cargo commands may deadlock on the outer + build directory or make tests unacceptably slow. Severity: medium. + Likelihood: medium. Mitigation: assemble a single temporary example workspace + per test run, use a separate target directory, prefer `--offline`, and + compile related examples together. + +- Risk: `v0.9.0` is not yet tagged and manifests still report `0.8.0`, so + copyable dependency declarations cannot resolve the unreleased version from + crates.io during tests. Severity: medium. Likelihood: high. Mitigation: + document `0.9.0` as the target release, then replace that dependency with an + absolute path to the current crate only inside the temporary test workspace. + Assert that the published fence still says `0.9.0`. + +- Risk: broad Markdown formatting may touch unrelated long-form documents. + Severity: low. Likelihood: medium. Mitigation: inspect formatter output, keep + only necessary changes, and isolate unavoidable formatter-only drift if + repository policy requires it. + +- Risk: the current root and crate READMEs may overlap. + Severity: medium. Likelihood: high. Mitigation: keep this task scoped to the + root `README.md`, and verify that its links and claims do not contradict + `ortho_config/README.md`. Escalate rather than silently expanding scope to a + second README. + +## Progress + +- [x] (2026-08-09 15:10Z) Confirmed a clean `v0-9-0-prep` baseline at + `2ff35cf` and compared `v0.8.0..HEAD`. +- [x] (2026-08-09 15:10Z) Used GrepAI's healthy `Projects` index for + configuration, derive, localization, injected-environment, and Netsuke + documentation-example discovery. +- [x] (2026-08-09 15:10Z) Registered the worktree with Leta and recorded that + Rust symbol queries remained empty after a language-server restart. +- [x] (2026-08-09 15:10Z) Audited Netsuke's `tested-example` loader, registry, + parser failure tests, behaviour checks, and end-to-end tests. +- [x] (2026-08-09 15:10Z) Drafted this ExecPlan and stopped at its approval + gate. +- [x] (2026-08-09 15:39Z) Obtained explicit approval and changed status to + `IN PROGRESS`. +- [x] (2026-08-09 16:17Z) Added the strict documentation-example loader, + malformed-input tests, closed registry, and the expected red failure on the + first unmarked README fence. +- [x] (2026-08-09 16:17Z) Rewrote the README and user's guide around 23 marked, + behaviourally checked examples: 11 Rust, 7 TOML, 3 console, 1 JSON, and 1 + YAML. +- [x] (2026-08-09 16:17Z) Completed the v0.9.0 impact inventory and worked + migrations. +- [x] (2026-08-09 16:17Z) Updated the documentation index and recorded the + documentation-test helper's ownership and reuse boundary. +- [x] (2026-08-09 16:17Z) Passed focused default/all-feature example tests, + formatting, Markdown lint, spelling, Mermaid, Makefile validation, and + `git diff --check`. +- [x] (2026-08-09 16:32Z) Passed rustdoc, Clippy, Whitaker, the complete + all-target/all-feature Rust suite, and the Python test suite. +- [x] (2026-08-09 17:16Z) Committed the implementation, received a green + independent scrutineer report, pushed the branch, and opened draft pull + request [#422](https://github.com/leynos/ortho-config/pull/422). +- [x] (2026-08-11 20:16Z) Validated the completed registry inventory at 23 + examples: 11 Rust, 7 TOML, 3 console, 1 JSON, and 1 YAML. + +## Surprises & discoveries + +- Observation: the existing migration guide says the release is additive, but + `ConfigDiscovery::load_first` now returns `Err` when candidates were found + and all failed. YAML also moved to YAML 1.2 parsing with strict booleans and + duplicate-key rejection. Evidence: `CHANGELOG.md` lines 62-70 and the + implementation in `ortho_config/src/discovery/load.rs`. Impact: both changes + require prominent compatibility guidance and before/after examples. + +- Observation: the release surface is considerably wider than the current + migration guide. It includes dependency aliasing, dependency re-exports, + forwarded format features, derive-controlled discovery, recursive subcommand + documentation, public localization helpers, agent-context structures, + environment injection, tracing, and optional metrics. Evidence: + `CHANGELOG.md` lines 7-53 and the public re-exports in + `ortho_config/src/lib.rs`. Impact: the guide needs a complete impact matrix + rather than three observability-focused sections. + +- Observation: Netsuke does not merely compile copied snippets. Its loader + reads marked fences from public Markdown, rejects every unmarked fence, + checks a closed expected-ID registry, and gives each example an appropriate + behavioural contract. Evidence: `tests/documentation_examples/mod.rs`, + `tests/documentation_examples_loader_tests.rs`, + `tests/documentation_examples_tests.rs`, and + `tests/documentation_examples_e2e_tests.rs` in the Netsuke repository. + Impact: OrthoConfig will adapt that structure and keep syntax parsing + separate from scenario behaviour. + +## Decision log + +- Decision: organize the user's guide around CLI developer jobs rather than API + inventory order. Rationale: the requested outcome is to make common CLI tasks + easy. Readers should first build and run a layered CLI, then add files, + environment values, subcommands, validation, localization, testing, + observability, and generated help as their application grows. Date/Author: + 2026-08-09 15:10Z / Codex. + +- Decision: classify migration items as `Required`, `Review`, `Recommended`, or + `Optional`. Rationale: these labels distinguish breaks, semantic + compatibility checks, valuable new usage patterns, and low-cost opt-ins more + clearly than a binary breaking/non-breaking table. Date/Author: 2026-08-09 + 15:10Z / Codex. + +- Decision: use a strict `tested-example` marker and closed example registry, + matching Netsuke's approach. Rationale: a strict loader makes an untested + fence a test failure and prevents documentation drift from silently bypassing + the behavioural suite. Date/Author: 2026-08-09 15:10Z / Codex. + +- Decision: use scoped Git and exact-text navigation after Leta failed to + return Rust symbols. Rationale: Leta was installed, the worktree was + registered, and the server was restarted; empty results could not serve as + evidence. GrepAI remained healthy and supplied the intent-based navigation + requested by the user. Date/Author: 2026-08-09 15:10Z / Codex. + +## Outcomes & retrospective + +The public surface now has three distinct levels: a concise README for first +contact, a task-oriented user's guide, and an impact-labelled migration guide. +The strict registry covers 23 fences: eleven Rust programs, seven TOML +manifests or configuration files, three console flows, one JSON document, and +one YAML file. Rust programs compile and run unchanged, console flows execute +their underlying commands, and data formats use their production parsers. +Detailed API signatures remain delegated to rustdoc, while the full +multi-module composition remains delegated to `examples/hello_world`. The +independent scrutineer reran every required gate against the staged +implementation and reported no findings. Draft pull request #422 contains the +approved plan and completed change. + +## Context and orientation + +The root `README.md` is currently a 530-line reference that duplicates much of +the 1,631-line `docs/users-guide.md` and still uses `0.8.0` in installation +examples. `docs/v0-9-0-migration-guide.md` is only 173 lines and covers +environment injection, discovery telemetry, metrics, and redaction, while +`CHANGELOG.md` records a broader public delta. + +`ortho_config/` is the runtime library crate. Its integration tests live in +`ortho_config/tests/`, which already has `anyhow`, `rstest`, `tempfile`, TOML, +and the current crate available as development dependencies. This is the right +home for documentation-example tests because the examples exercise the +published runtime and derive APIs. + +The Netsuke precedent uses an HTML comment of the form +`` immediately before every fenced block. A +shared loader returns the fence language and exact body, rejects malformed +documents, and enforces unique identifiers. Separate integration binaries test +the parser itself, a closed identifier registry, command behaviour, and +end-to-end effects. + +The existing `examples/hello_world/` crate remains the full application +reference. The rewritten public documents should use smaller examples for +learning, then link to Hello World when localization, generated help, agent +context, or multi-module application structure would overwhelm the first +journey. + +## Plan of work + +Stage A is complete when this draft is approved. Update its status to +`IN PROGRESS`, add it to `docs/contents.md`, and preserve the approval in the +decision log. + +Stage B establishes the red documentation contract. Add +`ortho_config/tests/documentation_examples/mod.rs` for strict Markdown loading, +`ortho_config/tests/documentation_examples_loader_tests.rs` for malformed-input +and property tests, and `ortho_config/tests/documentation_examples_tests.rs` +for the expected registry and scenario contracts. The first focused run must +fail because the current README and user's guide contain unmarked fences and do +not match the new registry. Record that failure here before editing the public +documents. + +Stage C rewrites the three public documents. Replace the root README with the +df12 structure: tagline, why, installation, one minimal full program, one run, +feature summary, and prominent resources. Rewrite the user's guide as worked +journeys: first layered CLI; naming and precedence; configuration discovery and +formats; collections and nested values; subcommands; validation and errors; +hermetic tests; localization; tracing and metrics; generated documentation and +agent context; migration and troubleshooting. Each fence receives a stable +marker and a test that consumes its exact body. Rewrite the migration guide +with an impact matrix and sections for every public change recorded in the +v0.8.0-to-HEAD evidence. + +Stage D completes the green behavioural implementation. Tests create temporary +Cargo projects from paired dependency and Rust fences, substitute only the +current local crate path, run them offline, exercise their CLI/file/environment +flows, and compare results with any documented output fences. Non-Rust fences +are parsed or used as live inputs. Refactor helpers only after the focused +suite passes, keeping files below 400 lines. + +Stage E records the internal convention in `docs/developers-guide.md`, links +the migration guide and this ExecPlan from `docs/contents.md`, formats all +changed files, and runs the repository gates. After focused validation passes, +commit the complete logical change. Then ask the scrutineer to run and +summarize the deterministic gates without editing files. Repair any valid +failures, rerun the relevant gates, push the branch, and create or update the +draft pull request. + +## Concrete steps + +Run all commands from the repository root. + +After approval, begin with the red stage: + +```bash +cargo test -p ortho_config --test documentation_examples_loader_tests +cargo test -p ortho_config --test documentation_examples_tests +``` + +The loader tests should pass. The second command should fail with a precise +message such as: + +```plaintext +README.md: fence is missing a tested-example marker +``` + +After rewriting and implementing scenario checks, run: + +```bash +cargo test -p ortho_config --test documentation_examples_loader_tests +cargo test -p ortho_config --test documentation_examples_tests +cargo test -p ortho_config --doc +``` + +All three commands must pass. Then format and validate the documentation: + +```bash +make fmt +make check-fmt +make markdownlint +make spellcheck +make nixie +mbake validate Makefile +git diff --check +``` + +Finally run the repository Rust gates, using a spacious target directory if a +clean rebuild is needed: + +```bash +make lint +make test +``` + +The scrutineer repeats the deterministic gates after the candidate commit and +returns a structured pass/fail summary. Do not create the pull request until +the worktree is clean and every required gate passes. + +## Validation and acceptance + +The change is accepted when all of the following are observable: + +- A new reader can copy the README dependency and Rust examples, run the + documented command, and observe the documented value without consulting the + user's guide. +- The README explains layered configuration's benefits and links to the user's + guide, v0.9.0 migration guide, Hello World example, API documentation, + changelog, design, roadmap, and contributing guidance. +- The user's guide provides a complete worked path for common CLI application + needs without requiring readers to reverse-engineer the derive macro. +- The migration guide accounts for dependency aliasing and re-exports, + forwarded format features, discovery customization, recursive subcommand + documentation, localized parsing, agent context, environment injection, + discovery telemetry and metrics, `load_first` error semantics, YAML 1.2 + semantics, improved `extends` errors, and generated documentation comments. +- Every fenced example in `README.md` and `docs/users-guide.md` has one unique + marker, appears in the expected registry, and has an appropriate executable + or parse-level behavioural assertion. +- The red test fails before the documentation rewrite for an unmarked fence and + the green test passes afterwards. +- `make check-fmt`, `make lint`, `make test`, `make markdownlint`, + `make spellcheck`, `make nixie`, `mbake validate Makefile`, and + `git diff --check` all pass. +- The scrutineer's independent summary reports no failures. +- The committed branch is pushed and has a draft pull request whose description + covers the full branch and links every mentioned file. + +## Idempotence and recovery + +The loader and tests are read-only apart from temporary directories, so focused +runs are repeatable. Temporary example crates must clean up through +`tempfile::TempDir`. Nested Cargo builds use a separate target directory so a +failed run can be retried without corrupting the outer workspace build. + +Markdown formatting is repeatable. Inspect `git diff` after `make fmt`; if it +changes unrelated files, restore only known formatter drift after verifying +those paths were clean at baseline. + +## Artefacts and notes + +The release evidence starts with: + +```plaintext +v0.8.0..HEAD +2ff35cf Chore(deps): Bump proc-macro2 from 1.0.106 to 1.0.107 (#406) +d3c9bdf Add an injectable environment source for configuration discovery +... +``` + +The strict marker form adapted from Netsuke is: + +```html + +``` + +The marker applies to the immediately following fenced block, with no +intervening blank line. Identifiers are unique across both public documents. + +## Interfaces and dependencies + +The shared test module in `ortho_config/tests/documentation_examples/mod.rs` +owns Markdown loading, marker parsing, example lookup, and source diagnostics. +`documentation_examples/workspace.rs` owns temporary-project assembly. Only +documentation-example integration test binaries may call them. They must not +become production APIs or general Markdown tooling. + +Define a `DocumentedExample` query value containing `id`, `language`, `body`, +and source location. Define `load_documented_examples()` and +`documented_example(id)` as fallible queries. Keep process execution in +separate scenario helpers so parsing remains pure and independently testable. + +Use `anyhow` for test-only context, `rstest` for behaviour matrices, `proptest` +for marker/parser invariants, `tempfile` for isolated workspaces, and +`std::process::Command` for nested Cargo or documented CLI execution. These are +already available to `ortho_config` tests; no production dependency changes are +permitted. + +Revision note: the user approved the plan on 2026-08-09. The implementation +then replaced the three public documents, added the strict executable example +contract, passed all required gates, and opened draft pull request #422. The +plan is complete. On 2026-08-11, the completed inventory was confirmed at 23 +examples and synchronized across the progress and outcome sections; this +changes the recorded counts, not the plan's status or remaining work. diff --git a/docs/users-guide.md b/docs/users-guide.md index 114ca241..b8250f50 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -1,1631 +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 - -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: - -- Normalizing values after all layers have been applied -- Performing validation that depends on multiple fields being merged -- Conditional transformations based on which sources contributed - -The Hello World example demonstrates this pattern with `GreetCommand`, which -uses a post-merge hook to clean up whitespace-only preambles. - -### Documentation and agent contracts - -`OrthoConfigDocs` remains the public contract for human documentation metadata. -It emits localized documentation IR through `DocMetadata` and -`ORTHO_DOCS_IR_VERSION`; `cargo-orthohelp` consumes that IR to produce -localized JSON, roff man pages, and PowerShell help. - -Agent invocation context is a separate compact schema. Its reusable Rust types -live under `ortho_config::agent_context` and use -`ORTHO_AGENT_CONTEXT_SCHEMA_VERSION`, so agent-facing compatibility can change -without forcing a documentation IR version bump. The schema deliberately keeps -out Fluent identifiers, localized long prose, roff details, and PowerShell help -structures. +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. -Skill manifest descriptors are part of that compact agent-context schema. -`AgentContext.skill_manifests` defaults to an empty list, and populated entries -use `SkillManifest` plus `SkillCommandRef` to record the manifest path, -downstream manifest schema version, and referenced command paths and long -flags. OrthoConfig records this metadata only; downstream skill prose and -manifest validation remain application-owned. - -```json -{ - "skill_manifests": [ - { - "id": "example-list", - "path": "skills/example-list.md", - "manifest_schema_version": "v1", - "commands": [ - { - "path": ["example-cli", "list"], - "flags": ["format"] - } - ] - } - ] -} -``` - -#### Downstream `context --json` commands - -Applications that expose agent invocation context use the downstream command -surface defined by [ADR-007](adr-007-downstream-context-command-naming.md): - -```console -example-cli context --json -``` - -The application command is `context`, not `agent-context`. The emitted payload -uses `kind: ".agent_context"` to identify the payload family and -`schema_version` to identify compatibility. Consumers should compare -`schema_version` with `ORTHO_AGENT_CONTEXT_SCHEMA_VERSION`; they should not -parse `kind` as a version. - -```rust,no_run -use ortho_config::{AgentCommand, AgentContext}; - -fn build_agent_context() -> AgentContext { - let mut context = AgentContext::new("example-cli"); - context.commands = vec![AgentCommand { - path: vec!["example-cli".to_owned(), "status".to_owned()], - summary: Some("Report the current service status.".to_owned()), - canonical_verb: Some("get".to_owned()), - inputs: Vec::new(), - output_modes: vec!["json".to_owned()], - interaction_mode: ortho_config::InteractionMode::NonInteractive, - mutation_effect: ortho_config::MutationEffect::ReadOnly, - async_submission: None, - delivery_route: None, - pagination: None, - examples: Vec::new(), - }]; - context -} -``` - -Use `AgentContext::to_json` for the command-surface form when the `serde_json` -feature is enabled. It writes compact JSON with a trailing newline for -stdout-oriented commands. - -Policy reports for agent-native warnings and hard failures are owned by -`cargo-orthohelp`. The `cargo_orthohelp::policy` module defines -`ORTHO_POLICY_REPORT_SCHEMA_VERSION` and machine-stable fields such as -`rule_id`, `code`, `severity`, and `message`. Future `cargo-orthohelp` commands -that emit those reports should keep machine-readable output separate from human -diagnostics. - -Existing `cargo-orthohelp --format ir`, `--format man`, `--format ps`, -`--format agent-context`, and `--format all` behaviour remains compatible. -`--format all` writes `agent-context.json` beside the human documentation -artefacts. Agent-native policy checking remains a future command surface unless -a later roadmap item explicitly enables it. - -Consumers that read `agent-context.json` may rely on the meaning of fields and -enum strings for a fixed `schema_version`. They must ignore object fields they -do not understand so newer producers can add optional metadata without breaking -older consumers. - -`AgentInput.default` is display-only. The generator removes unstable whitespace -around Rust `::` path separators only outside literals; the contents of -ordinary string, byte string, raw string, and character literals are preserved -verbatim. Lifetime syntax such as `'a` is not treated as a path separator or as -the start of a character literal, so it too is preserved unchanged. Consumers -must not treat this value as source-code-preserving formatter output: it is a -normalized rendering intended for display, not a faithful reproduction of the -original expression. The v1 contract also standardizes enum wire strings as -`snake_case`; pre-1.0 consumers must read the mutation effect as `read_only` -rather than `read-only`. - -### 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. - -Use `LocalizedParse` when catalogue keys are rooted at the command `bin_name`: - -```rust,ignore -use ortho_config::{LocalizedParse, Localizer}; - -# #[derive(clap::Parser)] -# struct Cli {} -fn parse_new_with_base( - localizer: &dyn Localizer, -) -> Result<(Cli, clap::ArgMatches), clap::Error> { - let command = Cli::command() - .with_base("demo.cli") - .localize(localizer); - - parse_localized_command(command, std::env::args_os(), localizer) -} -``` - -#### Migrating localized parsing code - -Earlier localization code often built a localized `clap::Command` and parsed it -directly. That kept translated help text, but it left parse-error localization -and `from_arg_matches` error enrichment to application glue: - -```rust,ignore -use clap::CommandFactory; -use ortho_config::{LocalizeCmd, Localizer}; - -# #[derive(clap::Parser)] -# struct Cli {} -fn parse_new_with_base( - localizer: &dyn Localizer, -) -> Result<(Cli, clap::ArgMatches), clap::Error> { - let command = Cli::command() - .with_base("demo.cli") - .localize(localizer); - - parse_localized_command(command, std::env::args_os(), localizer) -} -``` +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. -## Installation and dependencies +## Install OrthoConfig -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: +The default features support TOML and the JSON-backed merge machinery used by +the derive. Optional `json5`, `yaml`, and `metrics` features are covered later. -```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] -``` +## Build the first layered CLI -_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"] } -``` - -### 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. - -### Customizing configuration discovery - -Configuration discovery can be tailored per struct using the `discovery(...)` -attribute. The keys recognized today include: - -- `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. - -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. - -### Injecting the environment for discovery - -`ConfigDiscovery` reads several environment variables: the configuration-path -selector named by `env_var`, the XDG base directories, the Windows -application-data folders, and `HOME`/`USERPROFILE`. By default these come from -the live process. - -`env_source` supplies them instead, which lets tests drive discovery without -mutating global state — so they need no serializing lock and may run -concurrently. - -```rust,no_run -use ortho_config::{ConfigDiscovery, MapEnv}; -use std::sync::Arc; +That one definition provides three spellings for each field: -let env = Arc::new(MapEnv::new().with_var("DEMO_CONFIG", "/etc/demo.toml")); -let discovery = ConfigDiscovery::builder("demo") - .env_var("DEMO_CONFIG") - .env_source(env) - .build(); -``` +| 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` | -Implement `EnvSource` directly for anything richer than a fixed map; it is -object-safe and held as `Arc`. - -#### What it does and does not cover - -> [!IMPORTANT] -> An injected source controls **file discovery only**. It does not make a full -> `OrthoConfig` load independent of the process environment. - -- **Covered:** the `env_var` selector, XDG and Windows base directories, and - home resolution. -- **Not covered:** the `APP_*` configuration-value merge layer, which - `CsvEnv` (wrapping `figment`'s `Env` provider) reads from the process. - Injecting that source is out of scope here and is tracked separately by - [issue #412](https://github.com/leynos/ortho-config/issues/412). Until it - lands, a consumer needing to control `APP_*` configuration-value variables - must still set them in the process. - -#### Home fallback - -When neither `HOME` nor `USERPROFILE` is set, the default `ProcessEnv` falls -back to a platform home lookup that consults the real user database. A custom -source does **not**, because `EnvSource::home_fallback` defaults to `None`. - -That difference is deliberate. Were the fallback to apply to an injected -source, a test supplying no home would still pick up the host's, and the -candidate list would vary between machines. Implement `home_fallback` if a -custom source should provide one. - -#### Seeing why a file was chosen - -Discovery consults a dozen locations and returns only the winner, so "why did -it load _that_ file?" is otherwise unanswerable. It emits a `tracing` event at -each decision point: which environment source was used, how the selector -resolved, which variables supplied the base directories, and the outcome of -each load attempt. Attach any `tracing` subscriber at `DEBUG` level to see them. - -> [!IMPORTANT] -> The fields OrthoConfig attaches to these events never carry environment -> variable values, resolved paths, or file contents. Every such field is -> drawn from a fixed vocabulary such as `accepted`, `empty`, `unset`, or -> `not_found`. OrthoConfig's fields describe the _decision_, never the datum -> it was made from. A subscriber may still add span context, request IDs, or -> other attributes OrthoConfig does not control; those remain subject to the -> subscriber's own redaction policy before the record is forwarded. - -The consequence is worth stating plainly: the telemetry reports that the -selector was accepted, not which path it named. Pair it with -`ConfigDiscovery::candidates()` when a specific path is required. - -A successful `discovery.load` event additionally carries `source`, naming the -rung that produced the winning candidate: `required_explicit`, `explicit`, -`selector`, `xdg`, `windows`, `home`, or `project`. An unsuccessful outcome -has no winning candidate, so it carries no `source`. Like every other field, -`source` names the kind of location, not the path itself; reach for -`ConfigDiscovery::candidates()` when an actual path is needed. It is a -`tracing` field only — the `outcomes` counter below stays keyed on -`operation` and `outcome`, since adding `source` there would multiply that -series to record a fact the event already carries. - -Enabling the optional `metrics` feature additionally emits three counters -through the [`metrics`](https://docs.rs/metrics) facade, each with its own -label set: - -- `ortho_config.discovery.attempts` — labelled with the `operation`; -- `ortho_config.discovery.outcomes` — labelled with the `operation` and the - `outcome`; -- `ortho_config.discovery.candidate_failures` — labelled with the - `operation`, the candidate's bounded `source`, and its error `category`. +_Table 1: Rust fields and their command-line, environment, and TOML names._ -```toml -[dependencies] -ortho_config = { version = "0.8", features = ["metrics"] } -``` +Values are merged from lowest to highest precedence: -The feature is off by default. A library should not choose a metrics backend -for the application embedding it, and the facade records nothing until that -application installs a recorder — OrthoConfig never installs one. The `tracing` -events above are emitted either way. +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 -4. Adds a provider containing the CLI values (captured as `Option` fields) - as the final layer. +[[workers]] +name = "queue-b" +concurrency = 2 -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. +[labels] +region = "eu-west" +tier = "worker" +``` -6. Attempts to extract the merged configuration into the concrete struct. On - success it returns the completed configuration; otherwise an `OrthoError` is - returned. +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. -### Config path override +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. -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: +## Make discovery match the application +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/"]`. +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`. -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. +### Handle every `load_first` outcome -## 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 - -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. - -### 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: - -```toml -[cmds.pr] -reference = "https://github.com/leynos/mxd/pull/31" +## Test discovery without changing the process environment -[cmds.issue] -reference = "https://github.com/leynos/mxd/issues/7" -``` +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: -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::{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, Parser, 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. +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. -If `#[ortho_config(default = ...)]` is still provided, that explicit value -remains available for generated defaults/documentation metadata. +## Handle errors at the application boundary -**Precedence with the attribute (lowest to highest):** - -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. - -There is not currently an `OrthoError::MissingRequiredValues` variant. Missing -required values therefore use the existing error surface: - -- required command-line arguments rejected by `clap` become - `OrthoError::CliParsing`; -- required fields missing after layer merging generally become - `OrthoError::Merge` or another deserialization/gathering error; and -- multiple source failures may be wrapped in `OrthoError::Aggregate`. - -A richer missing-required-values diagnostic is planned in -[Improved error message design](improved-error-message-design.md). Until that -phase 7 work lands, consumers should not match on -`OrthoError::MissingRequiredValues`. - -The planned diagnostic shape is: - -```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. - -```rust -use ortho_config::docs::OrthoConfigDocs; - -#[derive(serde::Deserialize, serde::Serialize, ortho_config::OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct AppConfig { - #[ortho_config(default = 8080)] - port: u16, -} +Metrics are a low-cost opt-in when the application already has a `metrics` +recorder: -let ir = AppConfig::get_doc_metadata(); -let json = ortho_config::serde_json::to_string_pretty(&ir)?; -println!("{json}"); + +```toml +[dependencies] +ortho_config = { version = "0.9.0", features = ["metrics"] } ``` -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. +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. -### Documenting subcommands +## Generate help from the same metadata -For a root config with a clap subcommand selector, derive -`OrthoConfigSubcommandDocs` on the command enum and mark the selector field with -`#[command(subcommand)]`. The selector is not emitted as a normal config -field; instead, each enum variant contributes a child `DocMetadata` node. +`#[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. +Inspect the metadata in code: + + ```rust -use clap::{Args, Parser, Subcommand}; -use ortho_config::{OrthoConfig, OrthoConfigSubcommandDocs}; +use ortho_config::{OrthoConfig, OrthoConfigDocs}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Parser, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct Cli { - #[arg(long)] - verbose: bool, - #[serde(skip)] - #[command(subcommand)] - command: Commands, -} - -#[derive(Debug, Subcommand, OrthoConfigSubcommandDocs)] -enum Commands { - Run(RunArgs), - #[command(name = "take-leave")] - TakeLeave(TakeLeaveArgs), +#[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, } -impl Default for Commands { - fn default() -> Self { - Self::Run(RunArgs::default()) - } -} - -#[derive(Debug, Args, Default, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct RunArgs { - #[arg(long)] - dry_run: bool, -} - -#[derive(Debug, Args, Default, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct TakeLeaveArgs { - #[arg(long)] - message: Option, +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); } ``` -The derived enum implementation preserves declaration order and uses clap's -command label: the `Run` variant becomes `run`, and the explicit -`#[command(name = "take-leave")]` override becomes `take-leave`. If the root -struct also derives `Deserialize`, mark the selector with `#[serde(skip)]` and -provide a default command value so serde does not require the command enum to -deserialize from configuration files. - -Run `cargo orthohelp --format ir` against the package to inspect the generated -tree. The root node lists subcommands in declaration order, and each child node -carries its own fields, examples, headings, nested children, and optional -Windows wrapper metadata. The repository's -`ortho_config/tests/features/docs_ir_nested.feature` file shows the -behavioural-test style used to assert this recursive contract. - -The documentation IR is the human documentation contract. Agent-native work -adds a compact, independently versioned sibling agent-context output for -command invocation, vocabulary checks, structured output policy, bounded list -metadata, and mutation boundaries. Consumers can rely on OrthoConfig to model, -generate, serialize, and lint reusable command contracts; application crates -still own command execution and side effects. See -[Agent-native CLI assistance design](agent-native-cli-design.md) for the -canonical boundary and [Roadmap](roadmap.md) for the implementation sequence. - -Existing `cargo-orthohelp` documentation outputs are compatibility surfaces. -Until a versioned migration is approved, `--format ir`, `--format man`, -`--format ps`, `--format agent-context`, and `--format all` keep their accepted -spellings, defaulting, output paths, and success/failure behaviour. -Agent-context output is now part of `--format all`; future policy reports or -JSON status output are added beside those formats rather than changing them. - -For a fixed `schema_version`, agent-context consumers may rely on field -meanings, enum strings, and the documented null-versus-omitted behaviour. -Consumers must ignore unknown object fields; a version bump signals a breaking -wire-shape change that must be reviewed before accepting the payload. - -Crates that only consume human-facing documentation do not need to adopt -agent-context metadata. A package that installs generated man pages or -PowerShell help can keep treating those files as the public documentation -artefacts. A crate that parses localized IR directly should tolerate additive -optional fields and should apply documented defaults for fields omitted by -older derives, but it should not depend on agent-context or policy-report -fields unless it opts into those formats. The consumer dependency tiers for -downstream applications are defined in -[Agent-native CLI assistance design](agent-native-cli-design.md) §2.2. Human -documentation consumers may continue to use the existing roff and PowerShell -outputs without engaging with those tiers. - -### Generating IR with cargo-orthohelp - -`cargo-orthohelp` compiles a tiny bridge binary for the Cargo -external-subcommand entry point. Cargo injects the subcommand token for -`cargo orthohelp [OPTIONS]`, while direct `cargo-orthohelp [OPTIONS]` and -`cargo-orthohelp orthohelp [OPTIONS]` invocations reach the same CLI parser. -The wrapper 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 -root type to inspect. This wrapper defines the CLI entry-point structure, not -configuration loading. +Or use `cargo-orthohelp` to emit intermediate representation (IR), Unix man +pages, PowerShell help, compact agent context, or all formats: -```toml -[package.metadata.ortho_config] -root_type = "hello_world::cli::HelloWorldCli" -locales = ["en-US", "ja"] + +```console +cargo orthohelp --package hello_world --format agent-context ``` -Run the tool from the project root: - -```bash -cargo orthohelp --out-dir target/orthohelp --locale en-US -``` +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. -`--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. +## Offer a compact contract to automation -### Generating man pages +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. -`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: +The smallest valid context created by `AgentContext::new("acme")` serializes to +this shape: -```bash -cargo orthohelp --format man --out-dir target/man --locale en-US + +```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. -- `powershell//.psm1` – wrapper module. -- `powershell//.psd1` – module manifest. -- `powershell///-help.xml` – MAML help. -- `powershell///about_.help.txt` – about topic. +## Enable another file format -`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`. +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. -PowerShell options: + +```yaml +enabled: yes +mode: on +port: 8080 +``` -- `--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. +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. -To set defaults in `Cargo.toml`, use the Windows metadata table: +## A practical path from here -```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" -``` +For a new CLI, begin with the first layered struct and add only the required +sections. A typical progression is: + +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](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/v0-9-0-migration-guide.md b/docs/v0-9-0-migration-guide.md index 5fa80917..44176f5b 100644 --- a/docs/v0-9-0-migration-guide.md +++ b/docs/v0-9-0-migration-guide.md @@ -1,173 +1,428 @@ # Migration guide: v0.8.0 to v0.9.0 -## Table of contents +## Who should read this -- [Introduction](#introduction) -- [At-a-glance breaking changes](#at-a-glance-breaking-changes) -- [1. Update versions](#1-update-crate-versions) -- [2. Nothing to change for ambient discovery](#2-nothing-to-change-for-ambient-discovery) -- [3. Inject `MapEnv` for hermetic tests](#3-inject-mapenv-for-hermetic-tests) -- [4. Observe discovery telemetry](#4-observe-discovery-telemetry) -- [5. Enable the optional `metrics` feature](#5-enable-the-optional-metrics-feature) -- [6. Redaction contract for upgraders](#6-redaction-contract-for-upgraders) +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: -## Introduction +- `ConfigDiscovery::load_first` now reports accumulated candidate failures; and +- YAML files use YAML 1.2 parsing and reject duplicate keys. -This guide describes how to upgrade applications from `ortho-config` v0.8.0 to -v0.9.0. The release is additive: configuration discovery now reads the -environment through an injectable [`EnvSource`], rather than always reading -`std::env` directly, and emits structured `tracing` events (plus optional -`metrics` counters) describing its decisions. Existing callers of -`ConfigDiscovery` see no behavioural change unless they opt in to the new -`env_source(...)` builder method or the `metrics` feature. +Everything else is additive or removes integration work. The sections below +separate required changes from improvements that can be adopted when useful. -## At-a-glance breaking changes +## Impact at a glance -| Area | Impact | Section | -| ----------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------- | -| Core API | No breaking changes; the default `ConfigDiscovery` behaviour is unchanged. | [2](#2-nothing-to-change-for-ambient-discovery) | -| Test helpers | New opt-in `env_source(...)` builder method and `MapEnv` type for hermetic discovery tests. | [3](#3-inject-mapenv-for-hermetic-tests) | -| Observability | New `tracing` events at `DEBUG` level describe every discovery decision. | [4](#4-observe-discovery-telemetry) | -| Optional dependency | The new `metrics` feature is off by default and adds the `metrics` crate as an optional dependency. | [5](#5-enable-the-optional-metrics-feature) | +| 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: Summary of v0.9.0 changes and where to read more._ +_Table 1: Application-facing work when moving from v0.8.0 to v0.9.0._ -## 1. Update crate versions +## 1. Update dependency versions -### Before: v0.8.0 dependencies +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: v0.9.0 dependencies +After: ```toml +[dependencies] ortho_config = { version = "0.9.0", features = ["yaml"] } ortho_config_macros = "0.9.0" ``` -Update every `ortho_config` and `ortho_config_macros` dependency to `0.9.0`. -No other feature-flag changes are required unless you adopt the optional -`metrics` feature described below. +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. -## 2. Nothing to change for ambient discovery +## 4. Expect clearer inheritance errors -`ConfigDiscovery` reads several environment variables during discovery: the -configuration-path selector named by `env_var`, the XDG base directories, the -Windows application-data folders, and `HOME`/`USERPROFILE`. Previously these -came straight from `std::env`. They now flow through an [`EnvSource`] trait, -but the default source, `process_env_source()`, wraps the live process -environment and preserves the existing behaviour exactly — including the -platform home-directory fallback via `dirs::home_dir()` when neither `HOME` -nor `USERPROFILE` is set. +Missing `extends` targets now report the resolved absolute path and the file +that referenced it. This changes error text, not the success path. -Consumers that build `ConfigDiscovery` without calling the new -`env_source(...)` method require no changes at all. +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. -## 3. Inject `MapEnv` for hermetic tests +## 5. Adopt hermetic discovery tests -`ConfigDiscoveryBuilder::env_source(...)` accepts a `SharedEnvSource` -(`Arc`) and lets tests supply a fixed set of variables instead -of mutating the real process environment. Because the values live on the -`MapEnv` instance rather than in global state, tests using distinct `MapEnv` -values are independent and may run concurrently without a serializing lock. +### Why change an existing pattern -```rust,no_run +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 env = Arc::new(MapEnv::new().with_var("DEMO_CONFIG", "/etc/demo.toml")); -let discovery = ConfigDiscovery::builder("demo") - .env_var("DEMO_CONFIG") - .env_source(env) +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!(discovery.candidates().iter().any(|p| p.ends_with("demo.toml"))); +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, +} ``` -`MapEnv` also offers `insert`, `remove`, and `FromIterator<(K, V)>` for -building or mutating a fixture in place. Lookup remains by name only — there -is deliberately no way to enumerate an `EnvSource`, so injecting one never -risks exposing unrelated variables a test fixture happens to hold. - -Two points are easy to miss when adopting this: - -- **Scope.** An injected source controls file _discovery_ only — the - `env_var` selector, XDG/Windows base directories, and home resolution. It - does not affect the `APP_*` configuration-value merge layer (`CsvEnv`, - wrapping `figment`'s `Env` provider), which still reads the process - environment. Injecting that layer is tracked separately by - [issue #412](https://github.com/leynos/ortho-config/issues/412). -- **Home fallback.** `EnvSource::home_fallback` defaults to `None`, so a - `MapEnv` supplying neither `HOME` nor `USERPROFILE` yields no home - candidate at all, rather than falling back to the host's real home - directory. This keeps a test's candidate list independent of the machine it - runs on. Implement `home_fallback` on a custom `EnvSource` for a - source-specific fallback. - -Implement `EnvSource` directly (it is object-safe, `fmt::Debug + Send + -Sync`) for anything richer than a fixed map. - -## 4. Observe discovery telemetry - -Discovery now emits `tracing` events at `DEBUG` level at each decision point: -which environment source was selected (`discovery.source_selected`), how the -configuration-path selector resolved (`discovery.selector`), which variables -supplied the XDG/Windows base directories (`discovery.xdg`), how the home -directory was resolved (`discovery.home`), when an operation starts -(`discovery.attempt`), when an individual candidate is rejected -(`discovery.candidate`), and the terminal outcome of the operation -(`discovery.load`). Attach any `tracing` subscriber at `DEBUG` level to see -them; no configuration is required to enable emission. - -This is purely additive — no existing behaviour changes — but it is useful -context when diagnosing "why did discovery load _that_ file" during an -upgrade. See the [redaction contract](#6-redaction-contract-for-upgraders) -below for what these events do and do not carry. - -## 5. Enable the optional `metrics` feature - -Enabling the new `metrics` feature emits three counters through the -[`metrics`](https://docs.rs/metrics) facade, each with its own label set: - -- `ortho_config.discovery.attempts` — labelled with the `operation`. -- `ortho_config.discovery.outcomes` — labelled with the `operation` and the - `outcome`. -- `ortho_config.discovery.candidate_failures` — labelled with the - `operation`, the candidate's bounded `source`, and its error `category`. +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"] } ``` -The feature is off by default: a library should not choose a metrics backend -for the application embedding it, and the facade records nothing until that -application installs a recorder — `ortho_config` never installs one itself. -The `tracing` events described above are emitted regardless of whether this -feature is enabled. - -## 6. Redaction contract for upgraders - -Both the `tracing` events and the `metrics` labels are drawn from a closed, -fixed vocabulary — values such as `accepted`, `empty`, `unset`, `not_found`, -or bounded source/category names. They never carry environment variable -values, resolved filesystem paths, or file contents; the events describe the -_decision_, never the datum it was made from. `Debug` output for -`ConfigDiscoveryBuilder` and `MapEnv` follows the same discipline: it reports -counts (for example, how many variables a `MapEnv` holds, or how many project -roots a builder has) and the injected-versus-process distinction, never the -underlying paths or values. - -This guarantee covers OrthoConfig's own event fields: they exclude -environment values, resolved paths, and file contents, so forwarding them -unmodified from a process holding secrets in its environment needs no extra -redaction step. It does not extend to fields a subscriber adds afterwards — -span context, request identifiers, or other attributes attached before -forwarding remain subject to the subscriber's normal redaction policy. Pair -telemetry with `ConfigDiscovery::candidates()` when a specific path is -required for debugging, since the telemetry itself will not name it. - -[`EnvSource`]: https://docs.rs/ortho_config/latest/ortho_config/trait.EnvSource.html +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](users-guide.md) contains complete worked examples for each +new pattern. The [changelog](../CHANGELOG.md) remains the concise release +inventory. diff --git a/ortho_config/Cargo.toml b/ortho_config/Cargo.toml index 93f296f8..342fc5c2 100644 --- a/ortho_config/Cargo.toml +++ b/ortho_config/Cargo.toml @@ -71,6 +71,7 @@ ortho_config_macros = { path = "../ortho_config_macros", version = "0.8.0" } proptest = "1.11.0" tracing-subscriber = "0.3" metrics-util = { version = "0.20", features = ["debugging"] } +wait-timeout = "0.2.1" [[example]] name = "registry_ctl" diff --git a/ortho_config/README.md b/ortho_config/README.md index 1fff31ff..197c0a12 100644 --- a/ortho_config/README.md +++ b/ortho_config/README.md @@ -1,538 +1,176 @@ # OrthoConfig -**OrthoConfig** is a Rust configuration management library designed for -simplicity and power, inspired by the flexible configuration mechanisms found -in tools like `esbuild`. This enables an application to seamlessly load -configuration from command-line arguments, environment variables, and -configuration files, all with a clear order of precedence and minimal -boilerplate. - -The core principle is **orthographic option naming**: a single field in a Rust -configuration struct can be set through idiomatic naming conventions from -various sources (e.g., `--my-option` for CLI, `MY_APP_MY_OPTION` for -environment variables, `my_option` in a TOML file) without requiring extensive -manual aliasing. - -## Core Features - -- **Layered Configuration:** Sources configuration from multiple places with a - well-defined precedence: - 1. Command-Line Arguments (Highest) - 2. Environment Variables - 3. Configuration File (e.g., `config.toml`) - 4. Application-Defined Defaults (Lowest) -- **Orthographic Option Naming:** Automatically maps diverse external naming - conventions (kebab-case, UPPER_SNAKE_CASE, etc.) to a Rust struct's - snake_case fields. -- **Type-Safe Deserialization:** Uses `serde` to deserialize configuration into - strongly typed Rust structs. -- **Easy to Use:** A simple `#[derive(OrthoConfig)]` macro enables a quick - start. -- **Customizable:** Field-level attributes allow fine-grained control over - naming, defaults, and merging behaviour. -- **Config discovery attributes:** Use `#[ortho_config(discovery(...))]` to - rename the generated config override flag, adjust environment variables, and - customize the filenames searched for configuration files without bespoke glue - code. -- **Localized CLI parsing:** Use `LocalizedParse` or - `parse_localized_command` to translate `clap` help text and parse errors - through the same Fluent catalogue used by the rest of the application. -- **Nested Configuration:** Naturally supports nested structs for organized - configuration. -- **Sensible Defaults:** Aims for intuitive behaviour out-of-the-box. - -## Quick Start - - -1. **Add `OrthoConfig` to the project `Cargo.toml`:** +*One Rust struct keeps every configuration source on the straight and narrow.* -```toml -[dependencies] -ortho_config = "0.8.0" # Replace with the latest version -serde = { version = "1.0", features = ["derive"] } -``` - -`ortho_config` re-exports its parsing dependencies, so applications can import -`figment`, `uncased`, `xdg` (on Unix-like and Redox targets), and the optional -format parsers (`figment_json5`, `json5`, `serde_saphyr`, `toml`) without -declaring them directly. The `OrthoConfig` derive macro emits paths like -`ortho_config::figment::Figment`, so direct dependencies are only needed when -application code imports those crates independently. +[![Ask DeepWiki][dw]][dw-url] [![Crates.io Version][cr]][cr-url] -1. **Define the configuration struct:** - -```rust -use ortho_config::{OrthoConfig, OrthoResult}; -use serde::{Deserialize, Serialize}; // Required for OrthoConfig derive - -#[derive(Debug, Clone, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "DB")] // Nested prefix: e.g., APP_DB_URL -struct DatabaseConfig { - // Automatically maps to: - // CLI: --database-url (if clap flattens) or via file/env - // Env: APP_DB_URL= - // File: [database] url = - url: String, - - #[ortho_config(default = 5)] - pool_size: Option, // Optional value, defaults to `Some(5)` -} +> **TL;DR:** Derive `OrthoConfig`, call `load()`, and let your users choose +> defaults, configuration files, environment variables, or command-line +> options. OrthoConfig handles the naming, discovery, and precedence. -#[derive(Debug, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] // Prefix for environment variables (e.g., APP_LOG_LEVEL) -struct AppConfig { - log_level: String, - - // Automatically maps to: - // CLI: --port - // Env: APP_PORT= - // File: port = - #[ortho_config(default = 8080)] - port: u16, +______________________________________________________________________ - #[ortho_config(merge_strategy = "append")] // Default for Vec is append - features: Vec, +## Why OrthoConfig? - // Nested configuration - database: DatabaseConfig, +Configuration plumbing starts small, then quietly takes over the kitchen. +Every new setting needs a CLI flag, an environment variable, a file key, merge +rules, and useful errors when something goes wrong. - #[ortho_config(cli_short = 'v')] // Enable a short flag: -v - verbose: bool, // Defaults to false if not specified -} +OrthoConfig lets you describe that setting once, in the Rust struct your +application already needs. From there it gives you: -fn main() -> OrthoResult<()> { - let config = AppConfig::load()?; // Load configuration +- **less glue:** derive the interface instead of hand-wiring `clap`, Serde, and + file discovery; +- **familiar choices:** users can reach for a flag, an environment variable, or + a configuration file; +- **unsurprising overrides:** command-line values beat environment values, + which beat files and defaults; and +- **one source of truth:** the same metadata can generate human help, agent + context, man pages, and Windows PowerShell help. - println!("Loaded configuration: {:#?}", config); +You get to spend more time on what your application does—and less time teaching +four configuration systems to agree. - if config.verbose { - println!("Verbose mode enabled!"); - } - println!("Log level: {}", config.log_level); - println!("Listening on port: {}", config.port); - println!("Enabled features: {:?}", config.features); - println!("Database URL: {}", config.database.url); - println!("Database pool size: {:?}", config.database.pool_size); +______________________________________________________________________ - Ok(()) -} -``` +## Quick start -2. **Running the application**: +From an empty Rust binary to a layered CLI takes one derive and one call. -- With CLI arguments: - `cargo run -- --log-level debug --port 3000 -v --features extra_cli_feature` -- With environment variables: - `APP_LOG_LEVEL=warn APP_PORT=4000` - `APP_DB_URL="postgres://localhost/mydb"` - `APP_FEATURES="env_feat1,env_feat2" cargo run` -- With a `.app.toml` file (assuming `#[ortho_config(prefix = "APP_")]`; - adjust for the chosen prefix): +### Installation - -- With a `.app.toml` file (assuming `#[ortho_config(prefix = "APP_")]`; adjust - for your prefix): - -```toml -# .app.toml -log_level = "file_level" -port = 5000 -features = ["file_feat_a", "file_feat_b"] - -[database] -url = "mysql://localhost/prod_db" -pool_size = 10 -``` - -## Configuration Sources and Precedence - -OrthoConfig loads configuration from the following sources, with later sources -overriding earlier ones: - -1. **Application-Defined Defaults:** Specified using - `#[ortho_config(default =…)]` or `Option` fields (which default to - `None`). -2. **Configuration File:** Resolved in this order: - 1. `--config-path` CLI option (renameable through the - `discovery(...)` attribute) - 2. `[PREFIX]CONFIG_PATH` environment variable - 3. `..toml` in the current directory - 4. `..toml` in the user's home directory - (where `` comes from `#[ortho_config(prefix = "…")]` and defaults - to `config`). JSON5 and YAML support are feature gated. -3. **Environment Variables:** Variables prefixed with the string specified in - `#[ortho_config(prefix = "...")]` (e.g., `APP_`). Nested struct fields are - typically accessed using double underscores (e.g., `APP_DATABASE__URL` if - `prefix = "APP"` on `AppConfig` and no prefix on `DatabaseConfig`, or - `APP_DB_URL` with `#` on `DatabaseConfig`). -4. **Command-Line Arguments:** Parsed using `clap` conventions. Long flags are - derived from field names (e.g., `my_field` becomes `--my-field`). - -### File Format Support - -TOML parsing is enabled by default. Enable the `json5` and `yaml` features to -support additional formats: +Add OrthoConfig and Serde to `Cargo.toml`: + ```toml [dependencies] -ortho_config = { version = "0.8.0", features = ["json5", "yaml"] } +ortho_config = "0.9.0" +serde = { version = "1.0", features = ["derive"] } ``` -When the `yaml` feature is enabled, configuration files are parsed with -`serde-saphyr` configured for YAML 1.2 semantics. `Options::strict_booleans` -keeps legacy literals such as `yes` or `on` as plain strings, and duplicate -mapping keys raise errors instead of being silently overwritten. - -### Error interop helpers - -`OrthoConfig` includes small extensions to simplify error conversions: - -- `OrthoResultExt::into_ortho()` maps external errors into `OrthoResult`. -- `OrthoMergeExt::into_ortho_merge()` maps `figment::Error` into - `OrthoError::Merge` within `OrthoResult`. -- `ResultIntoFigment::to_figment()` converts `OrthoResult` into - `Result` for integrations that prefer Figment’s type. - -These keep examples and adapters concise while maintaining explicit semantics. +### Basic usage -To return multiple failures at once, `OrthoError::aggregate` builds an -aggregate error from either owned or shared errors. When the collection might -be empty, `OrthoError::try_aggregate` returns `Option`: +Define the settings your application needs and call `load()`: + ```rust -use ortho_config::OrthoError; - -let agg = OrthoError::aggregate(vec![ - OrthoError::validation("port", "must be positive"), // or explicit variant - OrthoError::gathering_arc(figment::Error::from("boom")), -]); - -assert!( - OrthoError::try_aggregate(std::iter::empty::()).is_none() -); -``` - -The file loader selects the parser based on the extension (`.toml`, `.json`, -`.json5`, `.yaml`, `.yml`). When the `json5` feature is active, both `.json` and -`.json5` files are parsed using the JSON5 format. Standard JSON is valid -JSON5, so existing `.json` files continue to work. Without this feature -enabled, attempting to load a `.json` or `.json5` file will result in an error. -When the `yaml` feature is enabled, `.yaml` and `.yml` files are also -discovered and parsed. Without this feature, those extensions are ignored -during path discovery. - -JSON5 extends JSON with conveniences such as comments, trailing commas, -single-quoted strings, and unquoted keys. - -## Orthographic Naming - -A key goal of OrthoConfig is to make configuration natural from any source. A -field like `max_connections: u32` in a Rust struct will, by default, be -configurable via: - -- CLI: `--max-connections ` -- Environment (assuming `#[ortho_config(prefix = "MYAPP")]`): - `MYAPP_MAX_CONNECTIONS=` -- TOML file: `max_connections = ` -- JSON5 file: `max_connections` or `maxConnections` (configurable) - -You can customize these mappings using `#[ortho_config(…)]` attributes. - -## Field Attributes `#[ortho_config(…)]` - -Customize behaviour for each field: - -- `#[ortho_config(default =…)]`: Sets a default value. Can be a literal (e.g., - `"debug"`, `123`, `true`) or a path to a function (e.g., - `default = "my_default_fn"`). -- `#[ortho_config(cli_long = "custom-name")]`: Specifies a custom long CLI flag - (e.g., `--custom-name`). -- `#[ortho_config(cli_short = 'c')]`: Specifies a short CLI flag (e.g., `-c`). -- `#`: Specifies a custom environment variable suffix (appended to the - struct-level prefix). -- `#[ortho_config(file_key = "customKey")]`: Specifies a custom key name for - configuration files. -- `#[ortho_config(merge_strategy = "append")]`: For `Vec` fields, defines how - values from different sources are combined. Defaults to `"append"`. -- `#[ortho_config(flatten)]`: Similar to `serde(flatten)`, useful for inlining - fields from a nested struct into the parent's namespace for CLI or - environment variables. - -## Subcommand Configuration - -Applications using `clap` subcommands can keep per-command defaults in a -dedicated `cmds` namespace. The helper `load_and_merge_subcommand_for` or the -`SubcmdConfigMerge` trait reads these values from configuration files and -environment variables using the struct’s `prefix()` value. When no prefix is -set, environment variables use no prefix, whilst file discovery still defaults -to `.config.toml`. These values are then merged beneath the CLI arguments. +use ortho_config::{OrthoConfig, OrthoResult}; +use serde::{Deserialize, Serialize}; -```rust -use clap::{Args, Parser}; -use serde::Deserialize; -use ortho_config::OrthoConfig; -use ortho_config::SubcmdConfigMerge; - -#[derive(Debug, Deserialize, Args, OrthoConfig)] -#[ortho_config(prefix = "APP_")] -pub struct AddUserArgs { - username: Option, - admin: Option, -} +#[derive(Debug, Deserialize, Serialize, OrthoConfig)] +#[ortho_config(prefix = "HELLO_")] +struct Config { + #[ortho_config(default = String::from("127.0.0.1"))] + host: String, -#[derive(Parser)] -struct Cli { - #[command(flatten)] - args: AddUserArgs, + #[ortho_config(default = 8080)] + port: u16, } -fn main() -> Result<(), Box> { - let cli = Cli::parse(); - - // Reads `[cmds.add-user]` sections and `APP_CMDS_ADD_USER_*` variables - // then merges with CLI values - let args = cli.args.load_and_merge()?; - - println!("Final args: {args:?}"); +fn main() -> OrthoResult<()> { + let config = Config::load()?; + println!("Listening on {}:{}", config.host, config.port); Ok(()) } ``` -Configuration file example: +The same fields are now available as CLI options and `HELLO_HOST` or +`HELLO_PORT` environment variables. Command-line values take precedence: -```toml -[cmds.add-user] -username = "file_user" -admin = true + +```console +$ cargo run -- --host 0.0.0.0 --port 3000 +Listening on 0.0.0.0:3000 ``` -Environment variables override file values using the pattern -`CMDS__`: +That is the whole integration. Your application can grow into files, +subcommands, localization, and generated help when it needs them; it does not +have to start there. -```bash -APP_CMDS_ADD_USER_USERNAME=env_user -APP_CMDS_ADD_USER_ADMIN=false -``` +______________________________________________________________________ -### Documenting Subcommands +## Features -Derive `OrthoConfigSubcommandDocs` on the subcommand enum when the top-level -`OrthoConfig` struct contains a `#[command(subcommand)]` selector. The parent -metadata then receives one recursive `DocMetadata` entry per variant, in enum -declaration order. +- **Layered configuration:** combine typed defaults, configuration files, + environment variables, and command-line arguments predictably. +- **Convention without confinement:** get idiomatic names such as + `--log-level`, `APP_LOG_LEVEL`, and `log_level`, then customize the public + names that matter. +- **Practical file support:** discover configuration across platforms, extend + base files, and choose how collections merge. +- **CLI-shaped configuration:** support subcommands, localized help, and rich + source-aware errors without building a second settings model. +- **Documentation from code:** generate man pages, Windows PowerShell help, + human documentation, and compact agent context with `cargo-orthohelp`. +- **Production-friendly instrumentation:** opt into structured tracing and + low-cardinality metrics while keeping global setup in the application. -```rust -use clap::{Args, Parser, Subcommand}; -use ortho_config::{OrthoConfig, OrthoConfigSubcommandDocs}; -use serde::{Deserialize, Serialize}; +______________________________________________________________________ -#[derive(Debug, Parser, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct Cli { - #[serde(skip)] - #[command(subcommand)] - command: Commands, -} +## Now and next -#[derive(Debug, Subcommand, OrthoConfigSubcommandDocs)] -enum Commands { - Run(RunArgs), -} +**Now:** the configuration foundation is in place, from layered loading and +file inheritance to subcommands, Fluent localization, generated help, recursive +command metadata, and compact agent context. -impl Default for Commands { - fn default() -> Self { - Self::Run(RunArgs::default()) - } -} +**Next:** make agent-driven CLIs harder to misunderstand and safer to operate. +Skill manifests will be checked against the real command tree, while opt-in +policy flags inconsistent vocabulary, missing machine-readable results, unsafe +mutation surfaces, and unbounded list commands. `cargo-orthohelp` will dogfood +those contracts with structured results, actionable errors, and atomic output; +later metadata will describe profiles, delivery targets, and long-running jobs +so agents can reuse predictable workflows instead of inventing integration +glue. -#[derive(Debug, Args, Default, Deserialize, Serialize, OrthoConfig)] -#[ortho_config(prefix = "APP")] -struct RunArgs { - #[arg(long)] - dry_run: bool, -} -``` +See the [completed v0.8.0 roadmap][archived-roadmap] for the foundation and the +[active roadmap][roadmap] for the detailed sequence. -### Dispatching Subcommands +______________________________________________________________________ -Subcommands can be executed with defaults applied using -[`clap-dispatch`](https://docs.rs/clap-dispatch): +## Learn more -```rust -use clap::{Args, Parser}; -use clap_dispatch::clap_dispatch; -use serde::Deserialize; -use ortho_config::{load_and_merge_subcommand_for, OrthoConfig}; - -#[derive(Debug, Deserialize, Args, OrthoConfig)] -#[ortho_config(prefix = "APP_")] -pub struct AddUserArgs { - username: Option, - admin: Option, -} +- [User's guide][users-guide] — build a real CLI one practical task at a time, + with worked examples. +- [v0.9.0 migration guide][migration-guide] — separate required migrations + from improvements that can be adopted when useful. +- [Hello World application][hello-world] — explore a complete, + multi-module example with localization and generated help. +- [API documentation](https://docs.rs/ortho_config) — look up individual + traits, attributes, and types. +- [Developer's guide][developers-guide] — build, test, and contribute to + OrthoConfig. +- [Changelog][changelog] — review released features, fixes, and compatibility + notes. +- [Design document][design] — understand the architecture and guiding + decisions. +- [Roadmap][roadmap] — see what has landed and what comes next. -#[derive(Debug, Deserialize, Args, OrthoConfig)] -pub struct ListItemsArgs { - category: Option, - all: Option, -} +______________________________________________________________________ -trait Run { - fn run(&self, db_url: &str) -> Result<(), String>; -} - -impl Run for AddUserArgs { /* application logic here */ } -impl Run for ListItemsArgs { /* application logic here */ } +## Licence -#[derive(Parser)] -#[command(name = "registry-ctl")] -#[clap_dispatch(fn run(self, db_url: &str) -> Result<(), String>)] -enum Commands { - AddUser(AddUserArgs), - ListItems(ListItemsArgs), -} +OrthoConfig is distributed under the [ISC licence][licence]. -fn main() -> Result<(), String> { - let cli = Commands::parse(); - let db_url = "postgres://user:pass@localhost/registry"; - - // merge per-command defaults - let cmd = match cli { - Commands::AddUser(args) => { - Commands::AddUser(load_and_merge_subcommand_for::(&args)?) - } - Commands::ListItems(args) => { - Commands::ListItems(load_and_merge_subcommand_for::(&args)?) - } - }; - - cmd.run(db_url) -} -``` - -## Why OrthoConfig? - -- **Reduced Boilerplate:** Define the configuration schema once and let - OrthoConfig handle multi-source loading and mapping. -- **Developer Ergonomics:** Intuitive mapping from external sources to Rust - code. -- **Flexibility:** Users of the application can configure it in the way that - best suits their environment (CLI for quick overrides, env vars for CI/CD, - files for persistent settings). -- **Clear Precedence:** Predictable configuration resolution. - -## Migration notes for v0.8.0 - -Use these notes when upgrading from v0.7.x to v0.8.0: - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.8.0`, - and ensure your toolchain is Rust `1.88` or newer. -- If you alias the runtime crate in `Cargo.toml` (for example, - `my_cfg = { package = "ortho_config", ... }`), add - `#[ortho_config(crate = "my_cfg")]` so derive-generated paths resolve. The - same attribute also applies to `SelectedSubcommandMerge`. -- If you use `cli_default_as_absent`, prefer typed clap defaults - (`default_value_t` / `default_values_t`). Inference from `default_value` is - rejected, and mixed clap default overrides on the same field now fail fast. -- YAML parsing now uses `serde-saphyr` with YAML 1.2 behaviour. Quote legacy - literals like `yes`, `on`, and `off` when they should remain strings, and - remove duplicate mapping keys that older parsers may have tolerated. -- For derive-generated code, use dependency re-exports from - `ortho_config::figment`, `ortho_config::uncased`, and `ortho_config::xdg` - unless your own application source imports those crates directly. -- If you generate documentation artefacts, wire in - `[package.metadata.ortho_config]` (`root_type`, `locales`) and optional - `[package.metadata.ortho_config.windows]` overrides, then run - `cargo orthohelp` (`--format man` / `--format ps`) against the emitted - `OrthoConfigDocs` metadata. -- To document command trees, add `OrthoConfigSubcommandDocs` to subcommand - enums and keep selector fields marked with `#[serde(skip)]` plus a default - command value. This is additive and does not change existing config loading. - -## Migration notes for v0.7.0 - -Use these notes when upgrading from v0.6.x to v0.7.0. For full examples and -background, see the [v0.7.0 migration guide](docs/v0-7-0-migration-guide.md). - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.7.0` - and keep feature flags (`toml`, `json5`, `yaml`) on `ortho_config`. -- If you disable default features, enable `serde_json` explicitly before using - selected-subcommand merge helpers or `cli_default_as_absent`. -- Adopt `compose_layers()` / `compose_layers_from_iter(...)` when you need to - inspect, amend, or aggregate layers before merging. -- Add `#[ortho_config(post_merge_hook)]` plus `PostMergeHook` only when - cross-field normalization or validation must run after merge resolution. -- For localized CLI copy and errors, use `FluentLocalizer` and - `localize_clap_error_with_command`. -- For `cli_default_as_absent`, pass `ArgMatches` into merge flows (and annotate - subcommand variants with `#[ortho_subcommand(with_matches)]` when needed) so - clap defaults do not override file/env values unless explicitly provided. - -## Migrating from 0.5 to 0.6 - -Version v0.6.0 streamlines dependency management, discovery, and YAML parsing. -For a full walkthrough see the -[v0.6.0 migration guide](docs/v0-6-0-migration-guide.md); the highlights are: - -- Update every `ortho_config` and `ortho_config_macros` dependency to `0.6.0`. - Feature flags now flow from the runtime crate to the macros, so you can drop - duplicated feature declarations on the derive crate. -- Use the crates re-exported via `ortho_config::figment` (and friends) instead - of keeping direct dependencies on Figment, `uncased`, or `xdg`. -- Prefer the `#[ortho_config(discovery(...))]` attribute to configure search - paths declaratively and bubble up errors from `ConfigDiscovery::load_first`, - which now returns `Err` whenever every candidate failed to load. -- Switch to the new `SaphyrYaml` provider (behind the existing `yaml` - feature) wherever Figment's YAML provider was used to benefit from YAML 1.2 - semantics and duplicate-key validation. - -## Migrating from 0.4 to 0.5 - -Version v0.5.0 introduces a small API refinement: - -- In v0.5.0 the helper `load_subcommand_config_for` was removed. Use - [`load_and_merge_subcommand_for`](#subcommand-configuration) to load defaults - and merge them with CLI arguments. -- Types deriving `OrthoConfig` expose an associated `prefix()` function. Use - this if you need the configured prefix directly. - -Update the `Cargo.toml` to depend on `ortho_config = "0.5.0"` and adjust code -to call `load_and_merge_subcommand_for` instead of manually merging defaults. - -## Version management - -- The `scripts/bump_version.py` helper keeps the workspace and member crates in - version sync. -- It requires [`uv`](https://docs.astral.sh/uv/) on the `PATH` as the shebang - uses `uv` for dependency resolution. -- Run it with the desired semantic version: - -```sh -./scripts/bump_version.py 1.2.3 -``` - -## Publish checks - -Run `make publish-check` before releasing to execute the `lading` publish -pre-flight validations with the repository's helper scripts on the `PATH`. The -target is parameterized via `PUBLISH_CHECK_FLAGS`, which now defaults to an -empty value, so the command enforces a clean working tree. Developers who want -the previous convenience may opt in explicitly: - -```sh -PUBLISH_CHECK_FLAGS="--allow-dirty" make publish-check -``` - -Run `make publish-check` in release validation workflows (for example, -`workflow_dispatch`) where the target versions are already published. +______________________________________________________________________ ## Contributing -Contributions are welcome! Please feel free to submit issues, fork the -repository, and send pull requests. - -## License - -OrthoConfig is distributed under the terms of both the ISC license. - -See LICENSE for details. +Found a rough edge, a missing example, or an idea that would make configuration +less of a chore? Contributions are welcome. Start with the +[developer's guide][developers-guide] and the repository's +[contributor guidance][contributor-guidance]. + +[archived-roadmap]: https://github.com/leynos/ortho-config/blob/main/docs/archive/v0-8-0-roadmap.md +[changelog]: https://github.com/leynos/ortho-config/blob/main/CHANGELOG.md +[contributor-guidance]: https://github.com/leynos/ortho-config/blob/main/AGENTS.md +[cr]: https://img.shields.io/crates/v/ortho_config "crates.io package" +[cr-url]: https://crates.io/crates/ortho_config +[design]: https://github.com/leynos/ortho-config/blob/main/docs/design.md +[developers-guide]: https://github.com/leynos/ortho-config/blob/main/docs/developers-guide.md +[dw]: https://deepwiki.com/badge.svg +[dw-url]: https://deepwiki.com/leynos/ortho-config +[hello-world]: https://github.com/leynos/ortho-config/tree/main/examples/hello_world +[licence]: https://github.com/leynos/ortho-config/blob/main/LICENSE +[migration-guide]: https://github.com/leynos/ortho-config/blob/main/docs/v0-9-0-migration-guide.md +[roadmap]: https://github.com/leynos/ortho-config/blob/main/docs/roadmap.md +[users-guide]: https://github.com/leynos/ortho-config/blob/main/docs/users-guide.md diff --git a/ortho_config/tests/documentation_examples/cargo_runner.rs b/ortho_config/tests/documentation_examples/cargo_runner.rs new file mode 100644 index 00000000..343e1776 --- /dev/null +++ b/ortho_config/tests/documentation_examples/cargo_runner.rs @@ -0,0 +1,398 @@ +//! Isolated Cargo commands for executable documentation tests. +//! +//! Shared test infrastructure clears inherited state, restores a closed +//! toolchain allow-list, and keeps artefacts out of the repository target tree. + +use anyhow::{Context, Result, ensure}; +use cap_std::{ambient_authority, fs::Dir}; +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::process::{Command, Output}; + +use crate::process_runner::{self, Operation}; + +const CARGO_ENV_ALLOWLIST: &[&str] = &[ + "CARGO_HOME", + "HOME", + "INCLUDE", + "LIB", + "LIBPATH", + "PATH", + "ProgramFiles", + "ProgramFiles(x86)", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + "SYSTEMROOT", + "TMPDIR", + "USERPROFILE", + "VCINSTALLDIR", + "VSCMD_ARG_TGT_ARCH", + "VSINSTALLDIR", + "WINDIR", + "WindowsSDKVersion", + "WindowsSdkDir", +]; + +#[derive(Default)] +struct PreparedCargoEnvironment(Vec<(OsString, OsString)>); + +/// Prepare and create a Cargo command with isolated process state. +/// +/// # Errors +/// +/// Returns an error when state validation or Windows preparation fails. +pub(super) fn prepare_cargo_command( + working_directory: &Path, + state_directory: &Path, +) -> Result { + let environment = prepare_cargo_environment(state_directory)?; + Ok(cargo_command( + working_directory, + state_directory, + &environment, + )) +} + +fn cargo_command( + working_directory: &Path, + state_directory: &Path, + environment: &PreparedCargoEnvironment, +) -> Command { + let mut command = Command::new("cargo"); + sanitize_environment(&mut command, CARGO_ENV_ALLOWLIST); + command + .current_dir(working_directory) + .env("CARGO_TARGET_DIR", state_directory.join("target")) + .envs(environment.0.iter().map(|(name, value)| (name, value))); + command +} + +/// Replace a command's environment with values from a closed host allow-list. +pub(super) fn sanitize_environment(command: &mut Command, allowlist: &[&str]) { + command.env_clear(); + for name in allowlist { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } +} + +fn prepare_cargo_environment(state_directory: &Path) -> Result { + Dir::open_ambient_dir(state_directory, ambient_authority()) + .context("open isolated Cargo state directory")?; + #[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] + { + prepare_msvc_environment(state_directory) + } + #[cfg(not(all(windows, target_env = "msvc", target_arch = "x86_64")))] + Ok(PreparedCargoEnvironment::default()) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn prepare_msvc_environment(state_directory: &Path) -> Result { + let Some((linker, vcvars)) = find_msvc_toolchain()? else { + return Ok(PreparedCargoEnvironment::default()); + }; + let mut variables = vec![( + OsString::from("CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER"), + linker, + )]; + variables.extend( + vcvars_environment(state_directory, &vcvars)? + .into_iter() + .map(|(name, value)| (name.into(), value.into())), + ); + Ok(PreparedCargoEnvironment(variables)) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn find_msvc_toolchain() -> Result> { + let Some(vswhere) = vswhere_path() else { + return Ok(None); + }; + let installation_output = run_vswhere(&vswhere, &["-property", "installationPath"])?; + let linker_output = run_vswhere( + &vswhere, + &["-find", r"VC\Tools\MSVC\**\bin\Hostx64\x64\link.exe"], + )?; + let installation_path = first_output_line(&installation_output) + .context("vswhere returned no Visual Studio installation path")? + .to_str() + .context("Visual Studio installation path should be UTF-8")?; + let linker_path = first_output_line(&linker_output) + .context("vswhere returned no MSVC linker path")? + .to_os_string(); + let vcvars = Path::new(installation_path) + .join("VC") + .join("Auxiliary") + .join("Build") + .join("vcvars64.bat"); + Ok(Some((linker_path, vcvars))) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn vswhere_path() -> Option { + let program_files = + std::env::var_os("ProgramFiles(x86)").or_else(|| std::env::var_os("ProgramFiles"))?; + Some( + Path::new(&program_files) + .join("Microsoft Visual Studio") + .join("Installer") + .join("vswhere.exe"), + ) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn run_vswhere(vswhere: &Path, query: &[&str]) -> Result> { + let mut command = Command::new(vswhere); + command.args( + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + ] + .into_iter() + .chain(query.iter().copied()) + .chain(["-utf8"]), + ); + Ok(run_process(&mut command, Operation("discover the MSVC toolchain"))?.stdout) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn vcvars_environment(state_directory: &Path, vcvars: &Path) -> Result> { + let directory = Dir::open_ambient_dir(state_directory, ambient_authority()) + .context("open isolated Cargo state directory")?; + let script_name = "msvc-environment.cmd"; + let script = format!("@call \"{}\" >nul\r\n@set\r\n", vcvars.display()); + directory + .write(script_name, script) + .context("write MSVC environment command file")?; + let mut command = Command::new("cmd.exe"); + command + .args(["/d", "/u", "/c"]) + .arg(state_directory.join(script_name)); + let output = run_process(&mut command, Operation("prepare the MSVC environment"))?; + let environment = decode_utf16le(&output.stdout)?; + Ok(allowed_environment(&environment)) +} + +#[cfg(all(windows, target_env = "msvc", target_arch = "x86_64"))] +fn decode_utf16le(output: &[u8]) -> Result { + let mut chunks = output.chunks_exact(2); + let code_units = chunks + .by_ref() + .filter_map(|pair| match pair { + [low, high] => Some(u16::from(*low) | (u16::from(*high) << 8)), + _ => None, + }) + .collect::>(); + ensure!( + chunks.remainder().is_empty(), + "MSVC environment output should contain complete UTF-16 code units" + ); + String::from_utf16(&code_units).context("MSVC environment output should be valid UTF-16") +} + +fn run_process(command: &mut Command, operation: Operation<'_>) -> Result { + let output = process_runner::run_command(command, operation)?; + let Operation(operation_name) = operation; + ensure!( + output.status.success(), + "{operation_name}: subprocess failed with {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + Ok(output) +} + +fn allowed_environment(environment: &str) -> Vec<(String, String)> { + environment + .lines() + .filter_map(|line| line.split_once('=')) + .filter_map(|(name, value)| { + CARGO_ENV_ALLOWLIST + .iter() + .find(|allowed| allowed.eq_ignore_ascii_case(name)) + .map(|allowed| ((*allowed).to_owned(), value.to_owned())) + }) + .collect() +} + +fn first_output_line(output: &[u8]) -> Option<&OsStr> { + let decoded_output = std::str::from_utf8(output).ok()?; + decoded_output + .lines() + .find(|line| !line.trim().is_empty()) + .map(OsStr::new) +} + +#[cfg(test)] +mod tests { + //! Regression coverage for the isolated Cargo process boundary. + + use super::{ + CARGO_ENV_ALLOWLIST, allowed_environment, first_output_line, prepare_cargo_command, + run_process, sanitize_environment, + }; + use crate::process_runner::{self, Operation}; + use anyhow::ensure; + use std::ffi::OsStr; + use std::process::Command; + use tempfile::TempDir; + + const SECRET_NAME: &str = "ORTHO_CONFIG_DOCUMENTATION_TEST_SECRET"; + + #[test] + fn cargo_child_does_not_observe_inherited_environment_values() { + let mut command = Command::new( + std::env::current_exe().expect("the integration-test executable should have a path"), + ); + command.env(SECRET_NAME, "must-not-leak"); + command.args([ + "--ignored", + "--nocapture", + "cargo_environment_sanitizer_probe", + ]); + let output = + process_runner::run_command(&mut command, Operation("run inherited-environment probe")) + .expect("the inherited-environment probe should run"); + assert!( + output.status.success(), + "inherited-environment probe failed: {output:?}" + ); + } + + #[test] + #[ignore = "executed in a sanitized child process"] + fn cargo_environment_sanitizer_probe() { + assert_eq!( + std::env::var(SECRET_NAME).as_deref(), + Ok("must-not-leak"), + "the probe process should inherit the excluded value" + ); + let mut command = Command::new( + std::env::current_exe().expect("the integration-test executable should have a path"), + ); + sanitize_environment(&mut command, CARGO_ENV_ALLOWLIST); + command.args(["--ignored", "--nocapture", "cargo_environment_probe"]); + let output = + process_runner::run_command(&mut command, Operation("run sanitized environment probe")) + .expect("the sanitized environment probe should run"); + assert!( + output.status.success(), + "sanitized environment probe failed: {output:?}" + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!("{SECRET_NAME}=")), + "inherited environment value reached the sanitized child: {output:?}" + ); + } + + #[test] + #[ignore = "executed in a sanitized child process"] + fn cargo_environment_probe() { + use std::io::Write; + + let value = std::env::var(SECRET_NAME).unwrap_or_else(|_| "".to_owned()); + writeln!(std::io::stdout().lock(), "{SECRET_NAME}={value}") + .expect("write environment-probe output"); + } + + #[test] + fn discovery_process_start_failures_are_reported() { + let mut command = Command::new("ortho-config-documentation-command-that-does-not-exist"); + let error = run_process(&mut command, Operation("discover the MSVC toolchain")) + .expect_err("a missing discovery command should return an error"); + let message = format!("{error:#}"); + assert!(message.contains("discover the MSVC toolchain: start subprocess")); + } + + #[test] + fn preparation_process_failures_are_reported() { + let mut command = Command::new( + std::env::current_exe().expect("the integration-test executable should have a path"), + ); + command.args(["--ignored", "--nocapture", "process_failure_probe"]); + let error = run_process(&mut command, Operation("prepare the MSVC environment")) + .expect_err("a failed preparation command should return an error"); + let message = format!("{error:#}"); + assert!(message.contains("prepare the MSVC environment: subprocess failed")); + } + + #[test] + #[ignore = "executed as a failing subprocess"] + fn process_failure_probe() { + std::process::exit(23); + } + + #[test] + fn concurrent_cargo_preparation_isolates_state_directories() -> anyhow::Result<()> { + let first_state = TempDir::new()?; + let second_state = TempDir::new()?; + let (first_result, second_result) = std::thread::scope(|scope| { + let first = + scope.spawn(|| prepare_cargo_command(first_state.path(), first_state.path())); + let second = + scope.spawn(|| prepare_cargo_command(second_state.path(), second_state.path())); + ( + first + .join() + .expect("first Cargo preparation should not panic"), + second + .join() + .expect("second Cargo preparation should not panic"), + ) + }); + let first_command = first_result?; + let second_command = second_result?; + let first_target = first_state.path().join("target"); + let second_target = second_state.path().join("target"); + ensure!( + target_directory(&first_command) == Some(first_target.as_os_str()), + "first Cargo command should use its own state directory" + ); + ensure!( + target_directory(&second_command) == Some(second_target.as_os_str()), + "second Cargo command should use its own state directory" + ); + ensure!( + target_directory(&first_command) != target_directory(&second_command), + "concurrent Cargo commands should not share state directories" + ); + Ok(()) + } + + fn target_directory(command: &Command) -> Option<&OsStr> { + command + .get_envs() + .find(|(name, _)| *name == OsStr::new("CARGO_TARGET_DIR")) + .and_then(|(_, value)| value) + } + + #[test] + fn visual_studio_discovery_uses_the_first_reported_linker() { + let output = b"C:\\Visual Studio\\link.exe\r\nC:\\Other\\link.exe\r\n"; + assert_eq!( + first_output_line(output), + Some(std::ffi::OsStr::new(r"C:\Visual Studio\link.exe")) + ); + } + + #[test] + fn visual_studio_environment_keeps_only_build_variables() { + let environment = concat!( + "LIB=C:\\Windows Kits\\Lib\r\n", + "Path=C:\\Visual Studio\\bin\r\n", + "SECRET=do-not-copy\r\n", + ); + assert_eq!( + allowed_environment(environment), + vec![ + ("LIB".to_owned(), r"C:\Windows Kits\Lib".to_owned()), + ("PATH".to_owned(), r"C:\Visual Studio\bin".to_owned()), + ] + ); + } +} diff --git a/ortho_config/tests/documentation_examples/mod.rs b/ortho_config/tests/documentation_examples/mod.rs new file mode 100644 index 00000000..119b105e --- /dev/null +++ b/ortho_config/tests/documentation_examples/mod.rs @@ -0,0 +1,267 @@ +//! Loads fenced examples from the public README and user's guide. +//! +//! Every fence in either document must be preceded by a stable +//! `tested-example` marker. Tests query the exact published text through this +//! module so copied fixtures cannot drift away from the documentation. +//! The registry is initialized once per integration-test process, then remains +//! immutable for that process's lifetime; callers cannot reset or replace it. + +use anyhow::{Context, Result, ensure}; +use cap_std::{ambient_authority, fs::Dir}; +use std::collections::HashSet; +use std::sync::LazyLock; + +const DOCUMENT_PATHS: &[&str] = &["README.md", "docs/users-guide.md"]; +const MARKER_PREFIX: &str = ""; + +static DOCUMENTED_EXAMPLES: LazyLock, String>> = + LazyLock::new(|| read_documented_examples().map_err(|error| format!("{error:#}"))); + +#[derive(Clone, Copy)] +struct Cursor { + source: &'static str, + line_index: usize, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +struct Fence { + delimiter: u8, + length: usize, +} + +impl Cursor { + fn error(self, message: &str) -> String { + format!("{}:{} {message}", self.source, self.line_index + 1) + } +} + +/// One marked fenced example loaded from user-facing documentation. +#[derive(Debug, Eq, PartialEq)] +pub struct DocumentedExample { + /// Stable identifier declared by the `tested-example` marker. + pub id: String, + /// Markdown fence language. + pub language: String, + /// Exact text inside the fence, including a trailing newline. + pub body: String, + /// Repository-relative source document. + pub source: &'static str, + /// One-based line containing the opening fence. + pub line: usize, +} + +/// Load every public example once and return the cached registry. +/// +/// # Errors +/// +/// Returns an error when a document cannot be read, a marker is malformed, a +/// fence is unmarked or unterminated, or an identifier is duplicated. +/// +/// # Examples +/// +/// ```no_run +/// let examples = load_documented_examples()?; +/// let example_ids: Vec<_> = examples.iter().map(|example| example.id.as_str()).collect(); +/// assert!(!example_ids.is_empty()); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub fn load_documented_examples() -> Result<&'static [DocumentedExample]> { + DOCUMENTED_EXAMPLES + .as_ref() + .map(Vec::as_slice) + .map_err(|message| anyhow::anyhow!(message.clone())) +} + +fn read_documented_examples() -> Result> { + let repository = repository_directory()?; + let mut examples = Vec::new(); + for path in DOCUMENT_PATHS { + let contents = repository + .read_to_string(path) + .with_context(|| format!("read {path}"))?; + examples.extend(parse_document(path, &contents)?); + } + + let mut ids = HashSet::new(); + for example in &examples { + ensure!( + ids.insert(example.id.as_str()), + "duplicate tested-example identifier '{}'", + example.id + ); + } + Ok(examples) +} + +/// Borrow the cached documented example identified by `id`. +/// +/// # Errors +/// +/// Returns an error when the documents are invalid or `id` is absent. +/// +/// # Examples +/// +/// ```no_run +/// let example = documented_example("guide-install")?; +/// assert_eq!(example.id, "guide-install"); +/// +/// let error = documented_example("absent-example") +/// .expect_err("an absent identifier should return an error"); +/// assert_eq!( +/// error.to_string(), +/// "documented example 'absent-example' should exist", +/// ); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub fn documented_example(id: &str) -> Result<&'static DocumentedExample> { + load_documented_examples()? + .iter() + .find(|example| example.id == id) + .with_context(|| format!("documented example '{id}' should exist")) +} + +/// Return whether an identifier is safe for documentation-workspace paths. +/// +/// This grammar is shared only by the documentation parser and its temporary +/// workspace. Validate before interpolating an identifier into any path. +pub(super) fn is_valid_example_id(id: &str) -> bool { + id.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && !id.ends_with('-') + && !id.contains("--") + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +pub(crate) fn parse_document( + source: &'static str, + contents: &str, +) -> Result> { + // The registry uses LF as its canonical representation. `lines()` removes + // source terminators, and `read_fence_body` restores one LF per body line. + let mut lines = contents.lines().enumerate(); + let mut examples = Vec::new(); + let mut ids = HashSet::new(); + + while let Some((line_index, line)) = lines.next() { + let cursor = Cursor { source, line_index }; + if let Some(id) = parse_marker(line) { + ensure!( + !id.trim().is_empty(), + "{}", + cursor.error("tested-example identifier must not be empty") + ); + ensure!( + is_valid_example_id(id), + "{}", + cursor.error( + "tested-example identifier must use lowercase letters, digits, and single hyphens" + ) + ); + ensure!(ids.insert(id), "duplicate tested-example identifier '{id}'"); + examples.push(read_marked_example(&cursor, id, &mut lines)?); + } else { + reject_invalid_example_line(&cursor, line)?; + } + } + + Ok(examples) +} + +fn repository_directory() -> Result { + let repository_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + Dir::open_ambient_dir(repository_root, ambient_authority()).context("open the repository root") +} + +fn reject_invalid_example_line(cursor: &Cursor, line: &str) -> Result<()> { + ensure!( + line != format!("{MARKER_PREFIX}{}", MARKER_SUFFIX.trim_start()), + "{}", + cursor.error("tested-example identifier must not be empty") + ); + ensure!( + parse_fence(line).is_none(), + "{}", + cursor.error("fence is missing a tested-example marker") + ); + Ok(()) +} + +fn read_marked_example<'a>( + cursor: &Cursor, + id: &str, + lines: &mut impl Iterator, +) -> Result { + let (fence_index, fence) = lines + .next() + .with_context(|| cursor.error("marker has no fence"))?; + let fence_cursor = Cursor { + source: cursor.source, + line_index: fence_index, + }; + let (opening_fence, language) = parse_fence(fence) + .with_context(|| fence_cursor.error("expected an opening fence after marker"))?; + ensure!( + !language.is_empty(), + "{}", + fence_cursor.error("fence should declare a language") + ); + let body = read_fence_body(cursor.source, fence_index, opening_fence, lines)?; + Ok(DocumentedExample { + id: id.to_owned(), + language: language.to_owned(), + body, + source: cursor.source, + line: fence_index + 1, + }) +} + +fn parse_marker(line: &str) -> Option<&str> { + line.strip_prefix(MARKER_PREFIX) + .and_then(|value| value.strip_suffix(MARKER_SUFFIX)) +} + +fn parse_fence(line: &str) -> Option<(Fence, &str)> { + let indentation = line.bytes().take_while(|byte| *byte == b' ').count(); + if indentation > 3 { + return None; + } + let remainder = line.get(indentation..)?; + let delimiter = *remainder.as_bytes().first()?; + if !matches!(delimiter, b'`' | b'~') { + return None; + } + let length = remainder + .bytes() + .take_while(|candidate| *candidate == delimiter) + .count(); + let language = remainder.get(length..)?; + (length >= 3).then_some((Fence { delimiter, length }, language)) +} + +fn is_matching_closing_fence(line: &str, opening_fence: Fence) -> bool { + matches!( + parse_fence(line), + Some((closing_fence, suffix)) + if closing_fence == opening_fence + && suffix.bytes().all(|byte| matches!(byte, b' ' | b'\t')) + ) +} + +fn read_fence_body<'a>( + source: &'static str, + fence_index: usize, + opening_fence: Fence, + lines: &mut impl Iterator, +) -> Result { + let mut body = String::new(); + for (_, line) in lines { + if is_matching_closing_fence(line, opening_fence) { + return Ok(body); + } + body.push_str(line); + body.push('\n'); + } + anyhow::bail!("{source}:{} fence is not terminated", fence_index + 1) +} diff --git a/ortho_config/tests/documentation_examples/process_runner.rs b/ortho_config/tests/documentation_examples/process_runner.rs new file mode 100644 index 00000000..9eca3873 --- /dev/null +++ b/ortho_config/tests/documentation_examples/process_runner.rs @@ -0,0 +1,252 @@ +//! Bounded subprocess execution for executable documentation tests. +//! +//! This module owns time and capture limits only. Callers remain responsible +//! for command construction, environment isolation, and exit-status policy. + +use anyhow::{Context, Result, anyhow}; +use std::io::Read; +use std::process::{Child, Command, ExitStatus, Output, Stdio}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use wait_timeout::ChildExt; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600); +const DEFAULT_OUTPUT_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Copy)] +pub(super) struct Operation<'a>(pub(super) &'a str); + +#[derive(Clone, Copy)] +enum OutputStream { + Stdout, + Stderr, +} + +impl OutputStream { + const fn name(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +#[derive(Clone, Copy)] +struct ProcessLimits { + timeout: Duration, + output_bytes: usize, +} + +impl Default for ProcessLimits { + fn default() -> Self { + Self { + timeout: DEFAULT_TIMEOUT, + output_bytes: DEFAULT_OUTPUT_BYTES, + } + } +} + +/// Run a command with the documentation-test timeout and output limits. +pub(super) fn run_command(command: &mut Command, operation: Operation<'_>) -> Result { + run_command_with_limits(command, operation, ProcessLimits::default()) +} + +fn run_command_with_limits( + command: &mut Command, + operation: Operation<'_>, + limits: ProcessLimits, +) -> Result { + let Operation(operation_name) = operation; + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("{operation_name}: start subprocess"))?; + let stdout_pipe = take_stdout(&mut child, operation)?; + let stderr_pipe = take_stderr(&mut child, operation)?; + let stdout_reader = spawn_bounded_reader(stdout_pipe, limits.output_bytes); + let stderr_reader = spawn_bounded_reader(stderr_pipe, limits.output_bytes); + + let status = wait_for_exit(&mut child, operation, limits.timeout); + if status.is_err() { + terminate_child(&mut child, operation)?; + } + let captured_stdout = join_reader(stdout_reader, operation, OutputStream::Stdout)?; + let captured_stderr = join_reader(stderr_reader, operation, OutputStream::Stderr)?; + + Ok(Output { + status: status?, + stdout: captured_stdout, + stderr: captured_stderr, + }) +} + +fn take_stdout( + child: &mut Child, + Operation(operation): Operation<'_>, +) -> Result { + child + .stdout + .take() + .ok_or_else(|| anyhow!("{operation}: capture subprocess stdout")) +} + +fn take_stderr( + child: &mut Child, + Operation(operation): Operation<'_>, +) -> Result { + child + .stderr + .take() + .ok_or_else(|| anyhow!("{operation}: capture subprocess stderr")) +} + +fn spawn_bounded_reader( + mut pipe: impl Read + Send + 'static, + output_limit: usize, +) -> JoinHandle>> { + thread::spawn(move || { + let mut captured = Vec::with_capacity(output_limit.min(8192)); + let mut buffer = [0_u8; 8192]; + loop { + let bytes_read = pipe.read(&mut buffer).context("read subprocess output")?; + if bytes_read == 0 { + break; + } + let bytes_to_capture = output_limit.saturating_sub(captured.len()).min(bytes_read); + captured.extend(buffer.iter().take(bytes_to_capture).copied()); + } + Ok(captured) + }) +} + +fn wait_for_exit( + child: &mut Child, + Operation(operation): Operation<'_>, + timeout: Duration, +) -> Result { + child + .wait_timeout(timeout) + .with_context(|| format!("{operation}: wait for subprocess"))? + .ok_or_else(|| { + anyhow!( + "{operation}: subprocess timed out after {}s", + timeout.as_secs() + ) + }) +} + +fn terminate_child(child: &mut Child, Operation(operation): Operation<'_>) -> Result<()> { + if child + .try_wait() + .with_context(|| format!("{operation}: poll timed-out subprocess"))? + .is_some() + { + return Ok(()); + } + child + .kill() + .with_context(|| format!("{operation}: kill timed-out subprocess"))?; + child + .wait() + .with_context(|| format!("{operation}: reap timed-out subprocess"))?; + Ok(()) +} + +fn join_reader( + reader: JoinHandle>>, + Operation(operation): Operation<'_>, + stream: OutputStream, +) -> Result> { + let stream_name = stream.name(); + reader + .join() + .map_err(|_| anyhow!("{operation}: {stream_name} reader thread panicked"))? +} + +#[cfg(test)] +mod tests { + //! Regression coverage for subprocess output and duration bounds. + + use super::{Operation, OutputStream, ProcessLimits, join_reader, run_command_with_limits}; + use rstest::rstest; + use std::io::Write; + use std::process::Command; + use std::time::Duration; + + #[test] + fn output_capture_is_limited_per_stream() { + let mut command = probe_command("bounded_output_probe"); + let output = run_command_with_limits( + &mut command, + Operation("capture bounded output"), + ProcessLimits { + timeout: Duration::from_secs(5), + output_bytes: 256, + }, + ) + .expect("bounded-output probe should run"); + assert!(output.status.success(), "probe failed: {output:?}"); + assert_eq!(output.stdout.len(), 256); + assert_eq!(output.stderr.len(), 256); + } + + #[test] + fn stalled_process_is_terminated_at_the_deadline() { + let mut command = probe_command("bounded_timeout_probe"); + let error = run_command_with_limits( + &mut command, + Operation("run timeout probe"), + ProcessLimits { + timeout: Duration::from_millis(100), + output_bytes: 256, + }, + ) + .expect_err("stalled probe should time out"); + assert!(format!("{error:#}").contains("run timeout probe: subprocess timed out")); + } + + #[rstest] + #[case::stdout(OutputStream::Stdout, "read probe: stdout reader thread panicked")] + #[case::stderr(OutputStream::Stderr, "read probe: stderr reader thread panicked")] + fn reader_thread_diagnostic_names_the_stream( + #[case] stream: OutputStream, + #[case] expected: &str, + ) { + let reader = std::thread::spawn(|| -> anyhow::Result> { + panic!("deliberate reader-thread failure") + }); + let error = join_reader(reader, Operation("read probe"), stream) + .expect_err("panicked reader thread should return an error"); + assert_eq!(format!("{error:#}"), expected); + } + + fn probe_command(test_name: &str) -> Command { + let mut command = Command::new( + std::env::current_exe().expect("the integration-test executable should have a path"), + ); + command.args(["--ignored", "--nocapture", test_name]); + command + } + + #[test] + #[ignore = "executed as a high-output subprocess"] + fn bounded_output_probe() { + let output = [b'x'; 4096]; + std::io::stdout() + .lock() + .write_all(&output) + .expect("write stdout probe data"); + std::io::stderr() + .lock() + .write_all(&output) + .expect("write stderr probe data"); + } + + #[test] + #[ignore = "executed as a stalled subprocess"] + fn bounded_timeout_probe() { + std::thread::sleep(Duration::from_secs(30)); + } +} diff --git a/ortho_config/tests/documentation_examples/workspace.rs b/ortho_config/tests/documentation_examples/workspace.rs new file mode 100644 index 00000000..710a64dd --- /dev/null +++ b/ortho_config/tests/documentation_examples/workspace.rs @@ -0,0 +1,341 @@ +//! Temporary Cargo workspaces for compiling and running documented Rust. + +use anyhow::{Context, Result, ensure}; +use cap_std::{ambient_authority, fs::Dir}; +use std::ffi::OsStr; +use std::path::{Component, Path}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +use super::documentation_examples::{DocumentedExample, is_valid_example_id}; +use crate::process_runner::{self, Operation}; + +pub(super) mod cargo_runner; + +const CHILD_ENV_ALLOWLIST: &[&str] = &["SYSTEMROOT", "WINDIR"]; + +/// A Cargo dependency alias used by the generated example package. +pub(super) struct DependencyAlias<'a>(pub(super) &'a str); + +/// The identifier attached to a documented example. +pub(super) struct ExampleId<'a>(pub(super) &'a str); + +/// One environment-variable override for a documented example process. +pub(super) struct EnvironmentVariable<'a> { + pub(super) name: &'a str, + pub(super) value: &'a str, +} + +/// A fixture file relative to one documented example's run directory. +pub(super) struct RunFile<'a> { + pub(super) path: &'a Path, + pub(super) contents: &'a str, +} + +/// An isolated package whose binary sources are exact Markdown fence bodies. +pub struct ExampleWorkspace { + root: TempDir, + directory: Dir, +} + +impl ExampleWorkspace { + /// Create a package using `dependency_alias` for the local `OrthoConfig` crate. + /// + /// The returned workspace contains a generated manifest and an empty binary + /// source directory. + /// + /// ```no_run + /// let workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn new(dependency_alias: DependencyAlias<'_>) -> Result { + let root = tempfile::tempdir().context("create documentation example workspace")?; + let directory = Dir::open_ambient_dir(root.path(), ambient_authority()) + .context("open documentation example workspace")?; + directory + .create_dir_all("src/bin") + .context("create bin directory")?; + directory + .write("Cargo.toml", manifest(dependency_alias)) + .context("write example manifest")?; + Ok(Self { root, directory }) + } + + /// Add an example as a binary without altering its published source. + /// + /// A successful result writes the exact fence body to `src/bin/.rs`. + /// + /// ```no_run + /// let workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// let example = documented_example("readme-main")?; + /// workspace.add_binary(example)?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn add_binary(&mut self, example: &DocumentedExample) -> Result<()> { + ensure!(example.language == "rust", "{} is not Rust", example.id); + ensure!( + is_valid_example_id(&example.id), + "{} is not a safe documented example identifier", + example.id + ); + self.directory + .write(format!("src/bin/{}.rs", example.id), &example.body) + .with_context(|| format!("write {} binary", example.id)) + } + + /// Build every documented binary in the package. + /// + /// `Ok(())` means every added binary compiled successfully in the isolated + /// target directory. + /// + /// ```no_run + /// # let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// # workspace.add_binary(documented_example("readme-main")?)?; + /// workspace.build()?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn build(&mut self) -> Result<()> { + let mut command = self.cargo_command()?; + command.args(["build", "--offline", "--bins"]); + let output = process_runner::run_command(&mut command, Operation("build documented Rust"))?; + ensure!( + output.status.success(), + "documented Rust failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + /// Run a built example in its own deterministic working directory. + /// + /// The returned [`std::process::Output`] contains the binary's exit status + /// and captured standard streams. + /// + /// ```no_run + /// # let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// let output = workspace.run(ExampleId("readme-main"), ["--port", "3000"])?; + /// assert!(output.status.success()); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn run(&mut self, ExampleId(id): ExampleId<'_>, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.run_with_environment( + ExampleId(id), + args, + std::iter::empty::>(), + ) + } + + /// Run a built example with explicit environment-variable overrides. + /// + /// Overrides are visible to the child alongside the deterministic home + /// directories; unrelated host variables remain absent. + /// + /// ```no_run + /// # let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// let output = workspace.run_with_environment( + /// ExampleId("guide-first-cli"), + /// ["--port", "3000"], + /// [EnvironmentVariable { name: "ACME_HOST", value: "api.internal" }], + /// )?; + /// assert!(output.status.success()); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn run_with_environment<'a, I, S, E>( + &mut self, + ExampleId(id): ExampleId<'_>, + args: I, + environment: E, + ) -> Result + where + I: IntoIterator, + S: AsRef, + E: IntoIterator>, + { + ensure!( + is_valid_example_id(id), + "{id} is not a safe documented example identifier" + ); + let run_dir_name = format!("run-{id}"); + self.directory + .create_dir_all(&run_dir_name) + .with_context(|| format!("create working directory for {id}"))?; + let run_dir = self.root.path().join(&run_dir_name); + let binary = self + .root + .path() + .join("target/debug") + .join(format!("{id}{}", std::env::consts::EXE_SUFFIX)); + let mut command = Command::new(binary); + cargo_runner::sanitize_environment(&mut command, CHILD_ENV_ALLOWLIST); + command + .args(args) + .current_dir(&run_dir) + .env("HOME", &run_dir) + .env("XDG_CONFIG_HOME", run_dir.join("xdg")) + .envs( + environment + .into_iter() + .map(|EnvironmentVariable { name, value }| (name, value)), + ); + let operation = format!("run documented binary {id}"); + process_runner::run_command(&mut command, Operation(&operation)) + } + + /// Write a file in one binary's deterministic working directory. + /// + /// A successful result creates the run directory when necessary and writes + /// the requested relative file within it. + /// + /// ```no_run + /// # let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + /// workspace.write_run_file( + /// ExampleId("guide-first-cli"), + /// RunFile { path: Path::new(".acme.toml"), contents: "port = 3000\n" }, + /// )?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn write_run_file( + &mut self, + ExampleId(id): ExampleId<'_>, + RunFile { path, contents }: RunFile<'_>, + ) -> Result<()> { + ensure!( + is_valid_example_id(id), + "{id} is not a safe documented example identifier" + ); + ensure!( + !path.as_os_str().is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))), + "run file path must stay within the example directory" + ); + let run_dir_name = format!("run-{id}"); + self.directory.create_dir_all(&run_dir_name)?; + let run_dir = self.directory.open_dir(&run_dir_name)?; + run_dir.write(path, contents)?; + Ok(()) + } + + fn cargo_command(&mut self) -> Result { + cargo_runner::prepare_cargo_command(self.root.path(), self.root.path()) + } +} + +fn manifest(DependencyAlias(dependency_name): DependencyAlias<'_>) -> String { + let crate_path = toml::Value::String(env!("CARGO_MANIFEST_DIR").to_owned()).to_string(); + render_manifest(dependency_name, &crate_path) +} + +/// Render the generated manifest from a serialized Cargo dependency path. +/// +/// This stays private to the documentation workspace and its path regression +/// test; callers must serialize the path as a TOML value before using it. +fn render_manifest(dependency_name: &str, serialized_crate_path: &str) -> String { + format!( + concat!( + "[package]\n", + "name = \"documented-examples\"\n", + "version = \"0.0.0\"\n", + "edition = \"2024\"\n\n", + "[dependencies]\n", + "{} = {{ package = \"ortho_config\", path = {} }}\n", + "clap = {{ version = \"4.5\", features = [\"derive\"] }}\n", + "serde = {{ version = \"1.0\", features = [\"derive\"] }}\n", + "tracing-subscriber = {{ version = \"0.3\", features = [\"env-filter\"] }}\n", + ), + dependency_name, serialized_crate_path, + ) +} + +#[cfg(test)] +mod tests { + //! Regression coverage for generated workspace manifests. + + use super::{ + DependencyAlias, DocumentedExample, ExampleId, ExampleWorkspace, RunFile, render_manifest, + }; + use anyhow::{Context, Result, ensure}; + use std::path::Path; + + #[test] + fn windows_dependency_path_produces_valid_toml() { + let windows_path = r#"D:\a\"quoted\"\ortho-config\ortho_config"#; + let serialized_path = toml::Value::String(windows_path.to_owned()).to_string(); + let generated = render_manifest("ortho_config", &serialized_path); + let parsed = toml::from_str::(&generated) + .expect("serialized documentation manifest should parse as TOML"); + let parsed_path = parsed + .get("dependencies") + .and_then(|dependencies| dependencies.get("ortho_config")) + .and_then(|dependency| dependency.get("path")) + .and_then(toml::Value::as_str); + assert_eq!(parsed_path, Some(windows_path)); + } + + #[test] + fn independently_owned_workspaces_support_concurrent_interleavings() -> Result<()> { + std::thread::scope(|scope| { + let first = scope.spawn(|| exercise_workspace_interleavings("first", "updated-first")); + let second = + scope.spawn(|| exercise_workspace_interleavings("second", "updated-second")); + + first + .join() + .map_err(|_| anyhow::anyhow!("first workspace thread panicked"))??; + second + .join() + .map_err(|_| anyhow::anyhow!("second workspace thread panicked"))??; + Ok(()) + }) + } + + fn exercise_workspace_interleavings(initial: &str, updated: &str) -> Result<()> { + let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + workspace.add_binary(&file_probe())?; + workspace.build()?; + + write_probe_value(&mut workspace, initial)?; + assert_probe_output(&mut workspace, initial)?; + write_probe_value(&mut workspace, updated)?; + assert_probe_output(&mut workspace, updated) + } + + fn file_probe() -> DocumentedExample { + DocumentedExample { + id: "workspace-probe".to_owned(), + language: "rust".to_owned(), + body: concat!( + "fn main() -> std::io::Result<()> {\n", + " print!(\"{}\", std::fs::read_to_string(\"value.txt\")?);\n", + " Ok(())\n", + "}\n", + ) + .to_owned(), + source: "workspace ownership probe", + line: 1, + } + } + + fn write_probe_value(workspace: &mut ExampleWorkspace, contents: &str) -> Result<()> { + workspace.write_run_file( + ExampleId("workspace-probe"), + RunFile { + path: Path::new("value.txt"), + contents, + }, + ) + } + + fn assert_probe_output(workspace: &mut ExampleWorkspace, expected: &str) -> Result<()> { + let output = workspace.run(ExampleId("workspace-probe"), std::iter::empty::<&str>())?; + ensure!(output.status.success(), "workspace probe should succeed"); + let stdout = String::from_utf8(output.stdout).context("workspace probe output is UTF-8")?; + ensure!(stdout == expected, "workspace probe output differed"); + Ok(()) + } +} diff --git a/ortho_config/tests/documentation_examples_loader_tests.rs b/ortho_config/tests/documentation_examples_loader_tests.rs new file mode 100644 index 00000000..ffe4e084 --- /dev/null +++ b/ortho_config/tests/documentation_examples_loader_tests.rs @@ -0,0 +1,240 @@ +//! Failure and invariant contracts for the documentation-example loader. + +mod documentation_examples; + +use anyhow::{Result, ensure}; +use documentation_examples::{documented_example, load_documented_examples, parse_document}; +use proptest::prelude::*; +use rstest::rstest; +use std::sync::{Arc, Barrier}; + +#[test] +fn public_loader_queries_are_callable() -> Result<()> { + const READER_COUNT: usize = 8; + let barrier = Arc::new(Barrier::new(READER_COUNT)); + let registry_pointers = std::thread::scope(|scope| { + let readers = (0..READER_COUNT) + .map(|_| { + let reader_barrier = Arc::clone(&barrier); + scope.spawn(move || { + reader_barrier.wait(); + load_documented_examples().map(|examples| examples.as_ptr() as usize) + }) + }) + .collect::>(); + readers + .into_iter() + .map(|reader| { + reader + .join() + .expect("concurrent registry loading should not panic") + }) + .collect::>>() + })?; + let examples = load_documented_examples()?; + ensure!( + !examples.is_empty(), + "documented registry should not be empty" + ); + ensure!( + registry_pointers + .iter() + .all(|pointer| *pointer == examples.as_ptr() as usize), + "concurrent first loads should share the immutable registry" + ); + let known = documented_example("readme-main")?; + let repeated = load_documented_examples()?; + ensure!( + std::ptr::eq(examples.as_ptr(), repeated.as_ptr()), + "documented registry should be cached per test target" + ); + ensure!( + examples.iter().any(|example| std::ptr::eq(example, known)), + "identifier lookup should borrow from the cached registry" + ); + ensure!( + known.id == "readme-main", + "known example lookup should succeed" + ); + Ok(()) +} + +#[rstest] +#[case( + "\n", + "tested-example identifier must not be empty" +)] +#[case( + "\n", + "tested-example identifier must not be empty" +)] +#[case("```toml\nport = 8080\n```\n", "missing a tested-example marker")] +#[case("~~~toml\nport = 8080\n~~~\n", "missing a tested-example marker")] +#[case::indented_backtick(" ```toml\nport = 8080\n ```\n", "missing a tested-example marker")] +#[case::indented_tilde(" ~~~toml\nport = 8080\n ~~~\n", "missing a tested-example marker")] +#[case( + "\n```toml\nport = 8080\n", + "fence is not terminated" +)] +#[case("\n", "marker has no fence")] +#[case( + "\n\n```toml\nport = 8080\n```\n", + "expected an opening fence after marker" +)] +#[case( + "\n```toml\nport = 8080\n```\n", + "tested-example identifier must use lowercase letters, digits, and single hyphens" +)] +#[case( + "\n```\nport = 8080\n```\n", + "fence should declare a language" +)] +#[case( + "\n~~~toml\nport = 8080\n```\n", + "fence is not terminated" +)] +#[case( + "\n~~~~toml\nport = 8080\n~~~\n", + "fence is not terminated" +)] +#[case( + concat!( + "\n```toml\nport = 8080\n```\n", + "\n```bash\ncargo run\n```\n" + ), + "duplicate tested-example identifier" +)] +fn malformed_documented_examples_are_rejected( + #[case] contents: &str, + #[case] expected_message: &str, +) -> Result<()> { + let error = parse_document("fixture.md", contents) + .expect_err("malformed documented example should be rejected"); + ensure!( + error.to_string().contains(expected_message), + "expected '{expected_message}' in '{error}'" + ); + Ok(()) +} + +#[rstest] +#[case::backtick(" ```toml\nport = 8080\n ```\n")] +#[case::tilde(" ~~~toml\nport = 8080\n ~~~\n")] +fn marked_indented_fence_is_loaded(#[case] fence: &str) -> Result<()> { + let examples = parse_document( + "fixture.md", + &format!("\n{fence}"), + )?; + let [example] = examples.as_slice() else { + anyhow::bail!("expected one indented fenced example, got {examples:?}"); + }; + ensure!(example.language == "toml", "language should be preserved"); + ensure!( + example.body == "port = 8080\n", + "fence body should be preserved" + ); + Ok(()) +} + +#[test] +fn crlf_fence_body_is_normalized_to_lf() -> Result<()> { + let contents = "\r\n```toml\r\nport = 8080\r\n```\r\n"; + let examples = parse_document("fixture.md", contents)?; + let [example] = examples.as_slice() else { + anyhow::bail!("expected one CRLF fenced example, got {examples:?}"); + }; + ensure!( + example.body == "port = 8080\n", + "fence bodies should use canonical LF terminators" + ); + Ok(()) +} + +#[rstest] +#[case::spaces(" ")] +#[case::tabs("\t\t")] +fn matching_closing_fence_allows_horizontal_whitespace(#[case] suffix: &str) -> Result<()> { + let contents = format!("\n```toml\nport = 8080\n```{suffix}\n"); + let examples = parse_document("fixture.md", &contents)?; + let [example] = examples.as_slice() else { + anyhow::bail!("expected one fenced example, got {examples:?}"); + }; + ensure!(example.body == "port = 8080\n", "body should be preserved"); + Ok(()) +} + +#[test] +fn matching_closing_fence_rejects_non_whitespace_suffix() -> Result<()> { + let contents = "\n```toml\nport = 8080\n```toml\n"; + let error = parse_document("fixture.md", contents) + .expect_err("closing-fence text should leave the fence unterminated"); + ensure!( + error.to_string().contains("fence is not terminated"), + "unexpected closing-fence error: {error}" + ); + Ok(()) +} + +#[test] +fn four_space_indented_code_block_is_not_a_fence() -> Result<()> { + let examples = parse_document("fixture.md", " ```toml\n port = 8080\n ```\n")?; + ensure!( + examples.is_empty(), + "four-space code blocks should not be parsed as fences" + ); + Ok(()) +} + +proptest! { + #[test] + fn marked_fence_round_trips( + id in "[a-z][a-z0-9]{0,10}(-[a-z0-9]+){0,2}", + language in "[a-z]{1,8}", + body_lines in prop::collection::vec("[A-Za-z0-9 .,/_=-]{0,40}", 0..8), + ) { + let body = if body_lines.is_empty() { + String::new() + } else { + format!("{}\n", body_lines.join("\n")) + }; + let document = format!( + "\n```{language}\n{body}```\n" + ); + + match parse_document("property.md", &document) { + Ok(examples) => match examples.as_slice() { + [example] => { + prop_assert_eq!(example.id.as_str(), id.as_str()); + prop_assert_eq!(example.language.as_str(), language.as_str()); + prop_assert_eq!(example.body.as_str(), body.as_str()); + } + _ => prop_assert!(false, "expected one example, got {examples:?}"), + }, + Err(error) => prop_assert!(false, "valid marked fence failed: {error}"), + } + } + + #[test] + fn duplicate_identifiers_are_rejected( + id in "[a-z][a-z0-9]{0,10}(-[a-z0-9]+){0,2}", + first_body in "[A-Za-z0-9 .,/_=-]{0,40}", + second_body in "[A-Za-z0-9 .,/_=-]{0,40}", + ) { + let document = format!( + concat!( + "\n```toml\n{}\n```\n", + "\n```bash\n{}\n```\n", + ), + id, first_body, id, second_body, + ); + let result = parse_document("property.md", &document); + + prop_assert!(result.is_err()); + if let Err(error) = result { + prop_assert!( + error.to_string().contains("duplicate tested-example identifier"), + "unexpected duplicate error: {error}", + ); + } + } +} diff --git a/ortho_config/tests/documentation_examples_rust_tests.rs b/ortho_config/tests/documentation_examples_rust_tests.rs new file mode 100644 index 00000000..885a1b46 --- /dev/null +++ b/ortho_config/tests/documentation_examples_rust_tests.rs @@ -0,0 +1,277 @@ +//! Compile-and-run contracts for Rust and console examples in public docs. + +mod documentation_examples; +#[path = "documentation_examples/process_runner.rs"] +mod process_runner; +#[path = "documentation_examples/workspace.rs"] +mod workspace; + +use anyhow::{Context, Result, ensure}; +use documentation_examples::{DocumentedExample, documented_example}; +use std::path::{Path, PathBuf}; +use workspace::{DependencyAlias, EnvironmentVariable, ExampleId, ExampleWorkspace, RunFile}; + +const STANDARD_RUST_EXAMPLES: &[&str] = &[ + "readme-main", + "guide-first-cli", + "guide-discovery", + "guide-hermetic-discovery", + "guide-load-first-outcomes", + "guide-subcommand", + "guide-errors", + "guide-localization", + "guide-tracing", + "guide-orthohelp-metadata", +]; + +#[test] +fn documented_rust_compiles_and_runs() -> Result<()> { + let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + for id in STANDARD_RUST_EXAMPLES { + workspace.add_binary(documented_example(id)?)?; + } + workspace.add_binary(&environment_probe())?; + workspace.build()?; + + assert_standard_example_runs(&mut workspace)?; + assert_tracing_flow(&mut workspace)?; + assert_run( + &mut workspace, + ExampleId("guide-orthohelp-metadata"), + [], + "field=host\n", + )?; + assert_sanitized_binary_environment(&mut workspace)?; + + assert_error_flow(&mut workspace)?; + + assert_console_flows(&mut workspace) +} + +fn assert_standard_example_runs(workspace: &mut ExampleWorkspace) -> Result<()> { + assert_run( + workspace, + ExampleId("readme-main"), + ["--host", "0.0.0.0", "--port", "3000"], + "Listening on 0.0.0.0:3000\n", + )?; + assert_run( + workspace, + ExampleId("guide-first-cli"), + [ + "--host", + "0.0.0.0", + "--port", + "3000", + "--log-level", + "debug", + ], + "host=0.0.0.0 port=3000 log_level=debug\n", + )?; + assert_run(workspace, ExampleId("guide-discovery"), [], "port=8080\n")?; + assert_run( + workspace, + ExampleId("guide-hermetic-discovery"), + [], + "candidate=/srv/acme/server.toml\n", + )?; + assert_run( + workspace, + ExampleId("guide-load-first-outcomes"), + [], + "discovery=absent\n", + )?; + assert_run( + workspace, + ExampleId("guide-subcommand"), + ["serve", "--port", "3000"], + "port=Some(3000)\n", + )?; + assert_run( + workspace, + ExampleId("guide-localization"), + [], + "verbose=true\n", + ) +} + +fn assert_error_flow(workspace: &mut ExampleWorkspace) -> Result<()> { + let output = workspace.run(ExampleId("guide-errors"), std::iter::empty::<&str>())?; + ensure!( + output.status.success(), + "guide-errors should handle its error" + ); + ensure!( + String::from_utf8_lossy(&output.stderr).contains("invalid value"), + "guide-errors should render clap's parse failure" + ); + Ok(()) +} + +fn assert_tracing_flow(workspace: &mut ExampleWorkspace) -> Result<()> { + let output = workspace.run(ExampleId("guide-tracing"), std::iter::empty::<&str>())?; + ensure!(output.status.success(), "guide-tracing should succeed"); + ensure!( + output.stdout == b"port=8080\n", + "guide-tracing stdout should stay clean" + ); + ensure!( + String::from_utf8_lossy(&output.stderr).contains("discovery.attempt"), + "guide-tracing should emit debug discovery diagnostics" + ); + Ok(()) +} + +#[test] +fn aliased_dependency_example_compiles_and_runs() -> Result<()> { + let mut workspace = ExampleWorkspace::new(DependencyAlias("config_layer"))?; + workspace.add_binary(documented_example("guide-alias-derive")?)?; + workspace.build()?; + assert_run( + &mut workspace, + ExampleId("guide-alias-derive"), + [], + "port=8080\n", + ) +} + +#[test] +fn workspace_rejects_paths_outside_an_example_directory() -> Result<()> { + let mut workspace = ExampleWorkspace::new(DependencyAlias("ortho_config"))?; + + let id_error = workspace + .run(ExampleId("../escape"), std::iter::empty::<&str>()) + .expect_err("path-like example identifiers should be rejected"); + ensure!( + id_error + .to_string() + .contains("not a safe documented example identifier"), + "unexpected identifier error: {id_error}" + ); + + for path in [Path::new(""), Path::new("../escape")] { + let path_error = workspace + .write_run_file(ExampleId("readme-main"), RunFile { path, contents: "" }) + .expect_err("unsafe run-file paths should be rejected"); + ensure!( + path_error + .to_string() + .contains("must stay within the example directory"), + "unexpected run-file error: {path_error}" + ); + } + + Ok(()) +} + +fn environment_probe() -> DocumentedExample { + DocumentedExample { + id: "environment-probe".to_owned(), + language: "rust".to_owned(), + body: concat!( + "fn main() {\n", + " let current = std::env::current_dir().expect(\"read current directory\");\n", + " let home = std::env::var_os(\"HOME\").expect(\"HOME should be set\");\n", + " let xdg = std::env::var_os(\"XDG_CONFIG_HOME\").expect(\"XDG should be set\");\n", + " println!(\"current={}\", current.display());\n", + " println!(\"home={}\", std::path::Path::new(&home).display());\n", + " println!(\"xdg={}\", std::path::Path::new(&xdg).display());\n", + " println!(\"path_present={}\", std::env::var_os(\"PATH\").is_some());\n", + "}\n", + ) + .to_owned(), + source: "environment probe", + line: 1, + } +} + +fn assert_sanitized_binary_environment(workspace: &mut ExampleWorkspace) -> Result<()> { + ensure!( + std::env::var_os("PATH").is_some(), + "the parent test process should provide PATH" + ); + let output = workspace.run(ExampleId("environment-probe"), std::iter::empty::<&str>())?; + ensure!(output.status.success(), "environment probe should succeed"); + let stdout = String::from_utf8(output.stdout).context("environment probe output is UTF-8")?; + let values = stdout + .lines() + .filter_map(|line| line.split_once('=')) + .collect::>(); + let value = |name| { + values + .get(name) + .copied() + .with_context(|| format!("environment probe should report {name}")) + }; + let current = PathBuf::from(value("current")?); + ensure!(Path::new(value("home")?) == current); + ensure!(Path::new(value("xdg")?) == current.join("xdg")); + ensure!(value("path_present")? == "false"); + Ok(()) +} + +fn assert_console_flows(workspace: &mut ExampleWorkspace) -> Result<()> { + let readme_console = documented_example("readme-run")?; + ensure!( + readme_console.body + == "$ cargo run -- --host 0.0.0.0 --port 3000\nListening on 0.0.0.0:3000\n", + "README command contract drifted" + ); + + let config_file = documented_example("guide-file")?; + workspace.write_run_file( + ExampleId("guide-first-cli"), + RunFile { + path: Path::new(".acme.toml"), + contents: &config_file.body, + }, + )?; + let output = workspace.run_with_environment( + ExampleId("guide-first-cli"), + ["--port", "3000"], + [EnvironmentVariable { + name: "ACME_HOST", + value: "api.internal", + }], + )?; + ensure!( + output.status.success(), + "file-backed guide command should succeed" + ); + let stdout = String::from_utf8(output.stdout).context("guide output should be UTF-8")?; + ensure!( + stdout == "host=api.internal port=3000 log_level=debug\n", + "file-backed guide output differed: {stdout:?}" + ); + let guide_console = documented_example("guide-file-run")?; + ensure!( + guide_console.body + == concat!( + "$ ACME_HOST=api.internal cargo run -- --port 3000\n", + "host=api.internal port=3000 log_level=debug\n", + ), + "user's-guide command contract drifted" + ); + Ok(()) +} + +fn assert_run( + workspace: &mut ExampleWorkspace, + ExampleId(id): ExampleId<'_>, + args: [&str; N], + expected_stdout: &str, +) -> Result<()> { + let output = workspace.run(ExampleId(id), args)?; + ensure!( + output.status.success(), + "{id} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = + String::from_utf8(output.stdout).with_context(|| format!("{id} stdout is UTF-8"))?; + ensure!( + stdout == expected_stdout, + "{id} stdout differed: expected {expected_stdout:?}, got {stdout:?}" + ); + Ok(()) +} diff --git a/ortho_config/tests/documentation_examples_tests.rs b/ortho_config/tests/documentation_examples_tests.rs new file mode 100644 index 00000000..f9a09f3d --- /dev/null +++ b/ortho_config/tests/documentation_examples_tests.rs @@ -0,0 +1,256 @@ +//! Executable contracts for examples in the README and user's guide. + +#[path = "documentation_examples/cargo_runner.rs"] +mod cargo_runner; +mod documentation_examples; +#[path = "documentation_examples/process_runner.rs"] +mod process_runner; + +use anyhow::{Context, Result, ensure}; +use cap_std::{ambient_authority, fs::Dir}; +use documentation_examples::{documented_example, load_documented_examples}; +use ortho_config::{AgentContext, toml}; +use process_runner::Operation; +use std::collections::BTreeSet; +use tempfile::TempDir; + +const EXPECTED_EXAMPLE_IDS: &[&str] = &[ + "guide-agent-context", + "guide-alias-derive", + "guide-alias-install", + "guide-collection-file", + "guide-discovery", + "guide-errors", + "guide-file", + "guide-file-run", + "guide-first-cli", + "guide-hermetic-discovery", + "guide-install", + "guide-load-first-outcomes", + "guide-localization", + "guide-metrics-install", + "guide-orthohelp-command", + "guide-orthohelp-metadata", + "guide-subcommand", + "guide-tracing", + "guide-tracing-install", + "guide-yaml", + "readme-install", + "readme-main", + "readme-run", +]; + +#[test] +fn published_crate_readme_matches_repository_readme() -> Result<()> { + let repository = Dir::open_ambient_dir(repository_root(), ambient_authority())?; + let repository_readme = repository.read_to_string("README.md")?; + let crate_readme = repository.read_to_string("ortho_config/README.md")?; + ensure!( + crate_readme == repository_readme, + "the packaged ortho_config README should match the repository README" + ); + Ok(()) +} + +#[test] +fn every_documented_fence_has_a_known_unique_identifier() -> Result<()> { + let examples = load_documented_examples()?; + let actual = examples + .iter() + .map(|example| example.id.as_str()) + .collect::>(); + let expected = EXPECTED_EXAMPLE_IDS + .iter() + .copied() + .collect::>(); + ensure!( + actual == expected, + "documented example registry drifted\nexpected: {expected:#?}\nactual: {actual:#?}" + ); + Ok(()) +} + +#[test] +fn installation_manifests_select_the_documented_release() -> Result<()> { + let readme = parse_toml("readme-install")?; + assert_dependency_version(&readme, "ortho_config", "0.9.0")?; + assert_dependency_version(&readme, "serde", "1.0")?; + + let guide = parse_toml("guide-install")?; + assert_dependency_version(&guide, "ortho_config", "0.9.0")?; + assert_dependency_version(&guide, "clap", "4.5")?; + assert_dependency_version(&guide, "serde", "1.0")?; + + let tracing = parse_toml("guide-tracing-install")?; + let subscriber = dependency(&tracing, "tracing-subscriber")?; + ensure!(subscriber["version"].as_str() == Some("0.3")); + ensure!( + subscriber["features"] + .as_array() + .is_some_and(|features| features + .iter() + .any(|value| value.as_str() == Some("env-filter"))), + "tracing-subscriber manifest should enable env-filter" + ); + Ok(()) +} + +#[test] +fn optional_and_aliased_manifests_preserve_the_intended_contract() -> Result<()> { + let metrics = parse_toml("guide-metrics-install")?; + let metrics_dependency = dependency(&metrics, "ortho_config")?; + ensure!(metrics_dependency["version"].as_str() == Some("0.9.0")); + ensure!( + metrics_dependency["features"] + .as_array() + .is_some_and(|features| features + .iter() + .any(|value| value.as_str() == Some("metrics"))), + "metrics manifest should enable the metrics feature" + ); + + let alias = parse_toml("guide-alias-install")?; + let aliased_dependency = dependency(&alias, "config_layer")?; + ensure!(aliased_dependency["package"].as_str() == Some("ortho_config")); + ensure!(aliased_dependency["version"].as_str() == Some("0.9.0")); + Ok(()) +} + +#[test] +fn configuration_files_deserialize_with_the_documented_shapes() -> Result<()> { + let file = parse_toml("guide-file")?; + ensure!(file.get("host").and_then(toml::Value::as_str) == Some("0.0.0.0")); + ensure!(file.get("port").and_then(toml::Value::as_integer) == Some(9000)); + ensure!(file.get("log_level").and_then(toml::Value::as_str) == Some("debug")); + + let collections = parse_toml("guide-collection-file")?; + let workers = collections + .get("workers") + .context("workers should exist")? + .as_array() + .context("workers should be an array of tables")?; + ensure!(workers.len() == 2); + let first_worker = workers.first().context("first worker should exist")?; + let second_worker = workers.get(1).context("second worker should exist")?; + ensure!(first_worker.get("name").and_then(toml::Value::as_str) == Some("queue-a")); + ensure!( + second_worker + .get("concurrency") + .and_then(toml::Value::as_integer) + == Some(2) + ); + let labels = collections.get("labels").context("labels should exist")?; + ensure!(labels.get("region").and_then(toml::Value::as_str) == Some("eu-west")); + Ok(()) +} + +#[test] +fn agent_context_json_matches_the_runtime_default() -> Result<()> { + let example = documented_example("guide-agent-context")?; + ensure!(example.language == "json"); + let documented: AgentContext = ortho_config::serde_json::from_str(&example.body)?; + ensure!(documented == AgentContext::new("acme")); + Ok(()) +} + +#[cfg(feature = "yaml")] +#[test] +fn yaml_example_uses_yaml_1_2_string_semantics() -> Result<()> { + let example = documented_example("guide-yaml")?; + ensure!(example.language == "yaml"); + let provider = ortho_config::file::SaphyrYaml::string("guide.yaml", &example.body); + let value = ortho_config::figment::Figment::from(provider) + .extract::()?; + ensure!(value.get("enabled").and_then(serde_json_value_as_str) == Some("yes")); + ensure!(value.get("mode").and_then(serde_json_value_as_str) == Some("on")); + ensure!( + value + .get("port") + .and_then(ortho_config::serde_json::Value::as_u64) + == Some(8080) + ); + Ok(()) +} + +#[test] +fn documented_orthohelp_command_generates_agent_context() -> Result<()> { + let example = documented_example("guide-orthohelp-command")?; + ensure!(example.language == "console"); + ensure!( + example.body == "cargo orthohelp --package hello_world --format agent-context\n", + "cargo-orthohelp command contract drifted" + ); + + let output_directory = TempDir::new().context("create orthohelp output directory")?; + let cargo_state = TempDir::new().context("create isolated Cargo state directory")?; + let mut command = cargo_runner::prepare_cargo_command(&repository_root(), cargo_state.path())?; + command + .args([ + "run", + "--offline", + "--quiet", + "-p", + "cargo-orthohelp", + "--", + "orthohelp", + "--package", + "hello_world", + "--format", + "agent-context", + "--out-dir", + ]) + .arg(output_directory.path()); + let output = process_runner::run_command( + &mut command, + Operation("run documented cargo-orthohelp flow"), + )?; + ensure!( + output.status.success(), + "cargo-orthohelp failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let output_dir = Dir::open_ambient_dir(output_directory.path(), ambient_authority())?; + let json = output_dir + .read_to_string("agent-context.json") + .context("read generated agent context")?; + let payload: ortho_config::serde_json::Value = ortho_config::serde_json::from_str(&json)?; + ensure!(payload.get("package").and_then(serde_json_value_as_str) == Some("hello_world")); + ensure!( + payload.get("kind").and_then(serde_json_value_as_str) == Some("hello_world.agent_context") + ); + Ok(()) +} + +fn parse_toml(id: &str) -> Result { + let example = documented_example(id)?; + ensure!(example.language == "toml", "{id} should be TOML"); + toml::from_str(&example.body).with_context(|| format!("parse {id}")) +} + +fn dependency<'a>(manifest: &'a toml::Value, name: &str) -> Result<&'a toml::Value> { + manifest + .get("dependencies") + .and_then(|dependencies| dependencies.get(name)) + .with_context(|| format!("manifest should declare {name}")) +} + +fn assert_dependency_version(manifest: &toml::Value, name: &str, version: &str) -> Result<()> { + let value = dependency(manifest, name)?; + let actual = value + .as_str() + .or_else(|| value.get("version").and_then(toml::Value::as_str)); + ensure!( + actual == Some(version), + "expected {name} version {version}, got {actual:?}" + ); + Ok(()) +} + +fn repository_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn serde_json_value_as_str(value: &ortho_config::serde_json::Value) -> Option<&str> { + value.as_str() +}