Skip to main content

faucet_cli/
conformance.rs

1//! Connector conformance scoring (#330).
2//!
3//! Grades every built-in source/sink against the faucet connector contract and
4//! its capabilities, assigns a 0–100 score and a maturity tier
5//! (`Stable` / `Experimental` / `Beta` / `Draft`), and lists capability badges.
6//!
7//! The score is computed from **authoritative, instantiation-free** signals the
8//! CLI already tracks — the registry index (`cli/connectors/registry.json`) and
9//! the per-kind capability functions in [`crate::registry`] — so it is
10//! deterministic and cannot drift from the code.
11//!
12//! Scoring model (max 100): the **core contract** is the `Stable` gate — a
13//! verified registry entry (40) + a real config schema (30) = 70. Everything
14//! else is a bonus that lifts the score without gating the tier: documentation
15//! (10), exactly-once delivery (10), and one kind-specific capability
16//! (source: dataset discovery 10; sink: upsert 6 + schema evolution 4). So every
17//! conforming built-in lands at `Stable` with capability badges, while an
18//! incomplete third-party connector (no verified entry / no schema) drops to
19//! `Experimental` / `Beta`.
20
21use crate::registry;
22use crate::registry_index::RegistryIndex;
23use serde::Serialize;
24
25/// Maturity tier derived from the conformance score.
26#[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    /// Human label.
37    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    /// The snake_case identifier used in JSON / `registry.json` (matches the
47    /// `#[serde(rename_all = "snake_case")]` wire form).
48    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    /// A colored dot for the terminal / catalog.
58    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    /// Ordinal for `--min-tier` comparisons: `Stable` is highest.
68    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    /// Parse a tier from its snake_case identifier (case-insensitive).
78    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    /// A shields.io-style badge URL third-party connector authors can drop into
89    /// their crate README (`![faucet](URL)`), color-matched to the tier.
90    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    /// Derive the tier from a 0–100 conformance score.
105    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/// Authoritative, instantiation-free capability signals for one connector.
116#[derive(Debug, Clone)]
117pub struct ConnectorFacts {
118    pub name: String,
119    pub is_source: bool,
120    /// A verified entry exists in `cli/connectors/registry.json`.
121    pub verified: bool,
122    /// `config_schema()` returns a non-empty object schema.
123    pub has_config_schema: bool,
124    /// A one-line description is present in the connector catalog.
125    pub documented: bool,
126    /// Deterministic replay (source) / atomic-watermark idempotent writes (sink).
127    pub exactly_once: bool,
128    /// Sink supports `write_mode: upsert|delete` (sink only).
129    pub upsert: bool,
130    /// Sink can evolve the destination schema on drift (sink only).
131    pub schema_evolution: bool,
132    /// Source supports `faucet discover` (source only).
133    pub discover: bool,
134}
135
136/// One scored dimension of a connector's conformance.
137#[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/// A connector's full conformance report.
146#[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
156/// Score a single connector from its facts. Pure and deterministic.
157pub fn score(f: &ConnectorFacts) -> Report {
158    let mut dims: Vec<Dimension> = Vec::new();
159
160    // ── Core contract (the Stable gate: 70 pts) ─────────────────────────────
161    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    // ── Bonuses (lift the score, never gate the tier) ───────────────────────
175    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
240/// Gather facts for one built-in connector kind from the registry.
241pub 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
294/// Build conformance reports for every compiled-in connector, sources first.
295pub 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
307/// The maturity tier of a single compiled-in connector kind — the lookup behind
308/// the Tier column in `faucet list`.
309pub 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/// Render the connector **capability matrix** as Markdown, derived entirely
315/// from the registry allowlists (`registry::*_KINDS`). Because those are
316/// feature-independent `const` arrays, this output is deterministic regardless
317/// of which connectors are compiled in — so a committed copy can be
318/// round-trip-asserted in CI (see `cli/tests/capability_matrix.rs`), which is
319/// what stops the matrix from being hand-maintained and drifting (#465 Part 3).
320///
321/// The single source of truth is the allowlists; `faucet conformance --matrix`
322/// prints exactly this, and the parity test (`registry_capability_parity`)
323/// separately proves the allowlists match the trait methods — so the rendered
324/// matrix is grounded in real behaviour, not a hand-kept table.
325pub 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    // ── Sink capabilities ────────────────────────────────────────────────
347    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    // ── Source capabilities ──────────────────────────────────────────────
372    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    // ── Exactly-once delivery compatibility (source × sink) ───────────────
391    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            // Both sides are capable by construction (source ∈ EO sources, sink ∈
413            // idempotent sinks), so every cell composes.
414            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)); // 40 + 30 + 10 = 80
484        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); // 80 + 10 + 10 = 100
496        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); // 80 + 6 + 4 = 90
508        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        // A sink is never scored on discover.
513        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; // only config schema (30)
532        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; // only verified (40)
542        let r = score(&f);
543        assert_eq!(r.score, 40);
544        assert_eq!(r.tier, Tier::Beta); // 40 is Beta; 45+ is Experimental
545    }
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            // Every shipped built-in has a verified entry + a config schema, so
553            // it must be at least Stable-gate-eligible (>= Experimental).
554            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        // postgres source is discoverable.
570        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        // bigquery sink is exactly-once + upsert-capable.
580        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}