Skip to main content

rivet/pipeline/
summary.rs

1//! **Layer: Observability**
2//!
3//! `RunSummary` is the single observability artifact for a pipeline run.
4//! It accumulates operational data during execution and is consumed by:
5//! - the end-of-run terminal output (`print`)
6//! - the metrics store (`state::record_metric`)
7//! - the notification system (`notify::maybe_send`)
8//!
9//! `RunSummary` is written to by execution modules (row counts, byte counts, retries)
10//! but it makes no execution decisions itself — it is a pure data accumulator.
11//!
12//! It embeds a `RunJournal` so that all pipeline modules — which already hold
13//! `&mut RunSummary` — can record structured events via `summary.journal.record()`
14//! without any signature changes.  In a future epic the relationship will invert:
15//! `RunSummary` will be derived from `RunJournal`.
16
17use 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
23/// Build a `PlanSnapshot` from a `ResolvedRunPlan`.
24///
25/// Lives here rather than on `journal` itself so that the journal module
26/// stays free of plan/pipeline dependencies (avoids the state→pipeline cycle
27/// we used to have via `state::journal_store`).
28fn 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    }
42}
43
44/// Context recorded when a run was launched via `rivet apply` rather than
45/// `rivet run`.  Provides the audit trail for **F5**: which plan artifact
46/// was applied, whether `--force` was passed, and which preflight checks
47/// `--force` actually bypassed.
48///
49/// `None` on `RunSummary` means the run came from `rivet run` (no plan
50/// artifact was applied).
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct ApplyContext {
53    /// `plan_id` from the applied `PlanArtifact`.
54    pub plan_id: String,
55    /// Was `--force` passed to `rivet apply`?
56    pub forced: bool,
57    /// Names of preflight checks that `--force` actually overrode for this
58    /// run.  Possible values: `"staleness"`, `"cursor_drift"`.  Empty when
59    /// `forced` is true but no check actually had to be bypassed (the plan
60    /// was fresh and the cursor matched).
61    pub force_bypassed: Vec<String>,
62}
63
64/// Accumulates operational data during a pipeline run for summary and metrics.
65///
66/// The embedded `journal` is the structured event log for this run.  Use
67/// `summary.journal.record(event)` at any call site that already holds
68/// `&mut RunSummary`.
69#[derive(Debug, Clone, Default)]
70pub struct RunSummary {
71    pub run_id: String,
72    pub export_name: String,
73    pub status: String,
74    pub total_rows: i64,
75    pub files_produced: usize,
76    pub bytes_written: u64,
77    /// Incremented after each successful `dest.write()`. Non-zero means a previous
78    /// attempt already committed data — retrying from the same cursor would duplicate rows.
79    pub files_committed: usize,
80    pub duration_ms: i64,
81    pub peak_rss_mb: i64,
82    pub retries: u32,
83    pub validated: Option<bool>,
84    pub schema_changed: Option<bool>,
85    pub quality_passed: Option<bool>,
86    /// Form B per-column value checksums (name-keyed), harvested from the sink;
87    /// recorded in the manifest so `validate` re-reads + verifies. Empty = none.
88    pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
89    /// The column the Form B checksum is keyed to (cursor/key column); `None` = un-keyed.
90    pub checksum_key_column: Option<String>,
91    /// Cursor range this run covered (incremental strategies) — travels to the
92    /// manifest's extraction section for warehouse-side continuity checks.
93    /// v1 ships column + low + high; cursor_type / source_row_count are
94    /// follow-ups (they need extra source plumbing).
95    pub cursor_column: Option<String>,
96    pub cursor_low: Option<String>,
97    pub cursor_high: Option<String>,
98    pub error_message: Option<String>,
99    /// `profile` from YAML, or `balanced (default)` if omitted.
100    pub tuning_profile: String,
101    /// Configured `batch_size` from YAML/profile (FETCH cap before `batch_size_memory_mb` override).
102    pub batch_size: usize,
103    /// When set, actual FETCH size is derived from schema (see logs).
104    pub batch_size_memory_mb: Option<usize>,
105    pub format: String,
106    pub mode: String,
107    pub compression: String,
108    /// Where the files were written, as an operator would type it to find them
109    /// again (`./output`, `s3://bucket/prefix`, …). `None` for stdout or a
110    /// summary built outside the run path (tests). Surfaced on the success card
111    /// so a newcomer isn't left wondering where their data landed.
112    pub destination_uri: Option<String>,
113    /// Postgres `pg_stat_database.temp_bytes` delta around the run. `None` for
114    /// non-Postgres sources or when the snapshot probe failed (no admin perms
115    /// not required — the view is readable by any role). When set and large,
116    /// indicates cursor / sort spill to `pgsql_tmp/` — the safe action is to
117    /// shrink `tuning.batch_size` or set `tuning.batch_size_memory_mb` below
118    /// PG's `work_mem`.
119    pub pg_temp_bytes_delta: Option<i64>,
120    /// Human-readable parenthetical attached to `status: skipped` so the
121    /// operator knows *why* there was nothing to export this run (e.g.
122    /// `"no new rows since cursor 'updated_at'"`). Always `None` when
123    /// `status != "skipped"`. Surfaced in the console summary card as
124    /// `status: skipped (<reason>)`.
125    pub skip_reason: Option<String>,
126    /// Source COUNT(*) result for reconciliation (None = not requested or not applicable).
127    pub source_count: Option<i64>,
128    /// Whether reconciliation passed (Some(true) = match, Some(false) = mismatch, None = skipped).
129    pub reconciled: Option<bool>,
130    /// Committed parts accumulated during the run, in commit order.  Populated by
131    /// `pipeline::manifest_writer::record_committed_part` at each `dest.write`
132    /// site (ADR-0012 M1 — Parts Before Manifest).  Drained at finalize into a
133    /// `RunManifest` by [`crate::pipeline::manifest_writer::write_manifest`].
134    pub manifest_parts: Vec<ManifestPart>,
135    /// xxh3 fingerprint of the dest-facing column schema for this run, in the
136    /// canonical `xxh3:<16-hex>` form produced by [`crate::state::schema_fingerprint`].
137    ///
138    /// Recorded by [`crate::pipeline::manifest_writer::record_run_schema_fingerprint`]
139    /// the first time the sink has resolved a schema (i.e. on the first batch
140    /// of any chunk).  Idempotent within a run — the schema is identical across
141    /// chunks, so later writes are no-ops.
142    ///
143    /// `finalize_manifest` reads this directly so the manifest's
144    /// `schema_fingerprint` no longer depends on the per-export schema row
145    /// happening to land in `state` before the manifest write.  The state
146    /// lookup remains a fallback for resume scenarios where the summary was
147    /// reconstructed without ever seeing a live schema.
148    pub schema_fingerprint: Option<String>,
149    /// Result of the manifest-aware `--validate` pass (ADR-0012 M5/M6,
150    /// ADR-0013).  Populated by `pipeline::job::finalize_validate_manifest`
151    /// after `finalize_manifest` succeeds; `None` when the run targeted a
152    /// streaming destination, when `--validate` was not requested, or when
153    /// the run failed before any manifest could be written.
154    pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
155    /// Apply-time context (plan_id, --force usage, bypassed checks).
156    /// `None` when the run came from `rivet run` rather than `rivet apply`.
157    /// See [`ApplyContext`] and finding **F5** of the 0.7.5 audit.
158    pub apply_context: Option<ApplyContext>,
159    /// Structured event log for this run.  Answers the four DoD observability questions.
160    pub journal: RunJournal,
161}
162
163/// One `(label, value)` line in the rendered summary block. The label is a
164/// fixed string literal; the value is computed per run.
165type Row = (&'static str, String);
166
167impl RunSummary {
168    pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
169        let run_id = format!(
170            "{}_{}",
171            plan.export_name,
172            chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
173        );
174        let mut journal = RunJournal::new(&run_id, &plan.export_name);
175        journal.record(RunEvent::PlanResolved(plan_snapshot_from(plan)));
176
177        ipc::emit_event(&ChildEvent::Started {
178            export_name: plan.export_name.clone(),
179            run_id: run_id.clone(),
180            mode: plan.strategy.mode_label().to_string(),
181            tuning_profile: plan.tuning_profile_label.clone(),
182            batch_size: plan.tuning.batch_size,
183        });
184
185        Self {
186            run_id,
187            export_name: plan.export_name.clone(),
188            status: "running".into(),
189            total_rows: 0,
190            files_produced: 0,
191            bytes_written: 0,
192            files_committed: 0,
193            duration_ms: 0,
194            peak_rss_mb: 0,
195            retries: 0,
196            validated: None,
197            schema_changed: None,
198            quality_passed: None,
199            error_message: None,
200            tuning_profile: plan.tuning_profile_label.clone(),
201            batch_size: plan.tuning.batch_size,
202            batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
203            format: plan.format.label().to_string(),
204            mode: plan.strategy.mode_label().to_string(),
205            compression: plan.compression.label().to_string(),
206            destination_uri: (!matches!(
207                plan.destination.destination_type,
208                crate::config::DestinationType::Stdout
209            ))
210            .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
211            pg_temp_bytes_delta: None,
212            skip_reason: None,
213            source_count: None,
214            reconciled: None,
215            manifest_parts: Vec::new(),
216            schema_fingerprint: None,
217            manifest_verification: None,
218            apply_context: None,
219            column_checksums: Vec::new(),
220            checksum_key_column: None,
221            cursor_column: None,
222            cursor_low: None,
223            cursor_high: None,
224            journal,
225        }
226    }
227
228    /// One canonical builder for tests across the crate + integration suite.
229    ///
230    /// Every field is filled with a sensible default; callers tweak only what
231    /// the test cares about via the chainable setters below.  This replaces
232    /// the seven copies of `stub_summary` / `fresh_summary` / `make_summary`
233    /// / `dummy_summary` / `empty_summary` that used to live in `notify.rs`,
234    /// `report.rs`, `chunked/{exec,mod}.rs`, `manifest_writer.rs`, and the
235    /// two integration-test files.  When `RunSummary` gains a field, it is
236    /// updated here once instead of across nine sites.
237    ///
238    /// Available outside `pipeline` (`pub` not `pub(crate)`) so integration
239    /// tests in `tests/` can use it via `RunSummary::stub_for_testing(...)`.
240    /// The `_for_testing` suffix is the convention from elsewhere in the
241    /// codebase (`destination_for_tests`, etc.) — production code should
242    /// never call it.
243    ///
244    /// `#[allow(dead_code)]` on each helper because the bin target's
245    /// dead-code analysis doesn't see uses from integration tests in `tests/`.
246    /// The lib's unit tests + the integration suite together exercise every
247    /// helper; the attribute is a no-op for them.
248    #[doc(hidden)]
249    #[allow(dead_code)]
250    pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
251        let run_id = run_id.into();
252        let export_name = export_name.into();
253        let journal = RunJournal::new(&run_id, &export_name);
254        Self {
255            run_id,
256            export_name,
257            status: "running".into(),
258            total_rows: 0,
259            files_produced: 0,
260            bytes_written: 0,
261            files_committed: 0,
262            duration_ms: 0,
263            peak_rss_mb: 0,
264            retries: 0,
265            validated: None,
266            schema_changed: None,
267            quality_passed: None,
268            error_message: None,
269            tuning_profile: "balanced".into(),
270            batch_size: 1000,
271            batch_size_memory_mb: None,
272            format: "parquet".into(),
273            mode: "snapshot".into(),
274            compression: "zstd".into(),
275            destination_uri: None,
276            pg_temp_bytes_delta: None,
277            skip_reason: None,
278            source_count: None,
279            reconciled: None,
280            manifest_parts: Vec::new(),
281            schema_fingerprint: None,
282            manifest_verification: None,
283            apply_context: None,
284            column_checksums: Vec::new(),
285            checksum_key_column: None,
286            cursor_column: None,
287            cursor_low: None,
288            cursor_high: None,
289            journal,
290        }
291    }
292
293    /// Test-only chainable setter for the run's status field.
294    ///
295    /// Used to build success/failed/running variants without re-listing every
296    /// field.  Keeps the journal in sync: terminal statuses get a matching
297    /// `RunCompleted` event recorded so consumers reading
298    /// `journal.final_outcome()` see the right shape.
299    #[doc(hidden)]
300    #[allow(dead_code)]
301    pub fn with_status(mut self, status: impl Into<String>) -> Self {
302        let s = status.into();
303        if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
304            self.journal.record(RunEvent::RunCompleted {
305                status: s.clone(),
306                error_message: self.error_message.clone(),
307                duration_ms: self.duration_ms,
308            });
309        }
310        self.status = s;
311        self
312    }
313
314    /// Test-only setter — record `files_committed` so resume-hint logic
315    /// (`pipeline::report`) can detect the "failed run with committed files"
316    /// path that produces a resume command.
317    #[doc(hidden)]
318    #[allow(dead_code)]
319    pub fn with_files_committed(mut self, n: usize) -> Self {
320        self.files_committed = n;
321        self
322    }
323
324    /// Test-only setter — replace the recorded manifest parts (and adjust
325    /// `total_rows` / `bytes_written` / `files_produced` to keep them
326    /// consistent with the parts list, the way real production code does).
327    #[doc(hidden)]
328    #[allow(dead_code)]
329    pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
330        self.total_rows = parts.iter().map(|p| p.rows).sum();
331        self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
332        self.files_produced = parts.len();
333        self.files_committed = parts.len();
334        self.manifest_parts = parts;
335        self
336    }
337
338    /// Test-only setter — error_message + (optionally) status.  Common
339    /// shape for the "failed-run" fixtures that populated 4+ existing
340    /// stubs.
341    #[doc(hidden)]
342    #[allow(dead_code)]
343    pub fn with_error(mut self, msg: impl Into<String>) -> Self {
344        self.error_message = Some(msg.into());
345        self
346    }
347
348    /// Test-only setter — record a PlanResolved event in the journal so
349    /// downstream observability paths (`journal.plan_snapshot()`,
350    /// `RunReport::from_summary` plan_origin lookup) see the same shape
351    /// they would on a real run.
352    #[doc(hidden)]
353    #[allow(dead_code)]
354    pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
355        self.journal.record(RunEvent::PlanResolved(snap));
356        self
357    }
358
359    pub(super) fn print(&self) {
360        // Capturing mode (IPC child or in-process channel): emit a
361        // `Finished` event and let the unified UI thread render the card.
362        // No stderr block here — the renderer owns the screen.
363        if ipc::capturing_events() {
364            ipc::emit_event(&ChildEvent::Finished {
365                export_name: self.export_name.clone(),
366                run_id: self.run_id.clone(),
367                status: self.status.clone(),
368                total_rows: self.total_rows,
369                files_produced: self.files_produced as u64,
370                bytes_written: self.bytes_written,
371                duration_ms: self.duration_ms,
372                peak_rss_mb: self.peak_rss_mb,
373                error_message: self.error_message.clone(),
374            });
375            return;
376        }
377
378        self.print_stderr_block();
379    }
380
381    /// Write the per-export summary block to stderr, bypassing IPC capture.
382    /// Used after the in-process card UI finishes on single-export sequential
383    /// runs so operators still get the detailed `── name ──` block below the
384    /// live card line.
385    pub(super) fn print_stderr_block(&self) {
386        let block = if multi_export_mode() {
387            self.render_compact()
388        } else {
389            // Render the whole block into a single buffer so the call site
390            // emits one `write_all` to stderr.  Without this, parallel
391            // exports could interleave individual lines from different
392            // `RunSummary::print()` calls — visible as garbled blocks in
393            // `--parallel-exports` runs.
394            self.render().trim_end_matches('\n').to_string()
395        };
396
397        use std::io::Write;
398        // V9 (CWE-150): the block embeds error_message, which can carry
399        // attacker-controlled ANSI/OSC escapes from a malicious source DB. The
400        // single-export path reaches the operator terminal here (the parallel
401        // renderer sanitises separately). Funnel the whole block through the
402        // shared sanitiser before write — it preserves the renderer's own
403        // multi-byte glyphs (✓/✗/──) and strips only C0/C1/DEL control bytes.
404        let mut buf = super::parent_ui::sanitize_terminal(&block);
405        buf.push('\n');
406        let stderr = std::io::stderr();
407        let mut handle = stderr.lock();
408        let _ = handle.write_all(buf.as_bytes());
409        let _ = handle.flush();
410    }
411
412    /// Compact one-line summary used when several exports run in the same
413    /// invocation.  Mirrors the parent_ui card line so `--parallel-exports`
414    /// (threads), sequential, and `--parallel-export-processes` (processes)
415    /// produce visually consistent per-export rows.
416    fn render_compact(&self) -> String {
417        const NAME_COL: usize = 22;
418        const MODE_COL: usize = 8;
419        let icon = match self.status.as_str() {
420            "success" => "✓",
421            "failed" => "✗",
422            _ => "•",
423        };
424        let body = if self.status == "failed" {
425            let err = self
426                .error_message
427                .as_deref()
428                .unwrap_or("(no error message recorded)");
429            let (cause, _) = strip_chunked_recovery_hint(err);
430            // Collapse multi-line / extremely long errors so the compact
431            // line stays one row tall.  Full payload lives in the stderr
432            // log above the run summary.
433            compact_error(cause)
434        } else {
435            let rss = if self.peak_rss_mb > 0 {
436                format!("  RSS {} MB", fmt_thousands(self.peak_rss_mb))
437            } else {
438                String::new()
439            };
440            format!(
441                "{} rows  {} files  {}  {}{}",
442                fmt_thousands(self.total_rows),
443                fmt_thousands(self.files_produced as i64),
444                format_bytes(self.bytes_written),
445                fmt_duration_ms(self.duration_ms),
446                rss
447            )
448        };
449        format!(
450            "{} {:<name$}  {:<mode$}  {}",
451            icon,
452            self.export_name,
453            self.mode,
454            body,
455            name = NAME_COL,
456            mode = MODE_COL,
457        )
458    }
459
460    /// Build the block as a string.  Module-private so tests can assert
461    /// formatting without capturing stderr.
462    ///
463    /// Adaptive layout: assemble the `(label, value)` rows that apply to this
464    /// run from a flat manifest of per-row providers, then [`format_block`]
465    /// pads labels to the longest so columns line up *within* the block (the
466    /// header is fixed-width so consecutive blocks stay uniform regardless of
467    /// which optional fields are present). Each provider owns one row's gate +
468    /// formatting and is unit-testable in isolation; this method owns only
469    /// their order — completing the pattern begun by [`incremental_position_line`]
470    /// / [`time_window_skip_line`].
471    fn render(&self) -> String {
472        let mut rows: Vec<Row> = Vec::with_capacity(16);
473        rows.push(("run_id", self.run_id.clone()));
474        rows.push(self.status_row());
475        rows.push(self.tuning_row());
476        rows.push(("rows", fmt_thousands(self.total_rows)));
477        rows.push(("files", fmt_thousands(self.files_produced as i64)));
478        rows.extend(self.output_row());
479        rows.extend(self.position_row());
480        rows.extend(self.bytes_row());
481        rows.push(("duration", fmt_duration_ms(self.duration_ms)));
482        rows.extend(self.peak_rss_row());
483        rows.extend(self.pg_temp_spill_row());
484        rows.extend(self.compression_row());
485        rows.extend(self.retries_row());
486        rows.extend(self.outcome_rows());
487        rows.extend(self.error_row());
488        format_block(&self.export_name, &rows)
489    }
490
491    /// `status`, annotated with the skip reason when the run was skipped.
492    fn status_row(&self) -> Row {
493        let value = match (&self.status, &self.skip_reason) {
494            (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
495            (s, _) => s.clone(),
496        };
497        ("status", value)
498    }
499
500    /// `tuning` — profile + configured batch_size, noting the memory-derived
501    /// FETCH override when `batch_size_memory_mb` is set.
502    fn tuning_row(&self) -> Row {
503        let value = match self.batch_size_memory_mb {
504            Some(mem) => format!(
505                "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
506                self.tuning_profile,
507                fmt_thousands(self.batch_size as i64),
508                mem
509            ),
510            None => format!(
511                "profile={}, batch_size={}",
512                self.tuning_profile,
513                fmt_thousands(self.batch_size as i64)
514            ),
515        };
516        ("tuning", value)
517    }
518
519    /// `output` — where the files landed, so a newcomer isn't left guessing
520    /// after a successful export. Only when we actually wrote somewhere
521    /// addressable (skips stdout and 0-file skips).
522    fn output_row(&self) -> Option<Row> {
523        if self.files_produced > 0 {
524            self.destination_uri.clone().map(|uri| ("output", uri))
525        } else {
526            None
527        }
528    }
529
530    /// `cursor` (incremental) xor `window` (time_window): the position line for
531    /// a 0-row run. On a bare `0 rows  0 files` run this tells the operator the
532    /// incremental boundary held / the rolling window was simply empty, rather
533    /// than leaving an empty run indistinguishable from a misconfigured one.
534    /// `None` for a run that produced rows or whose skip carries no position.
535    /// (Detail lives in the two helpers it wraps.)
536    fn position_row(&self) -> Option<Row> {
537        if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
538            Some(("cursor", pos))
539        } else {
540            time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
541        }
542    }
543
544    /// `bytes` — only when something was written.
545    fn bytes_row(&self) -> Option<Row> {
546        if self.bytes_written > 0 {
547            Some(("bytes", format_bytes(self.bytes_written)))
548        } else {
549            None
550        }
551    }
552
553    /// `peak RSS` — only when sampled during the run.
554    fn peak_rss_row(&self) -> Option<Row> {
555        if self.peak_rss_mb > 0 {
556            Some((
557                "peak RSS",
558                format!(
559                    "{} MB (sampled during run)",
560                    fmt_thousands(self.peak_rss_mb)
561                ),
562            ))
563        } else {
564            None
565        }
566    }
567
568    /// `pg temp spill` — PostgreSQL temp-file spill around the run. Chatters
569    /// only on actual spill (`> 0`); annotates a tuning hint above 100 MB.
570    /// `None` for non-Postgres sources, a failed probe, or no spill.
571    fn pg_temp_spill_row(&self) -> Option<Row> {
572        let temp = self.pg_temp_bytes_delta?;
573        if temp <= 0 {
574            return None;
575        }
576        let temp_mb = temp as f64 / (1024.0 * 1024.0);
577        let label = if temp > 100 * 1024 * 1024 {
578            format!(
579                "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
580                temp_mb
581            )
582        } else {
583            format!("{:.1} MB", temp_mb)
584        };
585        Some(("pg temp spill", label))
586    }
587
588    /// `compression` — only when it differs from the parquet default (zstd).
589    fn compression_row(&self) -> Option<Row> {
590        if self.format == "parquet" && self.compression != "zstd" {
591            Some(("compression", self.compression.clone()))
592        } else {
593            None
594        }
595    }
596
597    /// `retries` — only when the run had to retry.
598    fn retries_row(&self) -> Option<Row> {
599        if self.retries > 0 {
600            Some(("retries", self.retries.to_string()))
601        } else {
602            None
603        }
604    }
605
606    /// Post-extraction check outcomes: `--validate`, schema-drift, the quality
607    /// gate, `--reconcile`, plus the advisory verify nudge. Grouped so the
608    /// nudge's "ran neither verification pass" gate sits next to the very
609    /// fields it inspects — when it was added (#4) it landed as a 10-line block
610    /// appended to a flat ladder; here that dependency is local.
611    fn outcome_rows(&self) -> Vec<Row> {
612        let mut rows: Vec<Row> = Vec::new();
613        if let Some(v) = self.validated {
614            rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
615        }
616        if let Some(sc) = self.schema_changed {
617            rows.push((
618                "schema",
619                if sc {
620                    "CHANGED".into()
621                } else {
622                    "unchanged".into()
623                },
624            ));
625        }
626        if let Some(q) = self.quality_passed {
627            rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
628        }
629        if let Some(reconciled) = self.reconciled {
630            let src = self
631                .source_count
632                .map(fmt_thousands)
633                .unwrap_or_else(|| "?".into());
634            let exported = fmt_thousands(self.total_rows);
635            let value = if reconciled {
636                format!("MATCH ({exported}/{src})")
637            } else {
638                format!("MISMATCH (exported {exported} vs source {src})")
639            };
640            rows.push(("reconcile", value));
641        }
642        // Nudge: a successful run that wrote files but ran neither verification
643        // pass leaves completeness unconfirmed. Advisory only — so skipping
644        // verification is a deliberate choice, not an oversight (a pilot loaded
645        // hundreds of millions of rows across 5 runs with 0 verified).
646        if self.status == "success"
647            && self.files_produced > 0
648            && self.validated.is_none()
649            && self.reconciled.is_none()
650        {
651            rows.push((
652                "verify",
653                "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
654                    .into(),
655            ));
656        }
657        rows
658    }
659
660    /// `error` — the failure message, with its own multi-line structure
661    /// preserved (the detailed block indents continuation lines under the
662    /// value column; flattening to `"; "`-joined text is the compact
663    /// one-liner's job, not this one's — a quality failure's multi-line
664    /// `failed:\n  - <check>\n  Fix …` stays readable here).
665    fn error_row(&self) -> Option<Row> {
666        self.error_message
667            .as_ref()
668            .map(|err| ("error", err.trim_end().to_string()))
669    }
670
671    /// Sanity-check the post-run summary ↔ manifest_parts coherence. Used as
672    /// a `debug_assert!`-style runtime gate from `finalize_manifest` so any
673    /// future runner that bumps `bytes_written` / `files_committed` /
674    /// `files_produced` without going through `pipeline::commit::record_part`
675    /// is caught the moment it finishes a real export. Compiled out in
676    /// release builds via the `cfg!(debug_assertions)` guard at the call site.
677    ///
678    /// **Resume-safe inequalities only**: on resume, `manifest_parts` carries
679    /// prior runs' parts via `chunked::resume_m8` while `bytes_written` /
680    /// `files_committed` reflect only the current invocation — so strict
681    /// equality is wrong across resume boundaries. Strict equality on the
682    /// non-resume path is pinned by `pipeline::commit::tests`.
683    ///
684    /// Returns `Ok(())` when the summary satisfies the invariants, else an
685    /// `Err(String)` naming which one was violated and by how much.
686    pub fn check_post_run_invariants(&self) -> Result<(), String> {
687        let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
688
689        if self.files_committed > self.manifest_parts.len() {
690            return Err(format!(
691                "summary.files_committed ({}) > manifest_parts.len() ({}) — \
692                 a runner bumped files_committed without commit::record_part",
693                self.files_committed,
694                self.manifest_parts.len()
695            ));
696        }
697        if self.files_produced > self.manifest_parts.len() {
698            return Err(format!(
699                "summary.files_produced ({}) > manifest_parts.len() ({}) — \
700                 a runner bumped files_produced without commit::record_part",
701                self.files_produced,
702                self.manifest_parts.len()
703            ));
704        }
705        if self.bytes_written > parts_bytes {
706            return Err(format!(
707                "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
708                 a runner bumped bytes_written without commit::record_part",
709                self.bytes_written, parts_bytes
710            ));
711        }
712        if self.status == "success" && self.files_committed > 0 && self.manifest_parts.is_empty() {
713            return Err(format!(
714                "success run with files_committed={} has empty manifest_parts — \
715                 cloud manifest (ADR-0012 M1) would ship with no part list \
716                 (this is the gap parallel_checkpoint had before commit e9b0796)",
717                self.files_committed
718            ));
719        }
720        // Invariant audit gap #1, weak form: a successful run that produced
721        // rows for THIS invocation must have committed at least one file.
722        // The strict form ("rows_written <= rows_read") would require a
723        // separate source-side row counter we do not track, and concurrent
724        // INSERTs on the source (live_oltp_load) make a source_count
725        // comparison brittle. This weak form catches a fabrication shape:
726        // total_rows accumulated but nothing reached the destination — a
727        // runner that fetched and silently dropped rows produces exactly
728        // this signature. Resume-safe: total_rows reflects only this
729        // invocation, so a resume with no work to do legitimately ends at
730        // total_rows=0 / files_committed=0 and the guard does not fire.
731        if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
732            return Err(format!(
733                "summary.total_rows={} but files_committed=0 — rows extracted from \
734                 source but no files committed (no output reached the destination)",
735                self.total_rows
736            ));
737        }
738        Ok(())
739    }
740}
741
742/// Reduce a possibly-multi-line execution error to a single-line, bounded-
743/// length cause suitable for the per-export summary block and the compact
744/// one-liner.  Keeps the user-actionable bit and drops noisy diagnostic
745/// payloads (long URLs, query strings, repeated chunk errors).
746///
747/// Recognised shapes:
748/// - `parallel checkpoint worker errors:\nchunk N: <msg>\nchunk M: <msg>` →
749///   `parallel checkpoint workers failed: K chunk(s) (chunk N: <truncated>)`.
750///   The full per-chunk detail is already in stderr logs.
751/// - Generic multi-line: newlines are replaced with `; ` and the result is
752///   clamped to 240 characters with an ellipsis.
753fn compact_error(raw: &str) -> String {
754    const MAX_CHARS: usize = 240;
755    if let Some(summary) = summarize_parallel_chunk_errors(raw) {
756        return clamp_chars(&summary, MAX_CHARS);
757    }
758    let collapsed: String = raw
759        .lines()
760        .map(str::trim_end)
761        .filter(|s| !s.is_empty())
762        .collect::<Vec<_>>()
763        .join("; ");
764    clamp_chars(&collapsed, MAX_CHARS)
765}
766
767/// Derive the summary block's `cursor:` line from a `skip_reason`.
768///
769/// `skip_reason` for an incremental no-op is `"no new rows since cursor
770/// '<col>'"` (set by the runner); we lift the column out and report the
771/// position as held. Returns `None` for the non-cursor `"source returned 0
772/// rows"` skip and for `None` (a run that actually produced rows). The cursor
773/// *value* isn't carried on the summary yet, so this is column-level only.
774fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
775    let col = skip_reason?
776        .strip_prefix("no new rows since cursor '")?
777        .strip_suffix('\'')?;
778    Some(format!("'{col}' unchanged (no new rows this run)"))
779}
780
781/// Derive the summary block's `window:` line for a time_window run that
782/// returned nothing.
783///
784/// A `TimeWindow` strategy has no cursor column, so a 0-row run reports the
785/// generic `"source returned 0 rows"` skip — the `incremental_position_line`
786/// branch never fires and, without this line, an empty time window looks
787/// identical to any other empty export. Surfacing it lets the operator tell an
788/// *empty window* (data simply outside the rolling range) from a *misconfigured
789/// window* (wrong `time_column` / `days_window`).
790///
791/// Keyed on `mode == "timewindow"` (set from `ExtractionStrategy::mode_label`)
792/// plus a set skip reason, so it only fires on a skipped time_window run and
793/// never on incremental/snapshot/chunked/keyset. The window column, days, and
794/// computed lower bound are not carried on `RunSummary`, so this reports the
795/// strategy-level fact and where to look — the concrete bound is a follow-up
796/// once the runner records it onto the summary.
797fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
798    skip_reason?;
799    if mode != "timewindow" {
800        return None;
801    }
802    Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
803}
804
805fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
806    let header_pos = raw.find("parallel checkpoint worker errors:")?;
807    let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
808    let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
809
810    let chunk_lines: Vec<&str> = tail
811        .lines()
812        .map(str::trim)
813        .filter(|l| l.starts_with("chunk "))
814        .collect();
815    if chunk_lines.is_empty() {
816        return None;
817    }
818    let first_chunk_full = chunk_lines[0];
819    // Truncate the example chunk message; the URL/payload is in stderr logs.
820    let first_chunk_short = clamp_chars(first_chunk_full, 140);
821    let prefix = if prefix.is_empty() {
822        String::new()
823    } else {
824        format!("{}: ", prefix)
825    };
826    Some(format!(
827        "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
828        prefix,
829        chunk_lines.len(),
830        first_chunk_short
831    ))
832}
833
834fn clamp_chars(s: &str, max_chars: usize) -> String {
835    if max_chars == 0 {
836        return String::new();
837    }
838    if s.chars().count() <= max_chars {
839        return s.to_string();
840    }
841    let keep = max_chars.saturating_sub(1);
842    let mut out: String = s.chars().take(keep).collect();
843    out.push('…');
844    out
845}
846
847/// Render a `── name ─────…─` header plus one indented `label:  value` line
848/// per row, all joined into a single string ending with `\n`.
849fn format_block(name: &str, rows: &[(&str, String)]) -> String {
850    const HEADER_WIDTH: usize = 60;
851    let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
852
853    let prefix = format!("── {} ", name);
854    let prefix_chars = prefix.chars().count();
855    let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
856    let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
857    out.push('\n');
858    out.push_str(&prefix);
859    for _ in 0..dashes {
860        out.push('─');
861    }
862    out.push('\n');
863    // Continuation lines of a multi-line value (e.g. the multi-line `error`
864    // row) are indented to align under the value column, so block-shaped
865    // messages stay readable instead of being flattened onto one line.
866    let value_indent = " ".repeat(2 + (label_w + 1) + 2);
867    for (label, value) in rows {
868        // `label_w + 1` so the colon stays attached to the label and the
869        // value column starts uniformly two spaces after it.
870        let mut lines = value.split('\n');
871        let first = lines.next().unwrap_or("");
872        out.push_str(&format!(
873            "  {:<width$}  {}\n",
874            format!("{label}:"),
875            first,
876            width = label_w + 1
877        ));
878        for cont in lines {
879            out.push_str(&value_indent);
880            out.push_str(cont);
881            out.push('\n');
882        }
883    }
884    out
885}
886
887fn fmt_duration_ms(ms: i64) -> String {
888    if ms < 1000 {
889        return format!("{}ms", ms);
890    }
891    let total_secs = ms / 1000;
892    let h = total_secs / 3600;
893    let m = (total_secs % 3600) / 60;
894    let s_frac = (ms % 60_000) as f64 / 1000.0;
895    if h > 0 {
896        format!("{}h {:02}m {:04.1}s", h, m, s_frac)
897    } else if m > 0 {
898        format!("{}m {:04.1}s", m, s_frac)
899    } else {
900        format!("{:.1}s", ms as f64 / 1000.0)
901    }
902}
903
904/// Format integers with a comma every three digits.  Negative values keep
905/// their sign.  Used for rows / files / batch_size so large numbers stay
906/// readable: `39_990_376` → `39,990,376`.
907fn fmt_thousands(n: i64) -> String {
908    let abs = n.unsigned_abs();
909    let s = abs.to_string();
910    let bytes = s.as_bytes();
911    let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
912    if n < 0 {
913        out.push('-');
914    }
915    for (i, b) in bytes.iter().enumerate() {
916        let from_end = bytes.len() - i;
917        if i > 0 && from_end.is_multiple_of(3) {
918            out.push(',');
919        }
920        out.push(*b as char);
921    }
922    out
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    #[test]
930    fn fmt_thousands_handles_small_and_large() {
931        assert_eq!(fmt_thousands(0), "0");
932        assert_eq!(fmt_thousands(7), "7");
933        assert_eq!(fmt_thousands(999), "999");
934        assert_eq!(fmt_thousands(1_000), "1,000");
935        assert_eq!(fmt_thousands(1_000_908), "1,000,908");
936        assert_eq!(fmt_thousands(39_990_376), "39,990,376");
937        assert_eq!(fmt_thousands(-1_234), "-1,234");
938        assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
939    }
940
941    #[test]
942    fn fmt_duration_picks_unit() {
943        assert_eq!(fmt_duration_ms(0), "0ms");
944        assert_eq!(fmt_duration_ms(800), "800ms");
945        assert_eq!(fmt_duration_ms(1_500), "1.5s");
946        assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
947        assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
948    }
949
950    #[test]
951    fn format_block_pads_labels_uniformly() {
952        let rows = vec![
953            ("run_id", "abc".to_string()),
954            ("rows", "42".to_string()),
955            ("compression", "zstd".to_string()),
956        ];
957        let out = format_block("orders", &rows);
958
959        // Each value column starts at the same character position.
960        let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
961        assert_eq!(lines.len(), 3);
962        let value_starts: Vec<usize> = lines
963            .iter()
964            .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
965            .collect();
966        // The value (after `label:` plus padding plus two spaces) starts at the
967        // same column for every row.  We verify by checking all lines have the
968        // value substring at the same byte offset.
969        let value_col = lines[0].rfind("abc").unwrap();
970        assert_eq!(lines[1].rfind("42").unwrap(), value_col);
971        assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
972        // Sanity: silence unused.
973        let _ = value_starts;
974    }
975
976    #[test]
977    fn format_block_header_has_consistent_width() {
978        let block_a = format_block("a", &[("rows", "1".into())]);
979        let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
980        let header_a = block_a.lines().nth(1).unwrap();
981        let header_b = block_b.lines().nth(1).unwrap();
982        assert_eq!(
983            header_a.chars().count(),
984            header_b.chars().count(),
985            "headers must be the same width regardless of name length: {:?} vs {:?}",
986            header_a,
987            header_b
988        );
989    }
990
991    #[test]
992    fn render_produces_a_single_string_with_trailing_newline() {
993        use crate::plan::{
994            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
995            MetaColumns, ResolvedRunPlan,
996        };
997        use crate::tuning::SourceTuning;
998        let plan = ResolvedRunPlan {
999            export_name: "orders".into(),
1000            base_query: "SELECT 1".into(),
1001            strategy: ExtractionStrategy::Snapshot,
1002            format: FormatType::Parquet,
1003            compression: CompressionType::default(),
1004            compression_level: None,
1005            max_file_size_bytes: None,
1006            skip_empty: false,
1007            meta_columns: MetaColumns::default(),
1008            destination: DestinationConfig {
1009                destination_type: DestinationType::Local,
1010                path: Some("./out".into()),
1011                ..Default::default()
1012            },
1013            quality: None,
1014            tuning: SourceTuning::from_config(None),
1015            tuning_profile_label: "balanced (default)".into(),
1016            validate: false,
1017            reconcile: false,
1018            resume: false,
1019            source: crate::config::SourceConfig {
1020                source_type: crate::config::SourceType::Postgres,
1021                url: Some("postgresql://localhost/test".into()),
1022                url_env: None,
1023                url_file: None,
1024                host: None,
1025                port: None,
1026                user: None,
1027                password: None,
1028                password_env: None,
1029                database: None,
1030                environment: None,
1031                tuning: None,
1032                tls: None,
1033                mongo: None,
1034            },
1035            column_overrides: Default::default(),
1036            verify: crate::config::VerifyMode::Size,
1037            schema_drift_policy: Default::default(),
1038            shape_drift_warn_factor: 2.0,
1039            parquet: None,
1040        };
1041        let mut s = RunSummary::new(&plan);
1042        s.status = "success".into();
1043        s.total_rows = 1_000_908;
1044        s.files_produced = 11;
1045        s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1046        s.duration_ms = 68_400;
1047        s.peak_rss_mb = 884;
1048
1049        let block = s.render();
1050        assert!(
1051            block.starts_with('\n'),
1052            "block should start with a blank line"
1053        );
1054        assert!(block.ends_with('\n'), "block should end with a newline");
1055        assert!(block.contains("── orders "));
1056        assert!(
1057            block.contains("1,000,908"),
1058            "rows should be formatted with thousands separator: {}",
1059            block
1060        );
1061        assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1062        // No raw progress-bar bleed: header dashes still present, no carriage
1063        // returns or escape sequences.
1064        assert!(!block.contains('\r'));
1065
1066        // Compact one-liner used in multi-export runs.
1067        let line = s.render_compact();
1068        assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1069        assert!(line.contains("orders"), "export name present: {:?}", line);
1070        assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1071        assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1072        assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1073        assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1074        assert!(!line.contains('\n'), "single line: {:?}", line);
1075    }
1076
1077    #[test]
1078    fn compact_error_summarises_parallel_chunk_errors() {
1079        let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1080                   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\
1081                   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";
1082        let out = compact_error(raw);
1083        assert!(
1084            out.contains("2 chunk(s)"),
1085            "should report number of failed chunks: {:?}",
1086            out
1087        );
1088        assert!(
1089            out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1090            "should keep export prefix and use compact phrasing: {:?}",
1091            out
1092        );
1093        assert!(
1094            out.contains("chunk 4:"),
1095            "should include the first chunk as an example: {:?}",
1096            out
1097        );
1098        assert!(!out.contains('\n'), "single line output: {:?}", out);
1099        assert!(
1100            out.chars().count() <= 240,
1101            "must be clamped to <=240 chars, got {}: {:?}",
1102            out.chars().count(),
1103            out
1104        );
1105    }
1106
1107    #[test]
1108    fn compact_error_collapses_generic_multiline() {
1109        let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1110        let out = compact_error(raw);
1111        assert_eq!(
1112            out, "first line of trouble; second line with detail; third line",
1113            "newlines should collapse to '; ' and blanks dropped"
1114        );
1115    }
1116
1117    #[test]
1118    fn compact_error_clamps_excessively_long_lines() {
1119        let raw = "x".repeat(1_000);
1120        let out = compact_error(&raw);
1121        assert_eq!(out.chars().count(), 240);
1122        assert!(out.ends_with('…'));
1123    }
1124
1125    #[test]
1126    fn render_compact_strips_chunked_recovery_hint_for_failed() {
1127        use crate::plan::{
1128            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1129            MetaColumns, ResolvedRunPlan,
1130        };
1131        use crate::tuning::SourceTuning;
1132        let plan = ResolvedRunPlan {
1133            export_name: "events".into(),
1134            base_query: "SELECT 1".into(),
1135            strategy: ExtractionStrategy::Snapshot,
1136            format: FormatType::Parquet,
1137            compression: CompressionType::default(),
1138            compression_level: None,
1139            max_file_size_bytes: None,
1140            skip_empty: false,
1141            meta_columns: MetaColumns::default(),
1142            destination: DestinationConfig {
1143                destination_type: DestinationType::Local,
1144                path: Some("./out".into()),
1145                ..Default::default()
1146            },
1147            quality: None,
1148            tuning: SourceTuning::from_config(None),
1149            tuning_profile_label: "balanced (default)".into(),
1150            validate: false,
1151            reconcile: false,
1152            resume: false,
1153            source: crate::config::SourceConfig {
1154                source_type: crate::config::SourceType::Postgres,
1155                url: Some("postgresql://localhost/test".into()),
1156                url_env: None,
1157                url_file: None,
1158                host: None,
1159                port: None,
1160                user: None,
1161                password: None,
1162                password_env: None,
1163                database: None,
1164                environment: None,
1165                tuning: None,
1166                tls: None,
1167                mongo: None,
1168            },
1169            column_overrides: Default::default(),
1170            verify: crate::config::VerifyMode::Size,
1171            schema_drift_policy: Default::default(),
1172            shape_drift_warn_factor: 2.0,
1173            parquet: None,
1174        };
1175        let mut s = RunSummary::new(&plan);
1176        s.status = "failed".into();
1177        s.error_message = Some(
1178            "export 'events': --resume but no in-progress chunk checkpoint; \
1179             run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1180                .to_string(),
1181        );
1182
1183        let line = s.render_compact();
1184        assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1185        assert!(line.contains("events"), "name present: {:?}", line);
1186        assert!(
1187            line.contains("--resume but no in-progress chunk checkpoint"),
1188            "cause kept: {:?}",
1189            line
1190        );
1191        assert!(
1192            !line.contains("rivet state reset-chunks"),
1193            "recovery hint should be stripped from per-export line: {:?}",
1194            line
1195        );
1196        assert!(!line.contains('\n'), "single line: {:?}", line);
1197    }
1198
1199    fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1200        use crate::plan::{
1201            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1202            MetaColumns, ResolvedRunPlan,
1203        };
1204        use crate::tuning::SourceTuning;
1205        ResolvedRunPlan {
1206            export_name: export_name.into(),
1207            base_query: "SELECT 1".into(),
1208            strategy: ExtractionStrategy::Snapshot,
1209            format: FormatType::Parquet,
1210            compression: CompressionType::default(),
1211            compression_level: None,
1212            max_file_size_bytes: None,
1213            skip_empty: false,
1214            meta_columns: MetaColumns::default(),
1215            destination: DestinationConfig {
1216                destination_type: DestinationType::Local,
1217                path: Some("./out".into()),
1218                ..Default::default()
1219            },
1220            quality: None,
1221            tuning: SourceTuning::from_config(None),
1222            tuning_profile_label: "balanced (default)".into(),
1223            validate: false,
1224            reconcile: false,
1225            resume: false,
1226            source: crate::config::SourceConfig {
1227                source_type: crate::config::SourceType::Postgres,
1228                url: Some("postgresql://localhost/test".into()),
1229                url_env: None,
1230                url_file: None,
1231                host: None,
1232                port: None,
1233                user: None,
1234                password: None,
1235                password_env: None,
1236                database: None,
1237                environment: None,
1238                tuning: None,
1239                tls: None,
1240                mongo: None,
1241            },
1242            column_overrides: Default::default(),
1243            verify: crate::config::VerifyMode::Size,
1244            schema_drift_policy: Default::default(),
1245            shape_drift_warn_factor: 2.0,
1246            parquet: None,
1247        }
1248    }
1249
1250    #[test]
1251    fn render_preserves_multiline_error_block() {
1252        // L19: a multi-line error (a quality failure here) must stay multi-line
1253        // in the detailed single-export block — not collapsed to `"; "`-joined
1254        // text the way the compact one-liner does.
1255        let mut s = RunSummary::new(&plan_for("orders"));
1256        s.status = "failed".into();
1257        s.error_message = Some(
1258            "export 'orders': 1 quality check(s) failed:\n  \
1259             - row_count 10 below minimum 999999\n  \
1260             Fix the source data, or adjust the thresholds under `quality:` in your config."
1261                .to_string(),
1262        );
1263
1264        let block = s.render();
1265        // The collapsed form joined lines with `"; "` — assert that flattening
1266        // is gone and the original newline structure survives.
1267        assert!(
1268            !block.contains("failed:;"),
1269            "error must not be '; '-flattened in the detailed block: {block}"
1270        );
1271        assert!(
1272            block.contains("- row_count 10 below minimum 999999"),
1273            "failing check line present: {block}"
1274        );
1275        // Each part of the multi-line error lands on its own line.
1276        let err_lines: Vec<&str> = block
1277            .lines()
1278            .filter(|l| {
1279                l.contains("quality check(s) failed")
1280                    || l.contains("row_count 10 below minimum")
1281                    || l.contains("Fix the source data")
1282            })
1283            .collect();
1284        assert_eq!(
1285            err_lines.len(),
1286            3,
1287            "all three error lines should render on separate lines: {block}"
1288        );
1289        // Continuation lines are indented under the value column, not at col 0.
1290        for l in &err_lines {
1291            assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1292        }
1293    }
1294
1295    #[test]
1296    fn render_nudges_verification_when_unverified_success() {
1297        // #4: a successful run that wrote files but ran no verification pass
1298        // should surface an advisory `verify:` line — so skipping it is a choice.
1299        let mut s = RunSummary::new(&plan_for("orders"));
1300        s.status = "success".into();
1301        s.files_produced = 3;
1302        s.total_rows = 1_000;
1303        // validated / reconciled left None (no --validate / --reconcile).
1304        let block = s.render();
1305        assert!(
1306            block.lines().any(|l| l.trim_start().starts_with("verify:")),
1307            "expected a verify nudge on an unverified success: {block}"
1308        );
1309
1310        // A run that verified must NOT nudge.
1311        let mut s2 = RunSummary::new(&plan_for("orders"));
1312        s2.status = "success".into();
1313        s2.files_produced = 3;
1314        s2.validated = Some(true);
1315        let block2 = s2.render();
1316        assert!(
1317            !block2
1318                .lines()
1319                .any(|l| l.trim_start().starts_with("verify:")),
1320            "a verified run must not nudge: {block2}"
1321        );
1322    }
1323
1324    #[test]
1325    fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1326        // Direct provider test — previously this threshold logic was only
1327        // reachable by rendering a whole block (and no test set the field).
1328        let mut s = RunSummary::stub_for_testing("r", "orders");
1329        assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1330        s.pg_temp_bytes_delta = Some(0);
1331        assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1332        s.pg_temp_bytes_delta = Some(-5);
1333        assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1334
1335        s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1336        let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1337        assert_eq!(label, "pg temp spill");
1338        assert!(
1339            value.contains("50.0 MB") && !value.contains('⚠'),
1340            "small spill is plain info: {value:?}"
1341        );
1342
1343        s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1344        let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1345        assert!(
1346            value.contains('⚠') && value.contains("batch_size"),
1347            "spill over 100 MB carries the tuning hint: {value:?}"
1348        );
1349    }
1350
1351    #[test]
1352    fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1353        let mut s = RunSummary::stub_for_testing("r", "orders");
1354        s.reconciled = Some(true);
1355        s.source_count = Some(1_000);
1356        s.total_rows = 1_000;
1357        assert!(
1358            s.outcome_rows()
1359                .iter()
1360                .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1361            "match wording: {:?}",
1362            s.outcome_rows()
1363        );
1364
1365        s.reconciled = Some(false);
1366        s.source_count = Some(1_200);
1367        let rows = s.outcome_rows();
1368        let recon = rows
1369            .iter()
1370            .find(|(l, _)| *l == "reconcile")
1371            .expect("reconcile row");
1372        assert!(
1373            recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1374            "mismatch names both sides: {:?}",
1375            recon
1376        );
1377
1378        // A set reconcile result suppresses the verify nudge even on a
1379        // files-produced success (the nudge's gate lives beside this field).
1380        s.status = "success".into();
1381        s.files_produced = 2;
1382        assert!(
1383            !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1384            "a reconciled run must not also nudge"
1385        );
1386    }
1387
1388    #[test]
1389    fn render_surfaces_cursor_position_on_zero_new_incremental() {
1390        // L27: a 0-new incremental run shows `0 rows  0 files`; without a
1391        // cursor line the operator can't tell the boundary held. Assert the
1392        // dedicated `cursor:` line appears, derived from `skip_reason`.
1393        let mut s = RunSummary::new(&plan_for("orders"));
1394        s.status = "skipped".into();
1395        s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1396
1397        let block = s.render();
1398        let cursor_line = block
1399            .lines()
1400            .find(|l| l.trim_start().starts_with("cursor:"))
1401            .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1402        assert!(
1403            cursor_line.contains("'updated_at'"),
1404            "cursor line names the column: {cursor_line:?}"
1405        );
1406        assert!(
1407            cursor_line.contains("unchanged"),
1408            "cursor line reports the position held: {cursor_line:?}"
1409        );
1410    }
1411
1412    #[test]
1413    fn incremental_position_line_only_for_cursor_skips() {
1414        // The non-cursor 0-row skip and the no-skip case produce no cursor line.
1415        assert_eq!(
1416            incremental_position_line(Some("no new rows since cursor 'ts'")),
1417            Some("'ts' unchanged (no new rows this run)".into())
1418        );
1419        assert_eq!(
1420            incremental_position_line(Some("source returned 0 rows")),
1421            None
1422        );
1423        assert_eq!(incremental_position_line(None), None);
1424    }
1425
1426    #[test]
1427    fn render_surfaces_window_position_on_zero_row_time_window() {
1428        // L27 (time_window arm): a 0-row time_window run reports the generic
1429        // `"source returned 0 rows"` skip (the strategy has no cursor column),
1430        // so the `cursor:` branch never fires. Without a `window:` line the
1431        // operator can't tell an empty window from a wrong column/window —
1432        // assert the dedicated `window:` line appears for this mode.
1433        let mut s = RunSummary::new(&plan_for("events"));
1434        s.status = "skipped".into();
1435        s.mode = "timewindow".into();
1436        s.skip_reason = Some("source returned 0 rows".into());
1437
1438        let block = s.render();
1439        let window_line = block
1440            .lines()
1441            .find(|l| l.trim_start().starts_with("window:"))
1442            .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1443        assert!(
1444            window_line.contains("matched no rows"),
1445            "window line reports the empty window: {window_line:?}"
1446        );
1447        assert!(
1448            window_line.contains("time_column") && window_line.contains("days_window"),
1449            "window line points at the window config to check: {window_line:?}"
1450        );
1451        // The generic 0-row skip must not also produce a `cursor:` line.
1452        assert!(
1453            !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1454            "no cursor line for a non-cursor strategy: {block}"
1455        );
1456    }
1457
1458    #[test]
1459    fn time_window_skip_line_only_for_skipped_time_window() {
1460        // Fires only when the run skipped AND the strategy is time_window.
1461        assert_eq!(
1462            time_window_skip_line("timewindow", Some("source returned 0 rows")),
1463            Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1464        );
1465        // Wrong mode → no window line (incremental/snapshot handle their own).
1466        assert_eq!(
1467            time_window_skip_line("incremental", Some("source returned 0 rows")),
1468            None
1469        );
1470        assert_eq!(
1471            time_window_skip_line("full", Some("source returned 0 rows")),
1472            None
1473        );
1474        // A time_window run that produced rows (no skip) gets no window line.
1475        assert_eq!(time_window_skip_line("timewindow", None), None);
1476    }
1477}