use crate::error::{CliError, CliResult};
use crate::interpolate::interpolate;
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>,
#[cfg(feature = "schedule")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
}
#[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>,
}
#[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>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[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, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[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>,
}
#[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, 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,
}
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)
}
impl PipelineConfig {
pub fn from_path(path: impl AsRef<Path>) -> CliResult<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
path: path.to_path_buf(),
source,
})?;
let interpolated = interpolate(&raw)?;
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>) -> CliResult<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
path: path.to_path_buf(),
source,
})?;
let interpolated = interpolate(&raw)?;
Self::from_text(&interpolated, path)
}
pub async fn from_path_async(path: impl AsRef<Path>) -> CliResult<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
path: path.to_path_buf(),
source,
})?;
let interpolated = interpolate(&raw)?;
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)?;
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."
);
}
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_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).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).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).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).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).await.unwrap();
assert_eq!(cfg.version, 1);
}
}