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}