1use crate::registry;
22use crate::registry_index::RegistryIndex;
23use serde::Serialize;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Tier {
29 Stable,
30 Experimental,
31 Beta,
32 Draft,
33}
34
35impl Tier {
36 pub fn label(self) -> &'static str {
38 match self {
39 Tier::Stable => "Stable",
40 Tier::Experimental => "Experimental",
41 Tier::Beta => "Beta",
42 Tier::Draft => "Draft",
43 }
44 }
45
46 pub fn badge(self) -> &'static str {
48 match self {
49 Tier::Stable => "🟢",
50 Tier::Experimental => "🟡",
51 Tier::Beta => "🟠",
52 Tier::Draft => "⚪",
53 }
54 }
55
56 pub fn from_score(score: u32) -> Tier {
58 match score {
59 70..=u32::MAX => Tier::Stable,
60 45..=69 => Tier::Experimental,
61 20..=44 => Tier::Beta,
62 _ => Tier::Draft,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct ConnectorFacts {
70 pub name: String,
71 pub is_source: bool,
72 pub verified: bool,
74 pub has_config_schema: bool,
76 pub documented: bool,
78 pub exactly_once: bool,
80 pub upsert: bool,
82 pub schema_evolution: bool,
84 pub discover: bool,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct Dimension {
91 pub name: &'static str,
92 pub met: bool,
93 pub points: u32,
94 pub note: &'static str,
95}
96
97#[derive(Debug, Clone, Serialize)]
99pub struct Report {
100 pub name: String,
101 pub kind: &'static str,
102 pub score: u32,
103 pub tier: Tier,
104 pub dimensions: Vec<Dimension>,
105 pub badges: Vec<&'static str>,
106}
107
108pub fn score(f: &ConnectorFacts) -> Report {
110 let mut dims: Vec<Dimension> = Vec::new();
111
112 dims.push(Dimension {
114 name: "Registered & verified",
115 met: f.verified,
116 points: 40,
117 note: "verified entry in cli/connectors/registry.json",
118 });
119 dims.push(Dimension {
120 name: "Config schema",
121 met: f.has_config_schema,
122 points: 30,
123 note: "config_schema() powers faucet init / validate / schema",
124 });
125
126 dims.push(Dimension {
128 name: "Documented",
129 met: f.documented,
130 points: 10,
131 note: "one-line description in the connector catalog",
132 });
133 dims.push(Dimension {
134 name: "Exactly-once delivery",
135 met: f.exactly_once,
136 points: 10,
137 note: if f.is_source {
138 "deterministic replay from a bookmark"
139 } else {
140 "atomic-watermark idempotent writes"
141 },
142 });
143 if f.is_source {
144 dims.push(Dimension {
145 name: "Dataset discovery",
146 met: f.discover,
147 points: 10,
148 note: "faucet discover introspects the catalog",
149 });
150 } else {
151 dims.push(Dimension {
152 name: "Upsert / mirror",
153 met: f.upsert,
154 points: 6,
155 note: "write_mode: upsert|delete",
156 });
157 dims.push(Dimension {
158 name: "Schema evolution",
159 met: f.schema_evolution,
160 points: 4,
161 note: "evolves the destination schema on drift",
162 });
163 }
164
165 let score: u32 = dims.iter().filter(|d| d.met).map(|d| d.points).sum();
166 let tier = Tier::from_score(score);
167
168 let mut badges: Vec<&'static str> = Vec::new();
169 if f.exactly_once {
170 badges.push("exactly-once");
171 }
172 if f.is_source && f.discover {
173 badges.push("discover");
174 }
175 if !f.is_source && f.upsert {
176 badges.push("upsert");
177 }
178 if !f.is_source && f.schema_evolution {
179 badges.push("schema-evolution");
180 }
181
182 Report {
183 name: f.name.clone(),
184 kind: if f.is_source { "source" } else { "sink" },
185 score,
186 tier,
187 dimensions: dims,
188 badges,
189 }
190}
191
192pub fn facts_for(kind: &str, is_source: bool, index: &RegistryIndex) -> ConnectorFacts {
194 let role = if is_source { "source" } else { "sink" };
195 let verified = index
196 .connectors
197 .iter()
198 .any(|e| e.name == kind && e.kind == role && e.verified);
199
200 let schema = if is_source {
201 registry::source_schema(kind)
202 } else {
203 registry::sink_schema(kind)
204 };
205 let has_config_schema = schema
206 .ok()
207 .and_then(|s| {
208 s.get("properties")
209 .and_then(|p| p.as_object())
210 .map(|o| !o.is_empty())
211 })
212 .unwrap_or(false);
213
214 let descs = if is_source {
215 registry::source_descriptions()
216 } else {
217 registry::sink_descriptions()
218 };
219 let documented = descs.iter().any(|(k, d)| *k == kind && !d.is_empty());
220
221 let exactly_once = if is_source {
222 registry::source_supports_exactly_once(kind)
223 } else {
224 registry::sink_supports_idempotent_writes(kind)
225 };
226 let upsert = !is_source
227 && registry::sink_supported_write_modes(kind)
228 .iter()
229 .any(|m| matches!(m, faucet_core::WriteMode::Upsert));
230 let schema_evolution = !is_source && registry::sink_supports_schema_evolution(kind);
231 let discover = is_source && registry::source_supports_discover(kind);
232
233 ConnectorFacts {
234 name: kind.to_string(),
235 is_source,
236 verified,
237 has_config_schema,
238 documented,
239 exactly_once,
240 upsert,
241 schema_evolution,
242 discover,
243 }
244}
245
246pub fn build_reports() -> Vec<Report> {
248 let index = RegistryIndex::embedded();
249 let mut out = Vec::new();
250 for kind in registry::source_kinds() {
251 out.push(score(&facts_for(kind, true, &index)));
252 }
253 for kind in registry::sink_kinds() {
254 out.push(score(&facts_for(kind, false, &index)));
255 }
256 out
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn conforming(is_source: bool) -> ConnectorFacts {
264 ConnectorFacts {
265 name: "acme".into(),
266 is_source,
267 verified: true,
268 has_config_schema: true,
269 documented: true,
270 exactly_once: false,
271 upsert: false,
272 schema_evolution: false,
273 discover: false,
274 }
275 }
276
277 #[test]
278 fn tier_boundaries() {
279 assert_eq!(Tier::from_score(100), Tier::Stable);
280 assert_eq!(Tier::from_score(70), Tier::Stable);
281 assert_eq!(Tier::from_score(69), Tier::Experimental);
282 assert_eq!(Tier::from_score(45), Tier::Experimental);
283 assert_eq!(Tier::from_score(44), Tier::Beta);
284 assert_eq!(Tier::from_score(20), Tier::Beta);
285 assert_eq!(Tier::from_score(19), Tier::Draft);
286 assert_eq!(Tier::from_score(0), Tier::Draft);
287 }
288
289 #[test]
290 fn tier_label_and_badge() {
291 for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
292 assert!(!t.label().is_empty());
293 assert!(!t.badge().is_empty());
294 }
295 }
296
297 #[test]
298 fn conforming_source_is_stable_with_docs() {
299 let r = score(&conforming(true)); assert_eq!(r.score, 80);
301 assert_eq!(r.tier, Tier::Stable);
302 assert_eq!(r.kind, "source");
303 assert!(r.badges.is_empty());
304 }
305
306 #[test]
307 fn source_capabilities_add_points_and_badges() {
308 let mut f = conforming(true);
309 f.exactly_once = true;
310 f.discover = true;
311 let r = score(&f); assert_eq!(r.score, 100);
313 assert_eq!(r.tier, Tier::Stable);
314 assert!(r.badges.contains(&"exactly-once"));
315 assert!(r.badges.contains(&"discover"));
316 }
317
318 #[test]
319 fn sink_upsert_and_evolution() {
320 let mut f = conforming(false);
321 f.upsert = true;
322 f.schema_evolution = true;
323 let r = score(&f); assert_eq!(r.score, 90);
325 assert_eq!(r.kind, "sink");
326 assert!(r.badges.contains(&"upsert"));
327 assert!(r.badges.contains(&"schema-evolution"));
328 assert!(!r.badges.contains(&"discover"));
330 }
331
332 #[test]
333 fn unregistered_no_schema_is_draft() {
334 let mut f = conforming(true);
335 f.verified = false;
336 f.has_config_schema = false;
337 f.documented = false;
338 let r = score(&f);
339 assert_eq!(r.score, 0);
340 assert_eq!(r.tier, Tier::Draft);
341 }
342
343 #[test]
344 fn schema_only_is_beta() {
345 let mut f = conforming(false);
346 f.verified = false;
347 f.documented = false; let r = score(&f);
349 assert_eq!(r.score, 30);
350 assert_eq!(r.tier, Tier::Beta);
351 }
352
353 #[test]
354 fn verified_only_is_experimental() {
355 let mut f = conforming(true);
356 f.has_config_schema = false;
357 f.documented = false; let r = score(&f);
359 assert_eq!(r.score, 40);
360 assert_eq!(r.tier, Tier::Beta); }
362
363 #[test]
364 fn every_builtin_meets_the_bar() {
365 let reports = build_reports();
366 assert!(!reports.is_empty());
367 for r in &reports {
368 assert!(
371 r.score >= 45,
372 "{} `{}` scored {} ({:?})",
373 r.kind,
374 r.name,
375 r.score,
376 r.tier
377 );
378 assert!(matches!(r.tier, Tier::Stable | Tier::Experimental));
379 }
380 }
381
382 #[test]
383 fn known_capabilities_surface_in_reports() {
384 let reports = build_reports();
385 if let Some(pg) = reports
387 .iter()
388 .find(|r| r.name == "postgres" && r.kind == "source")
389 {
390 assert!(
391 pg.badges.contains(&"discover"),
392 "postgres source should discover"
393 );
394 }
395 if let Some(bq) = reports
397 .iter()
398 .find(|r| r.name == "bigquery" && r.kind == "sink")
399 {
400 assert!(bq.badges.contains(&"exactly-once"));
401 assert!(bq.badges.contains(&"upsert"));
402 }
403 }
404}