Skip to main content

rivet/preflight/
mod.rs

1mod analysis;
2mod cdc_health;
3pub(crate) mod cursor_expr;
4mod doctor;
5mod mssql;
6mod mysql;
7mod postgres;
8mod schema_error;
9pub mod type_report;
10
11pub(crate) use analysis::chunk_sparsity_from_counts;
12// Re-exported so the plan layer's strategy explainer can ground its "≥ threshold"
13// narrative on the same constant `check`/`init` use, not a hard-coded copy.
14pub(crate) use analysis::SMALL_TABLE_ROW_THRESHOLD;
15#[cfg(test)]
16use analysis::{
17    build_suggestion, check_connection_limit, check_dense_surrogate_cost,
18    check_parallel_memory_risk, check_sparse_range, compute_verdict, derive_strategy,
19    recommend_parallelism, recommend_profile,
20};
21#[allow(unused_imports)]
22pub use doctor::doctor;
23// Reused at the run-time connect seam (src/pipeline/single.rs) so a failed
24// `rivet run` carries the same category + remediation hint `rivet doctor` gives.
25pub(crate) use doctor::{categorize_source_error, source_error_hint};
26#[cfg(test)]
27use postgres::{extract_scan_type, parse_pg_row_estimate};
28
29use serde::Serialize;
30
31use crate::config::{Config, ExportConfig, SourceType};
32use crate::error::Result;
33use crate::types::policy::TypePolicy;
34use crate::types::target::{ExportTarget, TargetStatus};
35
36/// Serializes lowercase ("efficient"/"acceptable"/"degraded"/"unsafe") so
37/// `rivet check --json` consumers (CI gates, orchestrators) match on a stable,
38/// case-insensitive token rather than the SHOUTING `Display` form used in the
39/// human-readable table.
40#[derive(Debug, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum HealthVerdict {
43    Efficient,
44    Acceptable,
45    Degraded,
46    Unsafe,
47}
48
49impl std::fmt::Display for HealthVerdict {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Efficient => write!(f, "EFFICIENT"),
53            Self::Acceptable => write!(f, "ACCEPTABLE"),
54            Self::Degraded => write!(f, "DEGRADED"),
55            Self::Unsafe => write!(f, "UNSAFE"),
56        }
57    }
58}
59
60pub(crate) struct ExportDiagnostic {
61    pub export_name: String,
62    pub strategy: String,
63    pub mode: String,
64    pub cursor_column: Option<String>,
65    pub row_estimate: Option<i64>,
66    /// Average bytes per row from catalog/plan stats (PG EXPLAIN `width`,
67    /// MSSQL `dm_db_partition_stats` pages/row). `None` when unavailable
68    /// (e.g. MySQL, with no trustworthy scan-free estimate). Feeds the
69    /// oversized-chunk warning and is shown as the `Row width` line.
70    pub avg_row_bytes: Option<i64>,
71    pub cursor_min: Option<String>,
72    pub cursor_max: Option<String>,
73    pub scan_type: Option<String>,
74    pub uses_index: bool,
75    pub verdict: HealthVerdict,
76    pub recommended_profile: &'static str,
77    pub recommended_parallel: (u32, &'static str),
78    pub warnings: Vec<analysis::Warning>,
79    pub suggestion: Option<String>,
80}
81
82// Hand-rolled `Serialize` (rather than `#[derive]`) so the JSON shape stays
83// fully under our control without touching the three engine construction sites:
84//   - `recommended_parallel` (a raw `(u32, &str)`) becomes a self-describing
85//     `{ "level": N, "reason": "…" }` object instead of a positional 2-array;
86//   - a derived `capabilities` object ({uses_index, has_cursor, can_parallel})
87//     is computed from the sibling fields at serialization time — no stored
88//     field, no extra probe;
89//   - `None` optionals are skipped to keep the object lean for CI consumers.
90// `HealthVerdict` rides its own `#[derive(Serialize)]` (lowercase tokens).
91impl Serialize for ExportDiagnostic {
92    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
93    where
94        S: serde::Serializer,
95    {
96        use serde::ser::SerializeMap;
97
98        #[derive(Serialize)]
99        struct RecommendedParallel {
100            level: u32,
101            reason: &'static str,
102        }
103        #[derive(Serialize)]
104        struct Capabilities {
105            uses_index: bool,
106            has_cursor: bool,
107            can_parallel: bool,
108        }
109
110        let mut map = serializer.serialize_map(None)?;
111        map.serialize_entry("export_name", &self.export_name)?;
112        map.serialize_entry("strategy", &self.strategy)?;
113        map.serialize_entry("mode", &self.mode)?;
114        if let Some(v) = &self.cursor_column {
115            map.serialize_entry("cursor_column", v)?;
116        }
117        if let Some(v) = &self.row_estimate {
118            map.serialize_entry("row_estimate", v)?;
119        }
120        if let Some(v) = &self.avg_row_bytes {
121            map.serialize_entry("avg_row_bytes", v)?;
122        }
123        if let Some(v) = &self.cursor_min {
124            map.serialize_entry("cursor_min", v)?;
125        }
126        if let Some(v) = &self.cursor_max {
127            map.serialize_entry("cursor_max", v)?;
128        }
129        if let Some(v) = &self.scan_type {
130            map.serialize_entry("scan_type", v)?;
131        }
132        map.serialize_entry("uses_index", &self.uses_index)?;
133        map.serialize_entry("verdict", &self.verdict)?;
134        map.serialize_entry("recommended_profile", &self.recommended_profile)?;
135        map.serialize_entry(
136            "recommended_parallel",
137            &RecommendedParallel {
138                level: self.recommended_parallel.0,
139                reason: self.recommended_parallel.1,
140            },
141        )?;
142        map.serialize_entry("warnings", &self.warnings)?;
143        if let Some(v) = &self.suggestion {
144            map.serialize_entry("suggestion", v)?;
145        }
146        map.serialize_entry(
147            "capabilities",
148            &Capabilities {
149                uses_index: self.uses_index,
150                has_cursor: self.cursor_column.is_some(),
151                can_parallel: self.recommended_parallel.0 > 1,
152            },
153        )?;
154        map.end()
155    }
156}
157
158/// Return the diagnostic for a single export without printing anything.
159///
160/// Used by `rivet plan` to capture preflight data into a `PlanArtifact`.
161pub(crate) fn get_export_diagnostic(
162    config: &Config,
163    export: &ExportConfig,
164) -> Result<ExportDiagnostic> {
165    let url = config.source.resolve_url()?;
166    let tls = config.source.tls.as_ref();
167    crate::source::warn_if_tls_disabled(&config.source);
168    match config.source.source_type {
169        SourceType::Postgres => postgres::diagnose_export_pg(&url, tls, export),
170        SourceType::Mysql => mysql::diagnose_export_mysql(&url, tls, export),
171        SourceType::Mssql => mssql::diagnose_export_mssql(&url, tls, export),
172    }
173}
174
175/// Dedup identity for a destination, shared by `check`'s credential probe
176/// and `doctor`'s write probe. Must include every field that changes where
177/// a probe lands — notably `path`, so two local destinations with different
178/// paths are probed separately. Keeping one helper prevents the two call
179/// sites from drifting apart (doctor's inline copy once omitted `path` and
180/// silently skipped the second local destination).
181fn destination_identity(d: &crate::config::DestinationConfig) -> String {
182    format!(
183        "{:?}:{}:{}:{}",
184        d.destination_type,
185        d.bucket.as_deref().unwrap_or("-"),
186        d.endpoint.as_deref().unwrap_or("-"),
187        d.path.as_deref().unwrap_or("-"),
188    )
189}
190
191/// One-line note for the "fail ✗ but rc 0" case: a column rendered `fail ✗`
192/// for `--target` does NOT gate the exit code unless `--strict` is also passed
193/// (that is the gate by design). Without this note an operator or CI reading
194/// the glyph alone would wrongly assume a non-zero exit. Pure so the exact text
195/// is unit-tested.
196fn target_fail_note(n: usize, target_label: &str) -> String {
197    let col = if n == 1 { "column" } else { "columns" };
198    format!(
199        "Note: {n} {col} FAIL {target_label} compatibility; exit code is gated only with --strict (currently exit 0)"
200    )
201}
202
203/// Build one [`ExportDiagnostic`] per export via `diagnose`, collecting them (or
204/// short-circuiting on the first error). Single-sources the connect → loop →
205/// return contract every engine's `check_*` shares — only the per-export
206/// `diagnose` call differs — so the deferred print-vs-collect decision lives in
207/// one place rather than triplicated across postgres / mysql / mssql.
208pub(super) fn collect_diagnostics<F>(
209    exports: &[&ExportConfig],
210    mut diagnose: F,
211) -> Result<Vec<ExportDiagnostic>>
212where
213    F: FnMut(&ExportConfig) -> Result<ExportDiagnostic>,
214{
215    exports.iter().map(|&e| diagnose(e)).collect()
216}
217
218pub fn check(
219    config_path: &str,
220    export_name: Option<&str>,
221    params: Option<&std::collections::HashMap<String, String>>,
222    show_type_report: bool,
223    strict: bool,
224    json_output: bool,
225    target: Option<ExportTarget>,
226) -> Result<()> {
227    let config = Config::load_with_params(config_path, params)?;
228
229    let exports: Vec<&ExportConfig> = if let Some(name) = export_name {
230        let e = config
231            .exports
232            .iter()
233            .find(|e| e.name == name)
234            .ok_or_else(|| anyhow::anyhow!("export '{}' not found in config", name))?;
235        vec![e]
236    } else {
237        config.exports.iter().collect()
238    };
239
240    let url = config.source.resolve_url()?;
241    let tls = config.source.tls.as_ref();
242    // Surface the plaintext-transport warning at preflight time too —
243    // operators should hear it from `rivet check` before they wait
244    // through a full `rivet run` to learn the same thing. `Once` inside
245    // the helper keeps emission to one line per process even when both
246    // `check` and `run` flow through it.
247    crate::source::warn_if_tls_disabled(&config.source);
248    // Each engine connects once and returns one diagnostic per export without
249    // printing. Rendering is decided here: TEXT (the per-export table) for the
250    // default human path, or — under `--json` — the diagnostic is merged into
251    // each export's type-report JSON object below (see the `if show_type_report`
252    // block). `get_export_diagnostic` already proved the diag is computable
253    // without printing; this is the multi-export variant of that path.
254    let diagnostics: Vec<ExportDiagnostic> = match config.source.source_type {
255        SourceType::Postgres => postgres::check_postgres(&url, tls, &exports)?,
256        SourceType::Mysql => mysql::check_mysql(&url, tls, &exports)?,
257        SourceType::Mssql => mssql::check_mssql(&url, tls, &exports)?,
258    };
259    if !json_output {
260        for diag in &diagnostics {
261            print_diagnostic(diag);
262        }
263    } else if !show_type_report {
264        // `--json` WITHOUT a type report (the CLI forces `show_type_report` on
265        // under `--json`, so this only fires if `check` is called directly with
266        // `json_output=true, show_type_report=false`): there is no per-export
267        // type-report object to nest the diagnostic into, so emit the diagnostic
268        // alone — still NDJSON, one object per export per line. Keeps the
269        // verdict from being silently dropped regardless of caller.
270        for diag in &diagnostics {
271            println!("{}", serde_json::to_string(diag)?);
272        }
273    }
274    // Under `--json` WITH a type report, the diagnostics are emitted nested
275    // inside each export's type-report object (the `show_type_report` block)
276    // rather than as a standalone array — see the design note there. Built only
277    // on the `--json` path (empty otherwise) since the TEXT path printed above.
278    let diag_by_export: std::collections::HashMap<&str, &ExportDiagnostic> = if json_output {
279        diagnostics
280            .iter()
281            .map(|d| (d.export_name.as_str(), d))
282            .collect()
283    } else {
284        std::collections::HashMap::new()
285    };
286
287    // Destination credential-resolution preflight.  Until 0.7.6 `check` only
288    // probed the source: a config with `AWS_ACCESS_KEY_ID` unset would pass
289    // `rivet check` (rc=0) and then explode on `run`, while `rivet doctor`
290    // caught it.  We don't issue a write-probe here (that is `doctor`'s job
291    // and has side effects) — but we *do* call `create_destination`, which
292    // resolves env vars / credentials_file existence at construction time.
293    // Each unique destination is probed once per `check` to keep multi-export
294    // configs cheap.
295    let mut seen_destinations: std::collections::HashSet<String> = std::collections::HashSet::new();
296    for export in &exports {
297        let dest_key = destination_identity(&export.destination);
298        if !seen_destinations.insert(dest_key) {
299            continue;
300        }
301        let expanded = crate::plan::build::expand_destination_templates(
302            export.destination.clone(),
303            &export.name,
304        );
305        crate::destination::create_destination(&expanded).map_err(|e| {
306            anyhow::anyhow!(
307                "export '{}': destination preflight failed: {:#}",
308                export.name,
309                e
310            )
311        })?;
312    }
313
314    // Whether the check ends clean (no strict failure, no target-fail column).
315    // Stays true for the default `rivet check` (no type report), so the "Next:
316    // rivet run" pointer still fires.
317    let mut clean = true;
318
319    if show_type_report {
320        let policy = if strict {
321            TypePolicy::strict()
322        } else {
323            TypePolicy::warn_only()
324        };
325
326        let mut any_fatal = false;
327        // Count hard target-FAIL columns (and remember which target) so that —
328        // when --strict was NOT passed and the exit code is therefore 0 — we can
329        // print a note. The "fail ✗" glyph in the table implies a hard failure,
330        // but exit is gated only by --strict; without this note an operator or CI
331        // reading the glyph alone would be misled into thinking rc != 0.
332        let mut target_fail_cols = 0usize;
333        let mut target_fail_label: Option<&'static str> = None;
334        for export in &exports {
335            let column_overrides =
336                crate::plan::parse_column_overrides_pub(&export.columns, &export.name)?;
337            // CLI `--target` wins; otherwise fall back to the per-export
338            // `target:` from the config (slice #2a). A declared-but-unknown
339            // target is a loud error — never silently ignored.
340            if let Some(t) = export.target.as_deref()
341                && crate::types::target::ExportTarget::parse(t).is_none()
342            {
343                anyhow::bail!(
344                    "export '{}': unknown target '{t}' (expected: {})",
345                    export.name,
346                    crate::types::target::ExportTarget::valid_target_names()
347                );
348            }
349            let eff_target = target.or_else(|| {
350                export
351                    .target
352                    .as_deref()
353                    .and_then(crate::types::target::ExportTarget::parse)
354            });
355            let config_dir = std::path::Path::new(config_path)
356                .parent()
357                .unwrap_or_else(|| std::path::Path::new("."));
358            match type_report::collect_report(
359                &config,
360                export,
361                &column_overrides,
362                &policy,
363                eff_target,
364                config_dir,
365                params,
366            ) {
367                Ok(report) => {
368                    if report.has_fatal() {
369                        any_fatal = true;
370                    }
371                    if let Some(t) = eff_target
372                        && report.has_target_fail()
373                    {
374                        any_fatal = true;
375                        target_fail_cols += report
376                            .columns
377                            .iter()
378                            .filter(|c| c.target_status == Some(TargetStatus::Fail))
379                            .count();
380                        target_fail_label.get_or_insert(t.label());
381                    }
382                    if json_output {
383                        // `--json` + `--type-report` interaction (DESIGN):
384                        // emit BOTH, nested. Each export gets ONE JSON object
385                        // (NDJSON, one per line, unchanged) keeping the
386                        // top-level type-report keys (`export`/`columns`/
387                        // `violations`) so existing consumers and the
388                        // `check_json_flag_outputs_type_report_as_json` test
389                        // stay green — and we attach the per-export DIAGNOSTIC
390                        // verdict under a new `"diagnostic"` key. This is the
391                        // least-surprising shape because `check --json` already
392                        // emitted one type-report object per export; we simply
393                        // enrich each with its verdict rather than printing a
394                        // second, separate JSON value (which would break a
395                        // single-`from_str` parse of stdout).
396                        print_report_json_with_diagnostic(
397                            &report,
398                            diag_by_export.get(export.name.as_str()).copied(),
399                        )?;
400                    } else {
401                        type_report::print_table(&report, eff_target);
402                    }
403                }
404                Err(e) => {
405                    log::warn!("type report for '{}' failed: {:#}", export.name, e);
406                    // The type report could not be collected, but the diagnostic
407                    // was. Under --json the verdict must still reach the
408                    // consumer, so emit a diagnostic-only object (no `columns`/
409                    // `violations`) rather than silently dropping this export.
410                    if json_output
411                        && let Some(diag) = diag_by_export.get(export.name.as_str()).copied()
412                    {
413                        println!("{}", serde_json::to_string(diag)?);
414                    }
415                }
416            }
417        }
418
419        if strict && any_fatal {
420            anyhow::bail!("strict mode: unsafe type mappings found (see report above)");
421        } else if !strict && target_fail_cols > 0 && !json_output {
422            // The table showed "fail ✗" but rc is 0 — say so explicitly. Skipped
423            // under --json so NDJSON output stays one object per line.
424            clean = false;
425            println!();
426            println!(
427                "{}",
428                target_fail_note(target_fail_cols, target_fail_label.unwrap_or("target"))
429            );
430        }
431    }
432
433    if !json_output {
434        // Verdict legend — decode the EFFICIENT/ACCEPTABLE/DEGRADED/UNSAFE words
435        // printed above and reassure that `check` is advisory: never blocks a run.
436        println!();
437        println!(
438            "Verdicts: EFFICIENT > ACCEPTABLE > DEGRADED > UNSAFE — advisory only; the run is never blocked."
439        );
440        if clean {
441            // Keep the ladder going to the final rung instead of ending cold.
442            println!(
443                "Looks good. Next: rivet run -c {config_path} --validate   # export, then verify row counts"
444            );
445        }
446    }
447
448    Ok(())
449}
450
451/// Emit one export's `--json` line: the type report (`export`/`columns`/
452/// `violations`/…) with the per-export DIAGNOSTIC verdict attached under a new
453/// `"diagnostic"` key. NDJSON — exactly one JSON object, terminated by a
454/// newline, so a multi-export config prints one parseable object per line
455/// (preserving the prior `check --json` type-report wire shape, now enriched).
456///
457/// `diag` is `None` only if the diagnostic could not be paired by export name
458/// (it always can in practice); in that case the type report is emitted as
459/// before, so the worst case is a missing `diagnostic` key, never a panic.
460fn print_report_json_with_diagnostic(
461    report: &type_report::ExportTypeReport,
462    diag: Option<&ExportDiagnostic>,
463) -> Result<()> {
464    let mut value = serde_json::to_value(report)?;
465    if let (Some(obj), Some(diag)) = (value.as_object_mut(), diag) {
466        obj.insert("diagnostic".to_string(), serde_json::to_value(diag)?);
467    }
468    println!("{}", serde_json::to_string(&value)?);
469    Ok(())
470}
471
472fn print_diagnostic(diag: &ExportDiagnostic) {
473    println!();
474    println!("Export: {}", diag.export_name);
475    println!("  Strategy:     {}", diag.strategy);
476    println!("  Mode:         {}", diag.mode);
477    if let Some(est) = diag.row_estimate {
478        if est >= 1_000_000 {
479            println!("  Row estimate: ~{}M", est / 1_000_000);
480        } else if est >= 1_000 {
481            println!("  Row estimate: ~{}K", est / 1_000);
482        } else {
483            println!("  Row estimate: ~{}", est);
484        }
485    }
486    if let Some(w) = diag.avg_row_bytes {
487        println!("  Row width:    ~{} bytes", w);
488    }
489    if let (Some(min_v), Some(max_v)) = (&diag.cursor_min, &diag.cursor_max) {
490        println!("  Cursor range: {} .. {}", min_v, max_v);
491    }
492    if let Some(col) = &diag.cursor_column {
493        println!("  Cursor col:   {}", col);
494    }
495    // Plain-language access path instead of a raw EXPLAIN node dump
496    // (`Result (cost=0.00..0.01 rows=1 width=36)`). Keyed off the authoritative
497    // `uses_index` bool, gated on `scan_type.is_some()` so engines without an
498    // EXPLAIN probe (MSSQL) stay silent.
499    if diag.scan_type.is_some() {
500        let access = if diag.uses_index {
501            "index scan (the cursor/chunk column is indexed)"
502        } else {
503            "full table scan (no index on the read path)"
504        };
505        println!("  Access:       {access}");
506    }
507    println!("  Verdict:      {}", diag.verdict);
508    println!(
509        "  Recommended:  tuning.profile: {}",
510        diag.recommended_profile
511    );
512    let (par_level, par_reason) = diag.recommended_parallel;
513    if par_level > 1 {
514        println!("  Recommended:  parallel: {} ({})", par_level, par_reason);
515    } else {
516        println!("  Parallelism:  {} ({})", par_level, par_reason);
517    }
518    for w in &diag.warnings {
519        println!("  Warning:      [{}] {}", w.severity.label(), w.message);
520    }
521    if let Some(suggestion) = &diag.suggestion {
522        println!("  Suggestion:   {}", suggestion);
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::config::{DestinationConfig, DestinationType, ExportConfig, ExportMode, FormatType};
530    use doctor::{
531        categorize_dest_error, categorize_source_error, destination_error_hint, source_error_hint,
532    };
533    use serde_json::Value;
534
535    fn make_export(name: &str, mode: ExportMode, cursor: Option<&str>) -> ExportConfig {
536        // Baseline from the canonical test fixture; override only the fields
537        // these preflight tests vary (mode, cursor, CSV format, query, dest).
538        ExportConfig {
539            mode,
540            cursor_column: cursor.map(|s| s.to_string()),
541            query: Some("SELECT * FROM t".to_string()),
542            format: FormatType::Csv,
543            destination: DestinationConfig {
544                destination_type: DestinationType::Local,
545                path: Some("./out".to_string()),
546                ..Default::default()
547            },
548            ..crate::config::sample_export(name)
549        }
550    }
551
552    /// A representative incremental diagnostic for the `--json` serialization
553    /// tests: a cursor column (so `has_cursor` is true), an index (so
554    /// `uses_index` is true), a >1 parallel recommendation (so `can_parallel`
555    /// is true), and a couple of warnings.
556    fn sample_diagnostic(name: &str) -> ExportDiagnostic {
557        ExportDiagnostic {
558            export_name: name.to_string(),
559            strategy: "incremental(updated_at)".to_string(),
560            mode: "incremental".to_string(),
561            cursor_column: Some("updated_at".to_string()),
562            row_estimate: Some(1_234_567),
563            avg_row_bytes: Some(96),
564            cursor_min: Some("2020-01-01".to_string()),
565            cursor_max: Some("2024-01-01".to_string()),
566            scan_type: Some("Index Scan".to_string()),
567            uses_index: true,
568            verdict: HealthVerdict::Degraded,
569            recommended_profile: "safe",
570            recommended_parallel: (4, "large indexed dataset"),
571            warnings: vec![
572                analysis::Warning::new(analysis::Severity::Medium, "Sparse key range".to_string()),
573                analysis::Warning::new(analysis::Severity::High, "memory risk".to_string()),
574            ],
575            suggestion: Some("create an index".to_string()),
576        }
577    }
578
579    // ── `rivet check --json`: the per-export DIAGNOSTIC verdict as JSON ───────
580
581    #[test]
582    fn diagnostic_json_has_lowercase_verdict_and_core_fields() {
583        let diag = sample_diagnostic("orders");
584        let v: serde_json::Value =
585            serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
586
587        // Verdict serializes to a stable lowercase token (not the SHOUTING
588        // Display form), so CI can match on it case-sensitively.
589        assert_eq!(v["verdict"], "degraded", "got: {v}");
590        assert_eq!(v["strategy"], "incremental(updated_at)", "got: {v}");
591        assert_eq!(v["mode"], "incremental", "got: {v}");
592        assert_eq!(v["recommended_profile"], "safe", "got: {v}");
593        assert!(v["warnings"].is_array(), "warnings must be an array: {v}");
594        assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "got: {v}");
595        // Each warning is a `{ severity, message }` object (per-warning severity).
596        assert_eq!(v["warnings"][0]["severity"], "medium", "got: {v}");
597        assert_eq!(v["warnings"][0]["message"], "Sparse key range", "got: {v}");
598        assert_eq!(v["warnings"][1]["severity"], "high", "got: {v}");
599        assert_eq!(v["export_name"], "orders", "got: {v}");
600    }
601
602    #[test]
603    fn diagnostic_json_verdict_tokens_are_all_lowercase() {
604        for (verdict, token) in [
605            (HealthVerdict::Efficient, "efficient"),
606            (HealthVerdict::Acceptable, "acceptable"),
607            (HealthVerdict::Degraded, "degraded"),
608            (HealthVerdict::Unsafe, "unsafe"),
609        ] {
610            let mut diag = sample_diagnostic("t");
611            diag.verdict = verdict;
612            let v: serde_json::Value =
613                serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
614            assert_eq!(v["verdict"], token, "verdict must lowercase to {token}");
615        }
616    }
617
618    #[test]
619    fn diagnostic_json_recommended_parallel_is_named_object_not_tuple() {
620        // The raw `(u32, &str)` must NOT leak as a positional 2-array; consumers
621        // read `recommended_parallel.level` / `.reason`.
622        let diag = sample_diagnostic("t");
623        let v: serde_json::Value =
624            serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
625        assert!(
626            v["recommended_parallel"].is_object(),
627            "recommended_parallel must be an object, got: {}",
628            v["recommended_parallel"]
629        );
630        assert_eq!(v["recommended_parallel"]["level"], 4, "got: {v}");
631        assert_eq!(
632            v["recommended_parallel"]["reason"], "large indexed dataset",
633            "got: {v}"
634        );
635    }
636
637    #[test]
638    fn diagnostic_json_capabilities_are_derived_from_fields() {
639        let diag = sample_diagnostic("t");
640        let v: serde_json::Value =
641            serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
642        let caps = &v["capabilities"];
643        assert_eq!(caps["uses_index"], true, "got: {caps}");
644        assert_eq!(caps["has_cursor"], true, "got: {caps}");
645        assert_eq!(caps["can_parallel"], true, "got: {caps}");
646    }
647
648    #[test]
649    fn diagnostic_json_capabilities_flip_with_fields() {
650        // A non-cursor, no-index, single-worker diagnostic flips all three.
651        let mut diag = sample_diagnostic("t");
652        diag.cursor_column = None;
653        diag.uses_index = false;
654        diag.recommended_parallel = (1, "small dataset");
655        let v: serde_json::Value =
656            serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
657        let caps = &v["capabilities"];
658        assert_eq!(caps["uses_index"], false, "got: {caps}");
659        assert_eq!(caps["has_cursor"], false, "got: {caps}");
660        assert_eq!(caps["can_parallel"], false, "got: {caps}");
661    }
662
663    #[test]
664    fn diagnostic_json_skips_none_optionals() {
665        // `None` optionals are omitted (not `null`) to keep the object lean.
666        let mut diag = sample_diagnostic("t");
667        diag.suggestion = None;
668        diag.scan_type = None;
669        let v: serde_json::Value =
670            serde_json::from_str(&serde_json::to_string(&diag).unwrap()).unwrap();
671        let obj = v.as_object().unwrap();
672        assert!(!obj.contains_key("suggestion"), "None must be omitted: {v}");
673        assert!(!obj.contains_key("scan_type"), "None must be omitted: {v}");
674    }
675
676    /// Build the same `Value` `print_report_json_with_diagnostic` prints, so the
677    /// merged shape is asserted without capturing stdout.
678    fn merged_check_json(report: &type_report::ExportTypeReport, diag: &ExportDiagnostic) -> Value {
679        let mut value = serde_json::to_value(report).unwrap();
680        value.as_object_mut().unwrap().insert(
681            "diagnostic".to_string(),
682            serde_json::to_value(diag).unwrap(),
683        );
684        value
685    }
686
687    fn empty_report(export: &str) -> type_report::ExportTypeReport {
688        type_report::ExportTypeReport {
689            export: export.to_string(),
690            columns: Vec::new(),
691            violations: Vec::new(),
692            target_failures: false,
693            recovery_sql: None,
694        }
695    }
696
697    #[test]
698    fn check_json_merges_diagnostic_into_type_report_object() {
699        // The `--json` + `--type-report` interaction: ONE object per export
700        // keeping the type-report keys (`export`/`columns`/`violations`) — so
701        // the existing `check_json_flag_outputs_type_report_as_json` contract
702        // holds — PLUS a nested `diagnostic` carrying the verdict.
703        let report = empty_report("orders");
704        let diag = sample_diagnostic("orders");
705        let v = merged_check_json(&report, &diag);
706
707        // Pre-existing type-report keys still at the root.
708        assert_eq!(v["export"], "orders", "got: {v}");
709        assert!(v["columns"].is_array(), "columns at root: {v}");
710        assert!(v["violations"].is_array(), "violations at root: {v}");
711
712        // The diagnostic is nested and carries the verdict + advice.
713        let d = &v["diagnostic"];
714        assert_eq!(d["verdict"], "degraded", "got: {d}");
715        assert_eq!(d["strategy"], "incremental(updated_at)", "got: {d}");
716        assert_eq!(d["mode"], "incremental", "got: {d}");
717        assert_eq!(d["recommended_profile"], "safe", "got: {d}");
718        assert!(d["warnings"].is_array(), "warnings array: {d}");
719        assert_eq!(d["capabilities"]["has_cursor"], true, "got: {d}");
720    }
721
722    #[test]
723    fn check_json_object_is_a_single_parseable_line() {
724        // NDJSON: serializing yields exactly one JSON value with no trailing
725        // data, so `serde_json::from_str(line.trim())` (as the live test does)
726        // parses it whole.
727        let report = empty_report("orders");
728        let diag = sample_diagnostic("orders");
729        let line = serde_json::to_string(&merged_check_json(&report, &diag)).unwrap();
730        assert!(!line.contains('\n'), "one object per line: {line}");
731        let parsed: Value = serde_json::from_str(line.trim()).expect("must parse whole");
732        assert_eq!(parsed["export"], "orders");
733    }
734
735    // ── L8: 'fail ✗' note when --target FAILs but --strict was not passed ─────
736    // The glyph implies a hard failure; exit is gated only by --strict. The note
737    // tells an operator/CI the exit is 0 so the glyph doesn't mislead.
738    #[test]
739    fn target_fail_note_names_count_target_and_strict_gate() {
740        let note = target_fail_note(2, "bigquery");
741        assert!(note.contains("2 columns FAIL"), "got: {note}");
742        assert!(note.contains("bigquery"), "got: {note}");
743        assert!(note.contains("--strict"), "got: {note}");
744        assert!(note.contains("exit 0"), "got: {note}");
745    }
746
747    #[test]
748    fn target_fail_note_singular_for_one_column() {
749        let note = target_fail_note(1, "duckdb");
750        assert!(note.contains("1 column FAIL"), "got: {note}");
751        assert!(!note.contains("1 columns"), "should be singular: {note}");
752    }
753
754    #[test]
755    fn verdict_small_indexed_with_cursor_is_efficient() {
756        let v = compute_verdict(Some(500_000), true, true, None, 1);
757        assert!(matches!(v, HealthVerdict::Efficient), "got: {v}");
758    }
759
760    #[test]
761    fn verdict_large_indexed_with_cursor_is_acceptable() {
762        let v = compute_verdict(Some(20_000_000), true, true, None, 1);
763        assert!(matches!(v, HealthVerdict::Acceptable), "got: {v}");
764    }
765
766    #[test]
767    fn verdict_no_index_no_cursor_is_degraded() {
768        let v = compute_verdict(Some(500_000), false, false, None, 1);
769        assert!(matches!(v, HealthVerdict::Degraded), "got: {v}");
770    }
771
772    #[test]
773    fn verdict_huge_no_index_is_unsafe() {
774        let v = compute_verdict(Some(100_000_000), false, false, None, 1);
775        assert!(matches!(v, HealthVerdict::Unsafe), "got: {v}");
776    }
777
778    #[test]
779    fn parse_pg_row_estimate_from_sort_plan() {
780        let plan = "Sort  (cost=12345.67..12456.78 rows=1000455 width=50)\n  ->  Seq Scan on orders  (cost=0.00..8765.43 rows=1000455 width=50)";
781        assert_eq!(parse_pg_row_estimate(plan), Some(1_000_455));
782    }
783
784    #[test]
785    fn parse_pg_row_estimate_from_index_scan() {
786        let plan =
787            "Index Scan using idx_updated on orders  (cost=0.42..81676.36 rows=500000 width=50)";
788        assert_eq!(parse_pg_row_estimate(plan), Some(500_000));
789    }
790
791    #[test]
792    fn extract_scan_type_detects_seq_scan() {
793        let plan = "Sort  (cost=...)\n  ->  Seq Scan on users  (cost=...)";
794        let st = extract_scan_type(plan);
795        assert!(st.contains("Seq Scan"), "expected Seq Scan, got: {st}");
796    }
797
798    #[test]
799    fn extract_scan_type_detects_index_scan() {
800        let plan = "Index Scan using users_pkey on users  (cost=0.42..123.45 rows=100 width=50)";
801        let st = extract_scan_type(plan);
802        assert!(st.contains("Index Scan"), "expected Index Scan, got: {st}");
803    }
804
805    #[test]
806    fn suggestion_for_efficient_verdict_is_none() {
807        let e = make_export("t", ExportMode::Full, None);
808        let s = build_suggestion(&HealthVerdict::Efficient, Some(1000), true, &e);
809        assert!(
810            s.is_none(),
811            "efficient verdict should produce no suggestion"
812        );
813    }
814
815    #[test]
816    fn suggestion_for_degraded_verdict_recommends_safe_profile() {
817        let e = make_export("t", ExportMode::Full, None);
818        let s = build_suggestion(&HealthVerdict::Degraded, Some(500_000), false, &e);
819        let msg = s.expect("degraded verdict should produce a suggestion");
820        assert!(
821            msg.contains("safe"),
822            "suggestion should recommend safe profile, got: {msg}"
823        );
824    }
825
826    fn src_err(msg: &str) -> &'static str {
827        categorize_source_error(&anyhow::anyhow!("{}", msg))
828    }
829
830    #[test]
831    fn source_password_rejected_is_auth_error() {
832        assert_eq!(
833            src_err("password authentication failed for user \"rivet\""),
834            "auth error"
835        );
836    }
837
838    #[test]
839    fn source_authentication_failed_is_auth_error() {
840        assert_eq!(src_err("FATAL: authentication failed"), "auth error");
841    }
842
843    #[test]
844    fn source_access_denied_is_auth_error() {
845        assert_eq!(
846            src_err("Access denied for user 'rivet'@'localhost'"),
847            "auth error"
848        );
849    }
850
851    #[test]
852    fn source_connection_refused_is_connectivity() {
853        assert_eq!(
854            src_err("connection refused (os error 61)"),
855            "connectivity error"
856        );
857    }
858
859    #[test]
860    fn source_timed_out_is_connectivity() {
861        assert_eq!(src_err("connection timed out"), "connectivity error");
862    }
863
864    #[test]
865    fn source_dns_translate_host_is_connectivity() {
866        assert_eq!(
867            src_err("could not translate host name \"db.bad\" to address"),
868            "connectivity error"
869        );
870    }
871
872    #[test]
873    fn source_name_not_known_is_connectivity() {
874        assert_eq!(src_err("Name or service not known"), "connectivity error");
875    }
876
877    #[test]
878    fn source_unknown_error_is_generic() {
879        assert_eq!(src_err("something totally unexpected"), "error");
880    }
881
882    fn dest_config(dtype: DestinationType) -> DestinationConfig {
883        DestinationConfig {
884            destination_type: dtype,
885            bucket: Some("b".to_string()),
886            ..Default::default()
887        }
888    }
889
890    fn dest_err(msg: &str, dtype: DestinationType) -> &'static str {
891        let cfg = dest_config(dtype);
892        categorize_dest_error(&anyhow::anyhow!("{}", msg), &cfg)
893    }
894
895    fn local_dest(path: &str) -> DestinationConfig {
896        DestinationConfig {
897            destination_type: DestinationType::Local,
898            path: Some(path.to_string()),
899            ..Default::default()
900        }
901    }
902
903    // Regression (doctor-dedup): doctor's inline dedup key omitted `path`,
904    // so two local destinations with different paths collapsed to one entry
905    // and the second was never write-probed. The shared identity must keep
906    // them distinct.
907    #[test]
908    fn destination_identity_distinguishes_local_paths() {
909        assert_ne!(
910            destination_identity(&local_dest("/tmp/a")),
911            destination_identity(&local_dest("/tmp/b")),
912        );
913    }
914
915    #[test]
916    fn destination_identity_collapses_identical_local_destinations() {
917        assert_eq!(
918            destination_identity(&local_dest("/tmp/a")),
919            destination_identity(&local_dest("/tmp/a")),
920        );
921    }
922
923    #[test]
924    fn destination_identity_distinguishes_buckets() {
925        let a = DestinationConfig {
926            bucket: Some("bucket-a".to_string()),
927            ..dest_config(DestinationType::S3)
928        };
929        let b = DestinationConfig {
930            bucket: Some("bucket-b".to_string()),
931            ..dest_config(DestinationType::S3)
932        };
933        assert_ne!(destination_identity(&a), destination_identity(&b));
934    }
935
936    // Same bucket name on different endpoints (e.g. AWS vs MinIO) is two
937    // distinct destinations and must be probed separately.
938    #[test]
939    fn destination_identity_distinguishes_endpoints_for_same_bucket() {
940        let aws = dest_config(DestinationType::S3);
941        let minio = DestinationConfig {
942            endpoint: Some("http://localhost:9000".to_string()),
943            ..dest_config(DestinationType::S3)
944        };
945        assert_ne!(destination_identity(&aws), destination_identity(&minio));
946    }
947
948    #[test]
949    fn dest_credential_loading_is_auth_error() {
950        assert_eq!(
951            dest_err(
952                "loading credential to sign http request",
953                DestinationType::Gcs
954            ),
955            "auth error"
956        );
957    }
958
959    #[test]
960    fn dest_permission_denied_is_auth_error() {
961        assert_eq!(
962            dest_err("permission denied on resource bucket", DestinationType::S3),
963            "auth error"
964        );
965    }
966
967    #[test]
968    fn dest_forbidden_is_auth_error() {
969        assert_eq!(
970            dest_err("403 Forbidden", DestinationType::Gcs),
971            "auth error"
972        );
973    }
974
975    #[test]
976    fn dest_unauthorized_is_auth_error() {
977        assert_eq!(
978            dest_err("401 Unauthorized", DestinationType::S3),
979            "auth error"
980        );
981    }
982
983    #[test]
984    fn dest_invalid_grant_is_auth_error() {
985        assert_eq!(
986            dest_err(
987                "invalid_grant: token has been revoked",
988                DestinationType::Gcs
989            ),
990            "auth error"
991        );
992    }
993
994    #[test]
995    fn dest_nosuchbucket_s3_is_bucket_not_found() {
996        assert_eq!(
997            dest_err(
998                "NoSuchBucket: the specified bucket does not exist",
999                DestinationType::S3
1000            ),
1001            "bucket not found"
1002        );
1003    }
1004
1005    #[test]
1006    fn dest_not_found_gcs_is_bucket_not_found() {
1007        assert_eq!(
1008            dest_err("bucket not found (404)", DestinationType::Gcs),
1009            "bucket not found"
1010        );
1011    }
1012
1013    #[test]
1014    fn dest_not_found_local_is_path_not_found() {
1015        assert_eq!(
1016            dest_err("path not found: /tmp/missing", DestinationType::Local),
1017            "path not found"
1018        );
1019    }
1020
1021    #[test]
1022    fn dest_connection_refused_is_connectivity() {
1023        assert_eq!(
1024            dest_err("connection refused to endpoint", DestinationType::S3),
1025            "connectivity error"
1026        );
1027    }
1028
1029    #[test]
1030    fn dest_dns_error_is_connectivity() {
1031        assert_eq!(
1032            dest_err("dns error: failed to lookup address", DestinationType::S3),
1033            "connectivity error"
1034        );
1035    }
1036
1037    #[test]
1038    fn dest_timed_out_is_connectivity() {
1039        assert_eq!(
1040            dest_err("request timed out after 30s", DestinationType::Gcs),
1041            "connectivity error"
1042        );
1043    }
1044
1045    #[test]
1046    fn dest_unknown_error_is_generic() {
1047        assert_eq!(
1048            dest_err("something else entirely", DestinationType::S3),
1049            "error"
1050        );
1051    }
1052
1053    #[test]
1054    fn strategy_full_scan() {
1055        let e = make_export("t", ExportMode::Full, None);
1056        assert_eq!(derive_strategy(&e), "full-scan");
1057    }
1058
1059    #[test]
1060    fn strategy_full_parallel() {
1061        let mut e = make_export("t", ExportMode::Full, None);
1062        e.parallel = 4;
1063        assert_eq!(derive_strategy(&e), "full-parallel(4)");
1064    }
1065
1066    #[test]
1067    fn strategy_incremental() {
1068        let e = make_export("t", ExportMode::Incremental, Some("updated_at"));
1069        assert_eq!(derive_strategy(&e), "incremental(updated_at)");
1070    }
1071
1072    #[test]
1073    fn strategy_chunked() {
1074        let mut e = make_export("t", ExportMode::Chunked, None);
1075        e.chunk_column = Some("id".to_string());
1076        e.chunk_size = 50_000;
1077        assert_eq!(derive_strategy(&e), "chunked(id, size=50000)");
1078    }
1079
1080    #[test]
1081    fn strategy_chunked_parallel() {
1082        let mut e = make_export("t", ExportMode::Chunked, None);
1083        e.chunk_column = Some("id".to_string());
1084        e.chunk_size = 50_000;
1085        e.parallel = 3;
1086        assert_eq!(derive_strategy(&e), "chunked-parallel(id, size=50000, p=3)");
1087    }
1088
1089    #[test]
1090    fn strategy_time_window() {
1091        let mut e = make_export("t", ExportMode::TimeWindow, None);
1092        e.time_column = Some("created_at".to_string());
1093        e.days_window = Some(7);
1094        assert_eq!(derive_strategy(&e), "time-window(created_at, 7d)");
1095    }
1096
1097    #[test]
1098    fn profile_small_indexed_is_fast() {
1099        let e = make_export("t", ExportMode::Full, None);
1100        assert_eq!(recommend_profile(Some(500_000), true, &e), "fast");
1101    }
1102
1103    #[test]
1104    fn profile_medium_indexed_is_balanced() {
1105        let e = make_export("t", ExportMode::Full, None);
1106        assert_eq!(recommend_profile(Some(5_000_000), true, &e), "balanced");
1107    }
1108
1109    #[test]
1110    fn profile_large_indexed_is_safe() {
1111        let e = make_export("t", ExportMode::Full, None);
1112        assert_eq!(recommend_profile(Some(50_000_000), true, &e), "safe");
1113    }
1114
1115    #[test]
1116    fn profile_small_no_index_is_balanced() {
1117        let e = make_export("t", ExportMode::Full, None);
1118        assert_eq!(recommend_profile(Some(50_000), false, &e), "balanced");
1119    }
1120
1121    #[test]
1122    fn profile_small_no_index_parallel_is_safe() {
1123        let mut e = make_export("t", ExportMode::Full, None);
1124        e.parallel = 4;
1125        assert_eq!(recommend_profile(Some(50_000), false, &e), "safe");
1126    }
1127
1128    #[test]
1129    fn profile_medium_no_index_is_balanced() {
1130        let e = make_export("t", ExportMode::Full, None);
1131        assert_eq!(recommend_profile(Some(500_000), false, &e), "balanced");
1132    }
1133
1134    #[test]
1135    fn profile_large_no_index_is_safe() {
1136        let e = make_export("t", ExportMode::Full, None);
1137        assert_eq!(recommend_profile(Some(5_000_000), false, &e), "safe");
1138    }
1139
1140    #[test]
1141    fn sparse_range_warning_when_very_sparse() {
1142        let mut e = make_export("t", ExportMode::Chunked, None);
1143        e.chunk_column = Some("id".to_string());
1144        e.chunk_size = 100_000;
1145        let w = check_sparse_range(&e, Some(100_000), Some("1"), Some("10000000"));
1146        assert!(w.is_some(), "should warn about sparse range");
1147        let msg = w.unwrap();
1148        assert!(msg.contains("Sparse key range"), "got: {msg}");
1149        assert!(msg.contains("empty"), "got: {msg}");
1150    }
1151
1152    #[test]
1153    fn sparse_range_no_warning_when_dense() {
1154        let mut e = make_export("t", ExportMode::Chunked, None);
1155        e.chunk_column = Some("id".to_string());
1156        e.chunk_size = 100_000;
1157        let w = check_sparse_range(&e, Some(100_000), Some("1"), Some("100000"));
1158        assert!(w.is_none(), "should not warn for dense range");
1159    }
1160
1161    #[test]
1162    fn sparse_range_skipped_when_chunk_dense() {
1163        let mut e = make_export("t", ExportMode::Chunked, None);
1164        e.chunk_column = Some("id".to_string());
1165        e.chunk_dense = true;
1166        e.chunk_size = 100_000;
1167        let w = check_sparse_range(&e, Some(100_000), Some("1"), Some("10000000"));
1168        assert!(
1169            w.is_none(),
1170            "chunk_dense uses ordinals, not physical id span"
1171        );
1172    }
1173
1174    #[test]
1175    fn dense_surrogate_warning_when_chunk_dense_builtin() {
1176        let mut e = make_export("t", ExportMode::Chunked, None);
1177        e.chunk_column = Some("id".to_string());
1178        e.chunk_dense = true;
1179        e.query = Some("SELECT id FROM orders".to_string());
1180        let w = check_dense_surrogate_cost(&e);
1181        assert!(w.is_some(), "should warn about built-in ROW_NUMBER cost");
1182        assert!(w.unwrap().contains("global sort"));
1183    }
1184
1185    #[test]
1186    fn sparse_range_not_triggered_for_non_chunked() {
1187        let e = make_export("t", ExportMode::Full, None);
1188        let w = check_sparse_range(&e, Some(100), Some("1"), Some("1000000"));
1189        assert!(w.is_none(), "should not warn for non-chunked mode");
1190    }
1191
1192    #[test]
1193    fn dense_surrogate_warning_with_row_number() {
1194        let mut e = make_export("t", ExportMode::Chunked, None);
1195        e.chunk_column = Some("rn".to_string());
1196        e.query = Some("SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM orders".to_string());
1197        let w = check_dense_surrogate_cost(&e);
1198        assert!(w.is_some(), "should warn about ROW_NUMBER cost");
1199        assert!(w.unwrap().contains("global sort"));
1200    }
1201
1202    #[test]
1203    fn no_dense_surrogate_warning_without_row_number() {
1204        let mut e = make_export("t", ExportMode::Chunked, None);
1205        e.chunk_column = Some("id".to_string());
1206        e.query = Some("SELECT * FROM orders".to_string());
1207        let w = check_dense_surrogate_cost(&e);
1208        assert!(w.is_none());
1209    }
1210
1211    #[test]
1212    fn no_dense_surrogate_warning_for_non_chunked() {
1213        let mut e = make_export("t", ExportMode::Full, None);
1214        e.query = Some("SELECT ROW_NUMBER() OVER () AS rn FROM t".to_string());
1215        let w = check_dense_surrogate_cost(&e);
1216        assert!(w.is_none(), "should not warn for non-chunked mode");
1217    }
1218
1219    #[test]
1220    fn parallel_memory_warning_large_dataset() {
1221        let mut e = make_export("t", ExportMode::Chunked, None);
1222        e.parallel = 4;
1223        let w = check_parallel_memory_risk(&e, Some(10_000_000));
1224        assert!(w.is_some(), "should warn about memory risk");
1225        let msg = w.unwrap();
1226        assert!(msg.contains("Parallel=4"), "got: {msg}");
1227        assert!(msg.contains("memory"), "got: {msg}");
1228    }
1229
1230    #[test]
1231    fn no_parallel_memory_warning_small_dataset() {
1232        let mut e = make_export("t", ExportMode::Chunked, None);
1233        e.parallel = 4;
1234        let w = check_parallel_memory_risk(&e, Some(1_000));
1235        assert!(w.is_none(), "should not warn for small dataset");
1236    }
1237
1238    #[test]
1239    fn no_parallel_memory_warning_single_worker() {
1240        let e = make_export("t", ExportMode::Full, None);
1241        let w = check_parallel_memory_risk(&e, Some(100_000_000));
1242        assert!(w.is_none(), "should not warn when parallel=1");
1243    }
1244
1245    #[test]
1246    fn suggestion_degraded_full_recommends_incremental() {
1247        let e = make_export("t", ExportMode::Full, None);
1248        let s = build_suggestion(&HealthVerdict::Degraded, Some(500_000), false, &e).unwrap();
1249        assert!(s.contains("incremental"), "got: {s}");
1250    }
1251
1252    #[test]
1253    fn suggestion_degraded_chunked_recommends_index() {
1254        let mut e = make_export("t", ExportMode::Chunked, None);
1255        e.chunk_column = Some("id".to_string());
1256        let s = build_suggestion(&HealthVerdict::Degraded, Some(500_000), false, &e).unwrap();
1257        assert!(s.contains("index on 'id'"), "got: {s}");
1258    }
1259
1260    #[test]
1261    fn suggestion_degraded_time_window_recommends_index() {
1262        let mut e = make_export("t", ExportMode::TimeWindow, None);
1263        e.time_column = Some("created_at".to_string());
1264        e.days_window = Some(7);
1265        let s = build_suggestion(&HealthVerdict::Degraded, Some(500_000), false, &e).unwrap();
1266        assert!(s.contains("index on 'created_at'"), "got: {s}");
1267    }
1268
1269    #[test]
1270    fn suggestion_unsafe_full_recommends_incremental() {
1271        let e = make_export("t", ExportMode::Full, None);
1272        let s = build_suggestion(&HealthVerdict::Unsafe, Some(100_000_000), false, &e).unwrap();
1273        assert!(s.contains("incremental"), "got: {s}");
1274    }
1275
1276    #[test]
1277    fn suggestion_unsafe_chunked_recommends_index_and_parallel() {
1278        let mut e = make_export("t", ExportMode::Chunked, None);
1279        e.chunk_column = Some("id".to_string());
1280        let s = build_suggestion(&HealthVerdict::Unsafe, Some(100_000_000), false, &e).unwrap();
1281        assert!(s.contains("index on 'id'"), "got: {s}");
1282        assert!(s.contains("parallel"), "got: {s}");
1283    }
1284
1285    #[test]
1286    fn suggestion_unsafe_incremental_recommends_index_on_cursor() {
1287        let e = make_export("t", ExportMode::Incremental, Some("updated_at"));
1288        let s = build_suggestion(&HealthVerdict::Unsafe, Some(100_000_000), false, &e).unwrap();
1289        assert!(s.contains("index on 'updated_at'"), "got: {s}");
1290    }
1291
1292    #[test]
1293    fn suggestion_acceptable_large_full_recommends_incremental() {
1294        let e = make_export("t", ExportMode::Full, None);
1295        let s = build_suggestion(&HealthVerdict::Acceptable, Some(20_000_000), true, &e).unwrap();
1296        assert!(s.contains("incremental"), "got: {s}");
1297    }
1298
1299    #[test]
1300    fn parallel_only_for_chunked_mode() {
1301        let e = make_export("t", ExportMode::Full, None);
1302        let (level, _) = recommend_parallelism(&e, Some(1_000_000), true);
1303        assert_eq!(level, 1, "non-chunked mode should recommend 1");
1304    }
1305
1306    #[test]
1307    fn parallel_small_dataset_is_one() {
1308        let mut e = make_export("t", ExportMode::Chunked, None);
1309        e.chunk_column = Some("id".to_string());
1310        let (level, _) = recommend_parallelism(&e, Some(10_000), true);
1311        assert_eq!(level, 1, "small dataset should recommend 1");
1312    }
1313
1314    #[test]
1315    fn parallel_moderate_indexed_is_two() {
1316        let mut e = make_export("t", ExportMode::Chunked, None);
1317        e.chunk_column = Some("id".to_string());
1318        let (level, _) = recommend_parallelism(&e, Some(200_000), true);
1319        assert_eq!(level, 2, "moderate indexed dataset should recommend 2");
1320    }
1321
1322    #[test]
1323    fn parallel_large_indexed_is_four() {
1324        let mut e = make_export("t", ExportMode::Chunked, None);
1325        e.chunk_column = Some("id".to_string());
1326        let (level, _) = recommend_parallelism(&e, Some(2_000_000), true);
1327        assert_eq!(level, 4, "large indexed dataset should recommend 4");
1328    }
1329
1330    #[test]
1331    fn parallel_no_index_large_is_one() {
1332        let mut e = make_export("t", ExportMode::Chunked, None);
1333        e.chunk_column = Some("id".to_string());
1334        let (level, reason) = recommend_parallelism(&e, Some(10_000_000), false);
1335        assert_eq!(level, 1, "no index + large should recommend 1");
1336        assert!(reason.contains("no index"), "got: {reason}");
1337    }
1338
1339    #[test]
1340    fn parallel_no_index_moderate_is_conservative() {
1341        let mut e = make_export("t", ExportMode::Chunked, None);
1342        e.chunk_column = Some("id".to_string());
1343        let (level, _) = recommend_parallelism(&e, Some(200_000), false);
1344        assert_eq!(
1345            level, 2,
1346            "no index + moderate should recommend 2 (conservative)"
1347        );
1348    }
1349
1350    #[test]
1351    fn suggestion_acceptable_large_chunked_recommends_parallel() {
1352        let mut e = make_export("t", ExportMode::Chunked, None);
1353        e.chunk_column = Some("id".to_string());
1354        let s = build_suggestion(&HealthVerdict::Acceptable, Some(20_000_000), true, &e).unwrap();
1355        assert!(s.contains("parallel"), "got: {s}");
1356    }
1357
1358    #[test]
1359    fn connection_limit_warn_when_parallel_meets_max() {
1360        let w = check_connection_limit(20, Some(20));
1361        assert!(w.is_some(), "should warn when parallel == max_connections");
1362        let msg = w.unwrap();
1363        assert!(msg.contains("max_connections=20"), "got: {msg}");
1364        assert!(msg.contains("parallel=20"), "got: {msg}");
1365    }
1366
1367    #[test]
1368    fn connection_limit_warn_when_parallel_exceeds_max() {
1369        let w = check_connection_limit(100, Some(20));
1370        assert!(w.is_some(), "should warn when parallel > max_connections");
1371        let msg = w.unwrap();
1372        assert!(msg.contains("max_connections=20"), "got: {msg}");
1373    }
1374
1375    #[test]
1376    fn connection_limit_no_warn_when_parallel_below_max() {
1377        let w = check_connection_limit(4, Some(100));
1378        assert!(
1379            w.is_none(),
1380            "should not warn when parallel << max_connections"
1381        );
1382    }
1383
1384    #[test]
1385    fn connection_limit_no_warn_when_parallel_is_one() {
1386        let w = check_connection_limit(1, Some(5));
1387        assert!(
1388            w.is_none(),
1389            "single worker never triggers connection warning"
1390        );
1391    }
1392
1393    #[test]
1394    fn connection_limit_skipped_note_when_max_unknown_and_parallel_gt_one() {
1395        let w = check_connection_limit(100, None);
1396        assert!(w.is_some(), "should note that check was skipped");
1397        let msg = w.unwrap();
1398        assert!(msg.contains("skipped"), "got: {msg}");
1399    }
1400
1401    #[test]
1402    fn connection_limit_no_note_when_max_unknown_and_parallel_is_one() {
1403        let w = check_connection_limit(1, None);
1404        assert!(
1405            w.is_none(),
1406            "single worker never triggers connection warning"
1407        );
1408    }
1409
1410    #[test]
1411    fn connection_limit_suggests_headroom() {
1412        let w = check_connection_limit(25, Some(20)).unwrap();
1413        // Suggested safe max should be max_connections - 3 = 17
1414        assert!(
1415            w.contains("17"),
1416            "should suggest leaving headroom, got: {w}"
1417        );
1418    }
1419
1420    // ── v0.7.4: actionable hints next to categorised errors ───────────
1421
1422    fn src_hint(msg: &str, st: SourceType) -> Option<&'static str> {
1423        let err = anyhow::anyhow!("{}", msg);
1424        let cat = categorize_source_error(&err);
1425        source_error_hint(cat, &err, &st)
1426    }
1427
1428    fn dest_hint(msg: &str, dt: DestinationType) -> Option<&'static str> {
1429        let err = anyhow::anyhow!("{}", msg);
1430        let dest = DestinationConfig {
1431            destination_type: dt,
1432            bucket: Some("b".into()),
1433            ..Default::default()
1434        };
1435        let cat = categorize_dest_error(&err, &dest);
1436        destination_error_hint(cat, &dest)
1437    }
1438
1439    #[test]
1440    fn source_tls_handshake_returns_pg_specific_tls_hint() {
1441        let h = src_hint("TLS handshake failed", SourceType::Postgres).expect("hint");
1442        assert!(h.contains("tls.mode") && h.contains("ca_file"), "got: {h}");
1443    }
1444
1445    #[test]
1446    fn source_tls_handshake_returns_mysql_specific_tls_hint() {
1447        let h = src_hint("certificate verify failed", SourceType::Mysql).expect("hint");
1448        assert!(h.contains("tls.mode"), "got: {h}");
1449    }
1450
1451    #[test]
1452    fn source_auth_error_postgres_mentions_pg_hba() {
1453        let h = src_hint("password authentication failed", SourceType::Postgres).expect("hint");
1454        assert!(h.contains("pg_hba") && h.contains("SELECT"), "got: {h}");
1455    }
1456
1457    #[test]
1458    fn source_auth_error_mysql_mentions_grant() {
1459        let h = src_hint(
1460            "Access denied for user 'rivet'@'localhost'",
1461            SourceType::Mysql,
1462        )
1463        .expect("hint");
1464        assert!(h.contains("GRANT") && h.contains("FLUSH"), "got: {h}");
1465    }
1466
1467    #[test]
1468    fn source_connectivity_error_mentions_bastion_and_network() {
1469        let h = src_hint("connection refused", SourceType::Postgres).expect("hint");
1470        assert!(h.contains("bastion") || h.contains("VPN"), "got: {h}");
1471    }
1472
1473    #[test]
1474    fn source_unknown_error_returns_no_hint() {
1475        // Generic "error" category should yield no hint — better to
1476        // print the raw driver message than to mislead.
1477        let h = src_hint("totally unexpected", SourceType::Postgres);
1478        assert!(h.is_none(), "unknown errors should not produce a hint");
1479    }
1480
1481    #[test]
1482    fn dest_s3_auth_error_names_concrete_actions() {
1483        let h = dest_hint("permission denied", DestinationType::S3).expect("hint");
1484        assert!(
1485            h.contains("s3:PutObject") && h.contains("cloud-permissions"),
1486            "got: {h}"
1487        );
1488    }
1489
1490    #[test]
1491    fn dest_gcs_auth_error_names_concrete_actions() {
1492        let h = dest_hint("403 Forbidden", DestinationType::Gcs).expect("hint");
1493        assert!(
1494            h.contains("storage.objects") && h.contains("cloud-permissions"),
1495            "got: {h}"
1496        );
1497    }
1498
1499    #[test]
1500    fn categorize_dest_error_sas_expired_message_returns_sas_expired_category() {
1501        // Guard the load-bearing ordering in categorize_dest_error: the
1502        // "sas expired" early-return must fire before the generic "token"
1503        // branch, or destination_error_hint produces the wrong hint.
1504        // This test pins the *category string*, not just the final hint text.
1505        let err = anyhow::anyhow!(
1506            "Azure SAS token already expired (se=2024-01-01T00:00:00Z). Generate a new SAS and re-export."
1507        );
1508        let dest = DestinationConfig {
1509            destination_type: DestinationType::Azure,
1510            bucket: Some("c".into()),
1511            ..Default::default()
1512        };
1513        let cat = categorize_dest_error(&err, &dest);
1514        assert_eq!(
1515            cat, "sas expired",
1516            "expired-SAS error must categorise as 'sas expired', not '{cat}' — ordering in categorize_dest_error is load-bearing"
1517        );
1518    }
1519
1520    #[test]
1521    fn dest_azure_sas_expired_returns_regenerate_hint() {
1522        // The Azure preflight (v0.7.4) bails with "expired (se=…)" —
1523        // the hint must steer the operator to `az storage container
1524        // generate-sas` not "your IAM role is broken".
1525        let h = dest_hint(
1526            "Azure SAS token already expired (se=2024-01-01T00:00:00Z)",
1527            DestinationType::Azure,
1528        )
1529        .expect("hint");
1530        assert!(
1531            h.contains("generate-sas") && h.contains("AZURE_STORAGE_SAS_TOKEN"),
1532            "got: {h}"
1533        );
1534    }
1535
1536    #[test]
1537    fn dest_s3_bucket_not_found_says_no_auto_create() {
1538        let h = dest_hint("NoSuchBucket", DestinationType::S3).expect("hint");
1539        assert!(
1540            h.contains("does NOT auto-create") && h.contains("aws s3 mb"),
1541            "got: {h}"
1542        );
1543    }
1544
1545    #[test]
1546    fn dest_s3_connectivity_error_warns_about_region_mismatch() {
1547        let h = dest_hint("dns error", DestinationType::S3).expect("hint");
1548        assert!(h.contains("region") || h.contains("endpoint"), "got: {h}");
1549    }
1550}