use assert_cmd::Command;
use predicates::str::contains;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn csv_to_jsonl_yaml(csv: &Path, out: &Path) -> String {
format!(
r#"version: 1
name: csv_to_jsonl_smoke
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: jsonl
config:
path: {out}
"#,
csv = csv.display(),
out = out.display(),
)
}
#[test]
fn list_lists_compiled_in_connectors() {
Command::cargo_bin("faucet")
.unwrap()
.arg("list")
.assert()
.success()
.stdout(contains("Sources:"))
.stdout(contains("Sinks:"))
.stdout(contains("rest "))
.stdout(contains("jsonl "));
}
#[cfg(feature = "transforms")]
#[test]
fn list_lists_compiled_in_transforms() {
Command::cargo_bin("faucet")
.unwrap()
.arg("list")
.assert()
.success()
.stdout(contains("Transforms:"))
.stdout(contains("flatten "))
.stdout(contains("keys_case "))
.stdout(contains("Re-case every key"));
}
#[cfg(feature = "transforms")]
#[test]
fn schema_prints_transform_schema() {
Command::cargo_bin("faucet")
.unwrap()
.args(["schema", "transform", "flatten"])
.assert()
.success()
.stdout(contains("\"separator\""))
.stdout(contains("\"__\""));
}
#[cfg(feature = "transforms")]
#[test]
fn schema_transform_keys_case_lists_modes() {
Command::cargo_bin("faucet")
.unwrap()
.args(["schema", "transform", "keys_case"])
.assert()
.success()
.stdout(contains("snake"))
.stdout(contains("camel"))
.stdout(contains("screaming_snake"));
}
#[test]
fn schema_rejects_unknown_transform() {
Command::cargo_bin("faucet")
.unwrap()
.args(["schema", "transform", "make_uppercase"])
.assert()
.failure()
.stderr(contains("unknown transform 'make_uppercase'"));
}
#[test]
fn schema_prints_jsonl_sink_schema() {
Command::cargo_bin("faucet")
.unwrap()
.args(["schema", "sink", "jsonl"])
.assert()
.success()
.stdout(contains("\"path\""));
}
#[test]
fn schema_rejects_unknown_kind() {
Command::cargo_bin("faucet")
.unwrap()
.args(["schema", "source", "nope"])
.assert()
.failure()
.stderr(contains("unknown source 'nope'"));
}
#[test]
fn init_scaffolds_pipeline_yaml() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "my_pipeline", "--output"])
.arg(&out)
.assert()
.success()
.stdout(contains("wrote"));
let body = fs::read_to_string(&out).unwrap();
assert!(body.contains("name: my_pipeline"));
assert!(body.contains("type: rest"));
}
#[test]
fn init_refuses_to_overwrite_existing_file() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
fs::write(&out, "version: 1\n").unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "again", "--output"])
.arg(&out)
.assert()
.failure()
.stderr(contains("refusing to overwrite"));
}
#[test]
fn init_no_args_uses_rest_jsonl_defaults() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--output"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
assert!(body.contains("name: my-pipeline"));
assert!(body.contains("type: rest"));
assert!(body.contains("type: jsonl"));
}
#[test]
fn init_with_source_sink_flags_uses_those_kinds() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args([
"init", "my_pipe", "--source", "rest", "--sink", "bigquery", "-o",
])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
assert!(body.contains("name: my_pipe"));
assert!(body.contains("type: rest"));
assert!(body.contains("type: bigquery"));
assert!(body.contains("# REQUIRED"));
assert!(body.contains("project_id"));
assert!(body.contains("dataset_id"));
assert!(body.contains("# batch_size"));
assert!(
body.contains("Alternative variants"),
"missing alternatives block:\n{body}"
);
assert!(
body.contains("# type: bearer"),
"REST bearer alternative missing:\n{body}"
);
assert!(
body.contains("# type: oauth2"),
"REST oauth2 alternative missing:\n{body}"
);
assert!(
body.contains("# type: application_default"),
"BigQuery application_default alternative missing:\n{body}"
);
}
#[test]
fn init_unknown_source_kind_lists_available_kinds() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--source", "nope", "--output"])
.arg(&out)
.assert()
.failure()
.stderr(contains("unknown source 'nope'"))
.stderr(contains("rest"));
}
#[test]
fn init_unknown_sink_kind_lists_available_kinds() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--sink", "nope", "--output"])
.arg(&out)
.assert()
.failure()
.stderr(contains("unknown sink 'nope'"))
.stderr(contains("jsonl"));
}
#[test]
fn init_force_overwrites_existing_file() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
fs::write(&out, "stale: contents\n").unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--force", "--output"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
assert!(!body.contains("stale: contents"));
assert!(body.contains("type: rest"));
}
#[test]
fn init_output_is_valid_yaml() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("pipeline.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--source", "rest", "--sink", "jsonl", "--output"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
serde_yaml::from_str::<serde_yaml::Value>(&body).expect("init output should parse as YAML");
}
#[test]
fn validate_accepts_csv_to_jsonl_yaml() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name,score\nalice,1\nbob,2\n").unwrap();
let yaml = csv_to_jsonl_yaml(&csv, &out);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["validate"])
.arg(&cfg)
.assert()
.success()
.stdout(contains("source=csv"))
.stdout(contains("sink=jsonl"))
.stdout(contains("rows=1"));
}
#[test]
fn validate_accepts_upsert_on_postgres_with_key() {
let dir = TempDir::new().unwrap();
let cfg = dir.path().join("pipeline.yaml");
fs::write(
&cfg,
r#"version: 1
name: upsert_ok
pipeline:
source:
type: rest
config:
url: http://x
sink:
type: postgres
config:
connection_url: postgres://x
table_name: t
column_mapping: auto_map
write_mode: upsert
key: [id]
"#,
)
.unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["validate"])
.arg(&cfg)
.assert()
.success()
.stdout(contains("sink=postgres"));
}
#[test]
fn validate_rejects_upsert_on_jsonl_sink() {
let dir = TempDir::new().unwrap();
let cfg = dir.path().join("pipeline.yaml");
fs::write(
&cfg,
r#"version: 1
name: upsert_bad
pipeline:
source:
type: rest
config:
url: http://x
sink:
type: jsonl
config:
path: out.jsonl
write_mode: upsert
key: [id]
"#,
)
.unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["validate"])
.arg(&cfg)
.assert()
.failure()
.stderr(contains("write_mode"))
.stderr(contains("upsert"))
.stderr(contains("jsonl"));
}
#[test]
fn run_executes_csv_to_jsonl_pipeline() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name,score\nalice,1\nbob,2\n").unwrap();
let yaml = csv_to_jsonl_yaml(&csv, &out);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run"])
.arg(&cfg)
.assert()
.success()
.stdout(contains("wrote 2 records"))
.stdout(contains("1 invocation"));
let lines: Vec<_> = fs::read_to_string(&out)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"alice\""));
}
#[test]
fn run_with_schema_drift_warn_against_schemaless_sink_is_inert() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name,score\nalice,1\nbob,2\n").unwrap();
let yaml = format!(
r#"version: 1
name: csv_to_jsonl_drift
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: jsonl
config:
path: {out}
schema:
on_drift: warn
"#,
csv = csv.display(),
out = out.display(),
);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run"])
.arg(&cfg)
.assert()
.success()
.stdout(contains("wrote 2 records"))
.stdout(contains("1 invocation"));
let lines: Vec<_> = fs::read_to_string(&out)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"alice\""));
}
fn sqlite_exec(db: &Path, sql: &str) -> bool {
match std::process::Command::new("sqlite3")
.arg(db)
.arg(sql)
.output()
{
Ok(out) => {
assert!(
out.status.success(),
"sqlite3 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
true
}
Err(_) => false,
}
}
fn sqlite_query(db: &Path, sql: &str) -> String {
let out = std::process::Command::new("sqlite3")
.arg(db)
.arg(sql)
.output()
.expect("sqlite3 query");
assert!(
out.status.success(),
"sqlite3 query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn run_schema_drift_fail_fires_through_a_schema_reporting_sink() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let db = dir.path().join("dest.db");
fs::write(&csv, "id,name\n1,alice\n2,bob\n").unwrap();
if !sqlite_exec(&db, "CREATE TABLE t (id INTEGER);") {
eprintln!("skipping: sqlite3 CLI not available");
return;
}
let yaml = format!(
r#"version: 1
name: csv_to_sqlite_drift_fail
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: sqlite
config:
database_url: sqlite:{db}
table_name: t
column_mapping: auto_map
schema:
on_drift: fail
"#,
csv = csv.display(),
db = db.display(),
);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run"])
.arg(&cfg)
.assert()
.failure()
.stderr(contains("Schema drift"))
.stderr(contains("name"));
assert_eq!(sqlite_query(&db, "SELECT count(*) FROM t;"), "0");
}
#[test]
fn run_schema_drift_ignore_strips_unknown_columns_through_sqlite() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let db = dir.path().join("dest.db");
fs::write(&csv, "id,name\n1,alice\n2,bob\n").unwrap();
if !sqlite_exec(&db, "CREATE TABLE t (id INTEGER);") {
eprintln!("skipping: sqlite3 CLI not available");
return;
}
let yaml = format!(
r#"version: 1
name: csv_to_sqlite_drift_ignore
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: sqlite
config:
database_url: sqlite:{db}
table_name: t
column_mapping: auto_map
schema:
on_drift: ignore
"#,
csv = csv.display(),
db = db.display(),
);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run"])
.arg(&cfg)
.assert()
.success()
.stdout(contains("wrote 2 records"));
assert_eq!(sqlite_query(&db, "SELECT count(*) FROM t;"), "2");
assert_eq!(sqlite_query(&db, "SELECT id FROM t ORDER BY id;"), "1\n2");
}
#[test]
fn run_with_dry_run_does_not_touch_the_sink_path() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name\nalice\nbob\n").unwrap();
let yaml = csv_to_jsonl_yaml(&csv, &out);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run", "--dry-run"])
.arg(&cfg)
.assert()
.success();
assert!(
!out.exists(),
"dry-run must not write to the configured sink path"
);
}
#[test]
fn run_with_limit_caps_records_written() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name\nalice\nbob\ncarol\n").unwrap();
let yaml = csv_to_jsonl_yaml(&csv, &out);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.args(["run", "--limit", "2"])
.arg(&cfg)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
assert_eq!(body.lines().count(), 2);
}
#[test]
fn run_with_state_path_persists_a_bookmark_dir() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name\nalice\n").unwrap();
let yaml = csv_to_jsonl_yaml(&csv, &out);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, yaml).unwrap();
let state_dir = dir.path().join("state");
Command::cargo_bin("faucet")
.unwrap()
.args(["run", "--state-path"])
.arg(&state_dir)
.arg(&cfg)
.assert()
.success();
}
#[test]
fn env_interpolation_resolves_inside_config_values() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name\nalice\n").unwrap();
let cfg_text = format!(
r#"version: 1
pipeline:
source:
type: csv
config:
path: ${{env:FAUCET_TEST_CSV_PATH}}
sink:
type: jsonl
config:
path: {out}
"#,
out = out.display()
);
let cfg = dir.path().join("pipeline.yaml");
fs::write(&cfg, cfg_text).unwrap();
Command::cargo_bin("faucet")
.unwrap()
.env("FAUCET_TEST_CSV_PATH", &csv)
.args(["run"])
.arg(&cfg)
.assert()
.success();
}
#[test]
fn shipped_example_yamls_pass_validate() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let env_placeholders: &[(&str, &str)] = &[
("API_KEY", "x"),
("API_TOKEN", "x"),
("API_USER", "x"),
("API_PASS", "x"),
("AUTH_TOKEN", "x"),
("ES_USER", "x"),
("ES_PASS", "x"),
("ES_API_KEY", "x"),
("GCP_KEY_JSON", "{}"),
("GITHUB_TOKEN", "x"),
("GRPC_API_KEY", "x"),
("GRPC_TOKEN", "x"),
("INGEST_TOKEN", "x"),
("INGEST_USER", "x"),
("INGEST_PASS", "x"),
("MARQUEZ_URL", "http://localhost:5000/api/v1/lineage"),
("PG_URL", "postgres://u:p@localhost/db"),
("SOURCE_PG_URL", "postgres://u:p@localhost/src"),
("DEST_PG_URL", "postgres://u:p@localhost/dst"),
("SNOWFLAKE_OAUTH_TOKEN", "x"),
("SOAP_USER", "x"),
("SOAP_PASS", "x"),
("STRIPE_TOKEN", "x"),
("FEED_TOKEN", "x"),
("API_BASE_URL", "https://api.example.com"),
("API_TOKEN_URL", "https://auth.example.com/oauth/token"),
("API_CLIENT_ID", "x"),
("API_CLIENT_SECRET", "x"),
(
"GOOGLE_APPLICATION_CREDENTIALS",
"/tmp/service-account.json",
),
];
let examples_dir = std::path::Path::new(manifest_dir).join("examples");
let workdir = TempDir::new().unwrap();
fs::write(workdir.path().join("snowflake_key.pem"), "dummy-key").unwrap();
let mut count = 0;
for entry in fs::read_dir(&examples_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
if path.file_name().and_then(|f| f.to_str()) == Some("serve_minimal.yaml") {
continue;
}
#[cfg(not(feature = "schedule"))]
{
let yaml_text = fs::read_to_string(&path).unwrap_or_default();
if yaml_text.contains("\nschedule:") || yaml_text.starts_with("schedule:") {
continue;
}
}
count += 1;
let mut cmd = Command::cargo_bin("faucet").unwrap();
for (k, v) in env_placeholders {
cmd.env(k, v);
}
cmd.current_dir(workdir.path())
.args(["validate", "--no-secrets"])
.arg(&path)
.assert()
.success();
}
assert!(count >= 30, "expected many YAML examples, got {count}");
}
#[test]
fn run_auto_discovers_faucet_yaml_and_dotenv_in_cwd() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
let out = dir.path().join("out.jsonl");
fs::write(&csv, "name\nzed\n").unwrap();
fs::write(
dir.path().join(".env"),
format!("DISCOVERED_OUT={}\n", out.display()),
)
.unwrap();
fs::write(
dir.path().join("faucet.yaml"),
format!(
r#"version: 1
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: jsonl
config:
path: ${{env:DISCOVERED_OUT}}
"#,
csv = csv.display(),
),
)
.unwrap();
Command::cargo_bin("faucet")
.unwrap()
.current_dir(dir.path())
.env_remove("DISCOVERED_OUT")
.arg("run")
.assert()
.success()
.stdout(contains("wrote 1 record"));
assert!(out.exists(), "auto-discovered run should produce output");
}
#[test]
fn run_with_no_config_and_no_from_env_errors() {
let dir = TempDir::new().unwrap();
Command::cargo_bin("faucet")
.unwrap()
.current_dir(dir.path())
.arg("run")
.assert()
.failure()
.stderr(contains("no pipeline config"));
}
#[test]
fn run_no_env_file_skips_dotenv_auto_load() {
let dir = TempDir::new().unwrap();
let csv = dir.path().join("in.csv");
fs::write(&csv, "name\nx\n").unwrap();
fs::write(
dir.path().join(".env"),
"FAUCET_TEST_SKIPPED_PATH=/tmp/should-not-be-read.jsonl\n",
)
.unwrap();
fs::write(
dir.path().join("faucet.yaml"),
format!(
r#"version: 1
pipeline:
source:
type: csv
config:
path: {csv}
sink:
type: jsonl
config:
path: ${{env:FAUCET_TEST_SKIPPED_PATH}}
"#,
csv = csv.display(),
),
)
.unwrap();
Command::cargo_bin("faucet")
.unwrap()
.current_dir(dir.path())
.env_remove("FAUCET_TEST_SKIPPED_PATH")
.args(["run", "--no-env-file"])
.assert()
.failure()
.stderr(contains("FAUCET_TEST_SKIPPED_PATH"));
}
#[test]
fn init_with_template_flag_names_the_template() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("p.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--template", "users_api", "--output"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(&out).unwrap();
assert!(
body.contains(" sources:\n users_api:"),
"expected ` sources:\\n users_api:` in:\n{body}"
);
assert!(
body.contains(" sinks:\n users_api:"),
"expected ` sinks:\\n users_api:` in:\n{body}"
);
}
#[test]
fn missing_env_var_in_config_is_reported() {
let dir = TempDir::new().unwrap();
let cfg = dir.path().join("pipeline.yaml");
fs::write(
&cfg,
r#"version: 1
pipeline:
source:
type: csv
config:
path: ${env:FAUCET_DEFINITELY_UNSET}
sink:
type: jsonl
config:
path: /tmp/no.jsonl
"#,
)
.unwrap();
Command::cargo_bin("faucet")
.unwrap()
.env_remove("FAUCET_DEFINITELY_UNSET")
.args(["validate"])
.arg(&cfg)
.assert()
.failure()
.stderr(contains("missing environment variable"));
}
#[test]
fn init_output_loads_and_expands() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("p.yaml");
Command::cargo_bin("faucet")
.unwrap()
.args(["init", "--source", "rest", "--sink", "jsonl", "--output"])
.arg(&path)
.assert()
.success();
let cfg = faucet_cli::config::PipelineConfig::from_path(&path, None)
.expect("init output must load via PipelineConfig::from_path");
let nodes = faucet_cli::expand::expand(&cfg).expect("init output must expand cleanly");
assert_eq!(nodes.len(), 1, "expected exactly one expanded node");
assert_eq!(nodes[0].source.kind, "rest");
assert_eq!(nodes[0].sink.kind, "jsonl");
assert!(
nodes[0].source.config.is_object(),
"source config must be a JSON object (got {:?}); \
likely a CONFIG_INDENT bug causing fields to float above `config:`",
nodes[0].source.config
);
}