tapes_client/core/
coverage.rs1use std::collections::BTreeSet;
34
35use crate::core::contract::core;
36use crate::error::Result;
37
38pub type Table<'a> = &'a [(&'a str, &'a str)];
41
42#[derive(Debug, PartialEq, Eq)]
47pub struct CoverageReport {
48 pub unmapped: Vec<String>,
50 pub stale: Vec<String>,
52 pub contradictory: Vec<String>,
54}
55
56impl CoverageReport {
57 #[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
95pub 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
127pub 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
140pub 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 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 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 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 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}