use crate::error::{CliError, CliResult};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub vars: Option<HashMap<String, Value>>,
#[serde(default)]
pub auth: Option<HashMap<String, Value>>,
pub pipeline: PipelineSpec,
#[serde(default)]
pub matrix: Vec<MatrixRow>,
#[serde(default)]
pub execution: Option<ExecutionSpec>,
#[serde(default)]
pub observability: Option<ObservabilitySpec>,
#[serde(default)]
pub delivery: faucet_core::DeliveryMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resilience: Option<ResilienceSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shard: Option<ShardingSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replication: Option<crate::replication::spec::ReplicationSpec>,
#[cfg(feature = "schedule")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
#[cfg(feature = "lineage")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage: Option<faucet_lineage::LineageConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineSpec {
#[serde(default)]
pub source: Option<ConnectorSpec>,
#[serde(default)]
pub sink: Option<ConnectorSpec>,
#[serde(default)]
pub sources: HashMap<String, ConnectorSpec>,
#[serde(default)]
pub sinks: HashMap<String, ConnectorSpec>,
#[serde(default)]
pub transforms: Vec<TransformSpec>,
#[serde(default)]
pub state: Option<StateStoreSpec>,
#[serde(default)]
pub dlq: Option<DlqSpec>,
#[cfg(feature = "quality")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality: Option<faucet_core::QualitySpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<faucet_core::SchemaDriftSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConnectorSpec {
#[serde(rename = "type")]
pub kind: String,
#[serde(default = "empty_object")]
pub config: Value,
#[serde(default)]
pub transforms: Option<Vec<TransformSpec>>,
#[serde(default = "default_true")]
pub inherit_transforms: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialConnector {
#[serde(default)]
pub r#ref: Option<String>,
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub config: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TransformSpec {
#[serde(rename = "type")]
pub kind: String,
#[serde(default = "empty_object")]
pub config: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StateStoreSpec {
#[serde(rename = "type")]
pub kind: String,
#[serde(default = "empty_object")]
pub config: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MatrixRow {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub parent: Option<String>,
#[serde(default = "default_parent_key")]
pub parent_key: String,
#[serde(default)]
pub source: Option<PartialConnector>,
#[serde(default)]
pub sink: Option<PartialConnector>,
#[serde(default)]
pub transforms: Option<Vec<TransformSpec>>,
#[serde(default = "default_true")]
pub inherit_transforms: bool,
#[serde(default)]
pub state: Option<StateStoreSpec>,
#[serde(default, deserialize_with = "deserialize_dlq_override")]
pub dlq: Option<Option<DlqSpec>>,
#[serde(default)]
pub delivery: Option<faucet_core::DeliveryMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExecutionSpec {
#[serde(default)]
pub max_concurrent: Option<usize>,
#[serde(default)]
pub on_error: OnError,
#[serde(default)]
pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ShardingSpec {
pub count: usize,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OnError {
#[default]
Continue,
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObservabilitySpec {
#[serde(default)]
pub prometheus: Option<PrometheusSpec>,
#[serde(default)]
pub tracing: Option<TracingSpec>,
#[serde(default)]
pub otel: Option<OtelSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PrometheusSpec {
pub listen: String,
#[serde(default)]
pub buckets: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TracingSpec {
#[serde(default)]
pub level: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OtelSpec {
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub protocol: faucet_core::OtelProtocol,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
#[serde(default = "default_otel_ratio")]
pub sample_ratio: f64,
#[serde(default = "default_otel_export")]
pub export: Vec<faucet_core::OtelSignal>,
#[serde(default = "default_otel_service")]
pub service_name: String,
#[serde(default = "default_otel_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_otel_interval")]
pub metric_interval_secs: u64,
}
fn default_otel_ratio() -> f64 {
1.0
}
fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
vec![
faucet_core::OtelSignal::Traces,
faucet_core::OtelSignal::Metrics,
]
}
fn default_otel_service() -> String {
"faucet".to_string()
}
fn default_otel_timeout() -> u64 {
10
}
fn default_otel_interval() -> u64 {
60
}
impl OtelSpec {
pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
let cfg = faucet_core::OtelConfig {
endpoint: self.endpoint.clone(),
protocol: self.protocol,
headers: self.headers.clone(),
sample_ratio: self.sample_ratio,
export: self.export.clone(),
service_name: self.service_name.clone(),
timeout_secs: self.timeout_secs,
metric_interval_secs: self.metric_interval_secs,
};
cfg.validate()?;
Ok(cfg)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OnBatchErrorSpec {
#[default]
Propagate,
DlqAll,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DlqSpec {
pub sink: ConnectorSpec,
#[serde(default)]
pub on_batch_error: OnBatchErrorSpec,
#[serde(default)]
pub max_failures_per_page: Option<usize>,
#[serde(default)]
pub max_failures_total: Option<usize>,
#[serde(default = "default_true")]
pub include_original_payload: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ResilienceSpec {
#[serde(default)]
pub retry: RetrySpec,
#[serde(default)]
pub retry_on: Option<Vec<faucet_core::RetryClass>>,
#[serde(default)]
pub circuit_breaker: Option<CircuitBreakerSpec>,
#[serde(default)]
pub poison: Option<PoisonSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RetrySpec {
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default)]
pub backoff: BackoffSpec,
#[serde(default = "default_base_ms")]
pub base_ms: u64,
#[serde(default = "default_max_ms")]
pub max_ms: u64,
#[serde(default = "default_true")]
pub jitter: bool,
}
impl Default for RetrySpec {
fn default() -> Self {
Self {
max_attempts: default_max_attempts(),
backoff: BackoffSpec::default(),
base_ms: default_base_ms(),
max_ms: default_max_ms(),
jitter: true,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BackoffSpec {
None,
Fixed,
#[default]
Exponential,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CircuitBreakerSpec {
pub consecutive_failures: u32,
pub cooldown_secs: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PoisonSpec {
pub max_row_attempts: u32,
#[serde(default)]
pub action: PoisonActionSpec,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PoisonActionSpec {
#[default]
Dlq,
Drop,
Fail,
}
fn default_max_attempts() -> u32 {
5
}
fn default_base_ms() -> u64 {
200
}
fn default_max_ms() -> u64 {
30_000
}
impl ResilienceSpec {
pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
use crate::error::CliError;
if self.retry.max_attempts < 1 {
return Err(CliError::Config(
"resilience.retry.max_attempts must be >= 1".into(),
));
}
if self.retry.base_ms > self.retry.max_ms {
return Err(CliError::Config(
"resilience.retry.base_ms must be <= max_ms".into(),
));
}
let retry_on = match &self.retry_on {
Some(v) if v.is_empty() => {
return Err(CliError::Config(
"resilience.retry_on must not be empty".into(),
));
}
Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
None => faucet_core::RetryClassSet::default(),
};
let backoff = match self.retry.backoff {
BackoffSpec::None => faucet_core::BackoffKind::None,
BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
};
let circuit_breaker = match self.circuit_breaker {
Some(cb) if cb.consecutive_failures < 1 => {
return Err(CliError::Config(
"resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
));
}
Some(cb) => Some(faucet_core::CircuitBreakerConfig {
consecutive_failures: cb.consecutive_failures,
cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
}),
None => None,
};
let poison = match self.poison {
Some(p) if p.max_row_attempts < 1 => {
return Err(CliError::Config(
"resilience.poison.max_row_attempts must be >= 1".into(),
));
}
Some(p) => Some(faucet_core::PoisonPolicy {
max_row_attempts: p.max_row_attempts,
action: match p.action {
PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
},
}),
None => None,
};
Ok(faucet_core::ResiliencePolicy {
retry: faucet_core::RetryPolicy {
max_attempts: self.retry.max_attempts,
backoff,
base: std::time::Duration::from_millis(self.retry.base_ms),
max: std::time::Duration::from_millis(self.retry.max_ms),
jitter: self.retry.jitter,
retry_on,
},
circuit_breaker,
poison,
})
}
}
fn default_true() -> bool {
true
}
fn default_version() -> u32 {
1
}
fn default_parent_key() -> String {
"id".to_owned()
}
fn empty_object() -> Value {
Value::Object(Default::default())
}
fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<DlqSpec>::deserialize(deserializer).map(Some)
}
fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
use crate::interpolate::interpolate_value;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("yaml" | "yml") => {
let mut value: serde_json::Value =
serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: friendly_parse_error(&e.to_string()),
})?;
interpolate_value(&mut value)?;
serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: e.to_string(),
})
}
Some("json") => {
let mut value: serde_json::Value =
serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: friendly_parse_error(&e.to_string()),
})?;
interpolate_value(&mut value)?;
serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: e.to_string(),
})
}
_ => Err(CliError::UnknownExtension {
path: path.to_path_buf(),
}),
}
}
impl PipelineConfig {
pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
let path = path.as_ref();
let composed = crate::compose::compose(path, profile)?;
let interpolated = interpolate_document(&composed, path)?;
let cfg = Self::from_text(&interpolated, path)?;
crate::secrets::ensure_no_secret_directives(&cfg)?;
Ok(cfg)
}
pub fn from_path_tolerating_secrets(
path: impl AsRef<Path>,
profile: Option<&str>,
) -> CliResult<Self> {
let path = path.as_ref();
let composed = crate::compose::compose(path, profile)?;
let interpolated = interpolate_document(&composed, path)?;
Self::from_text(&interpolated, path)
}
pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
let path = path.as_ref();
let composed = crate::compose::compose(path, profile)?;
let interpolated = interpolate_document(&composed, path)?;
let mut cfg = Self::from_text(&interpolated, path)?;
crate::secrets::resolve_secrets(&mut cfg).await?;
Ok(cfg)
}
pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let cfg: PipelineConfig = match ext.as_deref() {
Some("yaml" | "yml") => {
serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: friendly_parse_error(&e.to_string()),
})?
}
Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
path: path.to_path_buf(),
message: friendly_parse_error(&e.to_string()),
})?,
_ => {
return Err(CliError::UnknownExtension {
path: path.to_path_buf(),
});
}
};
Self::finish(cfg, path)
}
pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
let synthetic = Path::new("<submitted>");
let cfg: PipelineConfig =
serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
path: synthetic.to_path_buf(),
message: friendly_parse_error(&e.to_string()),
})?;
Self::finish(cfg, synthetic)
}
fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
if cfg.version != 1 {
return Err(CliError::ParseConfig {
path: path.to_path_buf(),
message: format!(
"unsupported pipeline version {}, only version 1 is recognised",
cfg.version
),
});
}
crate::interpolate::resolve_config_refs(&mut cfg)?;
if let Some(obs) = cfg.observability.as_ref()
&& let Some(otel) = obs.otel.as_ref()
{
otel.to_core().map_err(CliError::Config)?;
}
Ok(cfg)
}
}
fn friendly_parse_error(raw: &str) -> String {
let lower = raw.to_ascii_lowercase();
if lower.contains("missing field `pipeline`") {
return format!(
"{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
);
}
if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
return format!(
"{raw}\n\nhint: config composition (`extends` / `profiles` / `!include`) is resolved only for file-based loads, not for configs submitted to `faucet serve` — resolve composition before submitting."
);
}
raw.to_owned()
}
pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
let synthetic = PathBuf::from(format!("pipeline.{ext}"));
PipelineConfig::from_text(text, &synthetic)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_minimal_pipeline_yaml() {
let yaml = r#"
version: 1
pipeline:
source:
type: rest
config:
base_url: https://api.example.com
sink:
type: jsonl
config:
path: ./out.jsonl
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
assert!(cfg.matrix.is_empty());
assert!(cfg.execution.is_none());
assert!(cfg.pipeline.transforms.is_empty());
assert!(cfg.pipeline.state.is_none());
}
#[test]
fn parses_replication_block() {
let yaml = r#"
version: 1
pipeline:
source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
state: { type: file, config: { path: ./st } }
replication:
mode: snapshot_then_cdc
snapshot:
source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let r = cfg.replication.expect("replication parsed");
assert_eq!(r.snapshot.source.kind, "postgres");
}
#[test]
fn pipeline_spec_parses_schema_block() {
let yaml = r#"
version: 1
pipeline:
source:
type: rest
config:
base_url: https://api.example.com
sink:
type: jsonl
config:
path: ./out.jsonl
schema:
on_drift: evolve
allow_type_widening: false
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let schema = cfg.pipeline.schema.expect("schema block parsed");
assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
assert!(!schema.allow_type_widening);
}
#[test]
fn parses_minimal_json() {
let raw = r#"{
"version": 1,
"pipeline": {
"source": {"type": "rest", "config": {}},
"sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
}
}"#;
let cfg = parse_with_extension(raw, "json").unwrap();
assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
}
#[test]
fn parses_matrix_rows_with_partial_overrides() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: https://api.example.com } }
sink: { type: jsonl, config: { path: ./out.jsonl } }
matrix:
- id: users
source: { config: { path: /v1/users } }
sink: { config: { path: ./users.jsonl } }
- id: posts
parent: users
parent_key: user_id
source: { config: { path: "/v1/users/${users.id}/posts" } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.matrix.len(), 2);
assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
assert!(cfg.matrix[0].parent.is_none());
let users_src = cfg.matrix[0].source.as_ref().unwrap();
assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
assert_eq!(cfg.matrix[1].parent_key, "user_id");
}
#[test]
fn parent_key_defaults_to_id() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
matrix:
- { id: users }
- { id: posts, parent: users }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.matrix[1].parent_key, "id");
}
#[test]
fn parses_execution_block() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
execution:
max_concurrent: 8
on_error: stop
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let exec = cfg.execution.unwrap();
assert_eq!(exec.max_concurrent, Some(8));
assert_eq!(exec.on_error, OnError::Stop);
}
#[test]
fn on_error_defaults_to_continue() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
execution: { max_concurrent: 2 }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
}
#[test]
fn rejects_old_top_level_source_sink_with_hint() {
let yaml = r#"
version: 1
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let err = parse_with_extension(yaml, "yaml").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("pipeline"),
"expected a hint about wrapping in `pipeline:`, got: {msg}"
);
}
#[test]
fn rejects_unknown_extension() {
let text = "version: 1\n";
let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
assert!(matches!(err, CliError::UnknownExtension { .. }));
}
#[test]
fn rejects_future_version() {
let yaml = r#"
version: 99
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./x } }
"#;
let err = parse_with_extension(yaml, "yaml").unwrap_err();
match err {
CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
other => panic!("expected ParseConfig, got {other:?}"),
}
}
#[test]
fn transforms_and_state_round_trip() {
let yaml = r#"
version: 1
pipeline:
source:
type: rest
config: {}
transforms:
- type: snake_case
- type: flatten
config: { separator: "__" }
sink:
type: jsonl
config: { path: "./out.jsonl" }
state:
type: file
config: { path: "./.faucet-state" }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.pipeline.transforms.len(), 2);
assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
assert_eq!(
cfg.pipeline.transforms[1].config,
json!({"separator": "__"})
);
let state = cfg.pipeline.state.unwrap();
assert_eq!(state.kind, "file");
}
#[test]
fn from_path_interpolates_env_var() {
unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pipeline.yaml");
std::fs::write(
&path,
r#"
version: 1
pipeline:
source:
type: rest
config:
base_url: ${env:FAUCET_CFG_URL}
sink:
type: jsonl
config:
path: ./out.jsonl
"#,
)
.unwrap();
let cfg = PipelineConfig::from_path(&path, None).unwrap();
assert_eq!(
cfg.pipeline.source.as_ref().unwrap().config["base_url"],
"https://x.example"
);
unsafe { std::env::remove_var("FAUCET_CFG_URL") };
}
#[test]
fn observability_block_parses() {
let y = r#"
version: 1
name: x
observability:
prometheus:
listen: "127.0.0.1:9464"
buckets: [0.01, 0.1, 1.0]
tracing:
level: "info"
pipeline:
source:
type: rest
config:
base_url: "https://example.com"
path: "/data"
sink:
type: jsonl
config:
path: "/tmp/faucet-test.jsonl"
"#;
let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
let obs = cfg.observability.expect("observability block parsed");
let p = obs.prometheus.expect("prometheus parsed");
assert_eq!(p.listen, "127.0.0.1:9464");
assert_eq!(p.buckets.unwrap().len(), 3);
assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
}
#[test]
fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pipeline.yaml");
std::fs::write(
&path,
r#"
version: 1
pipeline:
source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#,
)
.unwrap();
let cfg = PipelineConfig::from_path(&path, None).unwrap();
assert_eq!(
cfg.pipeline.source.as_ref().unwrap().config["path"],
"/v1/users/${users.id}/posts"
);
}
#[cfg(feature = "schedule")]
#[test]
fn parses_schedule_block() {
let yaml = r#"
version: 1
schedule:
cron: "0 2 * * *"
timezone: "America/Los_Angeles"
overlap_policy: skip
max_consecutive_failures: 5
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let s = cfg.schedule.expect("schedule parsed");
assert_eq!(s.cron, "0 2 * * *");
assert_eq!(s.timezone, "America/Los_Angeles");
assert_eq!(s.max_consecutive_failures, Some(5));
}
#[test]
fn execution_spec_parses_adaptive_block() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: https://api.example.com } }
sink: { type: jsonl, config: { path: ./out.jsonl } }
execution:
adaptive_batch_size:
enabled: true
min: 200
max: 4000
target_latency_ms: 800
"#;
let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
assert!(ab.enabled);
assert_eq!(ab.min, 200);
assert_eq!(ab.target_latency_ms, Some(800));
ab.validate().unwrap();
}
#[cfg(feature = "quality")]
#[test]
fn parses_quality_block() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { url: "https://x" } }
quality:
record:
- { type: not_null, field: id, on_failure: abort }
sink: { type: stdout, config: {} }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let q = cfg.pipeline.quality.expect("quality parsed");
assert_eq!(q.record.len(), 1);
}
#[test]
fn parses_dlq_block_with_defaults() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
dlq:
sink: { type: jsonl, config: { path: ./dlq.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let dlq = cfg.pipeline.dlq.expect("dlq parsed");
assert_eq!(dlq.sink.kind, "jsonl");
assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
assert!(dlq.max_failures_per_page.is_none());
assert!(dlq.max_failures_total.is_none());
assert!(dlq.include_original_payload);
}
#[test]
fn parses_dlq_block_with_dlq_all_and_budgets() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
dlq:
sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
on_batch_error: dlq_all
max_failures_per_page: 100
max_failures_total: 10000
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let dlq = cfg.pipeline.dlq.unwrap();
assert_eq!(dlq.sink.kind, "kafka");
assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
assert_eq!(dlq.max_failures_per_page, Some(100));
assert_eq!(dlq.max_failures_total, Some(10000));
}
#[test]
fn matrix_row_dlq_null_disables_inherited_dlq() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
dlq:
sink: { type: jsonl, config: { path: ./dlq.jsonl } }
matrix:
- id: a
- id: b
dlq: null
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert!(cfg.matrix[0].dlq.is_none());
assert_eq!(cfg.matrix[1].dlq, Some(None));
}
#[test]
fn matrix_row_dlq_object_replaces_inherited_dlq() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
dlq:
sink: { type: jsonl, config: { path: ./base.jsonl } }
matrix:
- id: a
dlq:
sink: { type: jsonl, config: { path: ./a.jsonl } }
on_batch_error: dlq_all
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
let sink_path = row_dlq.sink.config.get("path").unwrap();
assert_eq!(sink_path, "./a.jsonl");
}
#[test]
fn parses_named_sources_and_sinks() {
let yaml = r#"
version: 1
pipeline:
sources:
users_api:
type: rest
config: { base_url: https://api.example.com }
posts_api:
type: rest
config: { base_url: https://api.example.com }
sinks:
warehouse:
type: postgres
config: { connection_url: "postgres://x" }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert!(cfg.pipeline.source.is_none());
assert!(cfg.pipeline.sink.is_none());
assert_eq!(cfg.pipeline.sources.len(), 2);
assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
}
#[test]
fn legacy_singular_source_still_parses() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert!(cfg.pipeline.source.is_some());
assert!(cfg.pipeline.sink.is_some());
assert!(cfg.pipeline.sources.is_empty());
assert!(cfg.pipeline.sinks.is_empty());
}
#[test]
fn parses_matrix_row_with_ref_field() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
matrix:
- id: load_users
source:
ref: users_api
config: { path: /v1/users }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let src = cfg.matrix[0].source.as_ref().unwrap();
assert_eq!(src.r#ref.as_deref(), Some("users_api"));
assert_eq!(src.kind, None);
assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
}
#[test]
fn parses_top_level_vars_block() {
let yaml = r#"
version: 1
vars:
api_base: https://api.example.com
api_token_env: API_TOKEN
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let vars = cfg.vars.as_ref().unwrap();
assert_eq!(vars["api_base"], "https://api.example.com");
assert_eq!(vars["api_token_env"], "API_TOKEN");
}
#[test]
fn vars_block_is_optional() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert!(cfg.vars.is_none());
}
#[test]
fn from_path_resolves_vars_at_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pipeline.yaml");
std::fs::write(
&path,
r#"
version: 1
vars:
base: https://api.example.com
pipeline:
source: { type: rest, config: { url: "${vars.base}/v1" } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#,
)
.unwrap();
let cfg = PipelineConfig::from_path(&path, None).unwrap();
assert_eq!(
cfg.pipeline.source.as_ref().unwrap().config["url"],
"https://api.example.com/v1"
);
}
#[test]
fn sync_from_path_errors_on_secret_directive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("p.yaml");
std::fs::write(
&path,
r#"
version: 1
pipeline:
source: { type: rest, config: { url: "${vault:secret/x}" } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#,
)
.unwrap();
match PipelineConfig::from_path(&path, None).unwrap_err() {
CliError::SecretsRequireAsyncLoad => {}
other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
}
}
#[test]
fn from_value_accepts_v1_and_resolves_refs() {
let v = serde_json::json!({
"version": 1,
"vars": { "out": "resolved.jsonl" },
"pipeline": {
"source": { "type": "csv", "config": { "path": "x.csv" } },
"sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
}
});
let cfg = PipelineConfig::from_value(v).unwrap();
assert_eq!(cfg.version, 1);
assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
}
#[test]
fn from_value_rejects_non_v1() {
let v = serde_json::json!({ "version": 99, "pipeline": {} });
let err = PipelineConfig::from_value(v).unwrap_err();
match err {
CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
other => panic!("expected ParseConfig, got {other:?}"),
}
}
#[tokio::test]
async fn async_from_path_loads_without_secrets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("p.yaml");
std::fs::write(
&path,
r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: https://x } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#,
)
.unwrap();
let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
assert_eq!(cfg.version, 1);
}
#[cfg(feature = "lineage")]
#[test]
fn parses_lineage_block() {
let yaml = r#"
version: 1
lineage:
namespace: prod
transport: { type: file, config: { path: /tmp/ol.jsonl } }
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let l = cfg.lineage.expect("lineage parsed");
assert_eq!(l.namespace, "prod");
}
#[test]
fn from_path_resolves_extends_and_profile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("base.yaml"),
"version: 1\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: base.jsonl } }\nprofiles:\n prod:\n pipeline:\n sink: { config: { path: prod.jsonl } }\n",
)
.unwrap();
let app = dir.path().join("app.yaml");
std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
let cfg = PipelineConfig::from_path(&app, None).unwrap();
assert_eq!(
cfg.pipeline.sink.as_ref().unwrap().config["path"],
"base.jsonl"
);
let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
assert_eq!(
cfg.pipeline.sink.as_ref().unwrap().config["path"],
"prod.jsonl"
);
}
#[test]
fn from_value_rejects_extends_with_composition_hint() {
let v = serde_json::json!({
"version": 1,
"extends": "base.yaml",
"pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
});
let err = PipelineConfig::from_value(v).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("composition"),
"expected composition hint, got: {msg}"
);
}
#[test]
fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
let yaml2 = r#"
version: 1
delivery: exactly_once
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#;
let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
let yaml3 = r#"
version: 1
delivery: at_least_once
pipeline:
source: { type: rest, config: {} }
sink: { type: jsonl, config: { path: ./o.jsonl } }
matrix:
- id: a
- id: b
delivery: exactly_once
"#;
let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
assert_eq!(cfg3.matrix[0].delivery, None);
assert_eq!(
cfg3.matrix[1].delivery,
Some(faucet_core::DeliveryMode::ExactlyOnce)
);
}
#[test]
fn resilience_spec_parses_and_builds_policy() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: "https://x" } }
sink: { type: stdout, config: {} }
resilience:
retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
retry_on: [http_5xx, timeout]
circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
poison: { max_row_attempts: 2, action: dlq }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let spec = cfg.resilience.unwrap();
let policy = spec.to_policy().unwrap();
assert_eq!(policy.retry.max_attempts, 4);
assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
}
#[test]
fn resilience_rejects_zero_max_attempts() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: "https://x" } }
sink: { type: stdout, config: {} }
resilience: { retry: { max_attempts: 0 } }
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let err = cfg.resilience.unwrap().to_policy().unwrap_err();
assert!(err.to_string().contains("max_attempts"));
}
#[test]
fn observability_parses_otel_block() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: "http://x" } }
sink: { type: stdout, config: {} }
observability:
otel:
endpoint: http://collector:4317
protocol: grpc
export: [traces, metrics]
"#;
let cfg = parse_with_extension(yaml, "yaml").unwrap();
let otel = cfg.observability.unwrap().otel.unwrap();
assert_eq!(otel.endpoint, "http://collector:4317");
}
#[test]
fn otel_validation_rejects_bad_ratio() {
let yaml = r#"
version: 1
pipeline:
source: { type: rest, config: { base_url: "http://x" } }
sink: { type: stdout, config: {} }
observability:
otel:
sample_ratio: 9.0
"#;
let err = parse_with_extension(yaml, "yaml").unwrap_err();
assert!(format!("{err}").contains("sample_ratio"));
}
}