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#[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)); // 40 + 30 + 10 = 80
377        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); // 80 + 10 + 10 = 100
389        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); // 80 + 6 + 4 = 90
401        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        // A sink is never scored on discover.
406        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; // only config schema (30)
425        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; // only verified (40)
435        let r = score(&f);
436        assert_eq!(r.score, 40);
437        assert_eq!(r.tier, Tier::Beta); // 40 is Beta; 45+ is Experimental
438    }
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            // Every shipped built-in has a verified entry + a config schema, so
446            // it must be at least Stable-gate-eligible (>= Experimental).
447            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        // postgres source is discoverable.
463        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        // bigquery sink is exactly-once + upsert-capable.
473        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}