use anyhow::Context;
use std::path::Path;
use surreal_sync_core::InPlaceTransform;
use crate::pipeline::{load_pipeline_and_opts, ApplyOpts, Pipeline};
pub fn load_transforms_from_args(
transforms_config: Option<&Path>,
) -> anyhow::Result<(Pipeline, ApplyOpts)> {
match transforms_config {
None => {
tracing::info!(
"No --transforms-config; using identity transform pipeline (no stage dispatch)"
);
Ok((Pipeline::new(), ApplyOpts::identity()))
}
Some(path) => {
let (pipeline, opts) = load_pipeline_and_opts(path)
.with_context(|| format!("load --transforms-config {}", path.display()))?;
if pipeline.is_identity() {
tracing::info!(
path = %path.display(),
"Loaded --transforms-config but pipeline is identity (empty / passthrough-only)"
);
} else {
tracing::info!(
path = %path.display(),
stages = pipeline.len(),
max_in_flight = opts.max_in_flight,
batch_size = opts.batch_size,
failure_policy = ?opts.failure_policy,
"Transform pipeline active from --transforms-config"
);
}
Ok((pipeline, opts))
}
}
}
pub fn merge_inplace_transforms<I, T>(
transforms_config: Option<&Path>,
extra: I,
) -> anyhow::Result<(Pipeline, ApplyOpts)>
where
I: IntoIterator<Item = T>,
T: InPlaceTransform + 'static,
{
merge_inplace_boxed(
transforms_config,
extra
.into_iter()
.map(|t| Box::new(t) as Box<dyn InPlaceTransform>),
)
}
pub fn merge_inplace_boxed(
transforms_config: Option<&Path>,
extra: impl IntoIterator<Item = Box<dyn InPlaceTransform>>,
) -> anyhow::Result<(Pipeline, ApplyOpts)> {
let (mut pipeline, mut apply_opts) = load_transforms_from_args(transforms_config)?;
let before = pipeline.len();
for stage in extra {
pipeline.push_inplace_arc(std::sync::Arc::from(stage));
}
let added = pipeline.len().saturating_sub(before);
if added > 0 && apply_opts == ApplyOpts::identity() {
tracing::info!(
added,
"Rust InPlaceTransform stages appended after omitted --transforms-config; \
upgrading ApplyOpts from identity to default (batching on)"
);
apply_opts = ApplyOpts::default();
} else if added > 0 {
tracing::info!(
added,
stages = pipeline.len(),
"Appended Rust InPlaceTransform stages after --transforms-config"
);
}
Ok((pipeline, apply_opts))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::FlattenId;
use std::io::Write;
#[test]
fn missing_path_returns_identity() {
let (pipeline, opts) = load_transforms_from_args(None).expect("identity load");
assert!(pipeline.is_identity());
assert_eq!(opts, ApplyOpts::identity());
assert_eq!(
opts.max_in_flight, 1,
"omit path must pin W=1 for CDC cadence"
);
assert_eq!(opts.batch_size, 1);
}
#[test]
fn missing_file_fails_fast() {
let err = load_transforms_from_args(Some(Path::new(
"/nonexistent/surreal-sync-transforms-cli-xyz.toml",
)))
.expect_err("missing file must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("load --transforms-config"),
"expected load context in error, got: {msg}"
);
}
#[test]
fn empty_toml_is_identity_pipeline_with_default_opts() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("empty.toml");
std::fs::write(&path, "transforms = []\n").expect("write");
let (pipeline, opts) = load_transforms_from_args(Some(&path)).expect("load");
assert!(pipeline.is_identity());
assert_eq!(opts.batch_size, ApplyOpts::default().batch_size);
assert_ne!(opts.batch_size, ApplyOpts::identity().batch_size);
assert_eq!(opts, ApplyOpts::default());
}
#[test]
fn bad_toml_fails_fast() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad.toml");
let mut f = std::fs::File::create(&path).expect("create");
writeln!(f, "this is not [[transforms]] toml {{{{").expect("write");
let err = load_transforms_from_args(Some(&path)).expect_err("bad TOML must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("load --transforms-config"),
"expected load context in error, got: {msg}"
);
}
#[test]
fn bad_persistent_worker_fails_fast_with_load_context() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad-worker.toml");
std::fs::write(
&path,
r#"
[[transforms]]
type = "command"
mode = "persistent"
command = ["/nonexistent/surreal-sync-transform-worker-cli-xyz"]
"#,
)
.expect("write");
let err = load_transforms_from_args(Some(&path)).expect_err("bad worker must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("load --transforms-config"),
"expected load context in error, got: {msg}"
);
}
struct Tag;
impl InPlaceTransform for Tag {
fn transform(
&self,
_table: &str,
_id: &mut surreal_sync_core::Value,
fields: Option<&mut std::collections::HashMap<String, surreal_sync_core::Value>>,
) -> anyhow::Result<()> {
if let Some(fields) = fields {
fields.insert("tagged".into(), surreal_sync_core::Value::Bool(true));
}
Ok(())
}
}
#[test]
fn merge_rust_only_upgrades_identity_opts() {
let (pipeline, opts) = merge_inplace_transforms(None, [Tag]).expect("merge rust-only");
assert!(!pipeline.is_identity());
assert_eq!(pipeline.len(), 1);
assert_eq!(
opts,
ApplyOpts::default(),
"Rust stages after omitted --transforms-config must upgrade off identity cadence"
);
assert_ne!(opts, ApplyOpts::identity());
}
#[test]
fn merge_toml_flatten_id_then_rust() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("flatten.toml");
std::fs::write(
&path,
r#"
[[transforms]]
type = "flatten_id"
"#,
)
.expect("write");
let (pipeline, opts) =
merge_inplace_transforms(Some(path.as_path()), [Tag]).expect("merge toml+rust");
assert!(!pipeline.is_identity());
assert_eq!(
pipeline.len(),
2,
"TOML flatten_id then Rust stage → 2 stages"
);
assert_eq!(opts.batch_size, ApplyOpts::default().batch_size);
let _ = FlattenId::default();
}
#[test]
fn merge_no_extras_keeps_identity() {
let (pipeline, opts) =
merge_inplace_transforms(None, std::iter::empty::<Tag>()).expect("merge empty");
assert!(pipeline.is_identity());
assert_eq!(opts, ApplyOpts::identity());
}
#[test]
fn merge_boxed_heterogeneous() {
let (pipeline, opts) = merge_inplace_boxed(
None,
[
Box::new(FlattenId::default()) as Box<dyn InPlaceTransform>,
Box::new(Tag),
],
)
.expect("boxed merge");
assert_eq!(pipeline.len(), 2);
assert_eq!(opts, ApplyOpts::default());
}
}