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    /// A colored dot for the terminal / catalog.
47    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    /// Derive the tier from a 0–100 conformance score.
57    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/// Authoritative, instantiation-free capability signals for one connector.
68#[derive(Debug, Clone)]
69pub struct ConnectorFacts {
70    pub name: String,
71    pub is_source: bool,
72    /// A verified entry exists in `cli/connectors/registry.json`.
73    pub verified: bool,
74    /// `config_schema()` returns a non-empty object schema.
75    pub has_config_schema: bool,
76    /// A one-line description is present in the connector catalog.
77    pub documented: bool,
78    /// Deterministic replay (source) / atomic-watermark idempotent writes (sink).
79    pub exactly_once: bool,
80    /// Sink supports `write_mode: upsert|delete` (sink only).
81    pub upsert: bool,
82    /// Sink can evolve the destination schema on drift (sink only).
83    pub schema_evolution: bool,
84    /// Source supports `faucet discover` (source only).
85    pub discover: bool,
86}
87
88/// One scored dimension of a connector's conformance.
89#[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/// A connector's full conformance report.
98#[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
108/// Score a single connector from its facts. Pure and deterministic.
109pub fn score(f: &ConnectorFacts) -> Report {
110    let mut dims: Vec<Dimension> = Vec::new();
111
112    // ── Core contract (the Stable gate: 70 pts) ─────────────────────────────
113    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    // ── Bonuses (lift the score, never gate the tier) ───────────────────────
127    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
192/// Gather facts for one built-in connector kind from the registry.
193pub 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
246/// Build conformance reports for every compiled-in connector, sources first.
247pub 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)); // 40 + 30 + 10 = 80
300        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); // 80 + 10 + 10 = 100
312        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); // 80 + 6 + 4 = 90
324        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        // A sink is never scored on discover.
329        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; // only config schema (30)
348        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; // only verified (40)
358        let r = score(&f);
359        assert_eq!(r.score, 40);
360        assert_eq!(r.tier, Tier::Beta); // 40 is Beta; 45+ is Experimental
361    }
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            // Every shipped built-in has a verified entry + a config schema, so
369            // it must be at least Stable-gate-eligible (>= Experimental).
370            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        // postgres source is discoverable.
386        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        // bigquery sink is exactly-once + upsert-capable.
396        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}