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