antithesis_sdk/assert/mod.rs
1use std::sync::atomic::AtomicU64;
2#[cfg(feature = "full")]
3use std::{collections::HashMap, sync::{atomic::Ordering, Arc, RwLock}};
4use crate::details::Details;
5#[cfg(feature = "full")]
6use crate::internal;
7#[cfg(feature = "full")]
8use linkme::distributed_slice;
9#[cfg(feature = "full")]
10use once_cell::sync::Lazy;
11use serde::Serialize;
12use serde_json::Value;
13#[cfg(feature = "full")]
14use serde_json::json;
15
16mod macros;
17#[doc(hidden)]
18#[cfg(feature = "full")]
19pub mod guidance;
20
21/// Catalog of all antithesis assertions provided
22#[doc(hidden)]
23#[distributed_slice]
24#[cfg(feature = "full")]
25pub static ANTITHESIS_CATALOG: [AssertionCatalogInfo];
26
27/// Catalog of all antithesis guidances provided
28#[doc(hidden)]
29#[distributed_slice]
30#[cfg(feature = "full")]
31pub static ANTITHESIS_GUIDANCE_CATALOG: [self::guidance::GuidanceCatalogInfo];
32
33#[cfg(feature = "full")]
34pub(crate) static INIT_CATALOG: Lazy<()> = Lazy::new(|| {
35 for event in crate::catalog::registrations() {
36 internal::dispatch_output(&event);
37 }
38});
39
40/// The catalog-registration event for one assertion declaration, emitted by
41/// both `antithesis_init()` and `catalog::write`.
42#[cfg(feature = "full")]
43pub(crate) fn catalog_event(info: &AssertionCatalogInfo) -> Value {
44 let f_name: &str = info.function.as_ref();
45 let details = json!(null);
46 let assertion = AssertionInfo::new(
47 info.assert_type,
48 info.display_type,
49 info.condition,
50 info.message,
51 info.class,
52 f_name,
53 info.file,
54 info.begin_line,
55 info.begin_column,
56 false, /* hit */
57 info.must_hit,
58 info.id,
59 &details,
60 );
61 json!({ "antithesis_assert": &assertion })
62}
63
64pub struct TrackingInfo {
65 pub pass_count: AtomicU64,
66 pub fail_count: AtomicU64,
67}
68
69impl Default for TrackingInfo {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl TrackingInfo {
76 pub const fn new() -> Self {
77 TrackingInfo {
78 pass_count: AtomicU64::new(0),
79 fail_count: AtomicU64::new(0),
80 }
81 }
82}
83
84#[derive(Copy, Clone, PartialEq, Debug, Serialize)]
85#[serde(rename_all(serialize = "lowercase"))]
86pub enum AssertType {
87 Always,
88 Sometimes,
89 Reachability,
90}
91
92#[derive(Serialize, Debug)]
93struct AntithesisLocationInfo<'a> {
94 class: &'a str,
95 function: &'a str,
96 file: &'a str,
97 begin_line: u32,
98 begin_column: u32,
99}
100
101/// Internal representation for assertion catalog
102#[doc(hidden)]
103#[derive(Debug)]
104#[cfg(feature = "full")]
105pub struct AssertionCatalogInfo {
106 pub assert_type: AssertType,
107 pub display_type: &'static str,
108 pub condition: bool,
109 pub message: &'static str,
110 pub class: &'static str,
111 pub function: &'static Lazy<&'static str>,
112 pub file: &'static str,
113 pub begin_line: u32,
114 pub begin_column: u32,
115 pub must_hit: bool,
116 pub id: &'static str,
117}
118
119#[derive(Serialize)]
120struct AssertionInfo<'a> {
121 assert_type: AssertType,
122 display_type: &'a str,
123 condition: bool,
124 message: &'a str,
125 location: AntithesisLocationInfo<'a>,
126 hit: bool,
127 must_hit: bool,
128 id: &'a str,
129 // Serialized separately so declarations never evaluate the details serializer.
130 #[serde(skip)]
131 details: &'a dyn Details,
132}
133
134impl<'a> AssertionInfo<'a> {
135 #[allow(clippy::too_many_arguments)]
136 pub fn new(
137 assert_type: AssertType,
138 display_type: &'a str,
139 condition: bool,
140 message: &'a str,
141 class: &'a str,
142 function: &'a str,
143 file: &'a str,
144 begin_line: u32,
145 begin_column: u32,
146 hit: bool,
147 must_hit: bool,
148 id: &'a str,
149 details: &'a dyn Details,
150 ) -> Self {
151 let location = AntithesisLocationInfo {
152 class,
153 function,
154 file,
155 begin_line,
156 begin_column,
157 };
158
159 AssertionInfo {
160 assert_type,
161 display_type,
162 condition,
163 message,
164 location,
165 hit,
166 must_hit,
167 id,
168 details
169 }
170 }
171}
172
173#[cfg(feature = "full")]
174impl AssertionInfo<'_> {
175 // AssertionInfo::track_entry() determines if the assertion should
176 // actually be emitted:
177 //
178 // [X] If this is an assertion catalog
179 // registration (assertion.hit == false) then it is emitted.
180 //
181 // [X] if `condition` is true increment the tracker_entry.pass_count,
182 // otherwise increment the tracker_entry.fail_count.
183 //
184 // [X] if `condition` is true and tracker_entry_pass_count == 1 then
185 // actually emit the assertion.
186 //
187 // [X] if `condition` is false and tracker_entry_fail_count == 1 then
188 // actually emit the assertion.
189
190 fn track_entry(&self, info: Option<&TrackingInfo>) {
191 // Requirement: Catalog entries must always will emit()
192 if !self.hit {
193 self.emit();
194 return;
195 }
196
197 // Record the condition in the associated TrackingInfo entry,
198 // and emit the assertion when first seeing a condition
199 let emitting = match (info, self.condition) {
200 (None, _) => true,
201 (Some(info), true) => {
202 let prior_value = info.pass_count.fetch_add(1, Ordering::SeqCst);
203 prior_value == 0
204 }
205 (Some(info), false) => {
206 let prior_value = info.fail_count.fetch_add(1, Ordering::SeqCst);
207 prior_value == 0
208 }
209 };
210 if emitting {
211 Lazy::force(&INIT_CATALOG);
212 self.emit();
213 }
214 }
215
216 fn emit(&self) {
217 let mut assertion = serde_json::to_value(self).unwrap();
218 if self.hit {
219 if let Some(details) = self.details.object(self.id) {
220 assertion["details"] = Value::Object(details);
221 }
222 }
223 let json_event = json!({ "antithesis_assert": assertion });
224 internal::dispatch_output(&json_event)
225 }
226}
227
228#[cfg(not(feature = "full"))]
229impl AssertionInfo<'_> {
230 fn track_entry(&self, _info: Option<&TrackingInfo>) {
231 return
232 }
233}
234
235
236/// This is a low-level method designed to be used by third-party frameworks.
237/// Regular users of the assert package should not call it.
238///
239/// This is primarily intended for use by adapters from other diagnostic tools
240/// that intend to output Antithesis-style assertions.
241///
242/// Be certain to provide an assertion catalog entry for each assertion issued
243/// with ``assert_raw()``. Assertion catalog entries are also created using
244/// ``assert_raw()``, by setting the value of the ``hit`` parameter to false.
245///
246/// Details may be any borrowed value implementing `Serialize`, handled as
247/// the assertion macros handle theirs: objects are preserved, other non-null
248/// values are wrapped under `"value"`, `&Value::Null` means no details, and
249/// a serialization error emits an `antithesis_error` naming the assertion
250/// `id` while the assertion itself is still emitted without details.
251/// Catalog entries (`hit == false`) never serialize details.
252///
253/// Please refer to the general Antithesis documentation regarding the use of
254/// the
255/// [Fallback SDK](https://antithesis.com/docs/using_antithesis/sdk/fallback/assert/)
256/// for additional information.
257///
258///
259///
260/// # Example
261///
262/// ```
263/// use serde_json::{json};
264/// use antithesis_sdk::{assert, random};
265///
266/// struct Votes {
267/// num_voters: u32,
268/// candidate_1: u32,
269/// candidate_2: u32,
270/// }
271///
272/// fn main() {
273/// establish_catalog();
274///
275/// let mut all_votes = Votes {
276/// num_voters: 0,
277/// candidate_1: 0,
278/// candidate_2: 0,
279/// };
280///
281/// for _voter in 0..100 {
282/// tally_vote(&mut all_votes, random_bool(), random_bool());
283/// }
284/// }
285///
286/// fn random_bool() -> bool {
287/// let v1 = random::get_random() % 2;
288/// v1 == 1
289/// }
290///
291/// fn establish_catalog() {
292/// assert::assert_raw(
293/// false, /* condition */
294/// "Never extra votes".to_owned(), /* message */
295/// &json!({}), /* details */
296/// "mycrate::stuff".to_owned(), /* class */
297/// "mycrate::tally_vote".to_owned(), /* function */
298/// "src/voting.rs".to_owned(), /* file */
299/// 20, /* line */
300/// 3, /* column */
301/// false, /* hit */
302/// true, /* must_hit */
303/// assert::AssertType::Always, /* assert_type */
304/// "Always".to_owned(), /* display_type */
305/// "42-1005".to_owned() /* id */
306/// );
307/// }
308///
309/// fn tally_vote(votes: &mut Votes, candidate_1: bool, candidate_2: bool) {
310/// if candidate_1 || candidate_2 {
311/// votes.num_voters += 1;
312/// }
313/// if candidate_1 {
314/// votes.candidate_1 += 1;
315/// };
316/// if candidate_2 {
317/// votes.candidate_2 += 1;
318/// };
319///
320/// let num_votes = votes.candidate_1 + votes.candidate_2;
321/// assert::assert_raw(
322/// num_votes == votes.num_voters, /* condition */
323/// "Never extra votes".to_owned(), /* message */
324/// &json!({ /* details */
325/// "votes": num_votes,
326/// "voters": votes.num_voters
327/// }),
328/// "mycrate::stuff".to_owned(), /* class */
329/// "mycrate::tally_vote".to_owned(), /* function */
330/// "src/voting.rs".to_owned(), /* file */
331/// 20, /* line */
332/// 3, /* column */
333/// true, /* hit */
334/// true, /* must_hit */
335/// assert::AssertType::Always, /* assert_type */
336/// "Always".to_owned(), /* display_type */
337/// "42-1005".to_owned() /* id */
338/// );
339/// }
340///
341/// // Run example with output to /tmp/x7.json
342/// // ANTITHESIS_SDK_LOCAL_OUTPUT=/tmp/x7.json cargo test --doc
343/// //
344/// // Example output from /tmp/x7.json
345/// // Contents may vary due to use of random::get_random()
346/// //
347/// // {"antithesis_sdk":{"language":{"name":"Rust","version":"1.69.0"},"sdk_version":"0.1.2","protocol_version":"1.0.0"}}
348/// // {"assert_type":"always","display_type":"Always","condition":false,"message":"Never extra votes","location":{"class":"mycrate::stuff","function":"mycrate::tally_vote","file":"src/voting.rs","begin_line":20,"begin_column":3},"hit":false,"must_hit":true,"id":"42-1005"}
349/// // {"assert_type":"always","display_type":"Always","condition":true,"message":"Never extra votes","location":{"class":"mycrate::stuff","function":"mycrate::tally_vote","file":"src/voting.rs","begin_line":20,"begin_column":3},"hit":true,"must_hit":true,"id":"42-1005","details":{"voters":1,"votes":1}}
350/// // {"assert_type":"always","display_type":"Always","condition":false,"message":"Never extra votes","location":{"class":"mycrate::stuff","function":"mycrate::tally_vote","file":"src/voting.rs","begin_line":20,"begin_column":3},"hit":true,"must_hit":true,"id":"42-1005","details":{"voters":3,"votes":4}}
351/// ```
352#[allow(clippy::too_many_arguments)]
353#[cfg(feature = "full")]
354pub fn assert_raw<T: Details>(
355 condition: bool,
356 message: String,
357 details: &T,
358 class: String,
359 function: String,
360 file: String,
361 begin_line: u32,
362 begin_column: u32,
363 hit: bool,
364 must_hit: bool,
365 assert_type: AssertType,
366 display_type: String,
367 id: String,
368) {
369 // Only this thin shim is generic (and so compiled into the caller's
370 // crate); the tracker lookup and everything downstream are erased
371 // behind `dyn Details` and compiled once, here.
372 let info = raw_tracker_entry(&id);
373 assert_impl(
374 assert_type,
375 display_type.as_str(),
376 condition,
377 message.as_str(),
378 class.as_str(),
379 function.as_str(),
380 file.as_str(),
381 begin_line,
382 begin_column,
383 hit,
384 must_hit,
385 id.as_str(),
386 details,
387 Some(&*info),
388 )
389}
390
391// The per-id tracking entries of raw assertions (the macros hold theirs in
392// a static per call site).
393#[cfg(feature = "full")]
394fn raw_tracker_entry(id: &str) -> Arc<TrackingInfo> {
395 static ASSERT_TRACKER: Lazy<RwLock<HashMap<String, Arc<TrackingInfo>>>> =
396 Lazy::new(|| RwLock::new(HashMap::new()));
397
398 // The read guard must drop before the write lock is taken.
399 let existing = ASSERT_TRACKER.read().unwrap().get(id).cloned();
400 match existing {
401 Some(info) => info,
402 None => ASSERT_TRACKER
403 .write()
404 .unwrap()
405 .entry(id.to_owned())
406 .or_default()
407 .clone(),
408 }
409}
410
411#[allow(clippy::too_many_arguments)]
412#[cfg(not(feature = "full"))]
413pub fn assert_raw<T: Details>(
414 condition: bool,
415 message: String,
416 details: &T,
417 class: String,
418 function: String,
419 file: String,
420 begin_line: u32,
421 begin_column: u32,
422 hit: bool,
423 must_hit: bool,
424 assert_type: AssertType,
425 display_type: String,
426 id: String,
427) {
428 assert_impl(
429 assert_type,
430 display_type.as_str(),
431 condition,
432 message.as_str(),
433 class.as_str(),
434 function.as_str(),
435 file.as_str(),
436 begin_line,
437 begin_column,
438 hit,
439 must_hit,
440 id.as_str(),
441 details,
442 None,
443 )
444}
445
446#[doc(hidden)]
447#[allow(clippy::too_many_arguments)]
448pub fn assert_impl<'a>(
449 assert_type: AssertType,
450 display_type: &'a str,
451 condition: bool,
452 message: &'a str,
453 class: &'a str,
454 function: &'a str,
455 file: &'a str,
456 begin_line: u32,
457 begin_column: u32,
458 hit: bool,
459 must_hit: bool,
460 id: &'a str,
461 details: &dyn Details,
462 info: Option<&TrackingInfo>,
463) {
464 let assertion = AssertionInfo::new(
465 assert_type,
466 display_type,
467 condition,
468 message,
469 class,
470 function,
471 file,
472 begin_line,
473 begin_column,
474 hit,
475 must_hit,
476 id,
477 details,
478 );
479
480 let _ = &assertion.track_entry(info);
481}