Skip to content
docuconf
docuconf on GitHub

Get started / Rust

docuconf for Rust

Keep your #[derive(Deserialize)] config struct and add #[derive(Docuconf)]. Doc comments are the descriptions, the Rust type picks the contract type, and figment's files and profiles become the contract's defaults and profiles.

Builds on
figment + serde
Package
docuconf (crates.io)
Requires
Rust 1.89
Status
v0.1 alpha, not yet released · conformance 134 of 134
Source
docuconf-rust · the orders example

Not published yet

Every docuconf SDK is a v0.1 alpha and none is on a package registry yet, so step 1 installs from the main branch. After the first release it will be cargo add docuconf serde --features serde/derive.

The code below is the orders service, the example every SDK ships: six variables, one of them a secret, checked the same way in every language. Compare it across languages.

1.Install

The crate is not on crates.io yet. Add it from the main branch (Rust 1.89+), with serde:

cargo add docuconf --git https://github.com/Docuconf/docuconf-rust
cargo add serde --features derive

2.Declare your configuration

A serde struct with #[derive(Docuconf)]. The first paragraph of each /// comment is the description and the rest its details; #[docuconf(...)] sets defaults and constraints; Secret<T> marks a secret and keeps it out of Debug.

src/main.rs
/// The service's configuration. The first paragraph of each `///` comment
/// is the variable's description in the contract and the rest its details,
/// and the Rust type picks its contract type.
#[derive(Debug, Deserialize, Docuconf)]
struct Config {
    /// HTTP listen port.
    #[docuconf(default = 8080, min = 1)]
    port: u16,

    /// Minimum log level emitted.
    #[docuconf(default = "info")]
    log_level: LogLevel,

    /// Postgres connection string for the orders database.
    // `Secret` marks it `secret: true`; `schemes` makes it a `url`, and
    // `max_length` caps it in characters.
    #[docuconf(schemes("postgres"), max_length = 2048)]
    database_url: Secret<String>,

    /// Browser origins allowed to call the API.
    #[docuconf(default = ["http://localhost:3000"], min_items = 1)]
    allowed_origins: Vec<String>,

    /// Time allowed to read and answer one request.
    #[docuconf(default = "30s", min = "1s", max = "5m")]
    #[serde(with = "docuconf::humantime_serde")]
    request_timeout: Duration,

    /// Threads serving requests.
    ///
    /// Each worker answers one connection at a time, so this is also the
    /// number of requests served at once. Raise it when requests queue up;
    /// each worker holds a [`std::thread`] stack.
    ///
    /// Keep it at or below the database pool size:
    ///
    /// - one connection per worker;
    /// - plus one for migrations.
    #[docuconf(default = 4, min = 1, max = 64)]
    worker_count: u8,
}

3.Load it at boot

docuconf::load_or_exit() reads the environment and every file input and returns the struct, or prints every problem and exits 1. docuconf::load() returns an error listing them instead.

src/main.rs
let config: Config = docuconf::load_or_exit();

4.Run it, and see an error

Run the example with a valid environment:

DATABASE_URL=postgres://orders:pw@localhost:5432/orders cargo run -p orders

With PORT=0 and no DATABASE_URL, the service refuses to start, lists every problem with its error code and exits 1. The same lines go to /dev/termination-log.

Terminal
$ PORT=0 cargo run -q -p orders
docuconf: 2 configuration problems:
  DATABASE_URL: is required but not set (missing_required)
  PORT: 0 is below min 1 (out_of_range)

5.Test your configuration

Loader::env replaces the process environment, so a test passes exactly the variables it wants. Put the tests next to the struct, in a #[cfg(test)] module.

src/tests.rs
use super::*;
use docuconf::Code;

// Load from an explicit environment: the process environment is not read or changed.
fn load(env: &[(&str, &str)]) -> Result<Config, docuconf::Error> {
    docuconf::Loader::<Config>::new()
        .env(env.iter().copied())
        .termination_log(false)
        .load()
}

#[test]
fn defaults() {
    let config = load(&[("DATABASE_URL", "postgres://orders@db/orders")]).unwrap();
    assert_eq!(config.port, 8080);
    assert_eq!(config.worker_count, 4);
}

#[test]
fn rejects_bad_values() {
    let err = load(&[("PORT", "70000")]).unwrap_err();
    let found: Vec<_> = err.violations().iter().map(|v| (v.input.as_str(), v.code)).collect();
    assert!(found.contains(&("PORT", Code::OutOfRange)), "{found:?}");
    assert!(found.contains(&("DATABASE_URL", Code::MissingRequired)), "{found:?}");
}
cargo test -q

6.Export the contract

The app exports its own contract: docuconf::export_command handles an export [--check] [PATH] argument and exits, so CI can rerun it with --check and fail when the committed file differs.

cargo run -q -p orders -- export contract.cue
src/main.rs
// `orders export [--check] [PATH]` writes (or checks) the contract and
// exits; any other invocation carries on.
docuconf::export_command::<Config>(&Meta::new("orders-api").package("orders"));
contract.cue
// Code generated by docuconf. DO NOT EDIT.
package orders

import "docuconf.dev/contract"

contract.#Contract & {
	apiVersion: "docuconf.dev/v1alpha1"
	kind:       "ConfigContract"
	metadata: {
		name: "orders-api"
		generator: {
			language: "rust"
			sdk:      "docuconf"
			version:  "0.1.0"
		}
	}
	vars: {
		ALLOWED_ORIGINS: {
			type:        "list"
			description: "Browser origins allowed to call the API"
			default: ["http://localhost:3000"]
			items:    "string"
			encoding: "json"
			minItems: 1
		}
		DATABASE_URL: {
			type:        "url"
			description: "Postgres connection string for the orders database"
			required:    true
			secret:      true
			schemes: ["postgres"]
			maxLength: 2048
		}
		LOG_LEVEL: {
			type:        "enum"
			description: "Minimum log level emitted"
			default:     "info"
			values: ["debug", "info", "warn", "error"]
		}
		PORT: {
			type:        "int"
			description: "HTTP listen port"
			default:     8080
			min:         1
			max:         65535
		}
		REQUEST_TIMEOUT: {
			type:        "duration"
			description: "Time allowed to read and answer one request"
			default:     "30s"
			encoding:    "go"
			min:         "1s"
			max:         "5m"
		}
		WORKER_COUNT: {
			type:        "int"
			description: "Threads serving requests"
			details:     "Each worker answers one connection at a time, so this is also the\nnumber of requests served at once. Raise it when requests queue up;\neach worker holds a `std::thread` stack.\n\nKeep it at or below the database pool size:\n\n- one connection per worker;\n- plus one for migrations."
			default:     4
			min:         1
			max:         64
		}
	}
}

The platform then checks each environment's values against contract.cue before it deploys, with docuconf vet, the Helm chart or CUE. See How it works.

7.axum, Rocket and actix-web

Call docuconf::load() in main before you build the router, and share the struct as axum state or an actix Data. Rocket already uses figment: give docuconf's Loader your figment with .figment(...) and .profiles(...), so the contract carries the same files and profiles. Do not add figment's own Env provider alongside it: it trims values and guesses types. See the README.

Next

Every snippet on this page is compiled or run against docuconf-rust's main branch in CI. Facts checked 2026-10-08.