Skip to main content

faucet_cli/commands/
conformance.rs

1//! `faucet conformance` — score connectors against the faucet SDK contract and
2//! report a maturity tier + capabilities (#330).
3
4use crate::cli::ConformanceArgs;
5use crate::conformance::{Report, Tier, build_reports};
6use crate::error::{CliError, CliResult};
7
8/// Execute the `conformance` command.
9pub async fn run(args: ConformanceArgs) -> CliResult<()> {
10    // `--matrix` prints the generated capability matrix and exits — the scoring
11    // flags do not apply.
12    if args.matrix {
13        print!("{}", crate::conformance::capability_matrix_markdown());
14        return Ok(());
15    }
16    let kind_filter = match args.kind.as_deref() {
17        None => None,
18        Some("source") => Some("source"),
19        Some("sink") => Some("sink"),
20        Some(other) => {
21            return Err(CliError::Config(format!(
22                "--kind must be 'source' or 'sink' (got '{other}')"
23            )));
24        }
25    };
26
27    // Parse the optional `--min-tier` gate up front so a typo fails fast.
28    let min_tier = match args.min_tier.as_deref() {
29        None => None,
30        Some(s) => Some(Tier::parse(s).ok_or_else(|| {
31            CliError::Config(format!(
32                "--min-tier must be one of stable/experimental/beta/draft (got '{s}')"
33            ))
34        })?),
35    };
36
37    let mut reports: Vec<Report> = build_reports()
38        .into_iter()
39        .filter(|r| kind_filter.is_none_or(|k| r.kind == k))
40        .filter(|r| args.name.as_deref().is_none_or(|n| r.name == n))
41        .collect();
42    reports.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.name.cmp(&b.name)));
43
44    if reports.is_empty() {
45        if let Some(name) = &args.name {
46            return Err(CliError::Config(format!(
47                "no connector named '{name}' is compiled into this binary (try `faucet list`)"
48            )));
49        }
50        return Err(CliError::Config(
51            "no connectors matched the filter".to_string(),
52        ));
53    }
54
55    if args.json {
56        let json = serde_json::to_string_pretty(&reports)
57            .map_err(|e| CliError::Config(format!("serialize conformance report: {e}")))?;
58        println!("{json}");
59    } else if args.name.is_some() {
60        // A single named connector → detailed scorecard.
61        for r in &reports {
62            println!(
63                "{} {} ({}) — {} · {}/100",
64                r.tier.badge(),
65                r.name,
66                r.kind,
67                r.tier.label(),
68                r.score
69            );
70            for d in &r.dimensions {
71                println!(
72                    "  {} {:<24} (+{:>2})  {}",
73                    if d.met { "✓" } else { "·" },
74                    d.name,
75                    d.points,
76                    d.note
77                );
78            }
79            if !r.badges.is_empty() {
80                println!("  capabilities: {}", r.badges.join(", "));
81            }
82            println!("  badge: {}", r.tier.badge_url());
83        }
84    } else {
85        // All connectors → one line each, highest score first.
86        println!(
87            "faucet connector conformance  ({} connectors)\n",
88            reports.len()
89        );
90        for r in &reports {
91            let caps = if r.badges.is_empty() {
92                String::new()
93            } else {
94                format!("  · {}", r.badges.join(", "))
95            };
96            println!(
97                "{} {:<15} {:<7} {:>3}/100  {}{}",
98                r.tier.badge(),
99                r.name,
100                r.kind,
101                r.score,
102                r.tier.label(),
103                caps
104            );
105        }
106        let stable = reports
107            .iter()
108            .filter(|r| matches!(r.tier, Tier::Stable))
109            .count();
110        println!("\n{}/{} connectors at Stable.", stable, reports.len());
111    }
112
113    // `--min-tier` gate: fail if any scored connector is below the bar.
114    if let Some(min) = min_tier {
115        let below: Vec<&Report> = reports
116            .iter()
117            .filter(|r| r.tier.rank() < min.rank())
118            .collect();
119        if !below.is_empty() {
120            let names: Vec<String> = below
121                .iter()
122                .map(|r| format!("{} {} ({})", r.name, r.kind, r.tier.label()))
123                .collect();
124            return Err(CliError::Config(format!(
125                "{} connector(s) below the required `{}` tier: {}",
126                below.len(),
127                min.label(),
128                names.join(", ")
129            )));
130        }
131    }
132
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn args(name: Option<&str>, kind: Option<&str>, json: bool) -> ConformanceArgs {
141        ConformanceArgs {
142            name: name.map(str::to_string),
143            kind: kind.map(str::to_string),
144            all: false,
145            json,
146            min_tier: None,
147            matrix: false,
148        }
149    }
150
151    #[tokio::test]
152    async fn matrix_flag_prints_and_exits_ok() {
153        let mut a = args(None, None, false);
154        a.matrix = true;
155        assert!(run(a).await.is_ok());
156    }
157
158    #[test]
159    fn generated_matrix_has_expected_cells() {
160        let m = crate::conformance::capability_matrix_markdown();
161        assert!(m.contains("# Connector capability matrix"));
162        // A capable sink row and an append-only sink's absence from the sink rows.
163        assert!(
164            m.contains("| `postgres` | ✓ | ✓ | ✓ |"),
165            "postgres row: {m}"
166        );
167        assert!(
168            m.contains("| `iceberg` | ✓ |  | ✓ |"),
169            "iceberg is append-only: {m}"
170        );
171        // jsonl is not a capable sink, so it never appears as a matrix row.
172        assert!(!m.contains("| `jsonl` |"), "jsonl has no capability: {m}");
173    }
174
175    #[tokio::test]
176    async fn runs_over_all_connectors() {
177        assert!(run(args(None, None, false)).await.is_ok());
178    }
179
180    #[tokio::test]
181    async fn runs_json_with_source_filter() {
182        assert!(run(args(None, Some("source"), true)).await.is_ok());
183    }
184
185    #[tokio::test]
186    async fn single_connector_detailed_view() {
187        // postgres is compiled into the default test build.
188        assert!(run(args(Some("postgres"), None, false)).await.is_ok());
189    }
190
191    #[tokio::test]
192    async fn unknown_connector_is_an_error() {
193        assert!(
194            run(args(Some("definitely-not-a-connector"), None, false))
195                .await
196                .is_err()
197        );
198    }
199
200    #[tokio::test]
201    async fn bad_kind_is_an_error() {
202        assert!(run(args(None, Some("neither"), false)).await.is_err());
203    }
204
205    #[tokio::test]
206    async fn min_tier_stable_passes_for_builtins() {
207        // Every built-in scores Stable, so the strictest gate must pass.
208        let mut a = args(None, None, false);
209        a.min_tier = Some("stable".into());
210        assert!(run(a).await.is_ok());
211    }
212
213    #[tokio::test]
214    async fn bad_min_tier_is_an_error() {
215        let mut a = args(None, None, true);
216        a.min_tier = Some("platinum".into());
217        assert!(run(a).await.is_err());
218    }
219}