1use std::path::Path;
32
33use chrono::NaiveDate;
34
35use crate::config::Config;
36use crate::destination::placeholder::PlaceholderContext;
37use crate::error::Result;
38use crate::pipeline::ManifestVerification;
39use crate::pipeline::validate_manifest::{ValidateDepth, verify_at_destination};
40
41pub enum ValidateOutputFormat {
43 Pretty,
45 Json(Option<String>),
47}
48
49#[derive(Debug, Default, Clone)]
55pub struct ValidateTarget {
56 pub date: Option<NaiveDate>,
58 pub run_id: Option<String>,
60 pub prefix_override: Option<String>,
63 pub depth: ValidateDepth,
69}
70
71impl ValidateTarget {
72 fn placeholder_context(&self, export_name: &str) -> PlaceholderContext {
73 let mut ctx = match self.date {
74 Some(d) => PlaceholderContext::for_date(d, export_name),
75 None => PlaceholderContext::for_today(export_name),
76 };
77 if let Some(rid) = &self.run_id {
78 ctx = ctx.with_run_id(rid.clone());
79 }
80 ctx
81 }
82}
83
84pub fn run_validate_command(
93 config_path: &str,
94 export_name: Option<&str>,
95 format: ValidateOutputFormat,
96 target: ValidateTarget,
97) -> Result<()> {
98 let config = Config::load_with_params(config_path, None)?;
99
100 let exports: Vec<&crate::config::ExportConfig> = match export_name {
101 Some(name) => match config.exports.iter().find(|e| e.name == name) {
102 Some(e) => vec![e],
103 None => anyhow::bail!("export '{}' not found in config", name),
104 },
105 None => config.exports.iter().collect(),
106 };
107
108 if exports.is_empty() {
109 anyhow::bail!("no exports defined in config — nothing to validate");
110 }
111
112 if target.prefix_override.is_some() && exports.len() > 1 {
117 anyhow::bail!(
118 "--prefix requires --export <name>: cannot apply one override to {} exports",
119 exports.len()
120 );
121 }
122
123 let mut all_results: Vec<ExportVerdict> = Vec::with_capacity(exports.len());
124 let mut hard_failures: Vec<String> = Vec::new();
125
126 for export in &exports {
127 let ctx = target.placeholder_context(&export.name);
131 let mut expanded_dest =
132 crate::destination::placeholder::expand_destination(export.destination.clone(), &ctx);
133 if let Some(p) = &target.prefix_override {
134 expanded_dest.path = Some(p.clone());
138 expanded_dest.prefix = Some(p.clone());
139 }
140 let multiplex = if export.mode == crate::config::ExportMode::Cdc {
149 export.tables.as_deref().filter(|t| !t.is_empty())
150 } else {
151 None
152 };
153 let has_snapshot = export.mode == crate::config::ExportMode::Cdc
154 && export.cdc.as_ref().and_then(|c| c.initial)
155 == Some(crate::config::CdcInitialMode::Snapshot);
156 match multiplex {
157 Some(tables) => {
158 for table in tables {
159 verify_cdc_table(
160 crate::pipeline::cdc_job::dest_for_table(&expanded_dest, table),
161 format!("{}/{}", export.name, table),
162 export,
163 &target,
164 has_snapshot,
165 &mut all_results,
166 &mut hard_failures,
167 );
168 }
169 }
170 None if export.mode == crate::config::ExportMode::Cdc => {
171 verify_cdc_table(
173 expanded_dest,
174 export.name.clone(),
175 export,
176 &target,
177 has_snapshot,
178 &mut all_results,
179 &mut hard_failures,
180 );
181 }
182 None => {
183 verify_one_prefix(
186 expanded_dest,
187 export.name.clone(),
188 export,
189 &target,
190 false,
191 false,
192 &mut all_results,
193 &mut hard_failures,
194 );
195 }
196 }
197 }
198
199 match format {
200 ValidateOutputFormat::Pretty => render_pretty(&all_results, &hard_failures),
201 ValidateOutputFormat::Json(out_path) => {
202 render_json(&all_results, &hard_failures, out_path)?
203 }
204 }
205
206 let verified_wrong = all_results
231 .iter()
232 .filter(|r| {
233 verdict_fails_exit(&r.verification) && r.verification.has_verified_wrong_failure()
234 })
235 .count();
236 let could_not_verify_verdicts = all_results
237 .iter()
238 .filter(|r| {
239 verdict_fails_exit(&r.verification) && !r.verification.has_verified_wrong_failure()
240 })
241 .count();
242 if verified_wrong > 0 {
243 return Err(crate::error::DataIntegrityError::new(format!(
247 "rivet validate: {} export(s) failed verification",
248 hard_failures.len() + verified_wrong + could_not_verify_verdicts
249 ))
250 .into());
251 }
252 let could_not_verify = hard_failures.len() + could_not_verify_verdicts;
253 if could_not_verify > 0 {
254 anyhow::bail!("rivet validate: {could_not_verify} export(s) could not be verified");
257 }
258 Ok(())
259}
260
261fn verdict_fails_exit(v: &ManifestVerification) -> bool {
268 !v.passed && v.has_failures()
269}
270
271struct ExportVerdict {
275 name: String,
276 resolved_prefix: String,
277 verification: ManifestVerification,
278}
279
280fn resolved_prefix_for_display(dest: &crate::config::DestinationConfig) -> String {
287 dest.prefix
288 .clone()
289 .or_else(|| dest.path.clone())
290 .unwrap_or_else(|| "<unresolved>".into())
291}
292
293#[allow(clippy::too_many_arguments)]
300fn verify_cdc_table(
301 table_dest: crate::config::DestinationConfig,
302 display_name: String,
303 export: &crate::config::ExportConfig,
304 target: &ValidateTarget,
305 has_snapshot: bool,
306 all_results: &mut Vec<ExportVerdict>,
307 hard_failures: &mut Vec<String>,
308) {
309 if has_snapshot {
312 let snap = crate::pipeline::cdc_job::dest_for_table(&table_dest, "snapshot");
313 verify_one_prefix(
314 snap,
315 format!("{display_name}/snapshot"),
316 export,
317 target,
318 false,
319 false,
320 all_results,
321 hard_failures,
322 );
323 }
324 verify_one_prefix(
325 table_dest,
326 display_name,
327 export,
328 target,
329 true,
330 has_snapshot,
331 all_results,
332 hard_failures,
333 );
334}
335
336#[allow(clippy::too_many_arguments)]
342fn verify_one_prefix(
343 expanded_dest: crate::config::DestinationConfig,
344 display_name: String,
345 export: &crate::config::ExportConfig,
346 target: &ValidateTarget,
347 run_cdc_pos_check: bool,
348 drop_snapshot_untracked: bool,
349 all_results: &mut Vec<ExportVerdict>,
350 hard_failures: &mut Vec<String>,
351) {
352 let resolved_prefix = resolved_prefix_for_display(&expanded_dest);
353 let dest = match crate::destination::create_destination(&expanded_dest) {
354 Ok(d) => d,
355 Err(e) => {
356 hard_failures.push(format!(
357 "export '{}' (prefix: {}): could not open destination: {:#}",
358 display_name, resolved_prefix, e
359 ));
360 return;
361 }
362 };
363 if dest.capabilities().commit_protocol == crate::destination::WriteCommitProtocol::Streaming {
365 log::info!(
366 "export '{}': streaming destination, skipping (nothing to verify)",
367 display_name
368 );
369 return;
370 }
371 match verify_at_destination(&*dest, "", target.depth) {
372 Ok(mut v) => {
373 v.enforce_content_policy(export.verify.requires_content());
376 if drop_snapshot_untracked {
383 v.failures.retain(|f| {
384 !matches!(
385 f,
386 crate::pipeline::validate_manifest::Failure::UntrackedObject { key, .. }
387 if key.starts_with("snapshot/")
388 )
389 });
390 }
391 if target.prefix_override.is_some() {
401 v.require_manifest_present(&resolved_prefix);
402 }
403 let manifest_verified = v.manifest_found && v.passed;
407 all_results.push(ExportVerdict {
408 name: display_name.clone(),
409 resolved_prefix,
410 verification: v,
411 });
412 if target.depth.runs_part_download()
418 && run_cdc_pos_check
419 && export.format == crate::config::FormatType::Parquet
420 {
421 match crate::source::cdc::validate::check_positions(&*dest, "") {
422 Ok(pc) if pc.is_ok() => log::info!(
423 "export '{}': cdc __pos continuity OK — {} changes across {} parts, range {:?}..{:?}",
424 display_name,
425 pc.rows,
426 pc.parts,
427 pc.first,
428 pc.last
429 ),
430 Ok(pc) => {
435 if let Some(ev) = all_results.last_mut() {
436 ev.verification.passed = false;
437 for viol in &pc.violations {
438 ev.verification.failures.push(
439 crate::pipeline::validate_manifest::Failure::CdcPositionViolation {
440 detail: format!("export '{}': {}", display_name, viol),
441 },
442 );
443 }
444 }
445 }
446 Err(e) => hard_failures.push(format!(
449 "export '{}': cdc __pos check could not complete: {:#}",
450 display_name, e
451 )),
452 }
453 }
454 if target.depth.runs_part_download()
465 && manifest_verified
466 && export.format == crate::config::FormatType::Parquet
467 {
468 match crate::source::value_checksum::validate_manifest_checksums(&*dest, "") {
469 Ok(Some(detail)) => {
475 if let Some(ev) = all_results.last_mut() {
476 ev.verification.passed = false;
477 ev.verification.failures.push(
478 crate::pipeline::validate_manifest::Failure::ValueChecksumMismatch {
479 detail: format!("export '{}': {}", display_name, detail),
480 },
481 );
482 }
483 }
484 Err(e) => hard_failures.push(format!(
488 "export '{}': value-checksum re-read could not complete: {:#}",
489 display_name, e
490 )),
491 Ok(None) => {}
492 }
493 }
494 }
495 Err(e) => {
496 hard_failures.push(format!(
497 "export '{}' (prefix: {}): verify_at_destination failed: {:#}",
498 display_name, resolved_prefix, e
499 ));
500 }
501 }
502}
503
504fn render_pretty(results: &[ExportVerdict], hard_failures: &[String]) {
505 use std::io::Write;
506 let stdout = std::io::stdout();
507 let mut h = stdout.lock();
508
509 for r in results {
510 let _ = writeln!(h, "── {} ──", r.name);
511 let _ = writeln!(h, " prefix: {}", r.resolved_prefix);
512 let v = &r.verification;
513 let _ = writeln!(h, " depth: {}", v.depth_level);
517 if v.legacy_run {
518 let _ = writeln!(
519 h,
520 " status: legacy_run (no manifest at destination — pre-0.7.0 prefix)"
521 );
522 continue;
523 }
524 if !v.manifest_found {
525 let _ = writeln!(h, " status: NO MANIFEST");
526 for failure in &v.failures {
532 let _ = writeln!(h, " failure: [{}] {}", failure.error_code(), failure);
533 }
534 continue;
535 }
536 let _ = writeln!(
537 h,
538 " status: {}",
539 if v.passed { "PASSED" } else { "FAILED" }
540 );
541 let _ = writeln!(
542 h,
543 " parts: {} verified ({} md5, {} size-only), {} failed",
544 v.parts_verified,
545 v.parts_md5_verified,
546 v.parts_verified.saturating_sub(v.parts_md5_verified),
547 v.parts_failed
548 );
549 let _ = writeln!(
550 h,
551 " _SUCCESS: {}",
552 if v.success_marker_consistent {
553 "consistent"
554 } else if v.failures.iter().any(|f| matches!(
555 f,
556 crate::pipeline::ManifestVerificationFailure::SuccessMarkerStale { .. }
557 | crate::pipeline::ManifestVerificationFailure::SuccessMarkerMalformed { .. }
558 | crate::pipeline::ManifestVerificationFailure::SuccessMarkerReadError { .. }
559 )) {
560 "INCONSISTENT (see failures)"
561 } else {
562 "absent (no signal)"
563 }
564 );
565 let _ = writeln!(
566 h,
567 " manifest: {}",
568 if v.manifest_self_consistent {
569 "self-consistent"
570 } else {
571 "INCONSISTENT (see failures)"
572 }
573 );
574 for failure in &v.failures {
575 let label = if failure.is_fatal() {
583 "failure:"
584 } else {
585 "warning:"
586 };
587 let _ = writeln!(h, " {} [{}] {}", label, failure.error_code(), failure);
591 }
592 }
593
594 if !hard_failures.is_empty() {
595 let _ = writeln!(h);
596 let _ = writeln!(h, "── errors ──");
597 for e in hard_failures {
598 let _ = writeln!(h, " {}", e);
599 }
600 }
601 let _ = h.flush();
602}
603
604fn failure_json(f: &crate::pipeline::ManifestVerificationFailure) -> serde_json::Value {
612 let mut value = serde_json::json!(f);
613 if let Some(obj) = value.as_object_mut() {
614 obj.insert(
615 "code".to_string(),
616 serde_json::Value::String(f.error_code().to_string()),
617 );
618 }
619 value
620}
621
622fn verification_json(v: &ManifestVerification) -> serde_json::Value {
627 let mut value = serde_json::json!(v);
628 if let Some(obj) = value.as_object_mut() {
629 let failures: Vec<serde_json::Value> = v.failures.iter().map(failure_json).collect();
630 obj.insert("failures".to_string(), serde_json::Value::Array(failures));
631 }
632 value
633}
634
635fn render_json(
636 results: &[ExportVerdict],
637 hard_failures: &[String],
638 out_path: Option<String>,
639) -> Result<()> {
640 let warnings: Vec<serde_json::Value> = results
648 .iter()
649 .flat_map(|r| {
650 r.verification
651 .failures
652 .iter()
653 .filter(|f| !f.is_fatal())
654 .map(move |f| {
655 serde_json::json!({
656 "export_name": r.name,
657 "warning": failure_json(f),
658 })
659 })
660 })
661 .collect();
662
663 let payload = serde_json::json!({
664 "exports": results
665 .iter()
666 .map(|r| {
667 serde_json::json!({
668 "export_name": r.name,
669 "resolved_prefix": r.resolved_prefix,
670 "verification": verification_json(&r.verification),
671 })
672 })
673 .collect::<Vec<_>>(),
674 "warnings": warnings,
675 "errors": hard_failures,
676 });
677 let serialized = serde_json::to_string_pretty(&payload)?;
678 match out_path {
679 Some(p) => {
680 std::fs::write(Path::new(&p), &serialized)?;
681 log::info!("rivet validate: wrote JSON report to {}", p);
682 }
683 None => {
684 println!("{}", serialized);
685 }
686 }
687 Ok(())
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
697 fn target_default_uses_today() {
698 let target = ValidateTarget::default();
699 let ctx = target.placeholder_context("orders");
700 assert_eq!(ctx.date, chrono::Utc::now().date_naive());
701 assert_eq!(ctx.export_name, "orders");
702 assert!(ctx.run_id.is_none());
703 }
704
705 #[test]
706 fn target_with_date_overrides_today() {
707 let target = ValidateTarget {
708 date: Some(NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()),
709 ..Default::default()
710 };
711 let ctx = target.placeholder_context("orders");
712 assert_eq!(ctx.date, NaiveDate::from_ymd_opt(2026, 5, 21).unwrap());
713 assert!(ctx.run_id.is_none());
714 }
715
716 #[test]
717 fn target_composes_date_and_run_id() {
718 let target = ValidateTarget {
722 date: Some(NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()),
723 run_id: Some("r-abc123".into()),
724 prefix_override: None,
725 ..Default::default()
726 };
727 let ctx = target.placeholder_context("orders");
728 assert_eq!(ctx.date, NaiveDate::from_ymd_opt(2026, 5, 21).unwrap());
729 assert_eq!(ctx.run_id.as_deref(), Some("r-abc123"));
730 }
731
732 #[test]
735 fn resolved_prefix_prefers_cloud_prefix_over_path() {
736 let dest = crate::config::DestinationConfig {
737 destination_type: crate::config::DestinationType::S3,
738 prefix: Some("exports/2026-05-21/orders/".into()),
739 path: Some("/scratch".into()),
740 ..Default::default()
741 };
742 assert_eq!(
743 resolved_prefix_for_display(&dest),
744 "exports/2026-05-21/orders/",
745 );
746 }
747
748 #[test]
749 fn resolved_prefix_falls_back_to_path_when_prefix_missing() {
750 let dest = crate::config::DestinationConfig {
751 destination_type: crate::config::DestinationType::Local,
752 prefix: None,
753 path: Some("/data/out".into()),
754 ..Default::default()
755 };
756 assert_eq!(resolved_prefix_for_display(&dest), "/data/out");
757 }
758
759 use crate::pipeline::ManifestVerificationFailure as VFailure;
762
763 fn read_error_verdict() -> ManifestVerification {
767 ManifestVerification {
768 legacy_run: false,
769 failures: vec![VFailure::ManifestReadError {
770 detail: "permission denied".into(),
771 }],
772 ..ManifestVerification::legacy()
773 }
774 }
775
776 #[test]
777 fn exit_gate_counts_manifest_read_error_as_failure() {
778 assert!(verdict_fails_exit(&read_error_verdict()));
779 }
780
781 #[test]
782 fn exit_gate_keeps_legacy_run_at_zero() {
783 assert!(!verdict_fails_exit(&ManifestVerification::legacy()));
786 }
787
788 #[test]
789 fn exit_gate_keeps_advisory_untracked_at_zero() {
790 let v = ManifestVerification {
791 manifest_found: true,
792 legacy_run: false,
793 passed: true,
794 parts_verified: 1,
795 failures: vec![VFailure::UntrackedObject {
796 key: "stray.parquet".into(),
797 size_bytes: 9,
798 }],
799 ..ManifestVerification::legacy()
800 };
801 assert!(!verdict_fails_exit(&v));
802 }
803
804 #[test]
805 fn exit_gate_counts_fatal_failure_on_found_manifest() {
806 let v = ManifestVerification {
807 manifest_found: true,
808 legacy_run: false,
809 failures: vec![VFailure::PartMissing {
810 part_id: 1,
811 path: "part-000001.parquet".into(),
812 }],
813 ..ManifestVerification::legacy()
814 };
815 assert!(verdict_fails_exit(&v));
816 }
817
818 #[test]
819 fn value_checksum_mismatch_flips_verdict_and_fails_exit_gate() {
820 let reclassified = ManifestVerification {
826 manifest_found: true,
827 legacy_run: false,
828 passed: false,
829 failures: vec![VFailure::ValueChecksumMismatch {
830 detail: "export 'e': column 'id' checksum differs".into(),
831 }],
832 ..ManifestVerification::legacy()
833 };
834 assert!(
835 verdict_fails_exit(&reclassified),
836 "a value-checksum mismatch must fail the exit gate (exit 3), not pass silently"
837 );
838
839 let not_reclassified = ManifestVerification {
843 manifest_found: true,
844 legacy_run: false,
845 passed: true,
846 failures: vec![VFailure::ValueChecksumMismatch {
847 detail: "same corruption, left in hard_failures".into(),
848 }],
849 ..ManifestVerification::legacy()
850 };
851 assert!(
852 !verdict_fails_exit(¬_reclassified),
853 "with passed still true the gate wrongly passes — this is exactly bug #104"
854 );
855 }
856
857 use crate::manifest::{
861 MANIFEST_VERSION, ManifestDestination, ManifestPart, ManifestSource, ManifestStatus,
862 PartStatus, RunManifest,
863 };
864
865 fn success_manifest(parts: Vec<ManifestPart>) -> RunManifest {
866 let row_count: i64 = parts.iter().map(|p| p.rows).sum();
867 let part_count = parts.len() as u32;
868 RunManifest {
869 row_hash: None,
870 mode: "batch".to_string(),
871 manifest_version: MANIFEST_VERSION,
872 run_id: "r-validate-cmd".into(),
873 export_name: "orders".into(),
874 started_at: "2026-06-09T12:00:00Z".into(),
875 finished_at: "2026-06-09T12:01:00Z".into(),
876 status: ManifestStatus::Success,
877 source: ManifestSource {
878 engine: "postgres".into(),
879 schema: Some("public".into()),
880 table: Some("orders".into()),
881 extraction: None,
882 },
883 destination: ManifestDestination {
884 kind: "local".into(),
885 uri: "file:///tmp/out".into(),
886 },
887 format: "parquet".into(),
888 compression: "zstd".into(),
889 schema_fingerprint: "xxh3:0123456789abcdef".into(),
890 row_count,
891 part_count,
892 parts,
893 column_checksums: None,
894 checksum_key_column: None,
895 }
896 }
897
898 fn stage_dataset(prefix: &Path, m: &RunManifest) {
901 std::fs::create_dir_all(prefix).unwrap();
902 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
903 destination_type: crate::config::DestinationType::Local,
904 path: Some(prefix.to_string_lossy().into_owned()),
905 ..Default::default()
906 })
907 .unwrap();
908 crate::pipeline::write_manifest(&*dest, m).unwrap();
909 }
910
911 fn write_cfg(dir: &Path, prefix: &Path) -> std::path::PathBuf {
914 let cfg = dir.join("rivet.yaml");
915 let yaml = format!(
916 "source:\n type: postgres\n url: postgresql://nobody@localhost/nope\nexports:\n - name: orders\n query: \"SELECT 1\"\n mode: full\n format: parquet\n destination:\n type: local\n path: \"{}\"\n",
917 prefix.to_string_lossy()
918 );
919 std::fs::write(&cfg, yaml).unwrap();
920 cfg
921 }
922
923 #[cfg(unix)]
928 #[test]
929 fn unreadable_manifest_fails_the_command() {
930 use std::os::unix::fs::PermissionsExt;
931
932 let dir = tempfile::tempdir().unwrap();
933 let prefix = dir.path().join("out");
934 stage_dataset(&prefix, &success_manifest(Vec::new()));
935 let cfg = write_cfg(dir.path(), &prefix);
936
937 let manifest_path = prefix.join(crate::manifest::MANIFEST_FILENAME);
938 std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o000)).unwrap();
939 if std::fs::read(&manifest_path).is_ok() {
940 eprintln!("skipping unreadable_manifest_fails_the_command: running as root");
942 return;
943 }
944
945 let report = dir.path().join("report.json");
946 let err = run_validate_command(
947 cfg.to_str().unwrap(),
948 Some("orders"),
949 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
950 ValidateTarget::default(),
951 )
952 .expect_err("an unreadable manifest is an explicit failure, not exit 0");
953 assert!(
957 format!("{err:#}").contains("could not be verified"),
958 "got: {err:#}"
959 );
960 assert!(
961 err.downcast_ref::<crate::error::DataIntegrityError>()
962 .is_none(),
963 "a read error must not be classed as data-integrity (exit 3): {err:#}"
964 );
965
966 let json: serde_json::Value =
969 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
970 let verification = &json["exports"][0]["verification"];
971 assert_eq!(verification["manifest_found"], false);
972 assert_eq!(verification["legacy_run"], false);
973 assert_eq!(verification["failures"][0]["kind"], "manifest_read_error");
974 }
975
976 #[test]
977 fn untracked_surplus_alone_keeps_exit_zero() {
978 let dir = tempfile::tempdir().unwrap();
982 let prefix = dir.path().join("out");
983 stage_dataset(&prefix, &success_manifest(Vec::new()));
984 std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
985 let cfg = write_cfg(dir.path(), &prefix);
986
987 let report = dir.path().join("report.json");
988 run_validate_command(
989 cfg.to_str().unwrap(),
990 Some("orders"),
991 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
992 ValidateTarget::default(),
993 )
994 .expect("advisory untracked surplus must not flip the exit code");
995
996 let json: serde_json::Value =
997 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
998 let verification = &json["exports"][0]["verification"];
999 assert_eq!(verification["passed"], true);
1000 assert_eq!(verification["failures"][0]["kind"], "untracked_object");
1003
1004 let warnings = json["warnings"].as_array().expect("warnings array present");
1008 assert_eq!(warnings.len(), 1, "the untracked surplus is one warning");
1009 assert_eq!(warnings[0]["export_name"], "orders");
1010 assert_eq!(warnings[0]["warning"]["kind"], "untracked_object");
1011 assert_eq!(warnings[0]["warning"]["key"], "rogue.parquet");
1012 }
1013
1014 #[test]
1015 fn json_warnings_array_is_empty_when_no_advisory_failures() {
1016 let dir = tempfile::tempdir().unwrap();
1019 let prefix = dir.path().join("out");
1020 stage_dataset(&prefix, &success_manifest(Vec::new()));
1021 let cfg = write_cfg(dir.path(), &prefix);
1022
1023 let report = dir.path().join("report.json");
1024 run_validate_command(
1025 cfg.to_str().unwrap(),
1026 Some("orders"),
1027 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1028 ValidateTarget::default(),
1029 )
1030 .expect("a clean dataset must pass");
1031
1032 let json: serde_json::Value =
1033 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1034 assert_eq!(
1035 json["warnings"]
1036 .as_array()
1037 .expect("warnings array present")
1038 .len(),
1039 0,
1040 "no surplus → no warnings"
1041 );
1042 }
1043
1044 #[test]
1045 fn multiplex_cdc_validates_each_table_sub_prefix() {
1046 let dir = tempfile::tempdir().unwrap();
1052 let base = dir.path().join("cdc");
1053 stage_dataset(&base.join("alpha"), &success_manifest(Vec::new()));
1054 stage_dataset(&base.join("beta"), &success_manifest(Vec::new()));
1055 let cfg = write_multiplex_cfg(dir.path(), &base);
1056
1057 let report = dir.path().join("report.json");
1058 run_validate_command(
1059 cfg.to_str().unwrap(),
1060 None,
1061 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1062 ValidateTarget {
1066 depth: ValidateDepth::Sample,
1067 ..Default::default()
1068 },
1069 )
1070 .expect("both table sub-prefixes are complete — the stream must validate");
1071
1072 let json: serde_json::Value =
1073 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1074 let names: Vec<&str> = json["exports"]
1075 .as_array()
1076 .unwrap()
1077 .iter()
1078 .map(|e| e["export_name"].as_str().unwrap())
1079 .collect();
1080 assert_eq!(names, vec!["cdc/alpha", "cdc/beta"], "per-table descent");
1083 for e in json["exports"].as_array().unwrap() {
1084 assert_eq!(e["verification"]["passed"], true, "each table passes");
1085 }
1086 }
1087
1088 fn write_multiplex_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1089 let cfg = dir.join("rivet-cdc.yaml");
1090 let yaml = format!(
1091 "source:\n type: mysql\n url: mysql://nobody@localhost/nope\nexports:\n - name: cdc\n tables: [alpha, beta]\n mode: cdc\n format: parquet\n cdc:\n server_id: 1\n destination:\n type: local\n path: \"{}\"\n",
1092 base.to_string_lossy()
1093 );
1094 std::fs::write(&cfg, yaml).unwrap();
1095 cfg
1096 }
1097
1098 #[test]
1099 fn single_table_cdc_certifies_snapshot_and_hides_its_untracked() {
1100 let dir = tempfile::tempdir().unwrap();
1106 let base = dir.path().join("cdc");
1107 stage_dataset(&base, &success_manifest(Vec::new()));
1108 stage_dataset(&base.join("snapshot"), &success_manifest(Vec::new()));
1109 let cfg = write_single_cdc_cfg(dir.path(), &base);
1110
1111 let report = dir.path().join("report.json");
1112 run_validate_command(
1113 cfg.to_str().unwrap(),
1114 None,
1115 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1116 ValidateTarget {
1117 depth: ValidateDepth::Sample,
1118 ..Default::default()
1119 },
1120 )
1121 .expect("snapshot + change datasets are complete");
1122
1123 let json: serde_json::Value =
1124 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1125 let exports = json["exports"].as_array().unwrap();
1126 let names: Vec<&str> = exports
1127 .iter()
1128 .map(|e| e["export_name"].as_str().unwrap())
1129 .collect();
1130 assert!(
1132 names.contains(&"cdc/snapshot"),
1133 "snapshot certified: {names:?}"
1134 );
1135 assert!(names.contains(&"cdc"), "change-prefix verdict: {names:?}");
1136 let change = exports.iter().find(|e| e["export_name"] == "cdc").unwrap();
1138 let failures = change["verification"]["failures"].as_array().unwrap();
1139 assert!(
1140 failures.iter().all(|f| f["kind"] != "untracked_object"),
1141 "snapshot files must not read as untracked surplus: {failures:?}"
1142 );
1143 }
1144
1145 fn write_single_cdc_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1146 let cfg = dir.join("rivet-single-cdc.yaml");
1147 let yaml = format!(
1148 "source:\n type: mysql\n url: mysql://nobody@localhost/nope\nexports:\n - name: cdc\n table: t\n mode: cdc\n format: parquet\n cdc:\n initial: snapshot\n server_id: 1\n checkpoint: ./ck\n destination:\n type: local\n path: \"{}\"\n",
1149 base.to_string_lossy()
1150 );
1151 std::fs::write(&cfg, yaml).unwrap();
1152 cfg
1153 }
1154
1155 #[test]
1156 fn missing_part_fails_the_command() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let prefix = dir.path().join("out");
1159 let m = success_manifest(vec![ManifestPart {
1160 part_id: 1,
1161 path: "part-000001.parquet".into(),
1162 rows: 10,
1163 size_bytes: 4,
1164 content_fingerprint: "xxh3:1111111111111111".into(),
1165 content_md5: String::new(),
1166 status: PartStatus::Committed,
1167 }]);
1168 stage_dataset(&prefix, &m); let cfg = write_cfg(dir.path(), &prefix);
1170
1171 let err = run_validate_command(
1172 cfg.to_str().unwrap(),
1173 Some("orders"),
1174 ValidateOutputFormat::Json(None),
1175 ValidateTarget::default(),
1176 )
1177 .expect_err("a missing committed part must fail verification");
1178 assert!(
1179 format!("{err:#}").contains("1 export(s) failed verification"),
1180 "got: {err:#}"
1181 );
1182 }
1183
1184 #[test]
1189 fn prefix_override_with_real_manifest_passes() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let prefix = dir.path().join("out");
1192 stage_dataset(&prefix, &success_manifest(Vec::new()));
1193 let cfg = write_cfg(dir.path(), &prefix);
1194
1195 run_validate_command(
1196 cfg.to_str().unwrap(),
1197 Some("orders"),
1198 ValidateOutputFormat::Json(None),
1199 ValidateTarget {
1200 prefix_override: Some(prefix.to_string_lossy().into_owned()),
1201 ..Default::default()
1202 },
1203 )
1204 .expect("a real dataset under a pinned --prefix must pass");
1205 }
1206
1207 #[test]
1212 fn prefix_override_at_absent_manifest_fails() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let cfg_prefix = dir.path().join("cfg_dest");
1217 std::fs::create_dir_all(&cfg_prefix).unwrap();
1218 let cfg = write_cfg(dir.path(), &cfg_prefix);
1219 let empty_prefix = dir.path().join("never_written");
1220 std::fs::create_dir_all(&empty_prefix).unwrap();
1221
1222 let report = dir.path().join("report.json");
1223 let err = run_validate_command(
1224 cfg.to_str().unwrap(),
1225 Some("orders"),
1226 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1227 ValidateTarget {
1228 prefix_override: Some(empty_prefix.to_string_lossy().into_owned()),
1229 ..Default::default()
1230 },
1231 )
1232 .expect_err("a never-written prefix pinned via --prefix must fail, not legacy-pass");
1233 assert!(
1234 format!("{err:#}").contains("1 export(s) failed verification"),
1235 "got: {err:#}"
1236 );
1237
1238 let json: serde_json::Value =
1241 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1242 let verification = &json["exports"][0]["verification"];
1243 assert_eq!(verification["manifest_found"], false);
1244 assert_eq!(verification["legacy_run"], false);
1245 assert_eq!(
1246 verification["failures"][0]["kind"],
1247 "manifest_required_but_absent"
1248 );
1249 }
1250
1251 #[test]
1255 fn absent_manifest_without_prefix_override_stays_legacy_pass() {
1256 let dir = tempfile::tempdir().unwrap();
1257 let prefix = dir.path().join("out");
1258 std::fs::create_dir_all(&prefix).unwrap(); let cfg = write_cfg(dir.path(), &prefix);
1260
1261 run_validate_command(
1262 cfg.to_str().unwrap(),
1263 Some("orders"),
1264 ValidateOutputFormat::Json(None),
1265 ValidateTarget::default(), )
1267 .expect("an absent manifest with no pinned --prefix is a legacy pass (exit 0)");
1268 }
1269
1270 fn stage_dataset_form_b_would_fail(prefix: &Path) {
1279 std::fs::create_dir_all(prefix).unwrap();
1280 let part_body: &[u8] = b"AAAA";
1283 std::fs::write(prefix.join("part-000001.parquet"), part_body).unwrap();
1284
1285 let mut m = success_manifest(vec![ManifestPart {
1286 part_id: 1,
1287 path: "part-000001.parquet".into(),
1288 rows: 1,
1289 size_bytes: part_body.len() as u64,
1290 content_fingerprint: "xxh3:1111111111111111".into(),
1291 content_md5: String::new(),
1292 status: PartStatus::Committed,
1293 }]);
1294 m.column_checksums = Some(vec![crate::manifest::ColumnChecksum {
1297 name: "id".into(),
1298 checksum: "0".into(),
1299 }]);
1300 stage_dataset(prefix, &m);
1301 }
1302
1303 #[test]
1304 fn sample_depth_does_not_run_form_b() {
1305 let dir = tempfile::tempdir().unwrap();
1309 let prefix = dir.path().join("out");
1310 stage_dataset_form_b_would_fail(&prefix);
1311 let cfg = write_cfg(dir.path(), &prefix);
1312
1313 let report = dir.path().join("report.json");
1314 run_validate_command(
1315 cfg.to_str().unwrap(),
1316 Some("orders"),
1317 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1318 ValidateTarget {
1319 depth: ValidateDepth::Sample,
1320 ..Default::default()
1321 },
1322 )
1323 .expect("sample depth skips Form B, so a non-Parquet part still passes");
1324
1325 let json: serde_json::Value =
1326 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1327 let verification = &json["exports"][0]["verification"];
1328 assert_eq!(verification["passed"], true);
1329 assert_eq!(verification["parts_verified"], 1, "sample reconciles parts");
1330 assert_eq!(verification["depth_level"], "sample");
1331 }
1332
1333 #[test]
1334 fn full_depth_runs_form_b() {
1335 let dir = tempfile::tempdir().unwrap();
1340 let prefix = dir.path().join("out");
1341 stage_dataset_form_b_would_fail(&prefix);
1342 let cfg = write_cfg(dir.path(), &prefix);
1343
1344 let err = run_validate_command(
1345 cfg.to_str().unwrap(),
1346 Some("orders"),
1347 ValidateOutputFormat::Json(None),
1348 ValidateTarget {
1349 depth: ValidateDepth::Full,
1350 ..Default::default()
1351 },
1352 )
1353 .expect_err("full depth runs Form B, which fails on a non-Parquet part");
1354 assert!(
1355 format!("{err:#}").contains("1 export(s) failed verification"),
1356 "got: {err:#}"
1357 );
1358 }
1359
1360 #[test]
1361 fn json_report_carries_failure_code_and_depth_level() {
1362 let dir = tempfile::tempdir().unwrap();
1366 let prefix = dir.path().join("out");
1367 let m = success_manifest(vec![ManifestPart {
1368 part_id: 1,
1369 path: "part-000001.parquet".into(),
1370 rows: 10,
1371 size_bytes: 4,
1372 content_fingerprint: "xxh3:1111111111111111".into(),
1373 content_md5: String::new(),
1374 status: PartStatus::Committed,
1375 }]);
1376 stage_dataset(&prefix, &m); let cfg = write_cfg(dir.path(), &prefix);
1378
1379 let report = dir.path().join("report.json");
1380 let _ = run_validate_command(
1381 cfg.to_str().unwrap(),
1382 Some("orders"),
1383 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1384 ValidateTarget {
1385 depth: ValidateDepth::Sample,
1386 ..Default::default()
1387 },
1388 )
1389 .expect_err("a missing part fails the command");
1390
1391 let json: serde_json::Value =
1392 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1393 let verification = &json["exports"][0]["verification"];
1394 assert_eq!(verification["depth_level"], "sample");
1396 let failure = &verification["failures"][0];
1398 assert_eq!(failure["kind"], "part_missing");
1399 assert_eq!(failure["code"], "RIVET_VERIFY_PART_MISSING");
1400 assert_eq!(failure["part_id"], 1);
1403 }
1404
1405 #[test]
1406 fn json_warning_entry_also_carries_its_code() {
1407 let dir = tempfile::tempdir().unwrap();
1410 let prefix = dir.path().join("out");
1411 stage_dataset(&prefix, &success_manifest(Vec::new()));
1412 std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
1413 let cfg = write_cfg(dir.path(), &prefix);
1414
1415 let report = dir.path().join("report.json");
1416 run_validate_command(
1417 cfg.to_str().unwrap(),
1418 Some("orders"),
1419 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1420 ValidateTarget::default(),
1421 )
1422 .expect("advisory untracked surplus must not flip the exit code");
1423
1424 let json: serde_json::Value =
1425 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1426 let warning = &json["warnings"][0]["warning"];
1427 assert_eq!(warning["kind"], "untracked_object");
1428 assert_eq!(warning["code"], "RIVET_VERIFY_UNTRACKED_OBJECT");
1429 assert_eq!(json["exports"][0]["verification"]["depth_level"], "full");
1431 }
1432}