Skip to main content

faucet_source_singer/
discover.rs

1//! Singer catalog discovery: run `<tap> --config <tmp> --discover` and return
2//! the catalog it prints. Used by `faucet init --source singer --discover`.
3
4use std::process::Stdio;
5
6use faucet_core::{FaucetError, Value};
7use tokio::process::Command;
8
9use crate::config::SingerSourceConfig;
10use crate::process::write_temp;
11
12/// Run the tap in discovery mode and parse its stdout as a Singer catalog.
13///
14/// Spawns `<executable> --config <tmp> --discover <args…>`, materializing
15/// `tap_config` to a private temp file. A non-zero exit or non-JSON output is a
16/// [`FaucetError::Source`] (with the tap's stderr on failure).
17pub 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/// Result of [`select_streams`]: the rewritten catalog plus what was selected
49/// and any warnings the caller should surface to the user.
50#[derive(Debug, Clone)]
51pub struct StreamSelection {
52    /// The catalog with selection metadata applied.
53    pub catalog: Value,
54    /// Stream ids marked `selected` (the target plus any inferred parents).
55    pub selected: Vec<String>,
56    /// Human-facing warnings (target missing, uninferrable parents, …).
57    pub warnings: Vec<String>,
58}
59
60/// Mark `target` — and any **parent** streams inferable from the catalog — as
61/// `selected`, returning the rewritten catalog.
62///
63/// Most database / Meltano-SDK taps sync **nothing** unless a stream is
64/// explicitly selected in the catalog, so a passed-through unselected catalog is
65/// a silent no-op. Parent-keyed taps (e.g. `tap-github`'s `issues` needs
66/// `repositories`) additionally require the parent stream selected even though
67/// faucet only emits the configured child stream.
68///
69/// Parent relationships are inferred, best-effort, from a `parent_stream` /
70/// `parent` field on the stream object or its stream-level (`breadcrumb: []`)
71/// metadata — the places taps that expose the relationship put it. When no such
72/// hint exists but the catalog has other streams, a warning is emitted so the
73/// user knows a parent may need manual selection.
74pub 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    // Transitive closure of parents, starting from the target.
97    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    // We selected only the target, the catalog has other streams, and none of
130    // them expose a parent relationship we could follow — warn that a
131    // parent-child tap may need manual selection.
132    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
148/// A stream's id: `tap_stream_id`, falling back to `stream`.
149fn 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
157/// The stream-level (`breadcrumb: []`) metadata object of a stream, if present.
158fn 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
172/// Best-effort parent-stream id for `id`, from a `parent_stream` / `parent`
173/// field on the stream object or its stream-level metadata.
174fn 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
195/// Whether any stream in the catalog exposes a parent relationship.
196fn 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
209/// Mark a single stream `selected`, both stream-level (legacy taps read
210/// `stream.selected`) and in its `breadcrumb: []` metadata (Meltano-SDK taps
211/// read the metadata), creating the metadata entry if absent.
212fn 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
246/// Extract the stream ids from a Singer catalog (`tap_stream_id`, falling back
247/// to `stream`), in catalog order.
248pub 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    /// Whether a stream is marked selected via its `breadcrumb: []` metadata.
288    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        // A bare stream with no metadata array at all.
302        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        // Legacy stream-level flag is set too.
307        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        // Parent relationship was inferable → no manual-selection warning.
331        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}