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 as_str(self) -> &'static str {
49 match self {
50 Tier::Stable => "stable",
51 Tier::Experimental => "experimental",
52 Tier::Beta => "beta",
53 Tier::Draft => "draft",
54 }
55 }
56
57 pub fn badge(self) -> &'static str {
59 match self {
60 Tier::Stable => "🟢",
61 Tier::Experimental => "🟡",
62 Tier::Beta => "🟠",
63 Tier::Draft => "⚪",
64 }
65 }
66
67 pub fn rank(self) -> u8 {
69 match self {
70 Tier::Stable => 3,
71 Tier::Experimental => 2,
72 Tier::Beta => 1,
73 Tier::Draft => 0,
74 }
75 }
76
77 pub fn parse(s: &str) -> Option<Tier> {
79 match s.trim().to_ascii_lowercase().as_str() {
80 "stable" => Some(Tier::Stable),
81 "experimental" => Some(Tier::Experimental),
82 "beta" => Some(Tier::Beta),
83 "draft" => Some(Tier::Draft),
84 _ => None,
85 }
86 }
87
88 pub fn badge_url(self) -> String {
91 let color = match self {
92 Tier::Stable => "brightgreen",
93 Tier::Experimental => "yellow",
94 Tier::Beta => "orange",
95 Tier::Draft => "lightgrey",
96 };
97 format!(
98 "https://img.shields.io/badge/faucet-{}-{}",
99 self.as_str(),
100 color
101 )
102 }
103
104 pub fn from_score(score: u32) -> Tier {
106 match score {
107 70..=u32::MAX => Tier::Stable,
108 45..=69 => Tier::Experimental,
109 20..=44 => Tier::Beta,
110 _ => Tier::Draft,
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct ConnectorFacts {
118 pub name: String,
119 pub is_source: bool,
120 pub verified: bool,
122 pub has_config_schema: bool,
124 pub documented: bool,
126 pub exactly_once: bool,
128 pub upsert: bool,
130 pub schema_evolution: bool,
132 pub discover: bool,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct Dimension {
139 pub name: &'static str,
140 pub met: bool,
141 pub points: u32,
142 pub note: &'static str,
143}
144
145#[derive(Debug, Clone, Serialize)]
147pub struct Report {
148 pub name: String,
149 pub kind: &'static str,
150 pub score: u32,
151 pub tier: Tier,
152 pub dimensions: Vec<Dimension>,
153 pub badges: Vec<&'static str>,
154}
155
156pub fn score(f: &ConnectorFacts) -> Report {
158 let mut dims: Vec<Dimension> = Vec::new();
159
160 dims.push(Dimension {
162 name: "Registered & verified",
163 met: f.verified,
164 points: 40,
165 note: "verified entry in cli/connectors/registry.json",
166 });
167 dims.push(Dimension {
168 name: "Config schema",
169 met: f.has_config_schema,
170 points: 30,
171 note: "config_schema() powers faucet init / validate / schema",
172 });
173
174 dims.push(Dimension {
176 name: "Documented",
177 met: f.documented,
178 points: 10,
179 note: "one-line description in the connector catalog",
180 });
181 dims.push(Dimension {
182 name: "Exactly-once delivery",
183 met: f.exactly_once,
184 points: 10,
185 note: if f.is_source {
186 "deterministic replay from a bookmark"
187 } else {
188 "atomic-watermark idempotent writes"
189 },
190 });
191 if f.is_source {
192 dims.push(Dimension {
193 name: "Dataset discovery",
194 met: f.discover,
195 points: 10,
196 note: "faucet discover introspects the catalog",
197 });
198 } else {
199 dims.push(Dimension {
200 name: "Upsert / mirror",
201 met: f.upsert,
202 points: 6,
203 note: "write_mode: upsert|delete",
204 });
205 dims.push(Dimension {
206 name: "Schema evolution",
207 met: f.schema_evolution,
208 points: 4,
209 note: "evolves the destination schema on drift",
210 });
211 }
212
213 let score: u32 = dims.iter().filter(|d| d.met).map(|d| d.points).sum();
214 let tier = Tier::from_score(score);
215
216 let mut badges: Vec<&'static str> = Vec::new();
217 if f.exactly_once {
218 badges.push("exactly-once");
219 }
220 if f.is_source && f.discover {
221 badges.push("discover");
222 }
223 if !f.is_source && f.upsert {
224 badges.push("upsert");
225 }
226 if !f.is_source && f.schema_evolution {
227 badges.push("schema-evolution");
228 }
229
230 Report {
231 name: f.name.clone(),
232 kind: if f.is_source { "source" } else { "sink" },
233 score,
234 tier,
235 dimensions: dims,
236 badges,
237 }
238}
239
240pub fn facts_for(kind: &str, is_source: bool, index: &RegistryIndex) -> ConnectorFacts {
242 let role = if is_source { "source" } else { "sink" };
243 let verified = index
244 .connectors
245 .iter()
246 .any(|e| e.name == kind && e.kind == role && e.verified);
247
248 let schema = if is_source {
249 registry::source_schema(kind)
250 } else {
251 registry::sink_schema(kind)
252 };
253 let has_config_schema = schema
254 .ok()
255 .and_then(|s| {
256 s.get("properties")
257 .and_then(|p| p.as_object())
258 .map(|o| !o.is_empty())
259 })
260 .unwrap_or(false);
261
262 let descs = if is_source {
263 registry::source_descriptions()
264 } else {
265 registry::sink_descriptions()
266 };
267 let documented = descs.iter().any(|(k, d)| *k == kind && !d.is_empty());
268
269 let exactly_once = if is_source {
270 registry::source_supports_exactly_once(kind)
271 } else {
272 registry::sink_supports_idempotent_writes(kind)
273 };
274 let upsert = !is_source
275 && registry::sink_supported_write_modes(kind)
276 .iter()
277 .any(|m| matches!(m, faucet_core::WriteMode::Upsert));
278 let schema_evolution = !is_source && registry::sink_supports_schema_evolution(kind);
279 let discover = is_source && registry::source_supports_discover(kind);
280
281 ConnectorFacts {
282 name: kind.to_string(),
283 is_source,
284 verified,
285 has_config_schema,
286 documented,
287 exactly_once,
288 upsert,
289 schema_evolution,
290 discover,
291 }
292}
293
294pub fn build_reports() -> Vec<Report> {
296 let index = RegistryIndex::embedded();
297 let mut out = Vec::new();
298 for kind in registry::source_kinds() {
299 out.push(score(&facts_for(kind, true, &index)));
300 }
301 for kind in registry::sink_kinds() {
302 out.push(score(&facts_for(kind, false, &index)));
303 }
304 out
305}
306
307pub fn tier_for(kind: &str, is_source: bool) -> Tier {
310 let index = RegistryIndex::embedded();
311 score(&facts_for(kind, is_source, &index)).tier
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn conforming(is_source: bool) -> ConnectorFacts {
319 ConnectorFacts {
320 name: "acme".into(),
321 is_source,
322 verified: true,
323 has_config_schema: true,
324 documented: true,
325 exactly_once: false,
326 upsert: false,
327 schema_evolution: false,
328 discover: false,
329 }
330 }
331
332 #[test]
333 fn tier_boundaries() {
334 assert_eq!(Tier::from_score(100), Tier::Stable);
335 assert_eq!(Tier::from_score(70), Tier::Stable);
336 assert_eq!(Tier::from_score(69), Tier::Experimental);
337 assert_eq!(Tier::from_score(45), Tier::Experimental);
338 assert_eq!(Tier::from_score(44), Tier::Beta);
339 assert_eq!(Tier::from_score(20), Tier::Beta);
340 assert_eq!(Tier::from_score(19), Tier::Draft);
341 assert_eq!(Tier::from_score(0), Tier::Draft);
342 }
343
344 #[test]
345 fn tier_label_and_badge() {
346 for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
347 assert!(!t.label().is_empty());
348 assert!(!t.badge().is_empty());
349 assert!(!t.as_str().is_empty());
350 assert!(t.badge_url().contains(t.as_str()));
351 }
352 }
353
354 #[test]
355 fn tier_parse_roundtrips_and_orders() {
356 for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
357 assert_eq!(Tier::parse(t.as_str()), Some(t));
358 }
359 assert_eq!(Tier::parse("STABLE"), Some(Tier::Stable));
360 assert_eq!(Tier::parse(" beta "), Some(Tier::Beta));
361 assert_eq!(Tier::parse("nonsense"), None);
362 assert!(Tier::Stable.rank() > Tier::Experimental.rank());
363 assert!(Tier::Experimental.rank() > Tier::Beta.rank());
364 assert!(Tier::Beta.rank() > Tier::Draft.rank());
365 }
366
367 #[test]
368 fn tier_for_matches_reports() {
369 for r in build_reports() {
370 assert_eq!(tier_for(&r.name, r.kind == "source"), r.tier);
371 }
372 }
373
374 #[test]
375 fn conforming_source_is_stable_with_docs() {
376 let r = score(&conforming(true)); assert_eq!(r.score, 80);
378 assert_eq!(r.tier, Tier::Stable);
379 assert_eq!(r.kind, "source");
380 assert!(r.badges.is_empty());
381 }
382
383 #[test]
384 fn source_capabilities_add_points_and_badges() {
385 let mut f = conforming(true);
386 f.exactly_once = true;
387 f.discover = true;
388 let r = score(&f); assert_eq!(r.score, 100);
390 assert_eq!(r.tier, Tier::Stable);
391 assert!(r.badges.contains(&"exactly-once"));
392 assert!(r.badges.contains(&"discover"));
393 }
394
395 #[test]
396 fn sink_upsert_and_evolution() {
397 let mut f = conforming(false);
398 f.upsert = true;
399 f.schema_evolution = true;
400 let r = score(&f); assert_eq!(r.score, 90);
402 assert_eq!(r.kind, "sink");
403 assert!(r.badges.contains(&"upsert"));
404 assert!(r.badges.contains(&"schema-evolution"));
405 assert!(!r.badges.contains(&"discover"));
407 }
408
409 #[test]
410 fn unregistered_no_schema_is_draft() {
411 let mut f = conforming(true);
412 f.verified = false;
413 f.has_config_schema = false;
414 f.documented = false;
415 let r = score(&f);
416 assert_eq!(r.score, 0);
417 assert_eq!(r.tier, Tier::Draft);
418 }
419
420 #[test]
421 fn schema_only_is_beta() {
422 let mut f = conforming(false);
423 f.verified = false;
424 f.documented = false; let r = score(&f);
426 assert_eq!(r.score, 30);
427 assert_eq!(r.tier, Tier::Beta);
428 }
429
430 #[test]
431 fn verified_only_is_experimental() {
432 let mut f = conforming(true);
433 f.has_config_schema = false;
434 f.documented = false; let r = score(&f);
436 assert_eq!(r.score, 40);
437 assert_eq!(r.tier, Tier::Beta); }
439
440 #[test]
441 fn every_builtin_meets_the_bar() {
442 let reports = build_reports();
443 assert!(!reports.is_empty());
444 for r in &reports {
445 assert!(
448 r.score >= 45,
449 "{} `{}` scored {} ({:?})",
450 r.kind,
451 r.name,
452 r.score,
453 r.tier
454 );
455 assert!(matches!(r.tier, Tier::Stable | Tier::Experimental));
456 }
457 }
458
459 #[test]
460 fn known_capabilities_surface_in_reports() {
461 let reports = build_reports();
462 if let Some(pg) = reports
464 .iter()
465 .find(|r| r.name == "postgres" && r.kind == "source")
466 {
467 assert!(
468 pg.badges.contains(&"discover"),
469 "postgres source should discover"
470 );
471 }
472 if let Some(bq) = reports
474 .iter()
475 .find(|r| r.name == "bigquery" && r.kind == "sink")
476 {
477 assert!(bq.badges.contains(&"exactly-once"));
478 assert!(bq.badges.contains(&"upsert"));
479 }
480 }
481}