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 mode: "batch".to_string(),
870 manifest_version: MANIFEST_VERSION,
871 run_id: "r-validate-cmd".into(),
872 export_name: "orders".into(),
873 started_at: "2026-06-09T12:00:00Z".into(),
874 finished_at: "2026-06-09T12:01:00Z".into(),
875 status: ManifestStatus::Success,
876 source: ManifestSource {
877 engine: "postgres".into(),
878 schema: Some("public".into()),
879 table: Some("orders".into()),
880 extraction: None,
881 },
882 destination: ManifestDestination {
883 kind: "local".into(),
884 uri: "file:///tmp/out".into(),
885 },
886 format: "parquet".into(),
887 compression: "zstd".into(),
888 schema_fingerprint: "xxh3:0123456789abcdef".into(),
889 row_count,
890 part_count,
891 parts,
892 column_checksums: None,
893 checksum_key_column: None,
894 }
895 }
896
897 fn stage_dataset(prefix: &Path, m: &RunManifest) {
900 std::fs::create_dir_all(prefix).unwrap();
901 let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
902 destination_type: crate::config::DestinationType::Local,
903 path: Some(prefix.to_string_lossy().into_owned()),
904 ..Default::default()
905 })
906 .unwrap();
907 crate::pipeline::write_manifest(&*dest, m).unwrap();
908 }
909
910 fn write_cfg(dir: &Path, prefix: &Path) -> std::path::PathBuf {
913 let cfg = dir.join("rivet.yaml");
914 let yaml = format!(
915 "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",
916 prefix.to_string_lossy()
917 );
918 std::fs::write(&cfg, yaml).unwrap();
919 cfg
920 }
921
922 #[cfg(unix)]
927 #[test]
928 fn unreadable_manifest_fails_the_command() {
929 use std::os::unix::fs::PermissionsExt;
930
931 let dir = tempfile::tempdir().unwrap();
932 let prefix = dir.path().join("out");
933 stage_dataset(&prefix, &success_manifest(Vec::new()));
934 let cfg = write_cfg(dir.path(), &prefix);
935
936 let manifest_path = prefix.join(crate::manifest::MANIFEST_FILENAME);
937 std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o000)).unwrap();
938 if std::fs::read(&manifest_path).is_ok() {
939 eprintln!("skipping unreadable_manifest_fails_the_command: running as root");
941 return;
942 }
943
944 let report = dir.path().join("report.json");
945 let err = run_validate_command(
946 cfg.to_str().unwrap(),
947 Some("orders"),
948 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
949 ValidateTarget::default(),
950 )
951 .expect_err("an unreadable manifest is an explicit failure, not exit 0");
952 assert!(
956 format!("{err:#}").contains("could not be verified"),
957 "got: {err:#}"
958 );
959 assert!(
960 err.downcast_ref::<crate::error::DataIntegrityError>()
961 .is_none(),
962 "a read error must not be classed as data-integrity (exit 3): {err:#}"
963 );
964
965 let json: serde_json::Value =
968 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
969 let verification = &json["exports"][0]["verification"];
970 assert_eq!(verification["manifest_found"], false);
971 assert_eq!(verification["legacy_run"], false);
972 assert_eq!(verification["failures"][0]["kind"], "manifest_read_error");
973 }
974
975 #[test]
976 fn untracked_surplus_alone_keeps_exit_zero() {
977 let dir = tempfile::tempdir().unwrap();
981 let prefix = dir.path().join("out");
982 stage_dataset(&prefix, &success_manifest(Vec::new()));
983 std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
984 let cfg = write_cfg(dir.path(), &prefix);
985
986 let report = dir.path().join("report.json");
987 run_validate_command(
988 cfg.to_str().unwrap(),
989 Some("orders"),
990 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
991 ValidateTarget::default(),
992 )
993 .expect("advisory untracked surplus must not flip the exit code");
994
995 let json: serde_json::Value =
996 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
997 let verification = &json["exports"][0]["verification"];
998 assert_eq!(verification["passed"], true);
999 assert_eq!(verification["failures"][0]["kind"], "untracked_object");
1002
1003 let warnings = json["warnings"].as_array().expect("warnings array present");
1007 assert_eq!(warnings.len(), 1, "the untracked surplus is one warning");
1008 assert_eq!(warnings[0]["export_name"], "orders");
1009 assert_eq!(warnings[0]["warning"]["kind"], "untracked_object");
1010 assert_eq!(warnings[0]["warning"]["key"], "rogue.parquet");
1011 }
1012
1013 #[test]
1014 fn json_warnings_array_is_empty_when_no_advisory_failures() {
1015 let dir = tempfile::tempdir().unwrap();
1018 let prefix = dir.path().join("out");
1019 stage_dataset(&prefix, &success_manifest(Vec::new()));
1020 let cfg = write_cfg(dir.path(), &prefix);
1021
1022 let report = dir.path().join("report.json");
1023 run_validate_command(
1024 cfg.to_str().unwrap(),
1025 Some("orders"),
1026 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1027 ValidateTarget::default(),
1028 )
1029 .expect("a clean dataset must pass");
1030
1031 let json: serde_json::Value =
1032 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1033 assert_eq!(
1034 json["warnings"]
1035 .as_array()
1036 .expect("warnings array present")
1037 .len(),
1038 0,
1039 "no surplus → no warnings"
1040 );
1041 }
1042
1043 #[test]
1044 fn multiplex_cdc_validates_each_table_sub_prefix() {
1045 let dir = tempfile::tempdir().unwrap();
1051 let base = dir.path().join("cdc");
1052 stage_dataset(&base.join("alpha"), &success_manifest(Vec::new()));
1053 stage_dataset(&base.join("beta"), &success_manifest(Vec::new()));
1054 let cfg = write_multiplex_cfg(dir.path(), &base);
1055
1056 let report = dir.path().join("report.json");
1057 run_validate_command(
1058 cfg.to_str().unwrap(),
1059 None,
1060 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1061 ValidateTarget {
1065 depth: ValidateDepth::Sample,
1066 ..Default::default()
1067 },
1068 )
1069 .expect("both table sub-prefixes are complete — the stream must validate");
1070
1071 let json: serde_json::Value =
1072 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1073 let names: Vec<&str> = json["exports"]
1074 .as_array()
1075 .unwrap()
1076 .iter()
1077 .map(|e| e["export_name"].as_str().unwrap())
1078 .collect();
1079 assert_eq!(names, vec!["cdc/alpha", "cdc/beta"], "per-table descent");
1082 for e in json["exports"].as_array().unwrap() {
1083 assert_eq!(e["verification"]["passed"], true, "each table passes");
1084 }
1085 }
1086
1087 fn write_multiplex_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1088 let cfg = dir.join("rivet-cdc.yaml");
1089 let yaml = format!(
1090 "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",
1091 base.to_string_lossy()
1092 );
1093 std::fs::write(&cfg, yaml).unwrap();
1094 cfg
1095 }
1096
1097 #[test]
1098 fn single_table_cdc_certifies_snapshot_and_hides_its_untracked() {
1099 let dir = tempfile::tempdir().unwrap();
1105 let base = dir.path().join("cdc");
1106 stage_dataset(&base, &success_manifest(Vec::new()));
1107 stage_dataset(&base.join("snapshot"), &success_manifest(Vec::new()));
1108 let cfg = write_single_cdc_cfg(dir.path(), &base);
1109
1110 let report = dir.path().join("report.json");
1111 run_validate_command(
1112 cfg.to_str().unwrap(),
1113 None,
1114 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1115 ValidateTarget {
1116 depth: ValidateDepth::Sample,
1117 ..Default::default()
1118 },
1119 )
1120 .expect("snapshot + change datasets are complete");
1121
1122 let json: serde_json::Value =
1123 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1124 let exports = json["exports"].as_array().unwrap();
1125 let names: Vec<&str> = exports
1126 .iter()
1127 .map(|e| e["export_name"].as_str().unwrap())
1128 .collect();
1129 assert!(
1131 names.contains(&"cdc/snapshot"),
1132 "snapshot certified: {names:?}"
1133 );
1134 assert!(names.contains(&"cdc"), "change-prefix verdict: {names:?}");
1135 let change = exports.iter().find(|e| e["export_name"] == "cdc").unwrap();
1137 let failures = change["verification"]["failures"].as_array().unwrap();
1138 assert!(
1139 failures.iter().all(|f| f["kind"] != "untracked_object"),
1140 "snapshot files must not read as untracked surplus: {failures:?}"
1141 );
1142 }
1143
1144 fn write_single_cdc_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1145 let cfg = dir.join("rivet-single-cdc.yaml");
1146 let yaml = format!(
1147 "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",
1148 base.to_string_lossy()
1149 );
1150 std::fs::write(&cfg, yaml).unwrap();
1151 cfg
1152 }
1153
1154 #[test]
1155 fn missing_part_fails_the_command() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let prefix = dir.path().join("out");
1158 let m = success_manifest(vec![ManifestPart {
1159 part_id: 1,
1160 path: "part-000001.parquet".into(),
1161 rows: 10,
1162 size_bytes: 4,
1163 content_fingerprint: "xxh3:1111111111111111".into(),
1164 content_md5: String::new(),
1165 status: PartStatus::Committed,
1166 }]);
1167 stage_dataset(&prefix, &m); let cfg = write_cfg(dir.path(), &prefix);
1169
1170 let err = run_validate_command(
1171 cfg.to_str().unwrap(),
1172 Some("orders"),
1173 ValidateOutputFormat::Json(None),
1174 ValidateTarget::default(),
1175 )
1176 .expect_err("a missing committed part must fail verification");
1177 assert!(
1178 format!("{err:#}").contains("1 export(s) failed verification"),
1179 "got: {err:#}"
1180 );
1181 }
1182
1183 #[test]
1188 fn prefix_override_with_real_manifest_passes() {
1189 let dir = tempfile::tempdir().unwrap();
1190 let prefix = dir.path().join("out");
1191 stage_dataset(&prefix, &success_manifest(Vec::new()));
1192 let cfg = write_cfg(dir.path(), &prefix);
1193
1194 run_validate_command(
1195 cfg.to_str().unwrap(),
1196 Some("orders"),
1197 ValidateOutputFormat::Json(None),
1198 ValidateTarget {
1199 prefix_override: Some(prefix.to_string_lossy().into_owned()),
1200 ..Default::default()
1201 },
1202 )
1203 .expect("a real dataset under a pinned --prefix must pass");
1204 }
1205
1206 #[test]
1211 fn prefix_override_at_absent_manifest_fails() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let cfg_prefix = dir.path().join("cfg_dest");
1216 std::fs::create_dir_all(&cfg_prefix).unwrap();
1217 let cfg = write_cfg(dir.path(), &cfg_prefix);
1218 let empty_prefix = dir.path().join("never_written");
1219 std::fs::create_dir_all(&empty_prefix).unwrap();
1220
1221 let report = dir.path().join("report.json");
1222 let err = run_validate_command(
1223 cfg.to_str().unwrap(),
1224 Some("orders"),
1225 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1226 ValidateTarget {
1227 prefix_override: Some(empty_prefix.to_string_lossy().into_owned()),
1228 ..Default::default()
1229 },
1230 )
1231 .expect_err("a never-written prefix pinned via --prefix must fail, not legacy-pass");
1232 assert!(
1233 format!("{err:#}").contains("1 export(s) failed verification"),
1234 "got: {err:#}"
1235 );
1236
1237 let json: serde_json::Value =
1240 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1241 let verification = &json["exports"][0]["verification"];
1242 assert_eq!(verification["manifest_found"], false);
1243 assert_eq!(verification["legacy_run"], false);
1244 assert_eq!(
1245 verification["failures"][0]["kind"],
1246 "manifest_required_but_absent"
1247 );
1248 }
1249
1250 #[test]
1254 fn absent_manifest_without_prefix_override_stays_legacy_pass() {
1255 let dir = tempfile::tempdir().unwrap();
1256 let prefix = dir.path().join("out");
1257 std::fs::create_dir_all(&prefix).unwrap(); let cfg = write_cfg(dir.path(), &prefix);
1259
1260 run_validate_command(
1261 cfg.to_str().unwrap(),
1262 Some("orders"),
1263 ValidateOutputFormat::Json(None),
1264 ValidateTarget::default(), )
1266 .expect("an absent manifest with no pinned --prefix is a legacy pass (exit 0)");
1267 }
1268
1269 fn stage_dataset_form_b_would_fail(prefix: &Path) {
1278 std::fs::create_dir_all(prefix).unwrap();
1279 let part_body: &[u8] = b"AAAA";
1282 std::fs::write(prefix.join("part-000001.parquet"), part_body).unwrap();
1283
1284 let mut m = success_manifest(vec![ManifestPart {
1285 part_id: 1,
1286 path: "part-000001.parquet".into(),
1287 rows: 1,
1288 size_bytes: part_body.len() as u64,
1289 content_fingerprint: "xxh3:1111111111111111".into(),
1290 content_md5: String::new(),
1291 status: PartStatus::Committed,
1292 }]);
1293 m.column_checksums = Some(vec![crate::manifest::ColumnChecksum {
1296 name: "id".into(),
1297 checksum: "0".into(),
1298 }]);
1299 stage_dataset(prefix, &m);
1300 }
1301
1302 #[test]
1303 fn sample_depth_does_not_run_form_b() {
1304 let dir = tempfile::tempdir().unwrap();
1308 let prefix = dir.path().join("out");
1309 stage_dataset_form_b_would_fail(&prefix);
1310 let cfg = write_cfg(dir.path(), &prefix);
1311
1312 let report = dir.path().join("report.json");
1313 run_validate_command(
1314 cfg.to_str().unwrap(),
1315 Some("orders"),
1316 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1317 ValidateTarget {
1318 depth: ValidateDepth::Sample,
1319 ..Default::default()
1320 },
1321 )
1322 .expect("sample depth skips Form B, so a non-Parquet part still passes");
1323
1324 let json: serde_json::Value =
1325 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1326 let verification = &json["exports"][0]["verification"];
1327 assert_eq!(verification["passed"], true);
1328 assert_eq!(verification["parts_verified"], 1, "sample reconciles parts");
1329 assert_eq!(verification["depth_level"], "sample");
1330 }
1331
1332 #[test]
1333 fn full_depth_runs_form_b() {
1334 let dir = tempfile::tempdir().unwrap();
1339 let prefix = dir.path().join("out");
1340 stage_dataset_form_b_would_fail(&prefix);
1341 let cfg = write_cfg(dir.path(), &prefix);
1342
1343 let err = run_validate_command(
1344 cfg.to_str().unwrap(),
1345 Some("orders"),
1346 ValidateOutputFormat::Json(None),
1347 ValidateTarget {
1348 depth: ValidateDepth::Full,
1349 ..Default::default()
1350 },
1351 )
1352 .expect_err("full depth runs Form B, which fails on a non-Parquet part");
1353 assert!(
1354 format!("{err:#}").contains("1 export(s) failed verification"),
1355 "got: {err:#}"
1356 );
1357 }
1358
1359 #[test]
1360 fn json_report_carries_failure_code_and_depth_level() {
1361 let dir = tempfile::tempdir().unwrap();
1365 let prefix = dir.path().join("out");
1366 let m = success_manifest(vec![ManifestPart {
1367 part_id: 1,
1368 path: "part-000001.parquet".into(),
1369 rows: 10,
1370 size_bytes: 4,
1371 content_fingerprint: "xxh3:1111111111111111".into(),
1372 content_md5: String::new(),
1373 status: PartStatus::Committed,
1374 }]);
1375 stage_dataset(&prefix, &m); let cfg = write_cfg(dir.path(), &prefix);
1377
1378 let report = dir.path().join("report.json");
1379 let _ = run_validate_command(
1380 cfg.to_str().unwrap(),
1381 Some("orders"),
1382 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1383 ValidateTarget {
1384 depth: ValidateDepth::Sample,
1385 ..Default::default()
1386 },
1387 )
1388 .expect_err("a missing part fails the command");
1389
1390 let json: serde_json::Value =
1391 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1392 let verification = &json["exports"][0]["verification"];
1393 assert_eq!(verification["depth_level"], "sample");
1395 let failure = &verification["failures"][0];
1397 assert_eq!(failure["kind"], "part_missing");
1398 assert_eq!(failure["code"], "RIVET_VERIFY_PART_MISSING");
1399 assert_eq!(failure["part_id"], 1);
1402 }
1403
1404 #[test]
1405 fn json_warning_entry_also_carries_its_code() {
1406 let dir = tempfile::tempdir().unwrap();
1409 let prefix = dir.path().join("out");
1410 stage_dataset(&prefix, &success_manifest(Vec::new()));
1411 std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
1412 let cfg = write_cfg(dir.path(), &prefix);
1413
1414 let report = dir.path().join("report.json");
1415 run_validate_command(
1416 cfg.to_str().unwrap(),
1417 Some("orders"),
1418 ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1419 ValidateTarget::default(),
1420 )
1421 .expect("advisory untracked surplus must not flip the exit code");
1422
1423 let json: serde_json::Value =
1424 serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1425 let warning = &json["warnings"][0]["warning"];
1426 assert_eq!(warning["kind"], "untracked_object");
1427 assert_eq!(warning["code"], "RIVET_VERIFY_UNTRACKED_OBJECT");
1428 assert_eq!(json["exports"][0]["verification"]["depth_level"], "full");
1430 }
1431}