use crate::registry;
use crate::registry_index::RegistryIndex;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
Stable,
Experimental,
Beta,
Draft,
}
impl Tier {
pub fn label(self) -> &'static str {
match self {
Tier::Stable => "Stable",
Tier::Experimental => "Experimental",
Tier::Beta => "Beta",
Tier::Draft => "Draft",
}
}
pub fn as_str(self) -> &'static str {
match self {
Tier::Stable => "stable",
Tier::Experimental => "experimental",
Tier::Beta => "beta",
Tier::Draft => "draft",
}
}
pub fn badge(self) -> &'static str {
match self {
Tier::Stable => "🟢",
Tier::Experimental => "🟡",
Tier::Beta => "🟠",
Tier::Draft => "⚪",
}
}
pub fn rank(self) -> u8 {
match self {
Tier::Stable => 3,
Tier::Experimental => 2,
Tier::Beta => 1,
Tier::Draft => 0,
}
}
pub fn parse(s: &str) -> Option<Tier> {
match s.trim().to_ascii_lowercase().as_str() {
"stable" => Some(Tier::Stable),
"experimental" => Some(Tier::Experimental),
"beta" => Some(Tier::Beta),
"draft" => Some(Tier::Draft),
_ => None,
}
}
pub fn badge_url(self) -> String {
let color = match self {
Tier::Stable => "brightgreen",
Tier::Experimental => "yellow",
Tier::Beta => "orange",
Tier::Draft => "lightgrey",
};
format!(
"https://img.shields.io/badge/faucet-{}-{}",
self.as_str(),
color
)
}
pub fn from_score(score: u32) -> Tier {
match score {
70..=u32::MAX => Tier::Stable,
45..=69 => Tier::Experimental,
20..=44 => Tier::Beta,
_ => Tier::Draft,
}
}
}
#[derive(Debug, Clone)]
pub struct ConnectorFacts {
pub name: String,
pub is_source: bool,
pub verified: bool,
pub has_config_schema: bool,
pub documented: bool,
pub exactly_once: bool,
pub upsert: bool,
pub schema_evolution: bool,
pub discover: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Dimension {
pub name: &'static str,
pub met: bool,
pub points: u32,
pub note: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub name: String,
pub kind: &'static str,
pub score: u32,
pub tier: Tier,
pub dimensions: Vec<Dimension>,
pub badges: Vec<&'static str>,
}
pub fn score(f: &ConnectorFacts) -> Report {
let mut dims: Vec<Dimension> = Vec::new();
dims.push(Dimension {
name: "Registered & verified",
met: f.verified,
points: 40,
note: "verified entry in cli/connectors/registry.json",
});
dims.push(Dimension {
name: "Config schema",
met: f.has_config_schema,
points: 30,
note: "config_schema() powers faucet init / validate / schema",
});
dims.push(Dimension {
name: "Documented",
met: f.documented,
points: 10,
note: "one-line description in the connector catalog",
});
dims.push(Dimension {
name: "Exactly-once delivery",
met: f.exactly_once,
points: 10,
note: if f.is_source {
"deterministic replay from a bookmark"
} else {
"atomic-watermark idempotent writes"
},
});
if f.is_source {
dims.push(Dimension {
name: "Dataset discovery",
met: f.discover,
points: 10,
note: "faucet discover introspects the catalog",
});
} else {
dims.push(Dimension {
name: "Upsert / mirror",
met: f.upsert,
points: 6,
note: "write_mode: upsert|delete",
});
dims.push(Dimension {
name: "Schema evolution",
met: f.schema_evolution,
points: 4,
note: "evolves the destination schema on drift",
});
}
let score: u32 = dims.iter().filter(|d| d.met).map(|d| d.points).sum();
let tier = Tier::from_score(score);
let mut badges: Vec<&'static str> = Vec::new();
if f.exactly_once {
badges.push("exactly-once");
}
if f.is_source && f.discover {
badges.push("discover");
}
if !f.is_source && f.upsert {
badges.push("upsert");
}
if !f.is_source && f.schema_evolution {
badges.push("schema-evolution");
}
Report {
name: f.name.clone(),
kind: if f.is_source { "source" } else { "sink" },
score,
tier,
dimensions: dims,
badges,
}
}
pub fn facts_for(kind: &str, is_source: bool, index: &RegistryIndex) -> ConnectorFacts {
let role = if is_source { "source" } else { "sink" };
let verified = index
.connectors
.iter()
.any(|e| e.name == kind && e.kind == role && e.verified);
let schema = if is_source {
registry::source_schema(kind)
} else {
registry::sink_schema(kind)
};
let has_config_schema = schema
.ok()
.and_then(|s| {
s.get("properties")
.and_then(|p| p.as_object())
.map(|o| !o.is_empty())
})
.unwrap_or(false);
let descs = if is_source {
registry::source_descriptions()
} else {
registry::sink_descriptions()
};
let documented = descs.iter().any(|(k, d)| *k == kind && !d.is_empty());
let exactly_once = if is_source {
registry::source_supports_exactly_once(kind)
} else {
registry::sink_supports_idempotent_writes(kind)
};
let upsert = !is_source
&& registry::sink_supported_write_modes(kind)
.iter()
.any(|m| matches!(m, faucet_core::WriteMode::Upsert));
let schema_evolution = !is_source && registry::sink_supports_schema_evolution(kind);
let discover = is_source && registry::source_supports_discover(kind);
ConnectorFacts {
name: kind.to_string(),
is_source,
verified,
has_config_schema,
documented,
exactly_once,
upsert,
schema_evolution,
discover,
}
}
pub fn build_reports() -> Vec<Report> {
let index = RegistryIndex::embedded();
let mut out = Vec::new();
for kind in registry::source_kinds() {
out.push(score(&facts_for(kind, true, &index)));
}
for kind in registry::sink_kinds() {
out.push(score(&facts_for(kind, false, &index)));
}
out
}
pub fn tier_for(kind: &str, is_source: bool) -> Tier {
let index = RegistryIndex::embedded();
score(&facts_for(kind, is_source, &index)).tier
}
pub fn capability_matrix_markdown() -> String {
use crate::registry;
fn sorted(kinds: &[&str]) -> Vec<String> {
let mut v: Vec<String> = kinds.iter().map(|s| s.to_string()).collect();
v.sort_unstable();
v.dedup();
v
}
fn mark(present: bool) -> &'static str {
if present { "✓" } else { "" }
}
let mut out = String::new();
out.push_str("# Connector capability matrix\n\n");
out.push_str(
"<!-- GENERATED by `faucet conformance --matrix` from the registry \
allowlists. Do NOT hand-edit — run the command and commit its output. \
`cli/tests/capability_matrix.rs` fails if this file drifts. -->\n\n",
);
let mut sink_rows = sorted(registry::IDEMPOTENT_SINK_KINDS);
for k in sorted(registry::UPSERT_SINK_KINDS)
.into_iter()
.chain(sorted(registry::SCHEMA_EVOLUTION_SINK_KINDS))
{
if !sink_rows.contains(&k) {
sink_rows.push(k);
}
}
sink_rows.sort_unstable();
out.push_str("## Sinks\n\n");
out.push_str(
"| Sink | Exactly-once (atomic watermark) | Upsert / delete | Schema evolution |\n",
);
out.push_str("|---|:---:|:---:|:---:|\n");
for k in &sink_rows {
out.push_str(&format!(
"| `{k}` | {} | {} | {} |\n",
mark(registry::sink_supports_idempotent_writes(k)),
mark(registry::UPSERT_SINK_KINDS.contains(&k.as_str())),
mark(registry::sink_supports_schema_evolution(k)),
));
}
let mut source_rows = sorted(registry::EXACTLY_ONCE_SOURCE_KINDS);
for k in sorted(registry::DISCOVER_SOURCE_KINDS) {
if !source_rows.contains(&k) {
source_rows.push(k);
}
}
source_rows.sort_unstable();
out.push_str("\n## Sources\n\n");
out.push_str("| Source | Exactly-once replay | Dataset discovery |\n");
out.push_str("|---|:---:|:---:|\n");
for k in &source_rows {
out.push_str(&format!(
"| `{k}` | {} | {} |\n",
mark(registry::source_supports_exactly_once(k)),
mark(registry::source_supports_discover(k)),
));
}
let eo_sources = sorted(registry::EXACTLY_ONCE_SOURCE_KINDS);
let eo_sinks = sorted(registry::IDEMPOTENT_SINK_KINDS);
out.push_str("\n## Exactly-once delivery (source × sink)\n\n");
out.push_str(
"A `delivery: exactly_once` pipeline needs a replayable source **and** an \
atomic-watermark sink. Every ✓ pair below composes; any other pairing must \
use the keyed-upsert alternative (`write_mode: upsert` + `key`).\n\n",
);
out.push_str("| source ↓ / sink → |");
for s in &eo_sinks {
out.push_str(&format!(" `{s}` |"));
}
out.push('\n');
out.push('|');
for _ in 0..=eo_sinks.len() {
out.push_str("---|");
}
out.push('\n');
for src in &eo_sources {
out.push_str(&format!("| `{src}` |"));
for _ in &eo_sinks {
out.push_str(" ✓ |");
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn conforming(is_source: bool) -> ConnectorFacts {
ConnectorFacts {
name: "acme".into(),
is_source,
verified: true,
has_config_schema: true,
documented: true,
exactly_once: false,
upsert: false,
schema_evolution: false,
discover: false,
}
}
#[test]
fn tier_boundaries() {
assert_eq!(Tier::from_score(100), Tier::Stable);
assert_eq!(Tier::from_score(70), Tier::Stable);
assert_eq!(Tier::from_score(69), Tier::Experimental);
assert_eq!(Tier::from_score(45), Tier::Experimental);
assert_eq!(Tier::from_score(44), Tier::Beta);
assert_eq!(Tier::from_score(20), Tier::Beta);
assert_eq!(Tier::from_score(19), Tier::Draft);
assert_eq!(Tier::from_score(0), Tier::Draft);
}
#[test]
fn tier_label_and_badge() {
for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
assert!(!t.label().is_empty());
assert!(!t.badge().is_empty());
assert!(!t.as_str().is_empty());
assert!(t.badge_url().contains(t.as_str()));
}
}
#[test]
fn tier_parse_roundtrips_and_orders() {
for t in [Tier::Stable, Tier::Experimental, Tier::Beta, Tier::Draft] {
assert_eq!(Tier::parse(t.as_str()), Some(t));
}
assert_eq!(Tier::parse("STABLE"), Some(Tier::Stable));
assert_eq!(Tier::parse(" beta "), Some(Tier::Beta));
assert_eq!(Tier::parse("nonsense"), None);
assert!(Tier::Stable.rank() > Tier::Experimental.rank());
assert!(Tier::Experimental.rank() > Tier::Beta.rank());
assert!(Tier::Beta.rank() > Tier::Draft.rank());
}
#[test]
fn tier_for_matches_reports() {
for r in build_reports() {
assert_eq!(tier_for(&r.name, r.kind == "source"), r.tier);
}
}
#[test]
fn conforming_source_is_stable_with_docs() {
let r = score(&conforming(true)); assert_eq!(r.score, 80);
assert_eq!(r.tier, Tier::Stable);
assert_eq!(r.kind, "source");
assert!(r.badges.is_empty());
}
#[test]
fn source_capabilities_add_points_and_badges() {
let mut f = conforming(true);
f.exactly_once = true;
f.discover = true;
let r = score(&f); assert_eq!(r.score, 100);
assert_eq!(r.tier, Tier::Stable);
assert!(r.badges.contains(&"exactly-once"));
assert!(r.badges.contains(&"discover"));
}
#[test]
fn sink_upsert_and_evolution() {
let mut f = conforming(false);
f.upsert = true;
f.schema_evolution = true;
let r = score(&f); assert_eq!(r.score, 90);
assert_eq!(r.kind, "sink");
assert!(r.badges.contains(&"upsert"));
assert!(r.badges.contains(&"schema-evolution"));
assert!(!r.badges.contains(&"discover"));
}
#[test]
fn unregistered_no_schema_is_draft() {
let mut f = conforming(true);
f.verified = false;
f.has_config_schema = false;
f.documented = false;
let r = score(&f);
assert_eq!(r.score, 0);
assert_eq!(r.tier, Tier::Draft);
}
#[test]
fn schema_only_is_beta() {
let mut f = conforming(false);
f.verified = false;
f.documented = false; let r = score(&f);
assert_eq!(r.score, 30);
assert_eq!(r.tier, Tier::Beta);
}
#[test]
fn verified_only_is_experimental() {
let mut f = conforming(true);
f.has_config_schema = false;
f.documented = false; let r = score(&f);
assert_eq!(r.score, 40);
assert_eq!(r.tier, Tier::Beta); }
#[test]
fn every_builtin_meets_the_bar() {
let reports = build_reports();
assert!(!reports.is_empty());
for r in &reports {
assert!(
r.score >= 45,
"{} `{}` scored {} ({:?})",
r.kind,
r.name,
r.score,
r.tier
);
assert!(matches!(r.tier, Tier::Stable | Tier::Experimental));
}
}
#[test]
fn known_capabilities_surface_in_reports() {
let reports = build_reports();
if let Some(pg) = reports
.iter()
.find(|r| r.name == "postgres" && r.kind == "source")
{
assert!(
pg.badges.contains(&"discover"),
"postgres source should discover"
);
}
if let Some(bq) = reports
.iter()
.find(|r| r.name == "bigquery" && r.kind == "sink")
{
assert!(bq.badges.contains(&"exactly-once"));
assert!(bq.badges.contains(&"upsert"));
}
}
}