tapes_client/core/
coverage.rs1use std::collections::BTreeSet;
35
36use crate::core::contract::core;
37use crate::error::Result;
38
39pub type Table<'a> = &'a [(&'a str, &'a str)];
42
43#[derive(Debug, PartialEq, Eq)]
48pub struct CoverageReport {
49 pub unmapped: Vec<String>,
51 pub stale: Vec<String>,
53 pub contradictory: Vec<String>,
55}
56
57impl CoverageReport {
58 #[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
96pub 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
128pub 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
141pub 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 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 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 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 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}