faucet_source_singer/
discover.rs1use std::process::Stdio;
5
6use faucet_core::{FaucetError, Value};
7use tokio::process::Command;
8
9use crate::config::SingerSourceConfig;
10use crate::process::write_temp;
11
12pub async fn discover(config: &SingerSourceConfig) -> Result<Value, FaucetError> {
18 let config_file = write_temp("config", &config.tap_config)?;
19
20 let output = Command::new(&config.executable)
21 .arg("--config")
22 .arg(config_file.path())
23 .arg("--discover")
24 .args(&config.args)
25 .stdin(Stdio::null())
26 .output()
27 .await
28 .map_err(|e| {
29 FaucetError::Source(format!(
30 "failed to spawn tap '{}' for discovery: {e}",
31 config.executable
32 ))
33 })?;
34
35 if !output.status.success() {
36 let stderr = String::from_utf8_lossy(&output.stderr);
37 return Err(FaucetError::Source(format!(
38 "tap discovery exited with status {}; stderr:\n{}",
39 output.status,
40 stderr.trim()
41 )));
42 }
43
44 serde_json::from_slice(&output.stdout)
45 .map_err(|e| FaucetError::Source(format!("tap discovery output is not valid JSON: {e}")))
46}
47
48#[derive(Debug, Clone)]
51pub struct StreamSelection {
52 pub catalog: Value,
54 pub selected: Vec<String>,
56 pub warnings: Vec<String>,
58}
59
60pub fn select_streams(catalog: &Value, target: &str) -> StreamSelection {
75 let mut catalog = catalog.clone();
76 let mut selected: Vec<String> = Vec::new();
77 let mut warnings: Vec<String> = Vec::new();
78
79 let ids = catalog_stream_ids(&catalog);
80 if !ids.iter().any(|s| s == target) {
81 warnings.push(format!(
82 "stream '{target}' not found in the discovered catalog; available: {}",
83 if ids.is_empty() {
84 "(none)".to_string()
85 } else {
86 ids.join(", ")
87 }
88 ));
89 return StreamSelection {
90 catalog,
91 selected,
92 warnings,
93 };
94 }
95
96 let mut to_select = vec![target.to_string()];
98 let mut i = 0;
99 while i < to_select.len() {
100 let cur = to_select[i].clone();
101 if let Some(parent) = parent_stream_id(&catalog, &cur)
102 && !to_select.contains(&parent)
103 {
104 if ids.iter().any(|s| s == &parent) {
105 to_select.push(parent);
106 } else {
107 warnings.push(format!(
108 "stream '{cur}' references parent '{parent}', which is not in the \
109 catalog — select the parent manually"
110 ));
111 }
112 }
113 i += 1;
114 }
115
116 if let Some(streams) = catalog.get_mut("streams").and_then(Value::as_array_mut) {
117 for stream in streams.iter_mut() {
118 if let Some(id) = stream_id(stream)
119 && to_select.contains(&id)
120 {
121 mark_selected(stream);
122 if !selected.contains(&id) {
123 selected.push(id);
124 }
125 }
126 }
127 }
128
129 if selected.len() == 1 && ids.len() > 1 && !catalog_exposes_parent_metadata(&catalog) {
133 warnings.push(format!(
134 "selected only '{target}'. If this tap has parent/child streams, the catalog does \
135 not express the relationship — a required parent may need manual selection. \
136 Available streams: {}",
137 ids.join(", ")
138 ));
139 }
140
141 StreamSelection {
142 catalog,
143 selected,
144 warnings,
145 }
146}
147
148fn stream_id(stream: &Value) -> Option<String> {
150 stream
151 .get("tap_stream_id")
152 .or_else(|| stream.get("stream"))
153 .and_then(Value::as_str)
154 .map(str::to_string)
155}
156
157fn stream_level_metadata(stream: &Value) -> Option<&Value> {
159 stream
160 .get("metadata")
161 .and_then(Value::as_array)?
162 .iter()
163 .find(|m| {
164 m.get("breadcrumb")
165 .and_then(Value::as_array)
166 .map(|b| b.is_empty())
167 .unwrap_or(false)
168 })
169 .and_then(|m| m.get("metadata"))
170}
171
172fn parent_stream_id(catalog: &Value, id: &str) -> Option<String> {
175 let stream = catalog
176 .get("streams")
177 .and_then(Value::as_array)?
178 .iter()
179 .find(|s| stream_id(s).as_deref() == Some(id))?;
180
181 for key in ["parent_stream", "parent"] {
182 if let Some(p) = stream.get(key).and_then(Value::as_str) {
183 return Some(p.to_string());
184 }
185 if let Some(p) = stream_level_metadata(stream)
186 .and_then(|m| m.get(key))
187 .and_then(Value::as_str)
188 {
189 return Some(p.to_string());
190 }
191 }
192 None
193}
194
195fn catalog_exposes_parent_metadata(catalog: &Value) -> bool {
197 catalog
198 .get("streams")
199 .and_then(Value::as_array)
200 .map(|streams| {
201 streams
202 .iter()
203 .filter_map(stream_id)
204 .any(|id| parent_stream_id(catalog, &id).is_some())
205 })
206 .unwrap_or(false)
207}
208
209fn mark_selected(stream: &mut Value) {
213 if let Some(obj) = stream.as_object_mut() {
214 obj.insert("selected".to_string(), Value::Bool(true));
215
216 let metadata = obj
217 .entry("metadata")
218 .or_insert_with(|| Value::Array(Vec::new()));
219 if let Some(arr) = metadata.as_array_mut() {
220 let root = arr.iter_mut().find(|m| {
221 m.get("breadcrumb")
222 .and_then(Value::as_array)
223 .map(|b| b.is_empty())
224 .unwrap_or(false)
225 });
226 match root {
227 Some(entry) => {
228 if let Some(md) = entry.get_mut("metadata").and_then(Value::as_object_mut) {
229 md.insert("selected".to_string(), Value::Bool(true));
230 } else if let Some(eobj) = entry.as_object_mut() {
231 eobj.insert(
232 "metadata".to_string(),
233 serde_json::json!({ "selected": true }),
234 );
235 }
236 }
237 None => arr.push(serde_json::json!({
238 "breadcrumb": [],
239 "metadata": { "selected": true }
240 })),
241 }
242 }
243 }
244}
245
246pub fn catalog_stream_ids(catalog: &Value) -> Vec<String> {
249 catalog
250 .get("streams")
251 .and_then(Value::as_array)
252 .map(|streams| {
253 streams
254 .iter()
255 .filter_map(|s| {
256 s.get("tap_stream_id")
257 .or_else(|| s.get("stream"))
258 .and_then(Value::as_str)
259 .map(str::to_string)
260 })
261 .collect()
262 })
263 .unwrap_or_default()
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use serde_json::json;
270
271 #[test]
272 fn extracts_stream_ids_in_order() {
273 let catalog = json!({
274 "streams": [
275 { "tap_stream_id": "users", "stream": "users" },
276 { "stream": "orders" },
277 { "tap_stream_id": "audit_log" }
278 ]
279 });
280 assert_eq!(
281 catalog_stream_ids(&catalog),
282 vec!["users", "orders", "audit_log"]
283 );
284 assert!(catalog_stream_ids(&json!({})).is_empty());
285 }
286
287 fn is_selected(catalog: &Value, id: &str) -> bool {
289 catalog
290 .get("streams")
291 .and_then(Value::as_array)
292 .and_then(|streams| streams.iter().find(|s| stream_id(s).as_deref() == Some(id)))
293 .and_then(stream_level_metadata)
294 .and_then(|m| m.get("selected"))
295 .and_then(Value::as_bool)
296 .unwrap_or(false)
297 }
298
299 #[test]
300 fn selects_target_and_creates_metadata_when_absent() {
301 let catalog = json!({ "streams": [ { "tap_stream_id": "users", "schema": {} } ] });
303 let sel = select_streams(&catalog, "users");
304 assert_eq!(sel.selected, vec!["users"]);
305 assert!(is_selected(&sel.catalog, "users"));
306 assert_eq!(sel.catalog["streams"][0]["selected"], json!(true));
308 assert!(sel.warnings.is_empty(), "single-stream tap: no warning");
309 }
310
311 #[test]
312 fn includes_parent_stream_referenced_in_metadata() {
313 let catalog = json!({
314 "streams": [
315 { "tap_stream_id": "repositories", "schema": {},
316 "metadata": [{ "breadcrumb": [], "metadata": {} }] },
317 { "tap_stream_id": "issues", "schema": {},
318 "metadata": [{ "breadcrumb": [], "metadata": { "parent_stream": "repositories" } }] }
319 ]
320 });
321 let sel = select_streams(&catalog, "issues");
322 assert!(sel.selected.contains(&"issues".to_string()));
323 assert!(
324 sel.selected.contains(&"repositories".to_string()),
325 "parent must be auto-selected: {:?}",
326 sel.selected
327 );
328 assert!(is_selected(&sel.catalog, "issues"));
329 assert!(is_selected(&sel.catalog, "repositories"));
330 assert!(sel.warnings.is_empty(), "warnings: {:?}", sel.warnings);
332 }
333
334 #[test]
335 fn follows_parent_field_on_stream_object() {
336 let catalog = json!({
337 "streams": [
338 { "tap_stream_id": "accounts", "schema": {} },
339 { "tap_stream_id": "contacts", "parent": "accounts", "schema": {} }
340 ]
341 });
342 let sel = select_streams(&catalog, "contacts");
343 assert!(sel.selected.contains(&"accounts".to_string()));
344 }
345
346 #[test]
347 fn warns_when_target_missing() {
348 let catalog = json!({ "streams": [ { "tap_stream_id": "users" } ] });
349 let sel = select_streams(&catalog, "nope");
350 assert!(sel.selected.is_empty());
351 assert_eq!(sel.warnings.len(), 1);
352 assert!(sel.warnings[0].contains("not found"));
353 assert!(sel.warnings[0].contains("users"));
354 }
355
356 #[test]
357 fn warns_when_multiple_streams_but_no_parent_metadata() {
358 let catalog = json!({
359 "streams": [
360 { "tap_stream_id": "issues", "schema": {} },
361 { "tap_stream_id": "repositories", "schema": {} }
362 ]
363 });
364 let sel = select_streams(&catalog, "issues");
365 assert_eq!(sel.selected, vec!["issues"]);
366 assert_eq!(sel.warnings.len(), 1);
367 assert!(sel.warnings[0].contains("manual selection"));
368 assert!(sel.warnings[0].contains("repositories"));
369 }
370
371 #[test]
372 fn warns_when_referenced_parent_absent() {
373 let catalog = json!({
374 "streams": [
375 { "tap_stream_id": "issues", "parent": "repositories", "schema": {} }
376 ]
377 });
378 let sel = select_streams(&catalog, "issues");
379 assert_eq!(sel.selected, vec!["issues"]);
380 assert!(
381 sel.warnings
382 .iter()
383 .any(|w| w.contains("not in the catalog")),
384 "warnings: {:?}",
385 sel.warnings
386 );
387 }
388}