Skip to main content

surreal_sync_runtime/pipeline/
config.rs

1//! TOML transform pipeline configuration.
2//!
3//! Empty / missing `[[transforms]]` yields an identity [`Pipeline`] (no stage
4//! dispatch via [`Pipeline::is_identity`]). A lone `type = "passthrough"` stage
5//! is collapsed to the same empty pipeline. Operators configure `command`
6//! stages only — do not list `passthrough` alongside them.
7//!
8//! # Schema overview
9//!
10//! - **`[pipeline]`** — window / apply-runtime options shared by the whole
11//!   sync (`batch_size`, `max_in_flight`, `failure_policy`, …). Named
12//!   `pipeline` (not `apply`) so it is not confused with SurrealDB sink settings.
13//! - **`[[transforms]]`** — ordered daisy-chained stages. Each `type = "command"`
14//!   entry owns its own argv, stdio framer, timeout, and retry/backoff.
15
16use crate::pipeline::apply::{ApplyOpts, FailurePolicy};
17use crate::pipeline::external::{ChildStdioMode, ExternalTransform, RetryPolicy};
18use crate::pipeline::flatten_id::{FlattenId, DEFAULT_FLATTEN_ID_SEPARATOR};
19use crate::pipeline::framer::FramerKind;
20use crate::pipeline::pipeline::Pipeline;
21use anyhow::{bail, Context, Result};
22use serde::Deserialize;
23use std::path::Path;
24use std::time::Duration;
25
26/// Validated transform pipeline configuration (from TOML).
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct TransformsConfig {
29    /// Shared window / failure options (from `[pipeline]`).
30    pub pipeline: PipelineSection,
31    /// Real stages after collapsing passthrough-only entries.
32    pub stages: Vec<ConfiguredStage>,
33}
34
35/// Pipeline-wide apply-window options (`[pipeline]` in TOML).
36///
37/// These are **not** SurrealDB sink settings — they control how surreal-sync
38/// batches source events and overlaps transform work before ordered sink apply.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct PipelineSection {
41    /// Override for [`ApplyOpts::failure_policy`] when set.
42    pub failure_policy: Option<FailurePolicy>,
43    /// Override for [`ApplyOpts::batch_size`] when set.
44    pub batch_size: Option<usize>,
45    /// Override for [`ApplyOpts::batch_max_wait`] when set.
46    pub batch_max_wait: Option<Duration>,
47    /// Outer whole-pipeline transform timeout (covers all stages + retries).
48    pub timeout: Option<Duration>,
49    /// Override for [`ApplyOpts::max_in_flight`] when set.
50    pub max_in_flight: Option<usize>,
51}
52
53/// One configured pipeline stage (passthrough never appears here).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ConfiguredStage {
56    /// Spawn a child worker and speak framed stdio (`type = "command"`).
57    Command(CommandStageConfig),
58    /// Flatten Array record IDs to Text (`type = "flatten_id"`).
59    FlattenId(FlattenIdStageConfig),
60}
61
62/// Flatten-id stage settings from TOML (`type = "flatten_id"`).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct FlattenIdStageConfig {
65    /// Joiner between composite key parts (default `:`).
66    pub separator: String,
67}
68
69/// Command-stage settings from TOML (`type = "command"`).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct CommandStageConfig {
72    /// Argv to spawn (required).
73    pub command: Vec<String>,
74    /// Child lifecycle: persistent (default) or transient.
75    pub mode: ChildStdioMode,
76    /// Child-stdio framing settings.
77    pub stdio: StdioConfig,
78    /// Per-exchange timeout for this stage (optional).
79    pub timeout: Option<Duration>,
80    /// Per-stage retry/backoff (optional; default is a single attempt).
81    pub retry: RetryPolicy,
82}
83
84/// Child-stdio framing (`stdio.*` under a command stage).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct StdioConfig {
87    pub framer: FramerKind,
88}
89
90impl TransformsConfig {
91    /// Empty identity configuration.
92    pub fn identity() -> Self {
93        Self {
94            pipeline: PipelineSection::default(),
95            stages: Vec::new(),
96        }
97    }
98
99    /// Whether this config yields an empty / identity pipeline.
100    pub fn is_identity(&self) -> bool {
101        self.stages.is_empty()
102    }
103}
104
105/// Parse transforms TOML from a string.
106///
107/// Empty / whitespace-only input is identity. Missing or empty
108/// `[[transforms]]` is identity. A lone passthrough stage collapses to
109/// identity.
110pub fn parse_transforms_toml(contents: &str) -> Result<TransformsConfig> {
111    if contents.trim().is_empty() {
112        return Ok(TransformsConfig::identity());
113    }
114    let raw: RawTransformsFile = toml::from_str(contents).context("parse transforms TOML")?;
115    raw.try_into()
116}
117
118/// Load and parse a transforms TOML file from disk.
119pub fn load_transforms_config(path: impl AsRef<Path>) -> Result<TransformsConfig> {
120    let path = path.as_ref();
121    let contents = std::fs::read_to_string(path)
122        .with_context(|| format!("read transforms config {}", path.display()))?;
123    parse_transforms_toml(&contents)
124        .with_context(|| format!("parse transforms config {}", path.display()))
125}
126
127/// Build [`Pipeline`] + [`ApplyOpts`] from a config file path.
128///
129/// Spawns persistent external workers immediately (fail-fast on bad command).
130pub fn load_pipeline_and_opts(path: impl AsRef<Path>) -> Result<(Pipeline, ApplyOpts)> {
131    let cfg = load_transforms_config(path)?;
132    let opts = ApplyOpts::from_transforms_config(&cfg);
133    let pipeline = Pipeline::from_config(&cfg)?;
134    Ok((pipeline, opts))
135}
136
137impl ApplyOpts {
138    /// Derive apply options from `[pipeline]` (defaults when unset).
139    pub fn from_transforms_config(cfg: &TransformsConfig) -> Self {
140        let mut opts = ApplyOpts::default();
141        let p = &cfg.pipeline;
142        if let Some(policy) = p.failure_policy {
143            opts.failure_policy = policy;
144        }
145        if let Some(n) = p.batch_size {
146            opts.batch_size = n.max(1);
147        }
148        if let Some(d) = p.batch_max_wait {
149            opts.batch_max_wait = d;
150        }
151        if let Some(d) = p.timeout {
152            opts.timeout = d;
153        }
154        if let Some(n) = p.max_in_flight {
155            opts.max_in_flight = n.max(1);
156        }
157        opts
158    }
159}
160
161impl Pipeline {
162    /// Build a pipeline from validated config.
163    ///
164    /// Empty config → identity ([`is_identity`](Self::is_identity) true).
165    /// Command stages spawn child workers (persistent) or store argv (transient).
166    /// Both modes resolve `command[0]` at config time so bad argv fails
167    /// fast (transient would otherwise only fail on the first batch).
168    pub fn from_config(cfg: &TransformsConfig) -> Result<Self> {
169        let mut pipeline = Pipeline::new();
170        for stage in &cfg.stages {
171            match stage {
172                ConfiguredStage::Command(cmd) => {
173                    ensure_command_resolvable(&cmd.command)
174                        .context("command not resolvable at config load")?;
175                    let external = ExternalTransform::child_stdio(
176                        cmd.mode,
177                        cmd.command.clone(),
178                        cmd.stdio.framer,
179                    )
180                    .context("create command transform from config")?
181                    .with_timeout(cmd.timeout)
182                    .with_retry(cmd.retry.clone());
183                    pipeline.push_external(external);
184                }
185                ConfiguredStage::FlattenId(flat) => {
186                    pipeline.push_inplace(FlattenId::new(flat.separator.clone()));
187                }
188            }
189        }
190        Ok(pipeline)
191    }
192}
193
194/// Fail fast when `command[0]` is missing from PATH / filesystem.
195///
196/// Persistent workers also fail on spawn; this catches transient mode before
197/// the first batch and gives a clearer error for both modes.
198pub fn ensure_command_resolvable(command: &[String]) -> Result<()> {
199    if command.is_empty() {
200        bail!("command must not be empty");
201    }
202    let program = &command[0];
203    let path = std::path::Path::new(program);
204    if path.is_file() {
205        return Ok(());
206    }
207    // Absolute / relative path that isn't a file → do not fall through to PATH.
208    if program.contains('/') || program.contains('\\') {
209        bail!(
210            "command program not found as a file path: {program:?} \
211             (full argv: {command:?})"
212        );
213    }
214    // Bare program name: search PATH.
215    if let Some(path_var) = std::env::var_os("PATH") {
216        for dir in std::env::split_paths(&path_var) {
217            let candidate = dir.join(program);
218            if candidate.is_file() {
219                return Ok(());
220            }
221            #[cfg(windows)]
222            {
223                let with_exe = dir.join(format!("{program}.exe"));
224                if with_exe.is_file() {
225                    return Ok(());
226                }
227            }
228        }
229    }
230    bail!(
231        "command program not found on PATH: {program:?} \
232         (full argv: {command:?})"
233    );
234}
235
236/// Parse a human duration used in transforms TOML (`500ms`, `60s`, `1m`, `1h`).
237///
238/// Plain integers are seconds. Supported suffixes: `ms`, `s`, `m`, `h`
239/// (case-insensitive).
240pub fn parse_humantime(s: &str) -> Result<Duration> {
241    let s = s.trim();
242    if s.is_empty() {
243        bail!("empty duration string");
244    }
245    let lower = s.to_ascii_lowercase();
246
247    if let Some(num) = lower.strip_suffix("ms") {
248        let n: u64 = num
249            .trim()
250            .parse()
251            .with_context(|| format!("invalid milliseconds duration: {s}"))?;
252        return Ok(Duration::from_millis(n));
253    }
254    if let Some(num) = lower.strip_suffix('s') {
255        let n: u64 = num
256            .trim()
257            .parse()
258            .with_context(|| format!("invalid seconds duration: {s}"))?;
259        return Ok(Duration::from_secs(n));
260    }
261    if let Some(num) = lower.strip_suffix('m') {
262        let n: u64 = num
263            .trim()
264            .parse()
265            .with_context(|| format!("invalid minutes duration: {s}"))?;
266        return Ok(Duration::from_secs(n.saturating_mul(60)));
267    }
268    if let Some(num) = lower.strip_suffix('h') {
269        let n: u64 = num
270            .trim()
271            .parse()
272            .with_context(|| format!("invalid hours duration: {s}"))?;
273        return Ok(Duration::from_secs(n.saturating_mul(3600)));
274    }
275
276    let n: u64 = lower
277        .parse()
278        .with_context(|| format!("invalid duration (expected number or ms/s/m/h suffix): {s}"))?;
279    Ok(Duration::from_secs(n))
280}
281
282// --- serde raw shapes -------------------------------------------------------
283
284#[derive(Debug, Deserialize)]
285#[serde(deny_unknown_fields)]
286struct RawTransformsFile {
287    #[serde(default)]
288    pipeline: RawPipeline,
289    #[serde(default)]
290    transforms: Vec<RawStage>,
291}
292
293#[derive(Debug, Default, Deserialize)]
294#[serde(deny_unknown_fields)]
295struct RawPipeline {
296    #[serde(default)]
297    failure_policy: Option<RawFailurePolicy>,
298    #[serde(default)]
299    batch_size: Option<usize>,
300    #[serde(default)]
301    batch_max_wait: Option<String>,
302    #[serde(default)]
303    timeout: Option<String>,
304    #[serde(default)]
305    max_in_flight: Option<usize>,
306}
307
308#[derive(Debug, Deserialize)]
309#[serde(tag = "type", rename_all = "snake_case")]
310enum RawStage {
311    Passthrough {},
312    Command(RawCommandStage),
313    FlattenId(RawFlattenIdStage),
314}
315
316#[derive(Debug, Deserialize)]
317#[serde(deny_unknown_fields)]
318struct RawFlattenIdStage {
319    #[serde(default)]
320    separator: Option<String>,
321}
322
323#[derive(Debug, Deserialize)]
324#[serde(deny_unknown_fields)]
325struct RawCommandStage {
326    command: Option<Vec<String>>,
327    #[serde(default)]
328    mode: Option<String>,
329    #[serde(default)]
330    timeout: Option<String>,
331    #[serde(default)]
332    stdio: RawStdio,
333    #[serde(default)]
334    retry: RawRetry,
335}
336
337#[derive(Debug, Deserialize)]
338#[serde(rename_all = "snake_case")]
339enum RawFailurePolicy {
340    Fail,
341    Skip,
342}
343
344#[derive(Debug, Default, Deserialize)]
345#[serde(deny_unknown_fields)]
346struct RawStdio {
347    #[serde(default)]
348    framer: Option<String>,
349}
350
351#[derive(Debug, Default, Deserialize)]
352#[serde(deny_unknown_fields)]
353struct RawRetry {
354    #[serde(default)]
355    max_attempts: Option<u32>,
356    #[serde(default)]
357    initial_backoff: Option<String>,
358    #[serde(default)]
359    max_backoff: Option<String>,
360    #[serde(default)]
361    jitter: Option<bool>,
362}
363
364impl TryFrom<RawTransformsFile> for TransformsConfig {
365    type Error = anyhow::Error;
366
367    fn try_from(raw: RawTransformsFile) -> Result<Self> {
368        let pipeline = validate_pipeline(raw.pipeline)?;
369        let mut stages = Vec::new();
370        for (i, stage) in raw.transforms.into_iter().enumerate() {
371            match stage {
372                RawStage::Passthrough {} => {
373                    // Optional/unnecessary for operators; skipped so a lone
374                    // passthrough collapses to identity.
375                }
376                RawStage::Command(raw_cmd) => {
377                    let stage = validate_command(
378                        i,
379                        RawCommandFields {
380                            command: raw_cmd.command,
381                            mode: raw_cmd.mode,
382                            timeout: raw_cmd.timeout,
383                            stdio: raw_cmd.stdio,
384                            retry: raw_cmd.retry,
385                        },
386                    )?;
387                    stages.push(ConfiguredStage::Command(stage));
388                }
389                RawStage::FlattenId(raw) => {
390                    let separator = raw
391                        .separator
392                        .unwrap_or_else(|| DEFAULT_FLATTEN_ID_SEPARATOR.to_string());
393                    if separator.is_empty() {
394                        bail!(
395                            "transforms[{i}] (type = \"flatten_id\"): separator must not be empty"
396                        );
397                    }
398                    stages.push(ConfiguredStage::FlattenId(FlattenIdStageConfig {
399                        separator,
400                    }));
401                }
402            }
403        }
404        Ok(TransformsConfig { pipeline, stages })
405    }
406}
407
408fn validate_pipeline(raw: RawPipeline) -> Result<PipelineSection> {
409    let ctx = || "pipeline";
410    if let Some(0) = raw.batch_size {
411        bail!("{}: batch_size must be >= 1", ctx());
412    }
413    if let Some(0) = raw.max_in_flight {
414        bail!("{}: max_in_flight must be >= 1", ctx());
415    }
416    let batch_max_wait = raw
417        .batch_max_wait
418        .as_deref()
419        .map(parse_humantime)
420        .transpose()
421        .with_context(|| format!("{}: invalid batch_max_wait", ctx()))?;
422    let timeout = raw
423        .timeout
424        .as_deref()
425        .map(parse_humantime)
426        .transpose()
427        .with_context(|| format!("{}: invalid timeout", ctx()))?;
428    let failure_policy = raw.failure_policy.map(|p| match p {
429        RawFailurePolicy::Fail => FailurePolicy::Fail,
430        RawFailurePolicy::Skip => FailurePolicy::Skip,
431    });
432    Ok(PipelineSection {
433        failure_policy,
434        batch_size: raw.batch_size,
435        batch_max_wait,
436        timeout,
437        max_in_flight: raw.max_in_flight,
438    })
439}
440
441struct RawCommandFields {
442    command: Option<Vec<String>>,
443    mode: Option<String>,
444    timeout: Option<String>,
445    stdio: RawStdio,
446    retry: RawRetry,
447}
448
449fn validate_command(index: usize, raw: RawCommandFields) -> Result<CommandStageConfig> {
450    let ctx = || format!("transforms[{index}] (type = \"command\")");
451
452    let command = raw
453        .command
454        .filter(|c| !c.is_empty())
455        .with_context(|| format!("{}: command is required and must be non-empty", ctx()))?;
456
457    let mode = match raw
458        .mode
459        .as_deref()
460        .unwrap_or("persistent")
461        .trim()
462        .to_ascii_lowercase()
463        .as_str()
464    {
465        "persistent" => ChildStdioMode::Persistent,
466        "transient" => ChildStdioMode::Transient,
467        other => bail!(
468            "{}: unsupported mode {other:?} (expected \"persistent\" or \"transient\")",
469            ctx()
470        ),
471    };
472
473    let framer = match raw
474        .stdio
475        .framer
476        .as_deref()
477        .unwrap_or("ndjson")
478        .trim()
479        .to_ascii_lowercase()
480        .as_str()
481    {
482        "ndjson" => FramerKind::Ndjson,
483        other => bail!(
484            "{}: unsupported stdio.framer {other:?} (v1 supports \"ndjson\")",
485            ctx()
486        ),
487    };
488
489    let timeout = raw
490        .timeout
491        .as_deref()
492        .map(parse_humantime)
493        .transpose()
494        .with_context(|| format!("{}: invalid timeout", ctx()))?;
495
496    let retry = validate_retry(&ctx, raw.retry)?;
497
498    Ok(CommandStageConfig {
499        command,
500        mode,
501        stdio: StdioConfig { framer },
502        timeout,
503        retry,
504    })
505}
506
507fn validate_retry(ctx: &impl Fn() -> String, raw: RawRetry) -> Result<RetryPolicy> {
508    let max_attempts = raw.max_attempts.unwrap_or(1);
509    if max_attempts == 0 {
510        bail!("{}: retry.max_attempts must be >= 1", ctx());
511    }
512    let initial_backoff = raw
513        .initial_backoff
514        .as_deref()
515        .map(parse_humantime)
516        .transpose()
517        .with_context(|| format!("{}: invalid retry.initial_backoff", ctx()))?
518        .unwrap_or(Duration::from_millis(200));
519    let max_backoff = raw
520        .max_backoff
521        .as_deref()
522        .map(parse_humantime)
523        .transpose()
524        .with_context(|| format!("{}: invalid retry.max_backoff", ctx()))?
525        .unwrap_or(Duration::from_secs(30));
526    if max_backoff < initial_backoff {
527        bail!(
528            "{}: retry.max_backoff ({max_backoff:?}) must be >= retry.initial_backoff ({initial_backoff:?})",
529            ctx()
530        );
531    }
532    Ok(RetryPolicy {
533        max_attempts,
534        initial_backoff,
535        max_backoff,
536        jitter: raw.jitter.unwrap_or(true),
537    })
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn flatten_id_parses_with_default_separator() {
546        let cfg = parse_transforms_toml(
547            r#"
548[[transforms]]
549type = "flatten_id"
550"#,
551        )
552        .unwrap();
553        assert_eq!(cfg.stages.len(), 1);
554        match &cfg.stages[0] {
555            ConfiguredStage::FlattenId(f) => assert_eq!(f.separator, ":"),
556            other => panic!("expected FlattenId, got {other:?}"),
557        }
558        let pipeline = Pipeline::from_config(&cfg).unwrap();
559        assert!(!pipeline.is_identity());
560    }
561
562    #[test]
563    fn flatten_id_custom_separator() {
564        let cfg = parse_transforms_toml(
565            r#"
566[[transforms]]
567type = "flatten_id"
568separator = "_"
569"#,
570        )
571        .unwrap();
572        match &cfg.stages[0] {
573            ConfiguredStage::FlattenId(f) => assert_eq!(f.separator, "_"),
574            other => panic!("expected FlattenId, got {other:?}"),
575        }
576    }
577
578    #[test]
579    fn empty_string_is_identity() {
580        let cfg = parse_transforms_toml("").unwrap();
581        assert!(cfg.is_identity());
582        let pipeline = Pipeline::from_config(&cfg).unwrap();
583        assert!(pipeline.is_identity());
584        assert_eq!(ApplyOpts::from_transforms_config(&cfg).max_in_flight, 1);
585    }
586
587    #[test]
588    fn missing_transforms_key_is_identity() {
589        let cfg = parse_transforms_toml("# comment only\n").unwrap();
590        assert!(cfg.is_identity());
591    }
592
593    #[test]
594    fn empty_transforms_array_is_identity() {
595        let cfg = parse_transforms_toml("transforms = []\n").unwrap();
596        assert!(cfg.is_identity());
597    }
598
599    #[test]
600    fn passthrough_alone_collapses_to_identity() {
601        let cfg = parse_transforms_toml(
602            r#"
603[[transforms]]
604type = "passthrough"
605"#,
606        )
607        .unwrap();
608        assert!(cfg.is_identity());
609        let pipeline = Pipeline::from_config(&cfg).unwrap();
610        assert!(pipeline.is_identity());
611    }
612
613    #[test]
614    fn multiple_passthrough_collapses_to_identity() {
615        let cfg = parse_transforms_toml(
616            r#"
617[[transforms]]
618type = "passthrough"
619
620[[transforms]]
621type = "passthrough"
622"#,
623        )
624        .unwrap();
625        assert!(cfg.is_identity());
626    }
627
628    #[test]
629    fn command_only_parses_defaults() {
630        let cfg = parse_transforms_toml(
631            r#"
632[[transforms]]
633type = "command"
634command = ["kreuzberg-worker"]
635"#,
636        )
637        .unwrap();
638        assert!(!cfg.is_identity());
639        assert_eq!(cfg.stages.len(), 1);
640        let ConfiguredStage::Command(cmd) = &cfg.stages[0] else {
641            panic!("expected Command stage");
642        };
643        assert_eq!(cmd.command, vec!["kreuzberg-worker"]);
644        assert_eq!(cmd.mode, ChildStdioMode::Persistent);
645        assert_eq!(cmd.stdio.framer, FramerKind::Ndjson);
646        assert!(cmd.timeout.is_none());
647        assert_eq!(cmd.retry.max_attempts, 1);
648        assert!(cfg.pipeline.failure_policy.is_none());
649        let opts = ApplyOpts::from_transforms_config(&cfg);
650        assert_eq!(opts, ApplyOpts::default());
651    }
652
653    #[test]
654    fn command_full_example_with_pipeline() {
655        let cfg = parse_transforms_toml(
656            r#"
657[pipeline]
658failure_policy = "fail"
659batch_size = 1000
660batch_max_wait = "500ms"
661timeout = "120s"
662max_in_flight = 2
663
664[[transforms]]
665type = "command"
666command = ["kreuzberg-worker"]
667mode = "persistent"
668timeout = "60s"
669stdio.framer = "ndjson"
670retry.max_attempts = 5
671retry.initial_backoff = "200ms"
672retry.max_backoff = "30s"
673retry.jitter = false
674"#,
675        )
676        .unwrap();
677        assert_eq!(cfg.pipeline.failure_policy, Some(FailurePolicy::Fail));
678        assert_eq!(cfg.pipeline.batch_size, Some(1000));
679        assert_eq!(
680            cfg.pipeline.batch_max_wait,
681            Some(Duration::from_millis(500))
682        );
683        assert_eq!(cfg.pipeline.timeout, Some(Duration::from_secs(120)));
684        assert_eq!(cfg.pipeline.max_in_flight, Some(2));
685
686        let ConfiguredStage::Command(cmd) = &cfg.stages[0] else {
687            panic!("expected Command stage");
688        };
689        assert_eq!(cmd.mode, ChildStdioMode::Persistent);
690        assert_eq!(cmd.timeout, Some(Duration::from_secs(60)));
691        assert_eq!(cmd.retry.max_attempts, 5);
692        assert_eq!(cmd.retry.initial_backoff, Duration::from_millis(200));
693        assert_eq!(cmd.retry.max_backoff, Duration::from_secs(30));
694        assert!(!cmd.retry.jitter);
695
696        let opts = ApplyOpts::from_transforms_config(&cfg);
697        assert_eq!(opts.failure_policy, FailurePolicy::Fail);
698        assert_eq!(opts.batch_size, 1000);
699        assert_eq!(opts.batch_max_wait, Duration::from_millis(500));
700        assert_eq!(opts.timeout, Duration::from_secs(120));
701        assert_eq!(opts.max_in_flight, 2);
702    }
703
704    #[test]
705    fn two_command_stages_keep_distinct_argv_and_retry() {
706        let cfg = parse_transforms_toml(
707            r#"
708[pipeline]
709batch_size = 50
710max_in_flight = 2
711
712[[transforms]]
713type = "command"
714command = ["ocr-worker"]
715mode = "persistent"
716stdio.framer = "ndjson"
717retry.max_attempts = 5
718retry.initial_backoff = "200ms"
719
720[[transforms]]
721type = "command"
722command = ["embed-worker", "--model", "x"]
723mode = "transient"
724stdio.framer = "ndjson"
725timeout = "30s"
726retry.max_attempts = 3
727retry.initial_backoff = "100ms"
728retry.max_backoff = "5s"
729retry.jitter = false
730"#,
731        )
732        .unwrap();
733        assert_eq!(cfg.stages.len(), 2);
734        let ConfiguredStage::Command(a) = &cfg.stages[0] else {
735            panic!("expected Command stage");
736        };
737        let ConfiguredStage::Command(b) = &cfg.stages[1] else {
738            panic!("expected Command stage");
739        };
740        assert_eq!(a.command, vec!["ocr-worker"]);
741        assert_eq!(a.mode, ChildStdioMode::Persistent);
742        assert_eq!(a.retry.max_attempts, 5);
743        assert_eq!(b.command, vec!["embed-worker", "--model", "x"]);
744        assert_eq!(b.mode, ChildStdioMode::Transient);
745        assert_eq!(b.timeout, Some(Duration::from_secs(30)));
746        assert_eq!(b.retry.max_attempts, 3);
747        assert!(!b.retry.jitter);
748
749        let opts = ApplyOpts::from_transforms_config(&cfg);
750        assert_eq!(opts.batch_size, 50);
751        assert_eq!(opts.max_in_flight, 2);
752        // Pipeline opts come only from [pipeline], not last stage.
753        assert_eq!(opts.failure_policy, FailurePolicy::Fail);
754    }
755
756    #[test]
757    fn command_transient_and_pipeline_skip() {
758        let cfg = parse_transforms_toml(
759            r#"
760[pipeline]
761failure_policy = "skip"
762
763[[transforms]]
764type = "command"
765mode = "transient"
766command = ["worker", "--flag"]
767"#,
768        )
769        .unwrap();
770        let ConfiguredStage::Command(cmd) = &cfg.stages[0] else {
771            panic!("expected Command stage");
772        };
773        assert_eq!(cmd.mode, ChildStdioMode::Transient);
774        assert_eq!(cmd.command, vec!["worker", "--flag"]);
775        let opts = ApplyOpts::from_transforms_config(&cfg);
776        assert_eq!(opts.failure_policy, FailurePolicy::Skip);
777    }
778
779    #[test]
780    fn passthrough_then_command_keeps_command_only() {
781        let cfg = parse_transforms_toml(
782            r#"
783[[transforms]]
784type = "passthrough"
785
786[[transforms]]
787type = "command"
788command = ["w"]
789"#,
790        )
791        .unwrap();
792        assert_eq!(cfg.stages.len(), 1);
793        assert!(!cfg.is_identity());
794    }
795
796    #[test]
797    fn rejects_missing_command() {
798        let err = parse_transforms_toml(
799            r#"
800[[transforms]]
801type = "command"
802"#,
803        )
804        .unwrap_err();
805        assert!(
806            err.to_string().contains("command"),
807            "unexpected err: {err:#}"
808        );
809    }
810
811    #[test]
812    fn rejects_empty_command() {
813        let err = parse_transforms_toml(
814            r#"
815[[transforms]]
816type = "command"
817command = []
818"#,
819        )
820        .unwrap_err();
821        assert!(
822            err.to_string().contains("command"),
823            "unexpected err: {err:#}"
824        );
825    }
826
827    #[test]
828    fn rejects_old_external_type() {
829        let err = parse_transforms_toml(
830            r#"
831[[transforms]]
832type = "external"
833command = ["w"]
834"#,
835        )
836        .unwrap_err();
837        let msg = format!("{err:#}");
838        assert!(
839            msg.contains("external") || msg.contains("unknown") || msg.contains("did not match"),
840            "unexpected err: {msg}"
841        );
842    }
843
844    #[test]
845    fn rejects_legacy_apply_section() {
846        let err = parse_transforms_toml(
847            r#"
848[apply]
849batch_size = 10
850
851[[transforms]]
852type = "command"
853command = ["w"]
854"#,
855        )
856        .unwrap_err();
857        let msg = format!("{err:#}");
858        assert!(
859            msg.contains("apply") || msg.contains("unknown"),
860            "unexpected err: {msg}"
861        );
862    }
863
864    #[test]
865    fn rejects_unknown_top_level_key() {
866        let err = parse_transforms_toml(
867            r#"
868workers = 4
869
870[[transforms]]
871type = "command"
872command = ["w"]
873"#,
874        )
875        .unwrap_err();
876        let msg = format!("{err:#}");
877        assert!(
878            msg.contains("workers") || msg.contains("unknown"),
879            "unexpected err: {msg}"
880        );
881    }
882
883    #[test]
884    fn rejects_stdio_typo_key() {
885        let err = parse_transforms_toml(
886            r#"
887[[transforms]]
888type = "command"
889command = ["w"]
890stdio.frameer = "ndjson"
891"#,
892        )
893        .unwrap_err();
894        let msg = format!("{err:#}");
895        assert!(
896            msg.contains("frameer") || msg.contains("unknown"),
897            "unexpected err: {msg}"
898        );
899    }
900
901    #[test]
902    fn rejects_retry_typo_key() {
903        let err = parse_transforms_toml(
904            r#"
905[[transforms]]
906type = "command"
907command = ["w"]
908retry.max_attemps = 3
909"#,
910        )
911        .unwrap_err();
912        let msg = format!("{err:#}");
913        assert!(
914            msg.contains("max_attemps") || msg.contains("unknown"),
915            "unexpected err: {msg}"
916        );
917    }
918
919    #[test]
920    fn rejects_unknown_type() {
921        let err = parse_transforms_toml(
922            r#"
923[[transforms]]
924type = "wasm"
925"#,
926        )
927        .unwrap_err();
928        let msg = format!("{err:#}");
929        assert!(
930            msg.contains("wasm") || msg.contains("unknown") || msg.contains("did not match"),
931            "unexpected err: {msg}"
932        );
933    }
934
935    #[test]
936    fn rejects_bad_framer() {
937        let err = parse_transforms_toml(
938            r#"
939[[transforms]]
940type = "command"
941command = ["w"]
942stdio.framer = "protobuf"
943"#,
944        )
945        .unwrap_err();
946        assert!(
947            err.to_string().contains("framer"),
948            "unexpected err: {err:#}"
949        );
950    }
951
952    #[test]
953    fn rejects_bad_mode() {
954        let err = parse_transforms_toml(
955            r#"
956[[transforms]]
957type = "command"
958command = ["w"]
959mode = "always"
960"#,
961        )
962        .unwrap_err();
963        assert!(err.to_string().contains("mode"), "unexpected err: {err:#}");
964    }
965
966    #[test]
967    fn rejects_bad_failure_policy() {
968        let err = parse_transforms_toml(
969            r#"
970[pipeline]
971failure_policy = "retry"
972
973[[transforms]]
974type = "command"
975command = ["w"]
976"#,
977        )
978        .unwrap_err();
979        let msg = format!("{err:#}");
980        assert!(
981            msg.contains("failure_policy") || msg.contains("retry"),
982            "unexpected err: {msg}"
983        );
984    }
985
986    #[test]
987    fn rejects_zero_batch_size() {
988        let err = parse_transforms_toml(
989            r#"
990[pipeline]
991batch_size = 0
992
993[[transforms]]
994type = "command"
995command = ["w"]
996"#,
997        )
998        .unwrap_err();
999        assert!(
1000            err.to_string().contains("batch_size"),
1001            "unexpected err: {err:#}"
1002        );
1003    }
1004
1005    #[test]
1006    fn rejects_zero_retry_attempts() {
1007        let err = parse_transforms_toml(
1008            r#"
1009[[transforms]]
1010type = "command"
1011command = ["w"]
1012retry.max_attempts = 0
1013"#,
1014        )
1015        .unwrap_err();
1016        assert!(
1017            err.to_string().contains("max_attempts"),
1018            "unexpected err: {err:#}"
1019        );
1020    }
1021
1022    #[test]
1023    fn rejects_invalid_duration() {
1024        let err = parse_transforms_toml(
1025            r#"
1026[pipeline]
1027timeout = "nope"
1028
1029[[transforms]]
1030type = "command"
1031command = ["w"]
1032"#,
1033        )
1034        .unwrap_err();
1035        assert!(
1036            err.to_string().contains("timeout"),
1037            "unexpected err: {err:#}"
1038        );
1039    }
1040
1041    #[test]
1042    fn parse_humantime_variants() {
1043        assert_eq!(
1044            parse_humantime("500ms").unwrap(),
1045            Duration::from_millis(500)
1046        );
1047        assert_eq!(parse_humantime("60s").unwrap(), Duration::from_secs(60));
1048        assert_eq!(parse_humantime("2m").unwrap(), Duration::from_secs(120));
1049        assert_eq!(parse_humantime("1h").unwrap(), Duration::from_secs(3600));
1050        assert_eq!(parse_humantime("45").unwrap(), Duration::from_secs(45));
1051        assert_eq!(parse_humantime("1S").unwrap(), Duration::from_secs(1));
1052    }
1053
1054    #[test]
1055    fn transient_missing_command_fails_at_config_time() {
1056        let cfg = parse_transforms_toml(
1057            r#"
1058[[transforms]]
1059type = "command"
1060mode = "transient"
1061command = ["/nonexistent/surreal-sync-transform-worker-xyz"]
1062"#,
1063        )
1064        .unwrap();
1065        let err = Pipeline::from_config(&cfg).expect_err("missing transient argv must fail");
1066        let msg = format!("{err:#}");
1067        assert!(
1068            msg.contains("not found") || msg.contains("resolvable"),
1069            "unexpected error: {msg}"
1070        );
1071    }
1072
1073    #[test]
1074    fn rejects_stage_level_pipeline_keys() {
1075        let err = parse_transforms_toml(
1076            r#"
1077[[transforms]]
1078type = "command"
1079command = ["w"]
1080batch_size = 10
1081"#,
1082        )
1083        .unwrap_err();
1084        let msg = format!("{err:#}");
1085        assert!(
1086            msg.contains("batch_size") || msg.contains("unknown") || msg.contains("did not match"),
1087            "unexpected err: {msg}"
1088        );
1089    }
1090
1091    #[test]
1092    fn ensure_command_resolvable_rejects_empty() {
1093        let err = ensure_command_resolvable(&[]).unwrap_err();
1094        assert!(err.to_string().contains("empty"));
1095    }
1096
1097    #[test]
1098    fn load_transforms_config_missing_file_fails_fast() {
1099        let err = load_transforms_config("/nonexistent/surreal-sync-transforms-xyz.toml")
1100            .expect_err("missing file must fail");
1101        let msg = format!("{err:#}");
1102        assert!(
1103            msg.contains("read transforms config") || msg.contains("No such file"),
1104            "unexpected: {msg}"
1105        );
1106    }
1107
1108    #[test]
1109    fn load_transforms_config_bad_toml_fails_fast() {
1110        let dir = std::env::temp_dir();
1111        let path = dir.join(format!(
1112            "surreal-sync-bad-transforms-{}.toml",
1113            std::process::id()
1114        ));
1115        std::fs::write(&path, "[[transforms]]\ntype = \"command\"\ncommand = [\n")
1116            .expect("write temp");
1117        let err = load_transforms_config(&path).expect_err("bad TOML must fail");
1118        let _ = std::fs::remove_file(&path);
1119        let msg = format!("{err:#}");
1120        assert!(
1121            msg.contains("parse transforms config") || msg.contains("TOML"),
1122            "unexpected: {msg}"
1123        );
1124    }
1125}