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
314pub fn capability_matrix_markdown() -> String {
326 use crate::registry;
327
328 fn sorted(kinds: &[&str]) -> Vec<String> {
329 let mut v: Vec<String> = kinds.iter().map(|s| s.to_string()).collect();
330 v.sort_unstable();
331 v.dedup();
332 v
333 }
334 fn mark(present: bool) -> &'static str {
335 if present { "✓" } else { "" }
336 }
337
338 let mut out = String::new();
339 out.push_str("# Connector capability matrix\n\n");
340 out.push_str(
341 "<!-- GENERATED by `faucet conformance --matrix` from the registry \
342 allowlists. Do NOT hand-edit — run the command and commit its output. \
343 `cli/tests/capability_matrix.rs` fails if this file drifts. -->\n\n",
344 );
345
346 let mut sink_rows = sorted(registry::IDEMPOTENT_SINK_KINDS);
348 for k in sorted(registry::UPSERT_SINK_KINDS)
349 .into_iter()
350 .chain(sorted(registry::SCHEMA_EVOLUTION_SINK_KINDS))
351 {
352 if !sink_rows.contains(&k) {
353 sink_rows.push(k);
354 }
355 }
356 sink_rows.sort_unstable();
357 out.push_str("## Sinks\n\n");
358 out.push_str(
359 "| Sink | Exactly-once (atomic watermark) | Upsert / delete | Schema evolution |\n",
360 );
361 out.push_str("|---|:---:|:---:|:---:|\n");
362 for k in &sink_rows {
363 out.push_str(&format!(
364 "| `{k}` | {} | {} | {} |\n",
365 mark(registry::sink_supports_idempotent_writes(k)),
366 mark(registry::UPSERT_SINK_KINDS.contains(&k.as_str())),
367 mark(registry::sink_supports_schema_evolution(k)),
368 ));
369 }
370
371 let mut source_rows = sorted(registry::EXACTLY_ONCE_SOURCE_KINDS);
373 for k in sorted(registry::DISCOVER_SOURCE_KINDS) {
374 if !source_rows.contains(&k) {
375 source_rows.push(k);
376 }
377 }
378 source_rows.sort_unstable();
379 out.push_str("\n## Sources\n\n");
380 out.push_str("| Source | Exactly-once replay | Dataset discovery |\n");
381 out.push_str("|---|:---:|:---:|\n");
382 for k in &source_rows {
383 out.push_str(&format!(
384 "| `{k}` | {} | {} |\n",
385 mark(registry::source_supports_exactly_once(k)),
386 mark(registry::source_supports_discover(k)),
387 ));
388 }
389
390 let eo_sources = sorted(registry::EXACTLY_ONCE_SOURCE_KINDS);
392 let eo_sinks = sorted(registry::IDEMPOTENT_SINK_KINDS);
393 out.push_str("\n## Exactly-once delivery (source × sink)\n\n");
394 out.push_str(
395 "A `delivery: exactly_once` pipeline needs a replayable source **and** an \
396 atomic-watermark sink. Every ✓ pair below composes; any other pairing must \
397 use the keyed-upsert alternative (`write_mode: upsert` + `key`).\n\n",
398 );
399 out.push_str("| source ↓ / sink → |");
400 for s in &eo_sinks {
401 out.push_str(&format!(" `{s}` |"));
402 }
403 out.push('\n');
404 out.push('|');
405 for _ in 0..=eo_sinks.len() {
406 out.push_str("---|");
407 }
408 out.push('\n');
409 for src in &eo_sources {
410 out.push_str(&format!("| `{src}` |"));
411 for _ in &eo_sinks {
412 out.push_str(" ✓ |");
415 }
416 out.push('\n');
417 }
418 out
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn conforming(is_source: bool) -> ConnectorFacts {
426 ConnectorFacts {
427 name: "acme".into(),
428 is_source,
429 verified: true,
430 has_config_schema: true,
431 documented: true,
432 exactly_once: false,
433 upsert: false,
434 schema_evolution: false,
435 discover: false,
436 }
437 }
438
439 #[test]
440 fn tier_boundaries() {
441 assert_eq!(Tier::from_score(100), Tier::Stable);
442 assert_eq!(Tier::from_score(70), Tier::Stable);
443 assert_eq!(Tier::from_score(69), Tier::Experimental);
444 assert_eq!(Tier::from_score(45), Tier::Experimental);
445 assert_eq!(Tier::from_score(44), Tier::Beta);
446 assert_eq!(Tier::from_score(20), Tier::Beta);
447 assert_eq!(Tier::from_score(19), Tier::Draft);
448 assert_eq!(Tier::from_score(0), Tier::Draft);
449 }
450
451 #[test]
452 fn tier_label_and_badge() {
453 for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
454 assert!(!t.label().is_empty());
455 assert!(!t.badge().is_empty());
456 assert!(!t.as_str().is_empty());
457 assert!(t.badge_url().contains(t.as_str()));
458 }
459 }
460
461 #[test]
462 fn tier_parse_roundtrips_and_orders() {
463 for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
464 assert_eq!(Tier::parse(t.as_str()), Some(t));
465 }
466 assert_eq!(Tier::parse("STABLE"), Some(Tier::Stable));
467 assert_eq!(Tier::parse(" beta "), Some(Tier::Beta));
468 assert_eq!(Tier::parse("nonsense"), None);
469 assert!(Tier::Stable.rank() > Tier::Experimental.rank());
470 assert!(Tier::Experimental.rank() > Tier::Beta.rank());
471 assert!(Tier::Beta.rank() > Tier::Draft.rank());
472 }
473
474 #[test]
475 fn tier_for_matches_reports() {
476 for r in build_reports() {
477 assert_eq!(tier_for(&r.name, r.kind == "source"), r.tier);
478 }
479 }
480
481 #[test]
482 fn conforming_source_is_stable_with_docs() {
483 let r = score(&conforming(true)); assert_eq!(r.score, 80);
485 assert_eq!(r.tier, Tier::Stable);
486 assert_eq!(r.kind, "source");
487 assert!(r.badges.is_empty());
488 }
489
490 #[test]
491 fn source_capabilities_add_points_and_badges() {
492 let mut f = conforming(true);
493 f.exactly_once = true;
494 f.discover = true;
495 let r = score(&f); assert_eq!(r.score, 100);
497 assert_eq!(r.tier, Tier::Stable);
498 assert!(r.badges.contains(&"exactly-once"));
499 assert!(r.badges.contains(&"discover"));
500 }
501
502 #[test]
503 fn sink_upsert_and_evolution() {
504 let mut f = conforming(false);
505 f.upsert = true;
506 f.schema_evolution = true;
507 let r = score(&f); assert_eq!(r.score, 90);
509 assert_eq!(r.kind, "sink");
510 assert!(r.badges.contains(&"upsert"));
511 assert!(r.badges.contains(&"schema-evolution"));
512 assert!(!r.badges.contains(&"discover"));
514 }
515
516 #[test]
517 fn unregistered_no_schema_is_draft() {
518 let mut f = conforming(true);
519 f.verified = false;
520 f.has_config_schema = false;
521 f.documented = false;
522 let r = score(&f);
523 assert_eq!(r.score, 0);
524 assert_eq!(r.tier, Tier::Draft);
525 }
526
527 #[test]
528 fn schema_only_is_beta() {
529 let mut f = conforming(false);
530 f.verified = false;
531 f.documented = false; let r = score(&f);
533 assert_eq!(r.score, 30);
534 assert_eq!(r.tier, Tier::Beta);
535 }
536
537 #[test]
538 fn verified_only_is_experimental() {
539 let mut f = conforming(true);
540 f.has_config_schema = false;
541 f.documented = false; let r = score(&f);
543 assert_eq!(r.score, 40);
544 assert_eq!(r.tier, Tier::Beta); }
546
547 #[test]
548 fn every_builtin_meets_the_bar() {
549 let reports = build_reports();
550 assert!(!reports.is_empty());
551 for r in &reports {
552 assert!(
555 r.score >= 45,
556 "{} `{}` scored {} ({:?})",
557 r.kind,
558 r.name,
559 r.score,
560 r.tier
561 );
562 assert!(matches!(r.tier, Tier::Stable | Tier::Experimental));
563 }
564 }
565
566 #[test]
567 fn known_capabilities_surface_in_reports() {
568 let reports = build_reports();
569 if let Some(pg) = reports
571 .iter()
572 .find(|r| r.name == "postgres" && r.kind == "source")
573 {
574 assert!(
575 pg.badges.contains(&"discover"),
576 "postgres source should discover"
577 );
578 }
579 if let Some(bq) = reports
581 .iter()
582 .find(|r| r.name == "bigquery" && r.kind == "sink")
583 {
584 assert!(bq.badges.contains(&"exactly-once"));
585 assert!(bq.badges.contains(&"upsert"));
586 }
587 }
588}