Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rust-config-tree Manual

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

This is the English manual for rust-config-tree.

Start with Introduction, Quick Start, Transparent Array Sections, or the runnable Examples.

Introduction

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

rust-config-tree provides reusable configuration-tree loading and CLI helpers for Rust applications that use layered config files.

The crate is designed around a small division of responsibilities:

  • confique owns schema definitions, code defaults, validation, and config template generation.
  • figment owns runtime loading and runtime source metadata.
  • rust-config-tree owns recursive include traversal, include path resolution, .env loading, template target discovery, and reusable clap commands.

The crate is useful when an application wants a natural config file layout such as this:

include:
  - config/server.yaml
  - config/database.yaml

log:
  level: info

Each included file can use the same schema shape, and relative include paths are resolved from the file that declared them. The final config is still a normal confique schema value.

Main Features

  • Recursive include traversal with cycle detection.
  • Relative include paths resolved from the declaring file.
  • .env loading before environment providers are evaluated.
  • Schema-declared environment variables without delimiter splitting.
  • Figment metadata for runtime source tracking.
  • TRACE-level source tracking events through tracing.
  • Draft 7 JSON Schema generation for editor completion and basic schema checks.
  • Field value validation in application code through #[config(validate = Self::validate)], executed by load_config or validate-config.
  • YAML, TOML, JSON, and JSON5 template generation.
  • TOML #:schema, YAML Language Server schema modelines, and JSON/JSON5 $schema fields for generated templates.
  • Opt-in YAML template splitting for nested sections marked with x-tree-split.
  • Transparent array sections marked with x-tree-transparent-array for body-only split YAML arrays, runtime shape adaptation, and array-typed section schemas. See Transparent Array Sections.
  • Built-in clap subcommands for config templates, JSON Schema, and shell completions.
  • A lower-level tree API for callers that do not use confique.

Public Entry Points

Use these APIs for most applications:

  • load_config::<S>(path) loads the final schema.
  • load_config_with_figment::<S>(path) loads the schema and returns the Figment graph used for source tracking.
  • write_config_templates::<S>(config_path, output_path) writes the root template and recursively discovered child templates.
  • write_config_schemas::<S>(output_path) writes root and section Draft 7 JSON Schemas.
  • handle_config_command::<Cli, S>(command, config_path) handles built-in clap config commands.

Use load_config_tree when you need the traversal primitive without confique.

Quick Start

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Add the crate and the schema/runtime libraries used by your application:

[dependencies]
rust-config-tree = "0.2"
confique = { version = "0.4", features = ["yaml", "toml", "json5"] }
figment = { version = "0.10", features = ["yaml", "toml", "json", "env"] }
schemars = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }

Define a confique schema and implement ConfigSchema for the root type:

#![allow(unused)]
fn main() {
use std::path::PathBuf;

use confique::Config;
use rust_config_tree::ConfigSchema;

#[derive(Debug, Config)]
struct AppConfig {
    #[config(default = [])]
    include: Vec<PathBuf>,

    #[config(nested)]
    server: ServerConfig,
}

#[derive(Debug, Config)]
struct ServerConfig {
    #[config(default = "127.0.0.1")]
    #[config(env = "APP_SERVER_BIND")]
    bind: String,

    #[config(default = 8080)]
    #[config(env = "APP_SERVER_PORT")]
    port: u16,
}

impl ConfigSchema for AppConfig {
    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf> {
        layer.include.clone().unwrap_or_default()
    }
}
}

Load the config:

#![allow(unused)]
fn main() {
use rust_config_tree::load_config;

let config = load_config::<AppConfig>("config.yaml")?;
println!("{config:#?}");
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Use a root file with recursive includes:

# config.yaml
include:
  - config/server.yaml
# config/server.yaml
server:
  bind: 0.0.0.0
  port: 3000

The default load_config precedence is:

environment variables
  > config files, with later merged files overriding earlier files
    > confique code defaults

When includes are loaded by the high-level API, the root file has the highest file priority. Included files provide lower-priority values and can be used for defaults or section-specific files.

Command-line arguments are application-specific, so load_config does not read them automatically. Use the ConfigOverrides derive macro to build an override provider from parsed CLI flags:

#![allow(unused)]
fn main() {
use clap::Parser;
use rust_config_tree::{
    ConfigSchema,
    cli::ConfigOverrides,
    config::{build_config_figment, load_config_from_figment},
};

#[derive(Debug, Parser, ConfigOverrides)]
struct Cli {
    /// Config file path
    #[arg(long)]
    config: Option<std::path::PathBuf>,

    /// Override server port
    #[arg(long)]
    #[config_override(path = "server.port")]
    server_port: Option<u16>,

    /// Override log level
    #[arg(long)]
    #[config_override(path = "log.level")]
    log_level: Option<String>,
}

let cli = Cli::parse();
let figment = build_config_figment::<AppConfig>("config.yaml")?
    .merge(cli.config_overrides()?);
let config = load_config_from_figment::<AppConfig>(&figment)?;
let _ = config;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

The #[config_override(path = "...")] attribute maps each CLI flag to a dotted config path. Only provided flags produce override values; omitted flags disappear. The override provider is merged last, so provided flags override file and environment values:

command-line overrides
  > environment variables
    > config files
      > confique code defaults

Configuration Schema

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Application schemas are normal confique config types. The root schema must implement ConfigSchema so rust-config-tree can discover recursive includes from the intermediate confique layer.

The simplest way is to derive ConfigSchema:

#![allow(unused)]
fn main() {
use confique::Config;
use schemars::JsonSchema;
use rust_config_tree::ConfigSchema;

#[derive(Debug, Config, JsonSchema, ConfigSchema)]
struct AppConfig {
    #[config(default = [])]
    include: Vec<std::path::PathBuf>,

    #[config(nested)]
    #[schemars(extend("x-tree-split" = true))]
    database: DatabaseConfig,
}

#[derive(Debug, Config, JsonSchema)]
struct DatabaseConfig {
    #[config(env = "APP_DATABASE_URL")]
    url: String,

    #[config(default = 16)]
    #[config(env = "APP_DATABASE_POOL_SIZE")]
    pool_size: u32,
}
}

#[derive(ConfigSchema)] expects a field named include of type Vec<PathBuf> with a #[config(default = [])] attribute. For schemas that use a different field name or custom include logic, implement the trait manually:

#![allow(unused)]
fn main() {
use std::path::PathBuf;

use confique::Config;
use schemars::JsonSchema;
use rust_config_tree::ConfigSchema;

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(default = [])]
    include: Vec<PathBuf>,

    #[config(nested)]
    #[schemars(extend("x-tree-split" = true))]
    database: DatabaseConfig,
}

impl ConfigSchema for AppConfig {
    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf> {
        layer.include.clone().unwrap_or_default()
    }
}
}

Include Field

The include field can have any name. rust-config-tree only knows about it through ConfigSchema::include_paths.

The field should normally have an empty default:

#![allow(unused)]
fn main() {
#[config(default = [])]
include: Vec<PathBuf>,
}

The loader receives a partially loaded layer for each file. That lets it discover child config files before the final schema is merged and validated.

Nested Sections

Use #[config(nested)] for structured sections. Nested sections are always used for runtime loading. Add #[schemars(extend("x-tree-split" = true))] when a nested field should also be generated as an independent *.yaml template and <section>.schema.json schema:

#![allow(unused)]
fn main() {
#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(nested)]
    #[schemars(extend("x-tree-split" = true))]
    server: ServerConfig,
}
}

The natural YAML shape is:

server:
  bind: 127.0.0.1
  port: 8080

Transparent Array Sections

When a split section should appear as section: [...] in a single file and as a body-only [...] array in its split file, mark the field with both x-tree-split and x-tree-transparent-array, and pair it with transparent_array_section! or ArraySection<T>:

#![allow(unused)]
fn main() {
use rust_config_tree::transparent_array_section;

transparent_array_section! {
    pub struct ChildrenSection {
        #[config(default = [{ "name": "worker" }])]
        pub items: Vec<ChildDeclaration>,
    }
}

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(nested)]
    #[schemars(extend(
        "x-tree-split" = true,
        "x-tree-transparent-array" = true
    ))]
    children: ChildrenSection,
}
}

See Transparent Array Sections for the full workflow.

Environment-Only Fields

Mark a leaf field with #[schemars(extend("x-env-only" = true))] when the value must be supplied only by an environment variable and should not appear in generated config files. Generated YAML templates and JSON Schemas omit env-only fields, and empty parent objects left behind by those omissions are pruned.

#![allow(unused)]
fn main() {
#[config(env = "APP_SECRET")]
#[schemars(extend("x-env-only" = true))]
secret: String,
}

Field Value Validation

Generated *.schema.json files are for IDE completion and basic editor checks only. They do not decide whether a concrete field value is legal for the application.

Implement field value validation in code with #[config(validate = Self::validate)]. The validator runs when the final config is loaded through load_config or checked through validate-config.

Template Section Overrides

When a template source has no includes, the crate can derive child template files from nested schema sections marked with x-tree-split. The default top-level path is <section>.yaml relative to the root template directory.

Override that path with template_path_for_section:

#![allow(unused)]
fn main() {
impl ConfigSchema for AppConfig {
    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf> {
        layer.include.clone().unwrap_or_default()
    }

    fn template_path_for_section(section_path: &[&str]) -> Option<PathBuf> {
        match section_path {
            ["database"] => Some(PathBuf::from("examples/database.yaml")),
            _ => None,
        }
    }
}
}

Transparent Array Sections

English | 中文

Overview

A transparent array section lets a split config section appear as a YAML array while confique stores the data in an inner items field. The loader, template generator, and section schema all treat the section as an array rather than an { items: [...] } object.

In short: write children: [...] in a single file, or write only [...] in a split file without an items: wrapper.

Use this when list-shaped configuration (worker declarations, route tables, plugin lists) should live in its own split file while keeping that file as a plain array body.

Schema Markers

Transparent array sections require both x-tree-split and x-tree-transparent-array:

#![allow(unused)]
fn main() {
use rust_config_tree::transparent_array_section;

transparent_array_section! {
    /// Child declarations stored as a transparent array section.
    pub struct ChildrenSection {
        #[config(default = [{ "name": "worker" }])]
        pub items: Vec<ChildDeclaration>,
    }
}

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(nested)]
    #[schemars(extend(
        "x-tree-split" = true,
        "x-tree-transparent-array" = true
    ))]
    children: ChildrenSection,
}
}
ExtensionPurpose
x-tree-splitEmit a separate children.yaml template and children.schema.json
x-tree-transparent-arrayTreat the section as a YAML array at runtime and in templates
x-tree-inner-field (optional)Override the inner confique field name; default is "items"

Rust Type Options

transparent_array_section! macro

The macro generates len, is_empty, as_slice, and From<SectionName> for Vec<T>. Each section struct can declare its own #[config(default = ...)] template sample.

Generic ArraySection<T>

Skip the macro and use ArraySection<T> directly:

#![allow(unused)]
fn main() {
use rust_config_tree::ArraySection;

#[derive(Debug, Config, JsonSchema)]
struct ChildrenSection {
    #[config(default = [{ "name": "worker" }])]
    items: Vec<ChildDeclaration>,
}
}

ArraySection<T> supports only one #[config(default)]. When multiple sections need different template defaults, define a separate struct per section or use the macro.

YAML Shapes

Single file

Write the array directly in the root config:

children:
  - name: api
  - name: worker

Split file

Reference the split file from the root config:

include:
  - children.yaml
mode: demo

children.yaml contains only the array body. Do not add a section root key or an items: wrapper:

- name: api
- name: worker

Shapes accepted by the loader

load_config accepts all three forms:

  1. Transparent array: children: [...]
  2. Explicit inner field: children:\n items: [...]
  3. Body-only split file: children.yaml contains [...]

The loader merges split files into children: { items: [...] } before confique deserializes the final config.

Runtime defaults vs template defaults

Template generation uses #[config(default = ...)] to write sample entries (for example a worker row).

When the transparent section is omitted entirely at runtime, the library injects { items: [] } through TransparentSectionTracker so template defaults do not leak into runtime as phantom entries.

Therefore:

  • Template defaults guide authors and appear in generate-template output.
  • Runtime defaults yield an empty array when the section is omitted, not the template sample worker.

Templates and schemas

Generated children.schema.json uses a top-level array type, so IDEs complete array items directly while editing children.yaml.

Template generation emits block YAML array bodies:

  • No children: root key
  • No items: wrapper
  • No flow-style [{ ... }]

The root template config.example.yaml includes include: [children.yaml].

Accessing data

#![allow(unused)]
fn main() {
config.children.len();
config.children.is_empty();
config.children.as_slice();
let vec: Vec<ChildDeclaration> = config.children.into();
}

Both the macro and ArraySection expose this API surface.

Complete example

The repository ships a runnable demo:

cargo run --example transparent_array_section

The example:

  1. Writes split config files into a temporary directory
  2. Generates the section schema
  3. Calls load_config and verifies transparent array loading

For a downstream project, see rust-supervisor split-config for real groups and children usage.

Runtime Loading

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Runtime loading is intentionally split between Figment and confique:

figment:
  runtime file loading
  runtime environment loading
  runtime source metadata

confique:
  schema metadata
  defaults
  validation
  config templates

The main API is:

#![allow(unused)]
fn main() {
use rust_config_tree::load_config;

let config = load_config::<AppConfig>("config.yaml")?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Use load_config_with_figment when the application needs source metadata:

#![allow(unused)]
fn main() {
use rust_config_tree::load_config_with_figment;

let (config, figment) = load_config_with_figment::<AppConfig>("config.yaml")?;
let _ = (config, figment);
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Loading Steps

The high-level loader performs these steps:

  1. Resolve the root config path lexically.
  2. Load the first .env file found by walking upward from the root config directory.
  3. Load each config file as a partial layer to discover includes.
  4. Build a Figment graph from the discovered config files.
  5. Merge the ConfiqueEnvProvider with higher priority than files.
  6. Optionally merge application-specific CLI overrides.
  7. Extract a confique layer from Figment.
  8. Apply confique code defaults.
  9. Validate and construct the final schema.

load_config and load_config_with_figment perform steps 1-5 and 7-9. Step 6 is application-specific because this crate cannot infer how a CLI flag maps to a schema field.

Transparent Array Section Adaptation

When the schema marks a section with x-tree-transparent-array, the loader adapts YAML shapes after the Figment merge and before confique deserialization:

  1. Body-only split files (for example children.yaml containing [...]) merge into children: { items: [...] }.
  2. Single-file children: [...] normalizes to the same inner shape.
  3. When the section is omitted entirely at runtime, the library injects { items: [] } so template defaults do not leak in as phantom entries.

Applications call load_config directly and do not need post-normalize logic. See Transparent Array Sections for details.

File Formats

The runtime file provider is selected from the config path extension:

  • .yaml and .yml use YAML.
  • .toml uses TOML.
  • .json and .json5 use JSON.
  • unknown or missing extensions use YAML.

Template generation still uses confique’s template renderers for YAML, TOML, and JSON5-compatible output.

Include Priority

The high-level loader merges file providers so included files are lower priority than the file that included them. The root config file has the highest file priority.

Environment variables have higher priority than all config files. confique defaults are only used for values that are not supplied by runtime providers.

When CLI overrides are merged after build_config_figment, the full precedence is:

command-line overrides
  > environment variables
    > config files
      > confique code defaults

The command-line syntax is not defined by rust-config-tree. A flag like --server-port can override server.port if the application maps that parsed value into a nested serialized provider. A dotted --server.port or a.b.c syntax only exists if the application implements it.

This means CLI precedence applies only to keys present in the application’s override provider. Use it for operational values that are frequently changed for a single run. Leave durable configuration in files.

Use the ConfigOverrides derive macro to build an override provider from parsed CLI flags:

#![allow(unused)]
fn main() {
use clap::Parser;
use rust_config_tree::{
    ConfigSchema,
    cli::ConfigOverrides,
    config::{build_config_figment, load_config_from_figment},
};

#[derive(Debug, Parser, ConfigOverrides)]
struct Cli {
    /// Config file path
    #[arg(long)]
    config: Option<std::path::PathBuf>,

    /// Override server port
    #[arg(long)]
    #[config_override(path = "server.port")]
    server_port: Option<u16>,

    /// Override log level
    #[arg(long)]
    #[config_override(path = "log.level")]
    log_level: Option<String>,
}

let cli = Cli::parse();
let figment = build_config_figment::<AppConfig>("config.yaml")?
    .merge(cli.config_overrides()?);
let config = load_config_from_figment::<AppConfig>(&figment)?;
let _ = config;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

The #[config_override(path = "...")] attribute maps each CLI flag to a dotted config path. Only provided flags produce override values; omitted flags disappear.

Environment Variables

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Environment variable names are declared in the schema with confique:

#![allow(unused)]
fn main() {
#[derive(Debug, Config)]
struct DatabaseConfig {
    #[config(env = "APP_DATABASE_URL")]
    url: String,

    #[config(default = 16)]
    #[config(env = "APP_DATABASE_POOL_SIZE")]
    pool_size: u32,
}
}

rust-config-tree reads those names from confique::Config::META and builds a Figment provider that maps each environment variable to its exact field path.

Do not use delimiter-based Figment environment mapping for this crate:

#![allow(unused)]
fn main() {
// Do not use this pattern for rust-config-tree schemas.
Env::prefixed("APP_").split("_")
Env::prefixed("APP_").split("__")
}

split("_") treats underscores as nested key separators. That makes APP_DATABASE_POOL_SIZE become a path like database.pool.size, which conflicts with Rust field names such as pool_size.

With ConfiqueEnvProvider, this mapping is explicit:

APP_DATABASE_POOL_SIZE -> database.pool_size

Single underscores remain part of the environment variable name. Figment does not guess the nesting rule.

Dotenv Loading

Before runtime providers are evaluated, the loader searches for a .env file by walking upward from the root config file’s directory.

Existing process environment variables are preserved. Values from .env only fill missing environment variables.

Example:

APP_SERVER_PORT=9000
APP_DATABASE_POOL_SIZE=64

These variables override config file values when the schema declares matching #[config(env = "...")] attributes.

Parsing Values

The bridge provider lets Figment parse environment values. It does not call confique’s parse_env hooks. Keep complex values in config files unless the Figment environment value syntax is a good fit for the type.

Source Tracking

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Use load_config_with_figment to keep the Figment graph used by runtime loading:

#![allow(unused)]
fn main() {
use rust_config_tree::load_config_with_figment;

let (config, figment) = load_config_with_figment::<AppConfig>("config.yaml")?;
let _ = config;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

The returned Figment value can answer source questions for runtime values:

#![allow(unused)]
fn main() {
if let Some(metadata) = figment.find_metadata("database.pool_size") {
    let source = metadata.interpolate(
        &figment::Profile::Default,
        &["database", "pool_size"],
    );

    println!("database.pool_size came from {source}");
}
}

For values supplied by ConfiqueEnvProvider, interpolation returns the native environment variable name declared in the schema:

database.pool_size came from APP_DATABASE_POOL_SIZE

TRACE Events

The loader emits source tracking events with tracing::trace!. It does this only when TRACE is enabled:

#![allow(unused)]
fn main() {
use rust_config_tree::{load_config_with_figment, trace_config_sources};

let (config, figment) = load_config_with_figment::<AppConfig>("config.yaml")?;

// If the tracing subscriber is initialized after config loading, emit the
// same source events again after installing the subscriber.
trace_config_sources::<AppConfig>(&figment);
let _ = config;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Each event uses the rust_config_tree::config target and includes:

  • config_key: the dotted config key.
  • source: the rendered source metadata.

Values that came only from confique defaults do not have Figment runtime metadata. They are reported as confique default or unset optional field.

Template Generation

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Templates are generated from the same confique schema used at runtime. confique renders the actual template content, including doc comments, defaults, required fields, and declared environment variable names.

Use write_config_templates:

#![allow(unused)]
fn main() {
use rust_config_tree::write_config_templates;

write_config_templates::<AppConfig>("config.yaml", "config.example.yaml")?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Generate Draft 7 JSON Schemas for the root config and split nested sections:

#![allow(unused)]
fn main() {
use rust_config_tree::write_config_schemas;

write_config_schemas::<AppConfig>("schemas/myapp.schema.json")?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Mark a nested field with #[schemars(extend("x-tree-split" = true))] when it should be generated as its own *.yaml template and <section>.schema.json schema. Unmarked nested fields stay in the parent template and parent schema.

Mark a leaf field with #[schemars(extend("x-env-only" = true))] when the value must come only from environment variables. Generated templates and JSON Schemas omit env-only fields, and empty parent objects left behind by those omissions are pruned.

Generated schemas omit required constraints. IDEs can still offer completion, but partial files such as log.yaml do not report missing root fields. The root schema only completes fields that belong in the root file; split section fields are omitted there and completed by their own section schemas. Present fields can still receive basic editor checks, such as type, enum, and unknown property checks supported by the generated schema. Generated *.schema.json files do not decide whether a concrete field value is legal for the application. Implement field value validation in code with #[config(validate = Self::validate)]; load_config and validate-config execute that runtime validation.

Bind those schemas from generated TOML, YAML, JSON, and JSON5 templates:

#![allow(unused)]
fn main() {
use rust_config_tree::write_config_templates_with_schema;

write_config_templates_with_schema::<AppConfig>(
    "config.toml",
    "config.example.toml",
    "schemas/myapp.schema.json",
)?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Root templates bind the root schema and do not complete split child section fields. Split section YAML templates bind their section schema. JSON and JSON5 templates receive a top-level $schema field. Editor settings such as VS Code json.schemas can still be used as an alternative binding path.

The output format is inferred from the output path:

  • .yaml and .yml generate YAML.
  • .toml generates TOML.
  • .json and .json5 generate JSON5-compatible templates.
  • unknown or missing extensions generate YAML.

The template APIs write exactly under the output_path you pass. The built-in generate-template CLI command normalizes generated templates under config/<root_config_name>/; without --output, AppConfig writes config/app_config/app_config.example.yaml and the matching default schema config/app_config/app_config.schema.json.

Schema Bindings

With a schema path of schemas/myapp.schema.json, generated root templates use:

#:schema ./schemas/myapp.schema.json
# yaml-language-server: $schema=./schemas/myapp.schema.json

Generated section templates bind section schemas:

# log.yaml
# yaml-language-server: $schema=./schemas/log.schema.json

Generated JSON and JSON5 templates bind the schema with a top-level $schema field:

{
  "$schema": "./schemas/myapp.schema.json"
}

Editor settings are still useful when a project does not want an in-file binding:

{
  "json.schemas": [
    {
      "fileMatch": [
        "/config.json",
        "/config.*.json"
      ],
      "url": "./schemas/myapp.schema.json"
    }
  ]
}

Template Source Selection

Template generation chooses its source tree in this order:

  1. Existing config path.
  2. Existing output template path.
  3. Output path treated as a new empty template tree.

This lets a project update templates from current config files, update an existing template set, or create a new template set from only the schema.

Mirrored Include Trees

If the source file declares includes, generated templates mirror those include paths under the output directory.

# config.yaml
include:
  - server.yaml

Generating config.example.yaml writes:

config.example.yaml
server.yaml

Relative include targets are mirrored under the output file’s parent directory. Absolute include targets remain absolute.

Opt-in Section Splitting

When a source file has no includes, the crate can derive include targets from nested schema sections marked with x-tree-split. For a schema with a marked server section, an empty root template source can produce:

config.example.yaml
server.yaml

The root template receives an include block, and server.yaml contains only the server section. Unmarked nested sections stay inline in their parent template. Nested sections are split recursively only when those fields also carry x-tree-split.

Transparent Array Section Templates

When a nested section also carries x-tree-transparent-array, the section template emits only a block YAML array body. It does not write a section root key (such as children:), an items: wrapper, or flow-style [{ ... }].

The matching section schema uses a top-level array type rather than an { items: [...] } object, so IDEs complete array items directly while editing split files such as children.yaml.

See Transparent Array Sections for the full workflow.

IDE Completions

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Generated JSON Schemas can be used by TOML, YAML, JSON, and JSON5 config files. They are generated from the same Rust type used by confique:

#![allow(unused)]
fn main() {
use confique::Config;
use schemars::JsonSchema;

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(nested)]
    #[schemars(extend("x-tree-split" = true))]
    server: ServerConfig,
}
}

Generate them with:

#![allow(unused)]
fn main() {
use rust_config_tree::write_config_schemas;

write_config_schemas::<AppConfig>("schemas/myapp.schema.json")?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

This writes the root schema and section schemas such as schemas/server.schema.json. Generated schemas omit required constraints so completion works for partial config files without missing-field diagnostics. The root schema omits split nested section properties, so split child section completion is available only in files that bind the matching section schema. Unmarked nested sections remain in the root schema.

Fields marked with x-env-only are omitted from generated schemas, so IDEs do not suggest secrets or other values that must come only from environment variables.

IDE schemas are for completion and basic editor checks, such as type, enum, and unknown property checks supported by the generated schema. They do not decide whether a concrete field value is legal for the application. Implement field value validation in code with #[config(validate = Self::validate)], then run it through load_config or validate-config. Required fields and final merged config validation also use those runtime paths.

Transparent Array Sections

Use x-tree-transparent-array when a split section should serialize as a bare YAML array in both the root config and the split file body. Pair it with transparent_array_section! or ArraySection<T>. See Transparent Array Sections for the full workflow.

#![allow(unused)]
fn main() {
use rust_config_tree::transparent_array_section;

transparent_array_section! {
    pub struct ChildrenSection {
        #[config(default = [{ "name": "worker" }])]
        pub items: Vec<ChildDeclaration>,
    }
}

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(nested)]
    #[schemars(extend(
        "x-tree-split" = true,
        "x-tree-transparent-array" = true
    ))]
    children: ChildrenSection,
}
}

Behavior:

  • load_config accepts children: [...], children:\n items: [...], and body-only children.yaml.
  • Generated children.schema.json is a top-level array, not an { items: [...] } object.
  • Template generation emits block YAML array bodies without children: or flow-style [{ ... }].

Optional extension x-tree-inner-field = "items" overrides the confique inner field name. The default is "items".

Run cargo run --example transparent_array_section for a complete demo.

TOML

TOML files should bind the schema with a top-of-file #:schema directive:

#:schema ./schemas/myapp.schema.json

[server]
bind = "0.0.0.0"
port = 3000

Do not use a root $schema = "..." field in TOML. It becomes real config data and can affect runtime deserialization. write_config_templates_with_schema adds the #:schema directive automatically for TOML templates.

YAML

YAML files should use the YAML Language Server modeline:

# yaml-language-server: $schema=./schemas/myapp.schema.json

server:
  bind: 0.0.0.0
  port: 3000

write_config_templates_with_schema adds this modeline automatically for YAML templates. Split YAML templates bind their section schema, for example log.yaml binds ./schemas/log.schema.json.

JSON

JSON and JSON5 files can bind a schema with a top-level $schema property. write_config_templates_with_schema adds it automatically for generated JSON and JSON5 templates:

{
  "$schema": "./schemas/myapp.schema.json"
}

Editor settings are still useful when a project does not want an in-file binding:

{
  "json.schemas": [
    {
      "fileMatch": ["/config.json", "/config.*.json", "/deploy/*.json"],
      "url": "./schemas/myapp.schema.json"
    }
  ]
}

YAML can also be bound through VS Code settings:

{
  "yaml.schemas": {
    "./schemas/myapp.schema.json": [
      "config.yaml",
      "config.*.yaml",
      "deploy/*.yaml"
    ]
  }
}

The final layout is:

schemas/myapp.schema.json:
  Root file fields only

schemas/server.schema.json:
  Server section schema

config.toml:
  #:schema ./schemas/myapp.schema.json

config.yaml:
  # yaml-language-server: $schema=./schemas/myapp.schema.json

server.yaml:
  # yaml-language-server: $schema=./schemas/server.schema.json

config.json:
  "$schema": "./schemas/myapp.schema.json"

References:

CLI Integration

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

ConfigCommand provides reusable clap subcommands:

  • generate-template
  • generate-schema
  • validate-config
  • completions
  • install-completions
  • uninstall-completions

These built-in subcommands are separate from application-specific config override flags. Merge config override flags as Figment providers in the runtime loading path.

Config override flags remain part of the consuming application’s CLI. Their names do not need to match dotted config paths. For example, the application can parse --server-port and map it to the nested server.port config key. Only flags that the application maps into CliOverrides affect config values.

Flatten it into an application command enum:

  1. Keep the application’s own Parser type.
  2. Keep the application’s own Subcommand enum.
  3. Add #[command(flatten)] Config(ConfigCommand) to that enum.
  4. Clap expands the flattened ConfigCommand variants into the same command level as the application’s own variants.
  5. Match the Config(command) variant and pass it to handle_config_command.

The AppConfig type argument in handle_config_command::<Cli, AppConfig> is the schema that supplies fields, defaults, validation, and template output. generate-template does not discover arbitrary Rust structs at runtime. If an application exposes multiple config schemas, add an application level selector and dispatch to different handle_config_command::<Cli, S> calls.

use std::path::PathBuf;

use clap::{Parser, Subcommand};
use confique::Config;
use schemars::JsonSchema;
use rust_config_tree::{ConfigCommand, ConfigSchema, handle_config_command, load_config};

#[derive(Debug, Config, JsonSchema)]
struct AppConfig {
    #[config(default = [])]
    include: Vec<PathBuf>,
}

impl ConfigSchema for AppConfig {
    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf> {
        layer.include.clone().unwrap_or_default()
    }
}

#[derive(Debug, Parser)]
#[command(name = "demo")]
struct Cli {
    #[arg(long, default_value = "config.yaml")]
    config: PathBuf,

    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    Run,

    #[command(flatten)]
    Config(ConfigCommand),
}

fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let cli = Cli::parse();

    match cli.command {
        Command::Run => {
            let config = load_config::<AppConfig>(&cli.config)?;
            println!("{config:#?}");
        }
        Command::Config(command) => {
            handle_config_command::<Cli, AppConfig>(command, &cli.config)?;
        }
    }

    Ok(())
}

Config Templates

demo generate-template

The command writes templates to the output path specified by --output. If no --output is provided, the command writes config/<root_config_name>/<root_config_name>.example.yaml. Add --schema schemas/myapp.schema.json to bind generated TOML, YAML, JSON, and JSON5 templates to generated JSON Schemas. Split YAML templates bind the matching section schema. The command also writes the root and section schemas to the selected schema path.

demo generate-template --output app_config.example.toml --schema schemas/myapp.schema.json

Generate root and section JSON Schemas:

demo generate-schema

Without --output, generate-schema writes the root schema to config/<root_config_name>/<root_config_name>.schema.json.

Validate the complete runtime config tree:

demo validate-config
demo validate-config --config config.yaml

When --config is omitted, validate-config uses the consumer default path passed to handle_config_command.

Generated editor schemas intentionally avoid required-field diagnostics for split files. validate-config loads includes, applies defaults, and runs final confique validation, including validators declared with #[config(validate = Self::validate)]. Generated *.schema.json files remain for IDE completion and basic editor checks, not field value legality. It prints Configuration is ok when validation succeeds.

Shell Completions

Print completions to stdout:

demo completions zsh

Install completions:

demo install-completions zsh

Uninstall completions:

demo uninstall-completions zsh

The installer supports Bash, Elvish, Fish, PowerShell, and Zsh. It writes the completion file under the user’s home directory and updates the shell startup file for shells that require it.

Before changing an existing shell startup file such as ~/.zshrc, ~/.bashrc, an Elvish rc file, or a PowerShell profile, the command writes a backup next to the original file:

<rc-file>.backup.by.<program-name>.<timestamp>

Examples

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

The repository includes runnable examples for loading config trees, CLI overrides, built-in config commands, template generation, and the lower-level tree API.

Read the repository examples index:

Run examples from the repository root:

cargo run --example basic_loading
cargo run --example cli_overrides -- --server-port 9000
cargo run --example config_commands -- generate-template
cargo run --example config_commands -- generate-schema
cargo run --example config_commands -- validate-config
cargo run --example generate_templates
cargo run --example tree_api
cargo run --example transparent_array_section

The config_commands template and schema commands use the CLI defaults, so AppConfig writes generated files under config/app_config/.

See transparent-sections.md for transparent array section documentation.

Tree API

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

Use the lower-level tree API when the application does not use confique, or when it needs direct access to traversal results.

#![allow(unused)]
fn main() {
use std::{
    fs,
    io,
    path::{Path, PathBuf},
};

use rust_config_tree::{ConfigSource, load_config_tree};

fn load_source(path: &Path) -> io::Result<ConfigSource<String>> {
    let content = fs::read_to_string(path)?;
    let includes = content
        .lines()
        .filter_map(|line| line.strip_prefix("include: "))
        .map(PathBuf::from)
        .collect();

    Ok(ConfigSource::new(content, includes))
}

let tree = load_config_tree("config.yaml", load_source)?;

for node in tree.nodes() {
    println!("{}", node.path().display());
}
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}

Traversal Rules

The tree loader:

  • normalizes source paths lexically;
  • rejects empty include paths;
  • resolves relative includes from the file that declared them;
  • preserves absolute include paths;
  • detects recursive include cycles;
  • skips files already loaded through another include branch.

ConfigTreeOptions can reverse sibling include traversal:

#![allow(unused)]
fn main() {
use rust_config_tree::{ConfigTreeOptions, IncludeOrder};

let options = ConfigTreeOptions::default().include_order(IncludeOrder::Reverse);
let _ = options;
}

Path Helpers

The path helpers are lexical only. They do not resolve symbolic links and do not require paths to exist:

  • absolutize_lexical(path)
  • normalize_lexical(path)
  • resolve_include_path(parent_path, include_path)

GitHub Pages

English | 中文 | 日本語 | 한국어 | Français | Deutsch | Español | Português | Svenska | Suomi | Nederlands

This repository publishes the manual with mdBook and GitHub Pages.

Each language manual is an independent mdBook project. Each language has its own SUMMARY.md, so the left sidebar only contains pages for the current language:

rust-config-tree/manual/
  en/
    book.toml
    SUMMARY.md
    introduction.md
    quick-start.md
    ...
  zh/
    book.toml
    SUMMARY.md
    introduction.md
    quick-start.md
    ...
  ja/
    book.toml
    SUMMARY.md
    introduction.md
    quick-start.md
    ...
  ko/
  fr/
  de/
  es/
  pt/
  sv/
  fi/
  nl/

Build locally with:

scripts/publish-pages.sh

The generated site is written to:

target/mdbook

Publishing Workflow

The workflow in .github/workflows/pages.yml runs on pushes to main and on manual dispatch. It:

  1. Checks out the repository.
  2. Installs mdBook.
  3. Runs scripts/publish-pages.sh.
  4. Uploads target/mdbook as the Pages artifact.
  5. Deploys the artifact to GitHub Pages.

The published URL is:

https://developerworks.github.io/rust-config-tree/

Crate Release

For the complete commit, push, Pages deploy, and crate publish flow:

scripts/release.sh --execute --message "Release 0.1.3"

Use the crate release helper from the repository root:

scripts/publish-crate.sh

The default mode runs checks and cargo publish --dry-run. To publish to crates.io after the checks pass. If the current version already exists on crates.io, the script bumps the patch version automatically:

scripts/publish-crate.sh --execute

Script usage is summarized in scripts/README.md.