use std::process::Stdio;
use faucet_core::{FaucetError, Value};
use tokio::process::Command;
use crate::config::SingerSourceConfig;
use crate::process::write_temp;
pub async fn discover(config: &SingerSourceConfig) -> Result<Value, FaucetError> {
let config_file = write_temp("config", &config.tap_config)?;
let output = Command::new(&config.executable)
.arg("--config")
.arg(config_file.path())
.arg("--discover")
.args(&config.args)
.stdin(Stdio::null())
.output()
.await
.map_err(|e| {
FaucetError::Source(format!(
"failed to spawn tap '{}' for discovery: {e}",
config.executable
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(FaucetError::Source(format!(
"tap discovery exited with status {}; stderr:\n{}",
output.status,
stderr.trim()
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| FaucetError::Source(format!("tap discovery output is not valid JSON: {e}")))
}
#[derive(Debug, Clone)]
pub struct StreamSelection {
pub catalog: Value,
pub selected: Vec<String>,
pub warnings: Vec<String>,
}
pub fn select_streams(catalog: &Value, target: &str) -> StreamSelection {
let mut catalog = catalog.clone();
let mut selected: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let ids = catalog_stream_ids(&catalog);
if !ids.iter().any(|s| s == target) {
warnings.push(format!(
"stream '{target}' not found in the discovered catalog; available: {}",
if ids.is_empty() {
"(none)".to_string()
} else {
ids.join(", ")
}
));
return StreamSelection {
catalog,
selected,
warnings,
};
}
let mut to_select = vec![target.to_string()];
let mut i = 0;
while i < to_select.len() {
let cur = to_select[i].clone();
if let Some(parent) = parent_stream_id(&catalog, &cur)
&& !to_select.contains(&parent)
{
if ids.iter().any(|s| s == &parent) {
to_select.push(parent);
} else {
warnings.push(format!(
"stream '{cur}' references parent '{parent}', which is not in the \
catalog — select the parent manually"
));
}
}
i += 1;
}
if let Some(streams) = catalog.get_mut("streams").and_then(Value::as_array_mut) {
for stream in streams.iter_mut() {
if let Some(id) = stream_id(stream)
&& to_select.contains(&id)
{
mark_selected(stream);
if !selected.contains(&id) {
selected.push(id);
}
}
}
}
if selected.len() == 1 && ids.len() > 1 && !catalog_exposes_parent_metadata(&catalog) {
warnings.push(format!(
"selected only '{target}'. If this tap has parent/child streams, the catalog does \
not express the relationship — a required parent may need manual selection. \
Available streams: {}",
ids.join(", ")
));
}
StreamSelection {
catalog,
selected,
warnings,
}
}
fn stream_id(stream: &Value) -> Option<String> {
stream
.get("tap_stream_id")
.or_else(|| stream.get("stream"))
.and_then(Value::as_str)
.map(str::to_string)
}
fn stream_level_metadata(stream: &Value) -> Option<&Value> {
stream
.get("metadata")
.and_then(Value::as_array)?
.iter()
.find(|m| {
m.get("breadcrumb")
.and_then(Value::as_array)
.map(|b| b.is_empty())
.unwrap_or(false)
})
.and_then(|m| m.get("metadata"))
}
fn parent_stream_id(catalog: &Value, id: &str) -> Option<String> {
let stream = catalog
.get("streams")
.and_then(Value::as_array)?
.iter()
.find(|s| stream_id(s).as_deref() == Some(id))?;
for key in ["parent_stream", "parent"] {
if let Some(p) = stream.get(key).and_then(Value::as_str) {
return Some(p.to_string());
}
if let Some(p) = stream_level_metadata(stream)
.and_then(|m| m.get(key))
.and_then(Value::as_str)
{
return Some(p.to_string());
}
}
None
}
fn catalog_exposes_parent_metadata(catalog: &Value) -> bool {
catalog
.get("streams")
.and_then(Value::as_array)
.map(|streams| {
streams
.iter()
.filter_map(stream_id)
.any(|id| parent_stream_id(catalog, &id).is_some())
})
.unwrap_or(false)
}
fn mark_selected(stream: &mut Value) {
if let Some(obj) = stream.as_object_mut() {
obj.insert("selected".to_string(), Value::Bool(true));
let metadata = obj
.entry("metadata")
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(arr) = metadata.as_array_mut() {
let root = arr.iter_mut().find(|m| {
m.get("breadcrumb")
.and_then(Value::as_array)
.map(|b| b.is_empty())
.unwrap_or(false)
});
match root {
Some(entry) => {
if let Some(md) = entry.get_mut("metadata").and_then(Value::as_object_mut) {
md.insert("selected".to_string(), Value::Bool(true));
} else if let Some(eobj) = entry.as_object_mut() {
eobj.insert(
"metadata".to_string(),
serde_json::json!({ "selected": true }),
);
}
}
None => arr.push(serde_json::json!({
"breadcrumb": [],
"metadata": { "selected": true }
})),
}
}
}
}
pub fn catalog_stream_ids(catalog: &Value) -> Vec<String> {
catalog
.get("streams")
.and_then(Value::as_array)
.map(|streams| {
streams
.iter()
.filter_map(|s| {
s.get("tap_stream_id")
.or_else(|| s.get("stream"))
.and_then(Value::as_str)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extracts_stream_ids_in_order() {
let catalog = json!({
"streams": [
{ "tap_stream_id": "users", "stream": "users" },
{ "stream": "orders" },
{ "tap_stream_id": "audit_log" }
]
});
assert_eq!(
catalog_stream_ids(&catalog),
vec!["users", "orders", "audit_log"]
);
assert!(catalog_stream_ids(&json!({})).is_empty());
}
fn is_selected(catalog: &Value, id: &str) -> bool {
catalog
.get("streams")
.and_then(Value::as_array)
.and_then(|streams| streams.iter().find(|s| stream_id(s).as_deref() == Some(id)))
.and_then(stream_level_metadata)
.and_then(|m| m.get("selected"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
#[test]
fn selects_target_and_creates_metadata_when_absent() {
let catalog = json!({ "streams": [ { "tap_stream_id": "users", "schema": {} } ] });
let sel = select_streams(&catalog, "users");
assert_eq!(sel.selected, vec!["users"]);
assert!(is_selected(&sel.catalog, "users"));
assert_eq!(sel.catalog["streams"][0]["selected"], json!(true));
assert!(sel.warnings.is_empty(), "single-stream tap: no warning");
}
#[test]
fn includes_parent_stream_referenced_in_metadata() {
let catalog = json!({
"streams": [
{ "tap_stream_id": "repositories", "schema": {},
"metadata": [{ "breadcrumb": [], "metadata": {} }] },
{ "tap_stream_id": "issues", "schema": {},
"metadata": [{ "breadcrumb": [], "metadata": { "parent_stream": "repositories" } }] }
]
});
let sel = select_streams(&catalog, "issues");
assert!(sel.selected.contains(&"issues".to_string()));
assert!(
sel.selected.contains(&"repositories".to_string()),
"parent must be auto-selected: {:?}",
sel.selected
);
assert!(is_selected(&sel.catalog, "issues"));
assert!(is_selected(&sel.catalog, "repositories"));
assert!(sel.warnings.is_empty(), "warnings: {:?}", sel.warnings);
}
#[test]
fn follows_parent_field_on_stream_object() {
let catalog = json!({
"streams": [
{ "tap_stream_id": "accounts", "schema": {} },
{ "tap_stream_id": "contacts", "parent": "accounts", "schema": {} }
]
});
let sel = select_streams(&catalog, "contacts");
assert!(sel.selected.contains(&"accounts".to_string()));
}
#[test]
fn warns_when_target_missing() {
let catalog = json!({ "streams": [ { "tap_stream_id": "users" } ] });
let sel = select_streams(&catalog, "nope");
assert!(sel.selected.is_empty());
assert_eq!(sel.warnings.len(), 1);
assert!(sel.warnings[0].contains("not found"));
assert!(sel.warnings[0].contains("users"));
}
#[test]
fn warns_when_multiple_streams_but_no_parent_metadata() {
let catalog = json!({
"streams": [
{ "tap_stream_id": "issues", "schema": {} },
{ "tap_stream_id": "repositories", "schema": {} }
]
});
let sel = select_streams(&catalog, "issues");
assert_eq!(sel.selected, vec!["issues"]);
assert_eq!(sel.warnings.len(), 1);
assert!(sel.warnings[0].contains("manual selection"));
assert!(sel.warnings[0].contains("repositories"));
}
#[test]
fn warns_when_referenced_parent_absent() {
let catalog = json!({
"streams": [
{ "tap_stream_id": "issues", "parent": "repositories", "schema": {} }
]
});
let sel = select_streams(&catalog, "issues");
assert_eq!(sel.selected, vec!["issues"]);
assert!(
sel.warnings
.iter()
.any(|w| w.contains("not in the catalog")),
"warnings: {:?}",
sel.warnings
);
}
}