faucet_cli/commands/
conformance.rs1use crate::cli::ConformanceArgs;
5use crate::conformance::{Report, Tier, build_reports};
6use crate::error::{CliError, CliResult};
7
8pub async fn run(args: ConformanceArgs) -> CliResult<()> {
10 if args.matrix {
13 print!("{}", crate::conformance::capability_matrix_markdown());
14 return Ok(());
15 }
16 let kind_filter = match args.kind.as_deref() {
17 None => None,
18 Some("source") => Some("source"),
19 Some("sink") => Some("sink"),
20 Some(other) => {
21 return Err(CliError::Config(format!(
22 "--kind must be 'source' or 'sink' (got '{other}')"
23 )));
24 }
25 };
26
27 let min_tier = match args.min_tier.as_deref() {
29 None => None,
30 Some(s) => Some(Tier::parse(s).ok_or_else(|| {
31 CliError::Config(format!(
32 "--min-tier must be one of stable/experimental/beta/draft (got '{s}')"
33 ))
34 })?),
35 };
36
37 let mut reports: Vec<Report> = build_reports()
38 .into_iter()
39 .filter(|r| kind_filter.is_none_or(|k| r.kind == k))
40 .filter(|r| args.name.as_deref().is_none_or(|n| r.name == n))
41 .collect();
42 reports.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.name.cmp(&b.name)));
43
44 if reports.is_empty() {
45 if let Some(name) = &args.name {
46 return Err(CliError::Config(format!(
47 "no connector named '{name}' is compiled into this binary (try `faucet list`)"
48 )));
49 }
50 return Err(CliError::Config(
51 "no connectors matched the filter".to_string(),
52 ));
53 }
54
55 if args.json {
56 let json = serde_json::to_string_pretty(&reports)
57 .map_err(|e| CliError::Config(format!("serialize conformance report: {e}")))?;
58 println!("{json}");
59 } else if args.name.is_some() {
60 for r in &reports {
62 println!(
63 "{} {} ({}) — {} · {}/100",
64 r.tier.badge(),
65 r.name,
66 r.kind,
67 r.tier.label(),
68 r.score
69 );
70 for d in &r.dimensions {
71 println!(
72 " {} {:<24} (+{:>2}) {}",
73 if d.met { "✓" } else { "·" },
74 d.name,
75 d.points,
76 d.note
77 );
78 }
79 if !r.badges.is_empty() {
80 println!(" capabilities: {}", r.badges.join(", "));
81 }
82 println!(" badge: {}", r.tier.badge_url());
83 }
84 } else {
85 println!(
87 "faucet connector conformance ({} connectors)\n",
88 reports.len()
89 );
90 for r in &reports {
91 let caps = if r.badges.is_empty() {
92 String::new()
93 } else {
94 format!(" · {}", r.badges.join(", "))
95 };
96 println!(
97 "{} {:<15} {:<7} {:>3}/100 {}{}",
98 r.tier.badge(),
99 r.name,
100 r.kind,
101 r.score,
102 r.tier.label(),
103 caps
104 );
105 }
106 let stable = reports
107 .iter()
108 .filter(|r| matches!(r.tier, Tier::Stable))
109 .count();
110 println!("\n{}/{} connectors at Stable.", stable, reports.len());
111 }
112
113 if let Some(min) = min_tier {
115 let below: Vec<&Report> = reports
116 .iter()
117 .filter(|r| r.tier.rank() < min.rank())
118 .collect();
119 if !below.is_empty() {
120 let names: Vec<String> = below
121 .iter()
122 .map(|r| format!("{} {} ({})", r.name, r.kind, r.tier.label()))
123 .collect();
124 return Err(CliError::Config(format!(
125 "{} connector(s) below the required `{}` tier: {}",
126 below.len(),
127 min.label(),
128 names.join(", ")
129 )));
130 }
131 }
132
133 Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn args(name: Option<&str>, kind: Option<&str>, json: bool) -> ConformanceArgs {
141 ConformanceArgs {
142 name: name.map(str::to_string),
143 kind: kind.map(str::to_string),
144 all: false,
145 json,
146 min_tier: None,
147 matrix: false,
148 }
149 }
150
151 #[tokio::test]
152 async fn matrix_flag_prints_and_exits_ok() {
153 let mut a = args(None, None, false);
154 a.matrix = true;
155 assert!(run(a).await.is_ok());
156 }
157
158 #[test]
159 fn generated_matrix_has_expected_cells() {
160 let m = crate::conformance::capability_matrix_markdown();
161 assert!(m.contains("# Connector capability matrix"));
162 assert!(
164 m.contains("| `postgres` | ✓ | ✓ | ✓ |"),
165 "postgres row: {m}"
166 );
167 assert!(
168 m.contains("| `iceberg` | ✓ | | ✓ |"),
169 "iceberg is append-only: {m}"
170 );
171 assert!(!m.contains("| `jsonl` |"), "jsonl has no capability: {m}");
173 }
174
175 #[tokio::test]
176 async fn runs_over_all_connectors() {
177 assert!(run(args(None, None, false)).await.is_ok());
178 }
179
180 #[tokio::test]
181 async fn runs_json_with_source_filter() {
182 assert!(run(args(None, Some("source"), true)).await.is_ok());
183 }
184
185 #[tokio::test]
186 async fn single_connector_detailed_view() {
187 assert!(run(args(Some("postgres"), None, false)).await.is_ok());
189 }
190
191 #[tokio::test]
192 async fn unknown_connector_is_an_error() {
193 assert!(
194 run(args(Some("definitely-not-a-connector"), None, false))
195 .await
196 .is_err()
197 );
198 }
199
200 #[tokio::test]
201 async fn bad_kind_is_an_error() {
202 assert!(run(args(None, Some("neither"), false)).await.is_err());
203 }
204
205 #[tokio::test]
206 async fn min_tier_stable_passes_for_builtins() {
207 let mut a = args(None, None, false);
209 a.min_tier = Some("stable".into());
210 assert!(run(a).await.is_ok());
211 }
212
213 #[tokio::test]
214 async fn bad_min_tier_is_an_error() {
215 let mut a = args(None, None, true);
216 a.min_tier = Some("platinum".into());
217 assert!(run(a).await.is_err());
218 }
219}