Skip to main content

faucet_cli/
registry_index.rs

1//! Connector registry index (#208): the discovery/distribution layer behind
2//! `faucet search`, `faucet install`, and `faucet list --available`.
3//!
4//! The index is a committed, **feature-independent** JSON catalog
5//! (`cli/connectors/registry.json`, embedded at build time) of every connector the
6//! ecosystem knows about — the built-in `verified` ones plus any community
7//! `faucet-source-*` / `faucet-sink-*` crates added by PR. It is deliberately
8//! decoupled from which connectors a given binary compiled in, so `search` can
9//! surface a connector you don't yet have and `install` can tell you how to get
10//! it. Pass `--index <path>` to point at a custom/mirror index.
11
12use crate::error::{CliError, CliResult};
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15
16/// The committed built-in index, embedded so `search`/`install` work offline
17/// and regardless of compiled features.
18const EMBEDDED_INDEX: &str = include_str!("../connectors/registry.json");
19
20/// One connector in the registry index.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ConnectorEntry {
23    /// System name / YAML `type:` value (e.g. `kafka`).
24    pub name: String,
25    /// `"source"` or `"sink"`.
26    pub kind: String,
27    /// Verified = a first-party built-in that ships in the `faucet` binary.
28    /// Community connectors set `false`.
29    #[serde(default = "default_true")]
30    pub verified: bool,
31    /// One-line summary.
32    #[serde(default)]
33    pub description: String,
34    /// Crate name (defaults to `faucet-<kind>-<name>`).
35    #[serde(rename = "crate", default)]
36    pub krate: Option<String>,
37    /// CLI feature flag that compiles this connector in (defaults to
38    /// `<kind>-<name>`). Applies to built-ins; a community connector may set it
39    /// or leave it null (consumed via a custom binary).
40    #[serde(default)]
41    pub feature: Option<String>,
42    /// Extra crates.io keywords to match `search` against.
43    #[serde(default)]
44    pub keywords: Vec<String>,
45    /// faucet-core version compatibility (semver requirement), informational.
46    #[serde(default)]
47    pub core_compat: Option<String>,
48}
49
50fn default_true() -> bool {
51    true
52}
53
54impl ConnectorEntry {
55    /// Resolved crate name.
56    pub fn crate_name(&self) -> String {
57        self.krate
58            .clone()
59            .unwrap_or_else(|| format!("faucet-{}-{}", self.kind, self.name))
60    }
61
62    /// Resolved CLI feature flag.
63    pub fn feature_flag(&self) -> String {
64        self.feature
65            .clone()
66            .unwrap_or_else(|| format!("{}-{}", self.kind, self.name))
67    }
68
69    /// Whether this entry matches a lowercase search term (name / description /
70    /// keywords / crate).
71    pub fn matches(&self, lowered_term: &str) -> bool {
72        self.name.to_lowercase().contains(lowered_term)
73            || self.description.to_lowercase().contains(lowered_term)
74            || self.crate_name().to_lowercase().contains(lowered_term)
75            || self
76                .keywords
77                .iter()
78                .any(|k| k.to_lowercase().contains(lowered_term))
79    }
80}
81
82/// The parsed registry index.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RegistryIndex {
85    #[serde(default = "default_version")]
86    pub version: u32,
87    pub connectors: Vec<ConnectorEntry>,
88}
89
90fn default_version() -> u32 {
91    1
92}
93
94impl RegistryIndex {
95    /// The embedded built-in index.
96    pub fn embedded() -> Self {
97        serde_json::from_str(EMBEDDED_INDEX)
98            .expect("embedded cli/connectors/registry.json is valid")
99    }
100
101    /// Load from `path`, or the embedded index when `None`.
102    pub fn load(path: Option<&Path>) -> CliResult<Self> {
103        match path {
104            None => Ok(Self::embedded()),
105            Some(p) => {
106                let text = std::fs::read_to_string(p)?;
107                serde_json::from_str(&text).map_err(|e| {
108                    CliError::Config(format!("invalid connector index `{}`: {e}", p.display()))
109                })
110            }
111        }
112    }
113
114    /// Entries matching `term` (case-insensitive), sorted by kind then name.
115    pub fn search(&self, term: &str) -> Vec<&ConnectorEntry> {
116        let t = term.to_lowercase();
117        let mut hits: Vec<&ConnectorEntry> =
118            self.connectors.iter().filter(|c| c.matches(&t)).collect();
119        hits.sort_by(|a, b| {
120            (a.kind.as_str(), a.name.as_str()).cmp(&(b.kind.as_str(), b.name.as_str()))
121        });
122        hits
123    }
124
125    /// Find entries by exact `name`, optionally constrained to a `kind`.
126    pub fn find(&self, name: &str, kind: Option<&str>) -> Vec<&ConnectorEntry> {
127        self.connectors
128            .iter()
129            .filter(|c| c.name == name && kind.map(|k| k == c.kind).unwrap_or(true))
130            .collect()
131    }
132}
133
134/// How to obtain a connector — the pure output of `faucet install`.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum InstallRecipe {
137    /// A verified built-in already compiled into this binary.
138    AlreadyAvailable { feature: String },
139    /// A verified built-in — reinstall the CLI with its feature enabled.
140    CargoInstall { feature: String },
141    /// A community connector — build a custom binary that registers it.
142    CustomBinary { krate: String, feature: String },
143}
144
145/// Decide the install recipe for `entry`. `compiled_in` reports whether this
146/// binary already has the connector (via the connector registry).
147pub fn install_recipe(entry: &ConnectorEntry, compiled_in: bool) -> InstallRecipe {
148    let feature = entry.feature_flag();
149    if !entry.verified {
150        return InstallRecipe::CustomBinary {
151            krate: entry.crate_name(),
152            feature,
153        };
154    }
155    if compiled_in {
156        InstallRecipe::AlreadyAvailable { feature }
157    } else {
158        InstallRecipe::CargoInstall { feature }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn embedded_index_parses_and_is_non_empty() {
168        let idx = RegistryIndex::embedded();
169        assert_eq!(idx.version, 1);
170        assert!(idx.connectors.len() > 30, "expected the built-in catalog");
171        // Every built-in entry derives a crate/feature.
172        let kafka = idx
173            .find("kafka", Some("source"))
174            .into_iter()
175            .next()
176            .unwrap();
177        assert_eq!(kafka.crate_name(), "faucet-source-kafka");
178        assert_eq!(kafka.feature_flag(), "source-kafka");
179        assert!(kafka.verified);
180    }
181
182    #[test]
183    fn search_is_case_insensitive_over_fields() {
184        let idx = RegistryIndex::embedded();
185        let hits = idx.search("KAFKA");
186        assert!(hits.iter().any(|c| c.name == "kafka" && c.kind == "source"));
187        assert!(hits.iter().any(|c| c.name == "kafka" && c.kind == "sink"));
188        // description match
189        assert!(!idx.search("cdc").is_empty());
190        // no match
191        assert!(idx.search("definitely-not-a-connector").is_empty());
192    }
193
194    #[test]
195    fn install_recipe_for_builtin_compiled_and_not() {
196        let idx = RegistryIndex::embedded();
197        let entry = idx
198            .find("bigquery", Some("sink"))
199            .into_iter()
200            .next()
201            .unwrap();
202        assert_eq!(
203            install_recipe(entry, true),
204            InstallRecipe::AlreadyAvailable {
205                feature: "sink-bigquery".into()
206            }
207        );
208        assert_eq!(
209            install_recipe(entry, false),
210            InstallRecipe::CargoInstall {
211                feature: "sink-bigquery".into()
212            }
213        );
214    }
215
216    #[test]
217    fn install_recipe_for_community_is_custom_binary() {
218        let entry = ConnectorEntry {
219            name: "acme".into(),
220            kind: "source".into(),
221            verified: false,
222            description: "community".into(),
223            krate: None,
224            feature: None,
225            keywords: vec![],
226            core_compat: None,
227        };
228        assert_eq!(
229            install_recipe(&entry, false),
230            InstallRecipe::CustomBinary {
231                krate: "faucet-source-acme".into(),
232                feature: "source-acme".into()
233            }
234        );
235    }
236
237    #[test]
238    fn load_from_path_parses_custom_index() {
239        let dir = tempfile::tempdir().unwrap();
240        let p = dir.path().join("idx.json");
241        std::fs::write(
242            &p,
243            r#"{"version":1,"connectors":[{"name":"acme","kind":"source","verified":false,"description":"Acme","crate":"acme-faucet"}]}"#,
244        )
245        .unwrap();
246        let idx = RegistryIndex::load(Some(&p)).unwrap();
247        let e = &idx.connectors[0];
248        assert_eq!(e.crate_name(), "acme-faucet"); // explicit crate override
249        assert_eq!(e.feature_flag(), "source-acme"); // derived
250        assert!(!e.verified);
251    }
252
253    // Maintenance guard: adding a built-in connector without an index entry
254    // fails here (under `default`/`--all-features`, every built-in is compiled
255    // in, so `source_kinds()`/`sink_kinds()` is the full built-in set).
256    #[test]
257    fn index_covers_every_compiled_builtin() {
258        let idx = RegistryIndex::embedded();
259        for k in crate::registry::source_kinds() {
260            assert!(
261                idx.find(k, Some("source")).iter().any(|c| c.verified),
262                "built-in source `{k}` is missing a verified entry in cli/connectors/registry.json"
263            );
264        }
265        for k in crate::registry::sink_kinds() {
266            assert!(
267                idx.find(k, Some("sink")).iter().any(|c| c.verified),
268                "built-in sink `{k}` is missing a verified entry in cli/connectors/registry.json"
269            );
270        }
271    }
272
273    #[test]
274    fn load_bad_index_errors() {
275        let dir = tempfile::tempdir().unwrap();
276        let p = dir.path().join("bad.json");
277        std::fs::write(&p, "{ not json").unwrap();
278        assert!(matches!(
279            RegistryIndex::load(Some(&p)),
280            Err(CliError::Config(_))
281        ));
282    }
283}