faucet_cli/
registry_index.rs1use crate::error::{CliError, CliResult};
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15
16const EMBEDDED_INDEX: &str = include_str!("../connectors/registry.json");
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ConnectorEntry {
23 pub name: String,
25 pub kind: String,
27 #[serde(default = "default_true")]
30 pub verified: bool,
31 #[serde(default)]
33 pub description: String,
34 #[serde(rename = "crate", default)]
36 pub krate: Option<String>,
37 #[serde(default)]
41 pub feature: Option<String>,
42 #[serde(default)]
44 pub keywords: Vec<String>,
45 #[serde(default)]
47 pub core_compat: Option<String>,
48}
49
50fn default_true() -> bool {
51 true
52}
53
54impl ConnectorEntry {
55 pub fn crate_name(&self) -> String {
57 self.krate
58 .clone()
59 .unwrap_or_else(|| format!("faucet-{}-{}", self.kind, self.name))
60 }
61
62 pub fn feature_flag(&self) -> String {
64 self.feature
65 .clone()
66 .unwrap_or_else(|| format!("{}-{}", self.kind, self.name))
67 }
68
69 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#[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 pub fn embedded() -> Self {
97 serde_json::from_str(EMBEDDED_INDEX)
98 .expect("embedded cli/connectors/registry.json is valid")
99 }
100
101 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum InstallRecipe {
137 AlreadyAvailable { feature: String },
139 CargoInstall { feature: String },
141 CustomBinary { krate: String, feature: String },
143}
144
145pub 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 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 assert!(!idx.search("cdc").is_empty());
190 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"); assert_eq!(e.feature_flag(), "source-acme"); assert!(!e.verified);
251 }
252
253 #[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}