Skip to main content

rtb_test_support/
lib.rs

1//! Test-only helpers for constructing an [`rtb_app::app::App`] without
2//! the full `rtb_cli::Application::builder` wiring.
3//!
4//! # What this crate provides
5//!
6//! A fluent [`TestAppBuilder`] for tests that need an `App` without
7//! the full `rtb-cli` lifecycle (logging, miette hook install,
8//! signal handlers). Promoted to downstream crates that want a
9//! consistent test-helper API — a replacement for the bare
10//! `App::for_testing` in `rtb-app`.
11//!
12//! # Scope and honesty about sealing
13//!
14//! The builder is gated behind a sealed-trait pattern ([`TestWitness`]
15//! implements a crate-private `Sealed` trait) — so a crate that
16//! depends on `rtb-test-support` is making its intent visible in
17//! its `Cargo.toml`. Placing `rtb-test-support` only in
18//! `[dev-dependencies]` prevents a production binary from reaching
19//! the builder through an accidental imports.
20//!
21//! It is **not** watertight access control. `rtb_app::App` has
22//! `pub` fields (see the rtb-app v0.1 spec open questions), so any
23//! crate that depends on rtb-app can also construct an `App` via
24//! struct-literal. The seal is a speed bump, not a fence. Post-0.1
25//! a `pub(crate)` field refactor + accessor methods would close
26//! this; for v0.1, `rtb-test-support` is the promoted test-helper
27//! entry point for new downstream tests.
28//!
29//! # Usage
30//!
31//! ```ignore
32//! // In Cargo.toml:
33//! // [dev-dependencies]
34//! // rtb-test-support = { path = "../rtb-test-support" }
35//!
36//! use rtb_test_support::{TestAppBuilder, TestWitness};
37//!
38//! let app = TestAppBuilder::new(TestWitness::new())
39//!     .tool("mytool", "1.0.0")
40//!     .build();
41//! ```
42
43#![forbid(unsafe_code)]
44
45use std::sync::Arc;
46
47use rtb_app::app::App;
48use rtb_app::metadata::ToolMetadata;
49use rtb_app::typed_config::{erase, ErasedConfig, TypedConfigOps};
50use rtb_app::version::VersionInfo;
51use rtb_assets::Assets;
52use rtb_config::Config;
53use semver::Version;
54
55mod sealed {
56    /// Crate-private trait. Only [`super::TestWitness`] may
57    /// implement it.
58    pub trait Sealed {}
59}
60
61/// A zero-sized witness that the caller depends on
62/// `rtb-test-support`. Passing a `TestWitness` to [`TestAppBuilder`]
63/// is how the sealing pattern unlocks the bypass constructor.
64pub struct TestWitness(());
65
66impl TestWitness {
67    /// Construct a new witness. Available to any crate depending on
68    /// `rtb-test-support`.
69    #[must_use]
70    pub const fn new() -> Self {
71        Self(())
72    }
73}
74
75impl Default for TestWitness {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl sealed::Sealed for TestWitness {}
82
83/// Fluent builder for a test-only [`App`].
84#[must_use]
85pub struct TestAppBuilder<W: sealed::Sealed> {
86    _witness: W,
87    metadata: Option<ToolMetadata>,
88    version: Option<VersionInfo>,
89    /// Captured at builder time when `config` / `config_value` is
90    /// called. Mirrors the production
91    /// `rtb_cli::ApplicationBuilder::config<C>` step so tests can
92    /// exercise the schema-aware `App::config_schema` /
93    /// `App::config_value` / `App::typed_config<C>` paths without
94    /// pulling in the full `rtb-cli` wiring.
95    typed_config: Option<ErasedConfig>,
96    typed_config_ops: Option<Arc<TypedConfigOps>>,
97}
98
99impl TestAppBuilder<TestWitness> {
100    /// Start building with a witness.
101    pub const fn new(witness: TestWitness) -> Self {
102        Self {
103            _witness: witness,
104            metadata: None,
105            version: None,
106            typed_config: None,
107            typed_config_ops: None,
108        }
109    }
110
111    /// Convenience: set name + version-string in one call.
112    /// Version must parse as semver (panics otherwise — tests only).
113    pub fn tool(mut self, name: &str, version: &str) -> Self {
114        self.metadata = Some(ToolMetadata::builder().name(name).summary("test").build());
115        self.version = Some(VersionInfo::new(Version::parse(version).expect("parse test version")));
116        self
117    }
118
119    /// Override just the metadata.
120    pub fn metadata(mut self, m: ToolMetadata) -> Self {
121        self.metadata = Some(m);
122        self
123    }
124
125    /// Override just the version.
126    pub fn version(mut self, v: VersionInfo) -> Self {
127        self.version = Some(v);
128        self
129    }
130
131    /// Wire a fully-formed [`Config<C>`] — the full-fidelity match
132    /// for the production `rtb_cli::ApplicationBuilder::config<C>`.
133    /// Use this when the test needs to drive layered defaults /
134    /// overrides through `Config<C>` itself (e.g. a `Config::builder`
135    /// chain with an `embedded_default` plus a `user_file` override).
136    ///
137    /// For the common case where the test only cares about a single
138    /// merged value, prefer [`Self::config_value`] which wraps `c`
139    /// in a `Config<C>` for you.
140    pub fn config<C>(mut self, config: Config<C>) -> Self
141    where
142        C: serde::Serialize
143            + serde::de::DeserializeOwned
144            + schemars::JsonSchema
145            + Send
146            + Sync
147            + 'static,
148    {
149        let ops = TypedConfigOps::new::<C>();
150        self.typed_config = Some(erase(config));
151        self.typed_config_ops = Some(Arc::new(ops));
152        self
153    }
154
155    /// Wire `c` as the merged typed-config value — the ergonomic
156    /// shortcut for tests that just want `app.typed_config::<C>()`
157    /// to return `Arc<Config<C>>` carrying `c`. Internally wraps `c`
158    /// in [`Config::with_value`].
159    pub fn config_value<C>(self, c: C) -> Self
160    where
161        C: serde::Serialize
162            + serde::de::DeserializeOwned
163            + schemars::JsonSchema
164            + Send
165            + Sync
166            + 'static,
167    {
168        self.config(Config::<C>::with_value(c))
169    }
170
171    /// Finalise. Panics if neither `tool` nor explicit `metadata`/
172    /// `version` supplied — tests should be explicit.
173    #[must_use]
174    pub fn build(self) -> App {
175        let metadata = self.metadata.expect("TestAppBuilder: metadata not set");
176        let version = self.version.expect("TestAppBuilder: version not set");
177        let app = App::new(metadata, version, Config::<()>::default(), Assets::default(), None);
178        match (self.typed_config, self.typed_config_ops) {
179            (Some(erased), Some(ops)) => app.with_typed_config(erased, ops),
180            _ => app,
181        }
182    }
183}