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