1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct TransformsConfig {
29 pub pipeline: PipelineSection,
31 pub stages: Vec<ConfiguredStage>,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct PipelineSection {
41 pub failure_policy: Option<FailurePolicy>,
43 pub batch_size: Option<usize>,
45 pub batch_max_wait: Option<Duration>,
47 pub timeout: Option<Duration>,
49 pub max_in_flight: Option<usize>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ConfiguredStage {
56 Command(CommandStageConfig),
58 FlattenId(FlattenIdStageConfig),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct FlattenIdStageConfig {
65 pub separator: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct CommandStageConfig {
72 pub command: Vec<String>,
74 pub mode: ChildStdioMode,
76 pub stdio: StdioConfig,
78 pub timeout: Option<Duration>,
80 pub retry: RetryPolicy,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct StdioConfig {
87 pub framer: FramerKind,
88}
89
90impl TransformsConfig {
91 pub fn identity() -> Self {
93 Self {
94 pipeline: PipelineSection::default(),
95 stages: Vec::new(),
96 }
97 }
98
99 pub fn is_identity(&self) -> bool {
101 self.stages.is_empty()
102 }
103}
104
105pub 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
118pub 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
127pub 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 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 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
194pub 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 if program.contains('/') || program.contains('\\') {
209 bail!(
210 "command program not found as a file path: {program:?} \
211 (full argv: {command:?})"
212 );
213 }
214 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
236pub 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#[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 }
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 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}