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;
12pub(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;
23pub(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#[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 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
82impl 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
158pub(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
175fn 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
191fn 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
203pub(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 crate::source::warn_if_tls_disabled(&config.source);
248 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 for diag in &diagnostics {
271 println!("{}", serde_json::to_string(diag)?);
272 }
273 }
274 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 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 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 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 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 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 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 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 println!();
437 println!(
438 "Verdicts: EFFICIENT > ACCEPTABLE > DEGRADED > UNSAFE — advisory only; the run is never blocked."
439 );
440 if clean {
441 println!(
443 "Looks good. Next: rivet run -c {config_path} --validate # export, then verify row counts"
444 );
445 }
446 }
447
448 Ok(())
449}
450
451fn 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 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 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 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 #[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 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 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 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 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 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 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 let report = empty_report("orders");
704 let diag = sample_diagnostic("orders");
705 let v = merged_check_json(&report, &diag);
706
707 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 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 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 #[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 #[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 #[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 assert!(
1415 w.contains("17"),
1416 "should suggest leaving headroom, got: {w}"
1417 );
1418 }
1419
1420 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 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 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 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}