Skip to main content

rlg_test/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Test utilities for downstream crates that depend on `rlg`.
6//!
7//! ```
8//! use rlg_test::{assert_logged, capture, LogExt};
9//! use rlg::log::Log;
10//! use rlg::log_level::LogLevel;
11//!
12//! let capture = capture();
13//! Log::info("user authenticated")
14//!     .component("auth")
15//!     .with("user_id", 42_u64)
16//!     .log_to(&capture);
17//!
18//! assert_logged!(capture, level == LogLevel::INFO);
19//! assert_logged!(capture, contains "authenticated");
20//! assert_logged!(capture, attribute "user_id" => 42_u64);
21//! ```
22//!
23//! Two patterns are supported:
24//!
25//! 1. **Capture handle** ([`capture`]) — explicitly route records into
26//!    the handle via the `log_to` extension method or `capture.push()`.
27//!    Works regardless of the global engine state.
28//!
29//! 2. **Global capture** (not implemented in v0.0.11) — install the
30//!    handle as the engine's sink for the test scope. Tracked under
31//!    the v0.0.12 roadmap; deferred so this crate doesn't reach into
32//!    `rlg`'s engine internals.
33
34#![forbid(unsafe_code)]
35#![deny(missing_docs)]
36
37use parking_lot::Mutex;
38use rlg::log::Log;
39use rlg::log_level::LogLevel;
40use serde_json::Value;
41use std::sync::Arc;
42
43/// In-memory sink that captures every record routed to it.
44///
45/// Cheap to clone — internally it's an `Arc<Mutex<Vec<Log>>>`, so
46/// every clone observes the same buffer.
47#[derive(Debug, Clone, Default)]
48pub struct Capture {
49    inner: Arc<Mutex<Vec<Log>>>,
50}
51
52impl Capture {
53    /// Construct a fresh, empty capture handle.
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Append a record to the captured buffer.
60    pub fn push(&self, record: Log) {
61        self.inner.lock().push(record);
62    }
63
64    /// Return a snapshot of the records captured so far.
65    #[must_use]
66    pub fn records(&self) -> Vec<Log> {
67        self.inner.lock().clone()
68    }
69
70    /// How many records have been captured.
71    #[must_use]
72    pub fn len(&self) -> usize {
73        self.inner.lock().len()
74    }
75
76    /// Returns `true` if no records have been captured.
77    #[must_use]
78    pub fn is_empty(&self) -> bool {
79        self.inner.lock().is_empty()
80    }
81
82    /// Drop every captured record.
83    pub fn clear(&self) {
84        self.inner.lock().clear();
85    }
86}
87
88/// Shortcut for `Capture::new()`. Reads more naturally at call sites:
89/// `let capture = rlg_test::capture();`.
90#[must_use]
91pub fn capture() -> Capture {
92    Capture::new()
93}
94
95/// Extension trait that routes a [`Log`] entry into a [`Capture`]
96/// handle in-place of the global engine.
97pub trait LogExt {
98    /// Push `self` into `capture`.
99    fn log_to(self, capture: &Capture);
100}
101
102impl LogExt for Log {
103    fn log_to(self, capture: &Capture) {
104        capture.push(self);
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Predicates — exposed so the assert_logged! macro stays readable.
110// ---------------------------------------------------------------------------
111
112/// Did `capture` see at least one record with the given level?
113#[must_use]
114pub fn has_level(capture: &Capture, level: LogLevel) -> bool {
115    capture.inner.lock().iter().any(|r| r.level == level)
116}
117
118/// Did `capture` see at least one record whose `description`
119/// contains `needle`?
120#[must_use]
121pub fn description_contains(capture: &Capture, needle: &str) -> bool {
122    capture
123        .inner
124        .lock()
125        .iter()
126        .any(|r| r.description.contains(needle))
127}
128
129/// Did `capture` see at least one record whose `key` attribute
130/// equals `expected`?
131pub fn attribute_eq<V>(
132    capture: &Capture,
133    key: &str,
134    expected: V,
135) -> bool
136where
137    V: Into<Value>,
138{
139    let want = expected.into();
140    capture
141        .inner
142        .lock()
143        .iter()
144        .any(|r| r.attributes.get(key) == Some(&want))
145}
146
147/// Did `capture` see at least one record whose `component` matches?
148#[must_use]
149pub fn has_component(capture: &Capture, component: &str) -> bool {
150    capture
151        .inner
152        .lock()
153        .iter()
154        .any(|r| r.component.as_ref() == component)
155}
156
157// ---------------------------------------------------------------------------
158// assert_logged! macro
159// ---------------------------------------------------------------------------
160
161/// One-stop assertion macro for verifying that a [`Capture`] handle
162/// observed a record matching some predicate.
163///
164/// Supported forms:
165///
166/// | Syntax | Predicate |
167/// | --- | --- |
168/// | `assert_logged!(c, level == L::INFO)` | a record at level INFO exists |
169/// | `assert_logged!(c, contains "needle")` | a record's description contains the substring |
170/// | `assert_logged!(c, attribute "k" == v)` | a record's `k` attribute equals `v` |
171/// | `assert_logged!(c, component "auth")` | a record's component equals `"auth"` |
172/// | `assert_logged!(c, len == 3)` | exactly 3 records were captured |
173#[macro_export]
174macro_rules! assert_logged {
175    ($capture:expr, level == $level:expr) => {{
176        assert!(
177            $crate::has_level(&$capture, $level),
178            "expected a captured record at level {:?}, got: {:?}",
179            $level,
180            $capture.records()
181        );
182    }};
183    ($capture:expr, contains $needle:expr) => {{
184        assert!(
185            $crate::description_contains(&$capture, $needle),
186            "expected a captured record description to contain {:?}, got: {:?}",
187            $needle,
188            $capture.records()
189        );
190    }};
191    ($capture:expr, attribute $key:expr => $expected:expr) => {{
192        assert!(
193            $crate::attribute_eq(&$capture, $key, $expected),
194            "expected a captured record with attribute {:?} == {:?}, got: {:?}",
195            $key,
196            $expected,
197            $capture.records()
198        );
199    }};
200    ($capture:expr, component $component:expr) => {{
201        assert!(
202            $crate::has_component(&$capture, $component),
203            "expected a captured record with component {:?}, got: {:?}",
204            $component,
205            $capture.records()
206        );
207    }};
208    ($capture:expr, len == $n:expr) => {{
209        let n = $capture.len();
210        assert!(
211            n == $n,
212            "expected {} captured records, got {}: {:?}",
213            $n,
214            n,
215            $capture.records()
216        );
217    }};
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use rlg::log::Log;
224
225    #[test]
226    fn capture_starts_empty() {
227        let c = capture();
228        assert!(c.is_empty());
229        assert_eq!(c.len(), 0);
230    }
231
232    #[test]
233    fn log_to_appends() {
234        let c = capture();
235        Log::info("hi").component("svc").log_to(&c);
236        Log::warn("oops").log_to(&c);
237        assert_eq!(c.len(), 2);
238    }
239
240    #[test]
241    fn clear_drops_records() {
242        let c = capture();
243        Log::info("x").log_to(&c);
244        c.clear();
245        assert!(c.is_empty());
246    }
247
248    #[test]
249    fn predicates_match_levels() {
250        let c = capture();
251        Log::error("boom").log_to(&c);
252        assert!(has_level(&c, LogLevel::ERROR));
253        assert!(!has_level(&c, LogLevel::INFO));
254    }
255
256    #[test]
257    fn predicates_match_components() {
258        let c = capture();
259        Log::info("x").component("auth").log_to(&c);
260        assert!(has_component(&c, "auth"));
261        assert!(!has_component(&c, "other"));
262    }
263
264    #[test]
265    fn predicates_match_descriptions() {
266        let c = capture();
267        Log::info("authenticated user").log_to(&c);
268        assert!(description_contains(&c, "authenticated"));
269        assert!(!description_contains(&c, "missing"));
270    }
271
272    #[test]
273    fn predicates_match_attributes() {
274        let c = capture();
275        Log::info("x")
276            .with("user_id", 42_u64)
277            .with("region", "eu-west-1")
278            .log_to(&c);
279        assert!(attribute_eq(&c, "user_id", 42_u64));
280        assert!(attribute_eq(&c, "region", "eu-west-1"));
281        assert!(!attribute_eq(&c, "user_id", 99_u64));
282    }
283
284    #[test]
285    fn macro_succeeds_on_match() {
286        let c = capture();
287        Log::info("hello")
288            .component("svc")
289            .with("k", 1_u64)
290            .log_to(&c);
291        assert_logged!(c, level == LogLevel::INFO);
292        assert_logged!(c, contains "hello");
293        assert_logged!(c, component "svc");
294        assert_logged!(c, attribute "k" => 1_u64);
295        assert_logged!(c, len == 1);
296    }
297
298    #[test]
299    #[should_panic(expected = "expected a captured record at level")]
300    fn macro_panics_on_missing_level() {
301        let c = capture();
302        Log::info("x").log_to(&c);
303        assert_logged!(c, level == LogLevel::ERROR);
304    }
305
306    #[test]
307    #[should_panic(expected = "expected 3 captured records")]
308    fn macro_panics_on_wrong_count() {
309        let c = capture();
310        Log::info("x").log_to(&c);
311        assert_logged!(c, len == 3);
312    }
313
314    #[test]
315    fn capture_handle_is_cheap_to_clone() {
316        let a = capture();
317        let b = a.clone();
318        Log::info("x").log_to(&a);
319        assert_eq!(b.len(), 1, "clones share the same buffer");
320    }
321}