Skip to main content

tapes_client/core/
coverage.rs

1//! The operation-coverage gate.
2//!
3//! Vendoring a contract invites a quieter failure than drift: an operation the
4//! server grows that a client silently never exposes. The gate partitions every
5//! `operationId` in the vendored document into exposed and deliberately
6//! unexposed, and fails — naming the unmapped ids — the moment a contract bump
7//! adds one that is in neither list.
8//!
9//! # The tables stay with the consumer
10//!
11//! Only the *mechanism* lives here. `EXPOSED` / `UNEXPOSED` are a statement
12//! about one client's surface: an operator CLI deliberately exposes the admin
13//! operations a personal CLI deliberately does not, and each needs its own
14//! answer on record. Sharing the tables would make the gate report on the
15//! union of two surfaces and silently stop protecting whichever client
16//! differs — which is precisely the failure the gate exists to prevent,
17//! reintroduced one layer up.
18//!
19//! # Usage
20//!
21//! Each consumer keeps its own tables and calls [`check`] from a test:
22//!
23//! ```no_run
24//! # use tapes_client::core::coverage;
25//! const EXPOSED: &[(&str, &str)] = &[("listSessions", "sessions list")];
26//! const UNEXPOSED: &[(&str, &str)] = &[("ping", "liveness probe; no CLI health verb asked for")];
27//!
28//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
29//! coverage::check(EXPOSED, UNEXPOSED)?;
30//! # Ok(())
31//! # }
32//! ```
33
34use std::collections::BTreeSet;
35
36use crate::core::contract::core;
37use crate::error::Result;
38
39/// A coverage table: `operationId` paired with prose for the reviewer and the
40/// failure message.
41pub type Table<'a> = &'a [(&'a str, &'a str)];
42
43/// What a coverage check found wrong.
44///
45/// Rendered as a single message naming every offending id, because a gate that
46/// reports one failure at a time turns a contract bump into a sequence of runs.
47#[derive(Debug, PartialEq, Eq)]
48pub struct CoverageReport {
49    /// Ids in the contract that appear in neither table.
50    pub unmapped: Vec<String>,
51    /// Ids in a table that the contract does not have.
52    pub stale: Vec<String>,
53    /// Ids that appear in both tables.
54    pub contradictory: Vec<String>,
55}
56
57impl CoverageReport {
58    /// Whether the tables and the contract agree.
59    #[must_use]
60    pub fn is_clean(&self) -> bool {
61        self.unmapped.is_empty() && self.stale.is_empty() && self.contradictory.is_empty()
62    }
63}
64
65impl std::fmt::Display for CoverageReport {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        if !self.unmapped.is_empty() {
68            write!(
69                f,
70                "operations in the vendored tapes-api contract that this client neither exposes \
71                 nor allow-lists: {:?} — add each to the exposed table (and wire it up) or to the \
72                 unexposed table with the reason it stays unexposed. ",
73                self.unmapped,
74            )?;
75        }
76        if !self.stale.is_empty() {
77            write!(
78                f,
79                "operations named by a coverage table that the vendored tapes-api contract does \
80                 not have: {:?} — the contract dropped or renamed them, and the mapping must move \
81                 in the same change. ",
82                self.stale,
83            )?;
84        }
85        if !self.contradictory.is_empty() {
86            write!(
87                f,
88                "operations in both coverage tables: {:?}. ",
89                self.contradictory,
90            )?;
91        }
92        Ok(())
93    }
94}
95
96/// Compare a consumer's coverage tables against the vendored contract.
97///
98/// Returns the report whether or not it is clean; [`check`] is the assertion
99/// form.
100pub fn report(exposed: Table<'_>, unexposed: Table<'_>) -> Result<CoverageReport> {
101    let surface = core()?;
102    let known: BTreeSet<&str> = surface.operation_ids().collect();
103    let exposed_ids: BTreeSet<&str> = exposed.iter().map(|(id, _)| *id).collect();
104    let unexposed_ids: BTreeSet<&str> = unexposed.iter().map(|(id, _)| *id).collect();
105
106    let owned =
107        |ids: BTreeSet<&str>| -> Vec<String> { ids.into_iter().map(ToOwned::to_owned).collect() };
108
109    Ok(CoverageReport {
110        unmapped: owned(
111            known
112                .iter()
113                .filter(|id| !exposed_ids.contains(*id) && !unexposed_ids.contains(*id))
114                .copied()
115                .collect(),
116        ),
117        stale: owned(
118            exposed_ids
119                .union(&unexposed_ids)
120                .filter(|id| !known.contains(*id))
121                .copied()
122                .collect(),
123        ),
124        contradictory: owned(exposed_ids.intersection(&unexposed_ids).copied().collect()),
125    })
126}
127
128/// The assertion form of [`report`]: `Ok(())` when the tables and the contract
129/// agree, and the rendered report as an error otherwise.
130///
131/// Returns a `String` error rather than this crate's [`crate::Error`] because
132/// its only caller is a test assertion, and the message is the whole value.
133pub fn check(exposed: Table<'_>, unexposed: Table<'_>) -> std::result::Result<(), String> {
134    let report = report(exposed, unexposed).map_err(|e| e.to_string())?;
135    if report.is_clean() {
136        return Ok(());
137    }
138    Err(report.to_string())
139}
140
141/// Every `operationId` in the vendored contract, sorted.
142///
143/// The input a consumer's own gate reads; exposed as a convenience so a
144/// consumer that wants a different check than [`check`] does not have to reach
145/// through [`crate::core::contract::core`].
146pub fn operation_ids() -> Result<Vec<String>> {
147    let mut ids: Vec<String> = core()?.operation_ids().map(ToOwned::to_owned).collect();
148    ids.sort();
149    Ok(ids)
150}
151
152#[cfg(test)]
153#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
154mod tests {
155    use super::*;
156    use crate::core::contract::ops;
157
158    #[test]
159    fn the_contract_has_operations_to_gate() {
160        let ids = operation_ids().unwrap();
161        assert!(ids.contains(&ops::LIST_SESSIONS.to_owned()), "got: {ids:?}");
162    }
163
164    #[test]
165    fn an_operation_in_neither_table_is_reported_as_unmapped() {
166        // The gate's whole purpose: a contract bump that adds an operation
167        // must fail a consumer's build until somebody decides about it.
168        let report = report(&[(ops::LIST_SESSIONS, "sessions list")], &[]).unwrap();
169        assert!(!report.is_clean());
170        assert!(
171            report.unmapped.contains(&ops::GET_SESSION.to_owned()),
172            "got: {report:?}",
173        );
174        assert!(
175            report.to_string().contains("neither exposes"),
176            "got: {report}",
177        );
178    }
179
180    #[test]
181    fn a_table_entry_the_contract_does_not_have_is_reported_as_stale() {
182        // A dropped or renamed operation; the mapping must move in the same
183        // change rather than sit pointing at nothing.
184        let report = report(&[("launchMissiles", "nowhere")], &[]).unwrap();
185        assert_eq!(report.stale, vec!["launchMissiles".to_owned()]);
186    }
187
188    #[test]
189    fn an_operation_in_both_tables_is_reported_as_contradictory() {
190        let report = report(
191            &[(ops::LIST_SESSIONS, "sessions list")],
192            &[(ops::LIST_SESSIONS, "also here, somehow")],
193        )
194        .unwrap();
195        assert_eq!(report.contradictory, vec![ops::LIST_SESSIONS.to_owned()]);
196    }
197
198    #[test]
199    fn a_complete_partition_is_clean() {
200        // Build the tables from the contract itself: this asserts the
201        // mechanism, not any particular client's surface.
202        let ids = operation_ids().unwrap();
203        let exposed: Vec<(&str, &str)> = ids.iter().map(|id| (id.as_str(), "exposed")).collect();
204        assert_eq!(check(&exposed, &[]), Ok(()));
205    }
206
207    #[test]
208    fn the_failure_names_every_offending_id_at_once() {
209        // A gate that reported one at a time would turn a contract bump into
210        // a sequence of runs.
211        let err = check(&[], &[]).unwrap_err();
212        assert!(err.contains(ops::LIST_SESSIONS), "got: {err}");
213        assert!(err.contains(ops::GET_SESSION), "got: {err}");
214    }
215}