1use super::ipc::{self, ChildEvent};
18use super::{format_bytes, multi_export_mode, strip_chunked_recovery_hint};
19use crate::journal::{PlanSnapshot, RunEvent, RunJournal};
20use crate::manifest::ManifestPart;
21use crate::plan::ResolvedRunPlan;
22
23fn plan_snapshot_from(plan: &ResolvedRunPlan) -> PlanSnapshot {
29 PlanSnapshot {
30 export_name: plan.export_name.clone(),
31 base_query: plan.base_query.clone(),
32 strategy: plan.strategy.mode_label().to_string(),
33 format: plan.format.label().to_string(),
34 compression: plan.compression.label().to_string(),
35 destination_type: plan.destination.destination_type.label().to_string(),
36 tuning_profile: plan.tuning_profile_label.clone(),
37 batch_size: plan.tuning.batch_size,
38 validate: plan.validate,
39 reconcile: plan.reconcile,
40 resume: plan.resume,
41 chunk_key: plan.strategy.chunk_key().map(|c| c.to_string()),
42 resumable: plan.strategy.is_resumable(),
43 row_hash: crate::enrich::RowHashContract::of(&plan.meta_columns.row_hash),
44 }
45}
46
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct ApplyContext {
56 pub plan_id: String,
58 pub forced: bool,
60 pub force_bypassed: Vec<String>,
65}
66
67#[derive(Debug, Clone, Default)]
73pub struct RunSummary {
74 pub run_id: String,
75 pub export_name: String,
76 pub status: String,
77 pub total_rows: i64,
78 pub files_produced: usize,
79 pub bytes_written: u64,
80 pub files_committed: usize,
83 pub duration_ms: i64,
84 pub peak_rss_mb: i64,
85 pub retries: u32,
86 pub reconnects: u32,
91 pub resumed: bool,
95 pub chunks_precomputed: bool,
109 pub state_backed: bool,
118 pub validated: Option<bool>,
119 pub schema_changed: Option<bool>,
120 pub quality_passed: Option<bool>,
121 pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
124 pub checksum_key_column: Option<String>,
126 pub column_checksums_incomplete: bool,
137 pub cursor_column: Option<String>,
142 pub cursor_low: Option<String>,
143 pub cursor_high: Option<String>,
144 pub error_message: Option<String>,
145 pub offending_value: Option<String>,
153 pub server_context_json: Option<String>,
154 pub key_native_type: Option<String>,
158 pub tuning_profile: String,
160 pub batch_size: usize,
162 pub batch_size_memory_mb: Option<usize>,
164 pub format: String,
165 pub mode: String,
166 pub compression: String,
167 pub destination_uri: Option<String>,
172 pub pg_temp_bytes_delta: Option<i64>,
179 pub skip_reason: Option<String>,
185 pub source_count: Option<i64>,
187 pub reconciled: Option<bool>,
189 pub manifest_parts: Vec<ManifestPart>,
194 pub schema_fingerprint: Option<String>,
208 pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
214 pub apply_context: Option<ApplyContext>,
218 pub journal: RunJournal,
220}
221
222type Row = (&'static str, String);
225
226impl RunSummary {
227 pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
228 let run_id = format!(
229 "{}_{}",
230 plan.export_name,
231 chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
232 );
233 let mut journal = RunJournal::new(&run_id, &plan.export_name);
234 journal.record(RunEvent::PlanResolved(Box::new(plan_snapshot_from(plan))));
235
236 ipc::emit_event(&ChildEvent::Started {
237 export_name: plan.export_name.clone(),
238 run_id: run_id.clone(),
239 mode: plan.strategy.mode_label().to_string(),
240 tuning_profile: plan.tuning_profile_label.clone(),
241 batch_size: plan.tuning.batch_size,
242 });
243
244 Self {
245 run_id,
246 export_name: plan.export_name.clone(),
247 status: "running".into(),
248 total_rows: 0,
249 files_produced: 0,
250 bytes_written: 0,
251 files_committed: 0,
252 duration_ms: 0,
253 peak_rss_mb: 0,
254 retries: 0,
255 reconnects: 0,
256 resumed: false,
257 chunks_precomputed: false,
258 state_backed: false,
259 validated: None,
260 schema_changed: None,
261 quality_passed: None,
262 error_message: None,
263 offending_value: None,
264 server_context_json: None,
265 key_native_type: None,
266 tuning_profile: plan.tuning_profile_label.clone(),
267 batch_size: plan.tuning.batch_size,
268 batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
269 format: plan.format.label().to_string(),
270 mode: plan.strategy.mode_label().to_string(),
271 compression: plan.compression.label().to_string(),
272 destination_uri: (!matches!(
273 plan.destination.destination_type,
274 crate::config::DestinationType::Stdout
275 ))
276 .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
277 pg_temp_bytes_delta: None,
278 skip_reason: None,
279 source_count: None,
280 reconciled: None,
281 manifest_parts: Vec::new(),
282 schema_fingerprint: None,
283 manifest_verification: None,
284 apply_context: None,
285 column_checksums: Vec::new(),
286 column_checksums_incomplete: false,
287 checksum_key_column: None,
288 cursor_column: None,
289 cursor_low: None,
290 cursor_high: None,
291 journal,
292 }
293 }
294
295 #[doc(hidden)]
316 #[allow(dead_code)]
317 pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
318 let run_id = run_id.into();
319 let export_name = export_name.into();
320 let journal = RunJournal::new(&run_id, &export_name);
321 Self {
322 run_id,
323 export_name,
324 status: "running".into(),
325 total_rows: 0,
326 files_produced: 0,
327 bytes_written: 0,
328 files_committed: 0,
329 duration_ms: 0,
330 peak_rss_mb: 0,
331 retries: 0,
332 reconnects: 0,
333 resumed: false,
334 chunks_precomputed: false,
335 state_backed: false,
336 validated: None,
337 schema_changed: None,
338 quality_passed: None,
339 error_message: None,
340 offending_value: None,
341 server_context_json: None,
342 key_native_type: None,
343 tuning_profile: "balanced".into(),
344 batch_size: 1000,
345 batch_size_memory_mb: None,
346 format: "parquet".into(),
347 mode: "snapshot".into(),
348 compression: "zstd".into(),
349 destination_uri: None,
350 pg_temp_bytes_delta: None,
351 skip_reason: None,
352 source_count: None,
353 reconciled: None,
354 manifest_parts: Vec::new(),
355 schema_fingerprint: None,
356 manifest_verification: None,
357 apply_context: None,
358 column_checksums: Vec::new(),
359 column_checksums_incomplete: false,
360 checksum_key_column: None,
361 cursor_column: None,
362 cursor_low: None,
363 cursor_high: None,
364 journal,
365 }
366 }
367
368 #[doc(hidden)]
375 #[allow(dead_code)]
376 pub fn with_status(mut self, status: impl Into<String>) -> Self {
377 let s = status.into();
378 if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
379 self.journal.record(RunEvent::RunCompleted {
380 status: s.clone(),
381 error_message: self.error_message.clone(),
382 duration_ms: self.duration_ms,
383 });
384 }
385 self.status = s;
386 self
387 }
388
389 #[doc(hidden)]
393 #[allow(dead_code)]
394 pub fn with_files_committed(mut self, n: usize) -> Self {
395 self.files_committed = n;
396 self
397 }
398
399 #[doc(hidden)]
403 #[allow(dead_code)]
404 pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
405 self.total_rows = parts.iter().map(|p| p.rows).sum();
406 self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
407 self.files_produced = parts.len();
408 self.files_committed = parts.len();
409 self.manifest_parts = parts;
410 self
411 }
412
413 #[doc(hidden)]
417 #[allow(dead_code)]
418 pub fn with_error(mut self, msg: impl Into<String>) -> Self {
419 self.error_message = Some(msg.into());
420 self
421 }
422
423 #[doc(hidden)]
428 #[allow(dead_code)]
429 pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
430 self.journal.record(RunEvent::PlanResolved(Box::new(snap)));
431 self
432 }
433
434 pub(super) fn print(&self) {
435 if ipc::capturing_events() {
439 ipc::emit_event(&ChildEvent::Finished {
440 export_name: self.export_name.clone(),
441 run_id: self.run_id.clone(),
442 status: self.status.clone(),
443 total_rows: self.total_rows,
444 files_produced: self.files_produced as u64,
445 bytes_written: self.bytes_written,
446 duration_ms: self.duration_ms,
447 peak_rss_mb: self.peak_rss_mb,
448 error_message: self.error_message.clone(),
449 });
450 return;
451 }
452
453 self.print_stderr_block();
454 }
455
456 pub(super) fn print_stderr_block(&self) {
461 let block = if multi_export_mode() {
462 self.render_compact()
463 } else {
464 self.render().trim_end_matches('\n').to_string()
470 };
471
472 use std::io::Write;
473 let mut buf = super::parent_ui::sanitize_terminal(&block);
480 buf.push('\n');
481 let stderr = std::io::stderr();
482 let mut handle = stderr.lock();
483 let _ = handle.write_all(buf.as_bytes());
484 let _ = handle.flush();
485 }
486
487 fn render_compact(&self) -> String {
492 const NAME_COL: usize = 22;
493 const MODE_COL: usize = 8;
494 let icon = match self.status.as_str() {
495 "success" => "✓",
496 "failed" => "✗",
497 _ => "•",
498 };
499 let body = if self.status == "failed" {
500 let err = self
501 .error_message
502 .as_deref()
503 .unwrap_or("(no error message recorded)");
504 let (cause, _) = strip_chunked_recovery_hint(err);
505 compact_error(cause)
509 } else {
510 let rss = if self.peak_rss_mb > 0 {
511 format!(" RSS {} MB", fmt_thousands(self.peak_rss_mb))
512 } else {
513 String::new()
514 };
515 format!(
516 "{} rows {} files {} {}{}",
517 fmt_thousands(self.total_rows),
518 fmt_thousands(self.files_produced as i64),
519 format_bytes(self.bytes_written),
520 fmt_duration_ms(self.duration_ms),
521 rss
522 )
523 };
524 format!(
525 "{} {:<name$} {:<mode$} {}",
526 icon,
527 self.export_name,
528 self.mode,
529 body,
530 name = NAME_COL,
531 mode = MODE_COL,
532 )
533 }
534
535 fn render(&self) -> String {
547 let mut rows: Vec<Row> = Vec::with_capacity(16);
548 rows.push(("run_id", self.run_id.clone()));
549 rows.push(self.status_row());
550 rows.push(self.tuning_row());
551 rows.push(("rows", fmt_thousands(self.total_rows)));
552 rows.push(("files", fmt_thousands(self.files_produced as i64)));
553 rows.extend(self.output_row());
554 rows.extend(self.position_row());
555 rows.extend(self.bytes_row());
556 rows.push(("duration", fmt_duration_ms(self.duration_ms)));
557 rows.extend(self.peak_rss_row());
558 rows.extend(self.pg_temp_spill_row());
559 rows.extend(self.compression_row());
560 rows.extend(self.retries_row());
561 rows.extend(self.outcome_rows());
562 rows.extend(self.error_row());
563 format_block(&self.export_name, &rows)
564 }
565
566 fn status_row(&self) -> Row {
568 let value = match (&self.status, &self.skip_reason) {
569 (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
570 (s, _) => s.clone(),
571 };
572 ("status", value)
573 }
574
575 fn tuning_row(&self) -> Row {
578 let value = match self.batch_size_memory_mb {
579 Some(mem) => format!(
580 "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
581 self.tuning_profile,
582 fmt_thousands(self.batch_size as i64),
583 mem
584 ),
585 None => format!(
586 "profile={}, batch_size={}",
587 self.tuning_profile,
588 fmt_thousands(self.batch_size as i64)
589 ),
590 };
591 ("tuning", value)
592 }
593
594 fn output_row(&self) -> Option<Row> {
598 if self.files_produced > 0 {
599 self.destination_uri.clone().map(|uri| ("output", uri))
600 } else {
601 None
602 }
603 }
604
605 fn position_row(&self) -> Option<Row> {
612 if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
613 Some(("cursor", pos))
614 } else {
615 time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
616 }
617 }
618
619 fn bytes_row(&self) -> Option<Row> {
621 if self.bytes_written > 0 {
622 Some(("bytes", format_bytes(self.bytes_written)))
623 } else {
624 None
625 }
626 }
627
628 fn peak_rss_row(&self) -> Option<Row> {
630 if self.peak_rss_mb > 0 {
631 Some((
632 "peak RSS",
633 format!(
634 "{} MB (sampled during run)",
635 fmt_thousands(self.peak_rss_mb)
636 ),
637 ))
638 } else {
639 None
640 }
641 }
642
643 fn pg_temp_spill_row(&self) -> Option<Row> {
647 let temp = self.pg_temp_bytes_delta?;
648 if temp <= 0 {
649 return None;
650 }
651 let temp_mb = temp as f64 / (1024.0 * 1024.0);
652 let label = if temp > 100 * 1024 * 1024 {
653 format!(
654 "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
655 temp_mb
656 )
657 } else {
658 format!("{:.1} MB", temp_mb)
659 };
660 Some(("pg temp spill", label))
661 }
662
663 fn compression_row(&self) -> Option<Row> {
665 if self.format == "parquet" && self.compression != "zstd" {
666 Some(("compression", self.compression.clone()))
667 } else {
668 None
669 }
670 }
671
672 fn retries_row(&self) -> Option<Row> {
674 if self.retries > 0 {
675 Some(("retries", self.retries.to_string()))
676 } else {
677 None
678 }
679 }
680
681 fn outcome_rows(&self) -> Vec<Row> {
687 let mut rows: Vec<Row> = Vec::new();
688 if let Some(v) = self.validated {
689 rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
690 }
691 if let Some(sc) = self.schema_changed {
692 rows.push((
693 "schema",
694 if sc {
695 "CHANGED".into()
696 } else {
697 "unchanged".into()
698 },
699 ));
700 }
701 if let Some(q) = self.quality_passed {
702 rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
703 }
704 if let Some(reconciled) = self.reconciled {
705 let src = self
706 .source_count
707 .map(fmt_thousands)
708 .unwrap_or_else(|| "?".into());
709 let exported = fmt_thousands(self.total_rows);
710 let value = if reconciled {
711 format!("MATCH ({exported}/{src})")
712 } else {
713 format!("MISMATCH (exported {exported} vs source {src})")
714 };
715 rows.push(("reconcile", value));
716 }
717 if self.status == "success"
722 && self.files_produced > 0
723 && self.validated.is_none()
724 && self.reconciled.is_none()
725 {
726 rows.push((
727 "verify",
728 "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
729 .into(),
730 ));
731 }
732 rows
733 }
734
735 fn error_row(&self) -> Option<Row> {
741 self.error_message
742 .as_ref()
743 .map(|err| ("error", err.trim_end().to_string()))
744 }
745
746 pub fn check_post_run_invariants(&self, is_resume_run: bool) -> Result<(), String> {
769 let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
770
771 if self.files_committed > self.manifest_parts.len() {
772 return Err(format!(
780 "summary.files_committed ({}) > manifest_parts.len() ({}) — \
781 a runner bumped files_committed without commit::record_part. \
782 With an EMPTY part list this is also the ADR-0012 M1 gap: the \
783 cloud manifest would ship with no parts (what parallel_checkpoint \
784 did before e9b0796)",
785 self.files_committed,
786 self.manifest_parts.len()
787 ));
788 }
789 if self.files_produced > self.manifest_parts.len() {
790 return Err(format!(
791 "summary.files_produced ({}) > manifest_parts.len() ({}) — \
792 a runner bumped files_produced without commit::record_part",
793 self.files_produced,
794 self.manifest_parts.len()
795 ));
796 }
797 if self.bytes_written > parts_bytes {
798 return Err(format!(
799 "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
800 a runner bumped bytes_written without commit::record_part",
801 self.bytes_written, parts_bytes
802 ));
803 }
804 if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
816 return Err(format!(
817 "summary.total_rows={} but files_committed=0 — rows extracted from \
818 source but no files committed (no output reached the destination)",
819 self.total_rows
820 ));
821 }
822 if self.state_backed && self.status == "success" && self.files_committed > 0 {
831 if !self.resumed
842 && !is_resume_run
843 && !self.chunks_precomputed
844 && self.schema_changed.is_none()
845 {
846 return Err(
847 "state_backed success committed parts but schema_changed is None — \
848 the on_schema_drift gate was never applied (no runner called \
849 check_from_sink_schema / check_from_type_mappings). This is the \
850 runner-bypass class: a runner owning its loop skipped the facade."
851 .to_string(),
852 );
853 }
854 if self.format == "parquet"
859 && self.column_checksums.is_empty()
860 && !self.column_checksums_incomplete
861 {
862 return Err(
863 "state_backed success committed parts but column_checksums is empty \
864 and not flagged incomplete — Form-B was never harvested (no runner \
865 called harvest_column_checksums). Runner-bypass class."
866 .to_string(),
867 );
868 }
869 }
870 Ok(())
871 }
872}
873
874fn compact_error(raw: &str) -> String {
886 const MAX_CHARS: usize = 240;
887 if let Some(summary) = summarize_parallel_chunk_errors(raw) {
888 return clamp_chars(&summary, MAX_CHARS);
889 }
890 let collapsed: String = raw
891 .lines()
892 .map(str::trim_end)
893 .filter(|s| !s.is_empty())
894 .collect::<Vec<_>>()
895 .join("; ");
896 clamp_chars(&collapsed, MAX_CHARS)
897}
898
899fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
907 let col = skip_reason?
908 .strip_prefix("no new rows since cursor '")?
909 .strip_suffix('\'')?;
910 Some(format!("'{col}' unchanged (no new rows this run)"))
911}
912
913fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
930 skip_reason?;
931 if mode != "timewindow" {
932 return None;
933 }
934 Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
935}
936
937fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
938 let header_pos = raw.find("parallel checkpoint worker errors:")?;
939 let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
940 let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
941
942 let chunk_lines: Vec<&str> = tail
943 .lines()
944 .map(str::trim)
945 .filter(|l| l.starts_with("chunk "))
946 .collect();
947 if chunk_lines.is_empty() {
948 return None;
949 }
950 let first_chunk_full = chunk_lines[0];
951 let first_chunk_short = clamp_chars(first_chunk_full, 140);
953 let prefix = if prefix.is_empty() {
954 String::new()
955 } else {
956 format!("{}: ", prefix)
957 };
958 Some(format!(
959 "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
960 prefix,
961 chunk_lines.len(),
962 first_chunk_short
963 ))
964}
965
966fn clamp_chars(s: &str, max_chars: usize) -> String {
967 if max_chars == 0 {
968 return String::new();
969 }
970 if s.chars().count() <= max_chars {
971 return s.to_string();
972 }
973 let keep = max_chars.saturating_sub(1);
974 let mut out: String = s.chars().take(keep).collect();
975 out.push('…');
976 out
977}
978
979fn format_block(name: &str, rows: &[(&str, String)]) -> String {
982 const HEADER_WIDTH: usize = 60;
983 let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
984
985 let prefix = format!("── {} ", name);
986 let prefix_chars = prefix.chars().count();
987 let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
988 let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
989 out.push('\n');
990 out.push_str(&prefix);
991 for _ in 0..dashes {
992 out.push('─');
993 }
994 out.push('\n');
995 let value_indent = " ".repeat(2 + (label_w + 1) + 2);
999 for (label, value) in rows {
1000 let mut lines = value.split('\n');
1003 let first = lines.next().unwrap_or("");
1004 out.push_str(&format!(
1005 " {:<width$} {}\n",
1006 format!("{label}:"),
1007 first,
1008 width = label_w + 1
1009 ));
1010 for cont in lines {
1011 out.push_str(&value_indent);
1012 out.push_str(cont);
1013 out.push('\n');
1014 }
1015 }
1016 out
1017}
1018
1019fn fmt_duration_ms(ms: i64) -> String {
1020 if ms < 1000 {
1021 return format!("{}ms", ms);
1022 }
1023 let total_secs = ms / 1000;
1024 let h = total_secs / 3600;
1025 let m = (total_secs % 3600) / 60;
1026 let s_frac = (ms % 60_000) as f64 / 1000.0;
1027 if h > 0 {
1028 format!("{}h {:02}m {:04.1}s", h, m, s_frac)
1029 } else if m > 0 {
1030 format!("{}m {:04.1}s", m, s_frac)
1031 } else {
1032 format!("{:.1}s", ms as f64 / 1000.0)
1033 }
1034}
1035
1036fn fmt_thousands(n: i64) -> String {
1040 let abs = n.unsigned_abs();
1041 let s = abs.to_string();
1042 let bytes = s.as_bytes();
1043 let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
1044 if n < 0 {
1045 out.push('-');
1046 }
1047 for (i, b) in bytes.iter().enumerate() {
1048 let from_end = bytes.len() - i;
1049 if i > 0 && from_end.is_multiple_of(3) {
1050 out.push(',');
1051 }
1052 out.push(*b as char);
1053 }
1054 out
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn fmt_thousands_handles_small_and_large() {
1063 assert_eq!(fmt_thousands(0), "0");
1064 assert_eq!(fmt_thousands(7), "7");
1065 assert_eq!(fmt_thousands(999), "999");
1066 assert_eq!(fmt_thousands(1_000), "1,000");
1067 assert_eq!(fmt_thousands(1_000_908), "1,000,908");
1068 assert_eq!(fmt_thousands(39_990_376), "39,990,376");
1069 assert_eq!(fmt_thousands(-1_234), "-1,234");
1070 assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
1071 }
1072
1073 #[test]
1074 fn fmt_duration_picks_unit() {
1075 assert_eq!(fmt_duration_ms(0), "0ms");
1076 assert_eq!(fmt_duration_ms(800), "800ms");
1077 assert_eq!(fmt_duration_ms(1_500), "1.5s");
1078 assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
1079 assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
1080 }
1081
1082 #[test]
1083 fn format_block_pads_labels_uniformly() {
1084 let rows = vec![
1085 ("run_id", "abc".to_string()),
1086 ("rows", "42".to_string()),
1087 ("compression", "zstd".to_string()),
1088 ];
1089 let out = format_block("orders", &rows);
1090
1091 let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
1093 assert_eq!(lines.len(), 3);
1094 let value_starts: Vec<usize> = lines
1095 .iter()
1096 .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
1097 .collect();
1098 let value_col = lines[0].rfind("abc").unwrap();
1102 assert_eq!(lines[1].rfind("42").unwrap(), value_col);
1103 assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
1104 let _ = value_starts;
1106 }
1107
1108 #[test]
1109 fn format_block_header_has_consistent_width() {
1110 let block_a = format_block("a", &[("rows", "1".into())]);
1111 let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
1112 let header_a = block_a.lines().nth(1).unwrap();
1113 let header_b = block_b.lines().nth(1).unwrap();
1114 assert_eq!(
1115 header_a.chars().count(),
1116 header_b.chars().count(),
1117 "headers must be the same width regardless of name length: {:?} vs {:?}",
1118 header_a,
1119 header_b
1120 );
1121 }
1122
1123 #[test]
1124 fn render_produces_a_single_string_with_trailing_newline() {
1125 use crate::plan::{
1126 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1127 MetaColumns, ResolvedRunPlan,
1128 };
1129 use crate::tuning::SourceTuning;
1130 let plan = ResolvedRunPlan {
1131 export_name: "orders".into(),
1132 base_query: "SELECT 1".into(),
1133 strategy: ExtractionStrategy::Snapshot,
1134 format: FormatType::Parquet,
1135 compression: CompressionType::default(),
1136 compression_level: None,
1137 max_file_size_bytes: None,
1138 skip_empty: false,
1139 meta_columns: MetaColumns::default(),
1140 destination: DestinationConfig {
1141 destination_type: DestinationType::Local,
1142 path: Some("./out".into()),
1143 ..Default::default()
1144 },
1145 quality: None,
1146 tuning: SourceTuning::from_config(None),
1147 tuning_profile_label: "balanced (default)".into(),
1148 validate: false,
1149 reconcile: false,
1150 resume: false,
1151 source: crate::config::SourceConfig {
1152 source_type: crate::config::SourceType::Postgres,
1153 url: Some("postgresql://localhost/test".into()),
1154 url_env: None,
1155 url_file: None,
1156 host: None,
1157 port: None,
1158 user: None,
1159 password: None,
1160 password_env: None,
1161 database: None,
1162 environment: None,
1163 tuning: None,
1164 tls: None,
1165 mongo: None,
1166 },
1167 column_overrides: Default::default(),
1168 verify: crate::config::VerifyMode::Size,
1169 schema_drift_policy: Default::default(),
1170 shape_drift_warn_factor: 2.0,
1171 parquet: None,
1172 };
1173 let mut s = RunSummary::new(&plan);
1174 s.status = "success".into();
1175 s.total_rows = 1_000_908;
1176 s.files_produced = 11;
1177 s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1178 s.duration_ms = 68_400;
1179 s.peak_rss_mb = 884;
1180
1181 let block = s.render();
1182 assert!(
1183 block.starts_with('\n'),
1184 "block should start with a blank line"
1185 );
1186 assert!(block.ends_with('\n'), "block should end with a newline");
1187 assert!(block.contains("── orders "));
1188 assert!(
1189 block.contains("1,000,908"),
1190 "rows should be formatted with thousands separator: {}",
1191 block
1192 );
1193 assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1194 assert!(!block.contains('\r'));
1197
1198 let line = s.render_compact();
1200 assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1201 assert!(line.contains("orders"), "export name present: {:?}", line);
1202 assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1203 assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1204 assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1205 assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1206 assert!(!line.contains('\n'), "single line: {:?}", line);
1207 }
1208
1209 #[test]
1210 fn compact_error_summarises_parallel_chunk_errors() {
1211 let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1212 chunk 4: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202442_chunk4.parquet?partNumber=1&uploadId=ABPnzm7RqplA, called: http_util::Client::send } => send http request, source: error sending request: client error (SendRequest): dispatch task is gone\n\
1213 chunk 5: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202443_chunk5.parquet?partNumber=1&uploadId=ABPnzm6q, called: http_util::Client::send } => send http request, source: dispatch task is gone";
1214 let out = compact_error(raw);
1215 assert!(
1216 out.contains("2 chunk(s)"),
1217 "should report number of failed chunks: {:?}",
1218 out
1219 );
1220 assert!(
1221 out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1222 "should keep export prefix and use compact phrasing: {:?}",
1223 out
1224 );
1225 assert!(
1226 out.contains("chunk 4:"),
1227 "should include the first chunk as an example: {:?}",
1228 out
1229 );
1230 assert!(!out.contains('\n'), "single line output: {:?}", out);
1231 assert!(
1232 out.chars().count() <= 240,
1233 "must be clamped to <=240 chars, got {}: {:?}",
1234 out.chars().count(),
1235 out
1236 );
1237 }
1238
1239 #[test]
1240 fn compact_error_collapses_generic_multiline() {
1241 let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1242 let out = compact_error(raw);
1243 assert_eq!(
1244 out, "first line of trouble; second line with detail; third line",
1245 "newlines should collapse to '; ' and blanks dropped"
1246 );
1247 }
1248
1249 #[test]
1250 fn compact_error_clamps_excessively_long_lines() {
1251 let raw = "x".repeat(1_000);
1252 let out = compact_error(&raw);
1253 assert_eq!(out.chars().count(), 240);
1254 assert!(out.ends_with('…'));
1255 }
1256
1257 #[test]
1258 fn render_compact_strips_chunked_recovery_hint_for_failed() {
1259 use crate::plan::{
1260 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1261 MetaColumns, ResolvedRunPlan,
1262 };
1263 use crate::tuning::SourceTuning;
1264 let plan = ResolvedRunPlan {
1265 export_name: "events".into(),
1266 base_query: "SELECT 1".into(),
1267 strategy: ExtractionStrategy::Snapshot,
1268 format: FormatType::Parquet,
1269 compression: CompressionType::default(),
1270 compression_level: None,
1271 max_file_size_bytes: None,
1272 skip_empty: false,
1273 meta_columns: MetaColumns::default(),
1274 destination: DestinationConfig {
1275 destination_type: DestinationType::Local,
1276 path: Some("./out".into()),
1277 ..Default::default()
1278 },
1279 quality: None,
1280 tuning: SourceTuning::from_config(None),
1281 tuning_profile_label: "balanced (default)".into(),
1282 validate: false,
1283 reconcile: false,
1284 resume: false,
1285 source: crate::config::SourceConfig {
1286 source_type: crate::config::SourceType::Postgres,
1287 url: Some("postgresql://localhost/test".into()),
1288 url_env: None,
1289 url_file: None,
1290 host: None,
1291 port: None,
1292 user: None,
1293 password: None,
1294 password_env: None,
1295 database: None,
1296 environment: None,
1297 tuning: None,
1298 tls: None,
1299 mongo: None,
1300 },
1301 column_overrides: Default::default(),
1302 verify: crate::config::VerifyMode::Size,
1303 schema_drift_policy: Default::default(),
1304 shape_drift_warn_factor: 2.0,
1305 parquet: None,
1306 };
1307 let mut s = RunSummary::new(&plan);
1308 s.status = "failed".into();
1309 s.error_message = Some(
1310 "export 'events': --resume but no in-progress chunk checkpoint; \
1311 run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1312 .to_string(),
1313 );
1314
1315 let line = s.render_compact();
1316 assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1317 assert!(line.contains("events"), "name present: {:?}", line);
1318 assert!(
1319 line.contains("--resume but no in-progress chunk checkpoint"),
1320 "cause kept: {:?}",
1321 line
1322 );
1323 assert!(
1324 !line.contains("rivet state reset-chunks"),
1325 "recovery hint should be stripped from per-export line: {:?}",
1326 line
1327 );
1328 assert!(!line.contains('\n'), "single line: {:?}", line);
1329 }
1330
1331 fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1332 use crate::plan::{
1333 CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1334 MetaColumns, ResolvedRunPlan,
1335 };
1336 use crate::tuning::SourceTuning;
1337 ResolvedRunPlan {
1338 export_name: export_name.into(),
1339 base_query: "SELECT 1".into(),
1340 strategy: ExtractionStrategy::Snapshot,
1341 format: FormatType::Parquet,
1342 compression: CompressionType::default(),
1343 compression_level: None,
1344 max_file_size_bytes: None,
1345 skip_empty: false,
1346 meta_columns: MetaColumns::default(),
1347 destination: DestinationConfig {
1348 destination_type: DestinationType::Local,
1349 path: Some("./out".into()),
1350 ..Default::default()
1351 },
1352 quality: None,
1353 tuning: SourceTuning::from_config(None),
1354 tuning_profile_label: "balanced (default)".into(),
1355 validate: false,
1356 reconcile: false,
1357 resume: false,
1358 source: crate::config::SourceConfig {
1359 source_type: crate::config::SourceType::Postgres,
1360 url: Some("postgresql://localhost/test".into()),
1361 url_env: None,
1362 url_file: None,
1363 host: None,
1364 port: None,
1365 user: None,
1366 password: None,
1367 password_env: None,
1368 database: None,
1369 environment: None,
1370 tuning: None,
1371 tls: None,
1372 mongo: None,
1373 },
1374 column_overrides: Default::default(),
1375 verify: crate::config::VerifyMode::Size,
1376 schema_drift_policy: Default::default(),
1377 shape_drift_warn_factor: 2.0,
1378 parquet: None,
1379 }
1380 }
1381
1382 #[test]
1383 fn plan_snapshot_records_chunk_key_and_resumable_for_post_mortem() {
1384 use crate::plan::{ExtractionStrategy, KeysetPlan};
1385 let mut plan = plan_for("statistic_lifetime");
1389 plan.strategy = ExtractionStrategy::Keyset(KeysetPlan {
1390 key_column: "id".into(),
1391 chunk_size: 1_000_000,
1392 checkpoint: true,
1393 incremental: false,
1394 parallel: 1,
1395 });
1396 let s = RunSummary::new(&plan);
1397 let snap = s.journal.plan_snapshot().expect("PlanResolved recorded");
1398 assert_eq!(snap.chunk_key.as_deref(), Some("id"));
1399 assert!(
1400 snap.resumable,
1401 "chunk_checkpoint on → resumable must be recorded"
1402 );
1403
1404 let full = RunSummary::new(&plan_for("small"));
1406 let fsnap = full.journal.plan_snapshot().unwrap();
1407 assert_eq!(fsnap.chunk_key, None);
1408 assert!(!fsnap.resumable);
1409 }
1410
1411 #[test]
1417 fn plan_snapshot_records_the_row_hash_contract_the_run_applies() {
1418 let mut plan = plan_for("orders");
1419 let bare = RunSummary::new(&plan);
1420 assert_eq!(
1421 bare.journal.plan_snapshot().unwrap().row_hash,
1422 None,
1423 "no hash column written ⇒ nothing to advertise"
1424 );
1425
1426 plan.meta_columns.row_hash =
1427 crate::config::RowHash::Columns(vec!["id".into(), "status".into()]);
1428 let hashed = RunSummary::new(&plan);
1429 let snap = hashed.journal.plan_snapshot().unwrap();
1430 let c = snap.row_hash.clone().expect("a declared set is recorded");
1431 assert_eq!(c.column, crate::enrich::COL_ROW_HASH);
1432 assert_eq!(c.covered, plan.meta_columns.row_hash);
1433 assert_eq!(c.render, crate::enrich::ROW_HASH_RENDER_ID);
1434 }
1435
1436 #[test]
1437 fn plan_snapshot_deserializes_pre_field_journal_as_defaults() {
1438 let legacy = r#"{
1443 "export_name":"orders","base_query":"SELECT 1","strategy":"keyset",
1444 "format":"parquet","compression":"zstd","destination_type":"gcs",
1445 "tuning_profile":"balanced","batch_size":10000,
1446 "validate":false,"reconcile":false,"resume":false
1447 }"#;
1448 let snap: crate::journal::PlanSnapshot = serde_json::from_str(legacy).unwrap();
1449 assert_eq!(snap.chunk_key, None);
1450 assert!(!snap.resumable);
1451 assert_eq!(snap.strategy, "keyset");
1452 }
1453
1454 #[test]
1455 fn render_preserves_multiline_error_block() {
1456 let mut s = RunSummary::new(&plan_for("orders"));
1460 s.status = "failed".into();
1461 s.error_message = Some(
1462 "export 'orders': 1 quality check(s) failed:\n \
1463 - row_count 10 below minimum 999999\n \
1464 Fix the source data, or adjust the thresholds under `quality:` in your config."
1465 .to_string(),
1466 );
1467
1468 let block = s.render();
1469 assert!(
1472 !block.contains("failed:;"),
1473 "error must not be '; '-flattened in the detailed block: {block}"
1474 );
1475 assert!(
1476 block.contains("- row_count 10 below minimum 999999"),
1477 "failing check line present: {block}"
1478 );
1479 let err_lines: Vec<&str> = block
1481 .lines()
1482 .filter(|l| {
1483 l.contains("quality check(s) failed")
1484 || l.contains("row_count 10 below minimum")
1485 || l.contains("Fix the source data")
1486 })
1487 .collect();
1488 assert_eq!(
1489 err_lines.len(),
1490 3,
1491 "all three error lines should render on separate lines: {block}"
1492 );
1493 for l in &err_lines {
1495 assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1496 }
1497 }
1498
1499 #[test]
1500 fn render_nudges_verification_when_unverified_success() {
1501 let mut s = RunSummary::new(&plan_for("orders"));
1504 s.status = "success".into();
1505 s.files_produced = 3;
1506 s.total_rows = 1_000;
1507 let block = s.render();
1509 assert!(
1510 block.lines().any(|l| l.trim_start().starts_with("verify:")),
1511 "expected a verify nudge on an unverified success: {block}"
1512 );
1513
1514 let mut s2 = RunSummary::new(&plan_for("orders"));
1516 s2.status = "success".into();
1517 s2.files_produced = 3;
1518 s2.validated = Some(true);
1519 let block2 = s2.render();
1520 assert!(
1521 !block2
1522 .lines()
1523 .any(|l| l.trim_start().starts_with("verify:")),
1524 "a verified run must not nudge: {block2}"
1525 );
1526 }
1527
1528 #[test]
1529 fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1530 let mut s = RunSummary::stub_for_testing("r", "orders");
1533 assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1534 s.pg_temp_bytes_delta = Some(0);
1535 assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1536 s.pg_temp_bytes_delta = Some(-5);
1537 assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1538
1539 s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1540 let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1541 assert_eq!(label, "pg temp spill");
1542 assert!(
1543 value.contains("50.0 MB") && !value.contains('⚠'),
1544 "small spill is plain info: {value:?}"
1545 );
1546
1547 s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1548 let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1549 assert!(
1550 value.contains('⚠') && value.contains("batch_size"),
1551 "spill over 100 MB carries the tuning hint: {value:?}"
1552 );
1553 }
1554
1555 #[test]
1556 fn post_run_invariants_flag_a_runner_that_skipped_a_facade() {
1557 use crate::manifest::{ColumnChecksum, ManifestPart, PartStatus};
1558 let base = || {
1561 let mut s = RunSummary::stub_for_testing("r", "orders");
1562 s.status = "success".into();
1563 s.state_backed = true;
1564 s.files_committed = 1;
1565 s.files_produced = 1;
1566 s.bytes_written = 10;
1567 s.total_rows = 5;
1568 s.manifest_parts.push(ManifestPart {
1569 part_id: 1,
1570 path: "p.parquet".into(),
1571 rows: 5,
1572 size_bytes: 10,
1573 content_fingerprint: String::new(),
1574 content_md5: String::new(),
1575 status: PartStatus::Committed,
1576 });
1577 s
1578 };
1579 let ck = || ColumnChecksum {
1580 name: "id".into(),
1581 checksum: "1".into(),
1582 };
1583
1584 let mut ok = base();
1586 ok.schema_changed = Some(false);
1587 ok.column_checksums.push(ck());
1588 assert!(ok.check_post_run_invariants(false).is_ok());
1589
1590 let mut no_drift = base();
1592 no_drift.column_checksums.push(ck());
1593 let e = no_drift.check_post_run_invariants(false).unwrap_err();
1594 assert!(e.contains("on_schema_drift gate was never applied"), "{e}");
1595
1596 let mut no_formb = base();
1598 no_formb.schema_changed = Some(false);
1599 let e = no_formb.check_post_run_invariants(false).unwrap_err();
1600 assert!(e.contains("Form-B was never harvested"), "{e}");
1601
1602 let mut suppressed = base();
1604 suppressed.schema_changed = Some(false);
1605 suppressed.column_checksums_incomplete = true;
1606 assert!(suppressed.check_post_run_invariants(false).is_ok());
1607
1608 let mut cdc = base();
1610 cdc.state_backed = false;
1611 assert!(
1612 cdc.check_post_run_invariants(false).is_ok(),
1613 "a non-run_export run must not be held to the facade contract"
1614 );
1615
1616 let mut applied = base();
1625 applied.chunks_precomputed = true;
1626 applied.column_checksums.push(ck());
1627 assert!(
1629 applied.check_post_run_invariants(false).is_ok(),
1630 "an apply replaying precomputed chunks must not trip the drift-gate branch"
1631 );
1632
1633 let mut not_applied = base();
1637 not_applied.chunks_precomputed = false;
1638 not_applied.column_checksums.push(ck());
1639 assert!(
1640 not_applied.check_post_run_invariants(false).is_err(),
1641 "the precomputed exemption must not weaken the guard for ordinary runs"
1642 );
1643
1644 let mut nothing_committed = base();
1656 nothing_committed.files_committed = 0;
1657 nothing_committed.files_produced = 0;
1658 nothing_committed.bytes_written = 0;
1659 nothing_committed.total_rows = 0;
1660 nothing_committed.manifest_parts.clear();
1661 assert!(
1662 nothing_committed.check_post_run_invariants(false).is_ok(),
1663 "a success that committed no files owes no facades"
1664 );
1665
1666 let mut rows_but_no_files = base();
1669 rows_but_no_files.files_committed = 0;
1670 rows_but_no_files.files_produced = 0;
1671 rows_but_no_files.manifest_parts.clear();
1672 rows_but_no_files.bytes_written = 0;
1673 rows_but_no_files.total_rows = 5;
1674 let e = rows_but_no_files
1675 .check_post_run_invariants(false)
1676 .unwrap_err();
1677 assert!(e.contains("no files committed"), "{e}");
1678
1679 let mut empty_run = base();
1682 empty_run.files_committed = 0;
1683 empty_run.files_produced = 0;
1684 empty_run.total_rows = 0;
1685 empty_run.bytes_written = 0;
1686 empty_run.manifest_parts.clear();
1687 assert!(
1688 empty_run.check_post_run_invariants(false).is_ok(),
1689 "an empty run must not read as rows-extracted-but-nothing-landed"
1690 );
1691
1692 let mut failed = base();
1695 failed.status = "failed".into();
1696 failed.schema_changed = Some(false);
1697 failed.column_checksums.push(ck());
1698 assert!(
1699 failed.check_post_run_invariants(false).is_ok(),
1700 "a failed run must not be graded against the success-only invariants"
1701 );
1702
1703 let mut no_parts = base();
1707 no_parts.schema_changed = Some(false);
1708 no_parts.column_checksums.push(ck());
1709 no_parts.manifest_parts.clear();
1710 let e = no_parts.check_post_run_invariants(false).unwrap_err();
1711 assert!(e.contains("ADR-0012 M1"), "{e}");
1712
1713 let mut resumed = base();
1715 resumed.resumed = true;
1716 resumed.column_checksums.push(ck());
1717 assert!(
1719 resumed.check_post_run_invariants(false).is_ok(),
1720 "a resume must not trip the drift-gate branch"
1721 );
1722
1723 let mut resume_flag_no_adopt = base();
1729 resume_flag_no_adopt.resumed = false;
1730 resume_flag_no_adopt.column_checksums.push(ck());
1731 assert!(
1733 resume_flag_no_adopt.check_post_run_invariants(true).is_ok(),
1734 "a --resume run that adopted no prior work (crash before first commit) must be exempt"
1735 );
1736 assert!(
1738 resume_flag_no_adopt
1739 .check_post_run_invariants(false)
1740 .unwrap_err()
1741 .contains("on_schema_drift gate was never applied"),
1742 "a fresh (non-resume) run with the gate skipped must still be caught"
1743 );
1744
1745 let mut csv = base();
1747 csv.schema_changed = Some(false);
1748 csv.format = "csv".into();
1749 assert!(
1751 csv.check_post_run_invariants(false).is_ok(),
1752 "a non-Parquet export must not trip the Form-B branch"
1753 );
1754 }
1755
1756 #[test]
1757 fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1758 let mut s = RunSummary::stub_for_testing("r", "orders");
1759 s.reconciled = Some(true);
1760 s.source_count = Some(1_000);
1761 s.total_rows = 1_000;
1762 assert!(
1763 s.outcome_rows()
1764 .iter()
1765 .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1766 "match wording: {:?}",
1767 s.outcome_rows()
1768 );
1769
1770 s.reconciled = Some(false);
1771 s.source_count = Some(1_200);
1772 let rows = s.outcome_rows();
1773 let recon = rows
1774 .iter()
1775 .find(|(l, _)| *l == "reconcile")
1776 .expect("reconcile row");
1777 assert!(
1778 recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1779 "mismatch names both sides: {:?}",
1780 recon
1781 );
1782
1783 s.status = "success".into();
1786 s.files_produced = 2;
1787 assert!(
1788 !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1789 "a reconciled run must not also nudge"
1790 );
1791 }
1792
1793 #[test]
1794 fn render_surfaces_cursor_position_on_zero_new_incremental() {
1795 let mut s = RunSummary::new(&plan_for("orders"));
1799 s.status = "skipped".into();
1800 s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1801
1802 let block = s.render();
1803 let cursor_line = block
1804 .lines()
1805 .find(|l| l.trim_start().starts_with("cursor:"))
1806 .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1807 assert!(
1808 cursor_line.contains("'updated_at'"),
1809 "cursor line names the column: {cursor_line:?}"
1810 );
1811 assert!(
1812 cursor_line.contains("unchanged"),
1813 "cursor line reports the position held: {cursor_line:?}"
1814 );
1815 }
1816
1817 #[test]
1818 fn incremental_position_line_only_for_cursor_skips() {
1819 assert_eq!(
1821 incremental_position_line(Some("no new rows since cursor 'ts'")),
1822 Some("'ts' unchanged (no new rows this run)".into())
1823 );
1824 assert_eq!(
1825 incremental_position_line(Some("source returned 0 rows")),
1826 None
1827 );
1828 assert_eq!(incremental_position_line(None), None);
1829 }
1830
1831 #[test]
1832 fn render_surfaces_window_position_on_zero_row_time_window() {
1833 let mut s = RunSummary::new(&plan_for("events"));
1839 s.status = "skipped".into();
1840 s.mode = "timewindow".into();
1841 s.skip_reason = Some("source returned 0 rows".into());
1842
1843 let block = s.render();
1844 let window_line = block
1845 .lines()
1846 .find(|l| l.trim_start().starts_with("window:"))
1847 .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1848 assert!(
1849 window_line.contains("matched no rows"),
1850 "window line reports the empty window: {window_line:?}"
1851 );
1852 assert!(
1853 window_line.contains("time_column") && window_line.contains("days_window"),
1854 "window line points at the window config to check: {window_line:?}"
1855 );
1856 assert!(
1858 !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1859 "no cursor line for a non-cursor strategy: {block}"
1860 );
1861 }
1862
1863 #[test]
1864 fn time_window_skip_line_only_for_skipped_time_window() {
1865 assert_eq!(
1867 time_window_skip_line("timewindow", Some("source returned 0 rows")),
1868 Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1869 );
1870 assert_eq!(
1872 time_window_skip_line("incremental", Some("source returned 0 rows")),
1873 None
1874 );
1875 assert_eq!(
1876 time_window_skip_line("full", Some("source returned 0 rows")),
1877 None
1878 );
1879 assert_eq!(time_window_skip_line("timewindow", None), None);
1881 }
1882}