1use std::fmt;
2
3use serde::Serialize;
4
5use crate::error::CompatError;
6use crate::warnings::CompatWarning;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DiagnosticCode {
15 UnsupportedProtocol,
17 UnsupportedTransportWrapper,
19 UnsupportedFlag,
21 UnsupportedPlatform,
23 UnsupportedSecuritySensitiveLegacyFeature,
25 InvalidUriSyntax,
27 InvalidChainComposition,
29 MissingTarget,
31 MissingCredential,
33 InvalidCipherMethod,
35 BindFailure,
37 PrivilegeCapabilityMissing,
39 ExternalDependencyMissing,
41 RulefileError,
43 InvalidRegexPattern,
45 FancyRegexBackend,
47 UriPreservedUnsupportedComponent,
49 H2HandshakeFailure,
50 H2ConnectRejected,
51 H2StreamReset,
52 H2GoawayReceived,
53 H2PoolExhausted,
54 H2FlowControlStall,
55 H2AuthFailure,
56 H2UnsupportedCleartext,
57 H2TlsAlpnMismatch,
58}
59
60impl fmt::Display for DiagnosticCode {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 let label = match self {
63 Self::UnsupportedProtocol => "unsupported_protocol",
64 Self::UnsupportedTransportWrapper => "unsupported_transport_wrapper",
65 Self::UnsupportedFlag => "unsupported_flag",
66 Self::UnsupportedPlatform => "unsupported_platform",
67 Self::UnsupportedSecuritySensitiveLegacyFeature => {
68 "unsupported_security_sensitive_legacy_feature"
69 }
70 Self::InvalidUriSyntax => "invalid_uri_syntax",
71 Self::InvalidChainComposition => "invalid_chain_composition",
72 Self::MissingTarget => "missing_target",
73 Self::MissingCredential => "missing_credential",
74 Self::InvalidCipherMethod => "invalid_cipher_method",
75 Self::BindFailure => "bind_failure",
76 Self::PrivilegeCapabilityMissing => "privilege_capability_missing",
77 Self::ExternalDependencyMissing => "external_dependency_missing",
78 Self::RulefileError => "rulefile_error",
79 Self::InvalidRegexPattern => "invalid_regex_pattern",
80 Self::FancyRegexBackend => "fancy_regex_backend",
81 Self::UriPreservedUnsupportedComponent => "uri_preserved_unsupported_component",
82 Self::H2HandshakeFailure => "h2_handshake_failure",
83 Self::H2ConnectRejected => "h2_connect_rejected",
84 Self::H2StreamReset => "h2_stream_reset",
85 Self::H2GoawayReceived => "h2_goaway_received",
86 Self::H2PoolExhausted => "h2_pool_exhausted",
87 Self::H2FlowControlStall => "h2_flow_control_stall",
88 Self::H2AuthFailure => "h2_auth_failure",
89 Self::H2UnsupportedCleartext => "h2_unsupported_cleartext",
90 Self::H2TlsAlpnMismatch => "h2_tls_alpn_mismatch",
91 };
92 f.write_str(label)
93 }
94}
95
96#[derive(Debug, Clone, Serialize)]
102pub struct StructuredDiagnostic {
103 pub code: DiagnosticCode,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub feature_id: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub tier: Option<String>,
112 pub message: String,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub suggestion: Option<String>,
117}
118
119impl fmt::Display for StructuredDiagnostic {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 write!(f, "[{}] {}", self.code, self.message)?;
122 if let Some(ref tier) = self.tier {
123 write!(f, " (tier: {})", tier)?;
124 }
125 if let Some(ref suggestion) = self.suggestion {
126 write!(f, " — suggestion: {}", suggestion)?;
127 }
128 Ok(())
129 }
130}
131
132impl From<CompatError> for StructuredDiagnostic {
133 fn from(err: CompatError) -> Self {
134 match err {
135 CompatError::UnsupportedProtocol(proto) => StructuredDiagnostic {
136 code: DiagnosticCode::UnsupportedProtocol,
137 feature_id: None,
138 tier: Some("unsupported".to_string()),
139 message: format!("unsupported protocol: {}", proto),
140 suggestion: Some("use http, socks4, socks5, trojan, or ss".to_string()),
141 },
142 CompatError::UnsupportedFeature { feature, detail } => {
143 let (code, tier, suggestion) = classify_unsupported_feature(feature);
144 StructuredDiagnostic {
145 code,
146 feature_id: Some(feature.to_string()),
147 tier: Some(tier.to_string()),
148 message: detail,
149 suggestion: suggestion.map(String::from),
150 }
151 }
152 CompatError::InvalidUri { message } => StructuredDiagnostic {
153 code: DiagnosticCode::InvalidUriSyntax,
154 feature_id: None,
155 tier: None,
156 message,
157 suggestion: None,
158 },
159 CompatError::InvalidArgs { message } => StructuredDiagnostic {
160 code: DiagnosticCode::InvalidUriSyntax,
161 feature_id: None,
162 tier: None,
163 message,
164 suggestion: None,
165 },
166 CompatError::ConfigValidation { message } => StructuredDiagnostic {
167 code: DiagnosticCode::InvalidChainComposition,
168 feature_id: None,
169 tier: None,
170 message,
171 suggestion: None,
172 },
173 CompatError::MissingArgument(flag) => StructuredDiagnostic {
174 code: DiagnosticCode::MissingTarget,
175 feature_id: None,
176 tier: None,
177 message: format!("missing required argument: {}", flag),
178 suggestion: None,
179 },
180 }
181 }
182}
183
184impl From<&CompatWarning> for StructuredDiagnostic {
185 fn from(warn: &CompatWarning) -> Self {
186 match warn.category {
187 "unknown-flag" => StructuredDiagnostic {
188 code: DiagnosticCode::UnsupportedFlag,
189 feature_id: None,
190 tier: Some("unsupported".to_string()),
191 message: warn.message.clone(),
192 suggestion: None,
193 },
194 "direct-mode" => StructuredDiagnostic {
195 code: DiagnosticCode::MissingTarget,
196 feature_id: None,
197 tier: Some("compatible_with_warning".to_string()),
198 message: warn.message.clone(),
199 suggestion: Some("add -r <upstream-uri> for proxied connections".to_string()),
200 },
201 "credential-in-toml" => StructuredDiagnostic {
202 code: DiagnosticCode::MissingCredential,
203 feature_id: None,
204 tier: Some("compatible_with_warning".to_string()),
205 message: warn.message.clone(),
206 suggestion: Some(
207 "use secret sources or environment variables for credentials".to_string(),
208 ),
209 },
210 "verbose-mode" => StructuredDiagnostic {
211 code: DiagnosticCode::UnsupportedFlag,
212 feature_id: Some("verbose".to_string()),
213 tier: Some("compatible_with_warning".to_string()),
214 message: warn.message.clone(),
215 suggestion: Some("set RUST_LOG=debug".to_string()),
216 },
217 "debug-mode" => StructuredDiagnostic {
218 code: DiagnosticCode::UnsupportedFlag,
219 feature_id: Some("debug".to_string()),
220 tier: Some("compatible_with_warning".to_string()),
221 message: warn.message.clone(),
222 suggestion: Some(
223 "Eggress selects debug-level default tracing; set RUST_LOG explicitly to override it"
224 .to_string(),
225 ),
226 },
227 "scheduler" => StructuredDiagnostic {
228 code: DiagnosticCode::UnsupportedFlag,
229 feature_id: Some("scheduler".to_string()),
230 tier: Some("compatible_with_warning".to_string()),
231 message: warn.message.clone(),
232 suggestion: Some(
233 "use first-available, round-robin, or least-connections".to_string(),
234 ),
235 },
236 "alive-check" => StructuredDiagnostic {
237 code: DiagnosticCode::UnsupportedFlag,
238 feature_id: Some("alive".to_string()),
239 tier: Some("native_equivalent".to_string()),
240 message: warn.message.clone(),
241 suggestion: Some("configure health probes in eggress TOML".to_string()),
242 },
243 "ul-no-listener" => StructuredDiagnostic {
244 code: DiagnosticCode::MissingTarget,
245 feature_id: None,
246 tier: Some("compatible_with_warning".to_string()),
247 message: warn.message.clone(),
248 suggestion: None,
249 },
250 "pac-serving" => StructuredDiagnostic {
251 code: DiagnosticCode::UnsupportedFlag,
252 feature_id: Some("pac".to_string()),
253 tier: Some("compatible_with_warning".to_string()),
254 message: warn.message.clone(),
255 suggestion: Some(
256 "configure PAC serving in eggress TOML admin.pac block".to_string(),
257 ),
258 },
259 "test-mode" => StructuredDiagnostic {
260 code: DiagnosticCode::UnsupportedFlag,
261 feature_id: Some("test".to_string()),
262 tier: Some("native_equivalent".to_string()),
263 message: warn.message.clone(),
264 suggestion: Some("use 'eggress upstream test -c <config>'".to_string()),
265 },
266 "system-proxy" => StructuredDiagnostic {
267 code: DiagnosticCode::UnsupportedFlag,
268 feature_id: Some("sys".to_string()),
269 tier: Some("compatible_with_warning".to_string()),
270 message: warn.message.clone(),
271 suggestion: Some(
272 "compatibility mode restores prior settings after --sys".to_string(),
273 ),
274 },
275 "auth-timeout" => StructuredDiagnostic {
276 code: DiagnosticCode::UnsupportedFlag,
277 feature_id: Some("auth".to_string()),
278 tier: Some("compatible_with_warning".to_string()),
279 message: warn.message.clone(),
280 suggestion: Some(
281 "configure listener credentials to enable source-IP reuse".to_string(),
282 ),
283 },
284 "log-file" => StructuredDiagnostic {
285 code: DiagnosticCode::UnsupportedFlag,
286 feature_id: Some("log".to_string()),
287 tier: Some("compatible_with_warning".to_string()),
288 message: warn.message.clone(),
289 suggestion: Some(
290 "redirect stderr with shell redirection for file logging".to_string(),
291 ),
292 },
293 "reuse-port" => StructuredDiagnostic {
294 code: DiagnosticCode::UnsupportedFlag,
295 feature_id: Some("reuse".to_string()),
296 tier: Some("native_equivalent".to_string()),
297 message: warn.message.clone(),
298 suggestion: Some("SO_REUSEPORT applied to listener sockets".to_string()),
299 },
300 "get-static-content" => StructuredDiagnostic {
301 code: DiagnosticCode::UnsupportedFlag,
302 feature_id: Some("get".to_string()),
303 tier: Some("native_equivalent".to_string()),
304 message: warn.message.clone(),
305 suggestion: Some(
306 "configure the same PATH,FILE pair as static content in the Eggress admin server"
307 .to_string(),
308 ),
309 },
310 "rulefile-read" | "rulefile-parse" | "rulefile-partial" => StructuredDiagnostic {
311 code: DiagnosticCode::RulefileError,
312 feature_id: Some("rulefile".to_string()),
313 tier: Some("compatible_with_warning".to_string()),
314 message: warn.message.clone(),
315 suggestion: Some(
316 "configure rules in eggress TOML [[rules]] with structured matchers"
317 .to_string(),
318 ),
319 },
320 "chain-unsupported-hop" => StructuredDiagnostic {
321 code: DiagnosticCode::UnsupportedProtocol,
322 feature_id: Some("chain".to_string()),
323 tier: Some("unsupported".to_string()),
324 message: warn.message.clone(),
325 suggestion: Some(
326 "remove unsupported hops or use multi-r flag for alternatives".to_string(),
327 ),
328 },
329 "chain-backward-composition" => StructuredDiagnostic {
330 code: DiagnosticCode::UnsupportedProtocol,
331 feature_id: Some("chain".to_string()),
332 tier: Some("unsupported".to_string()),
333 message: warn.message.clone(),
334 suggestion: Some(
335 "use single-hop backward (+in) or split into separate -r flags".to_string(),
336 ),
337 },
338 _ => StructuredDiagnostic {
339 code: DiagnosticCode::UnsupportedFlag,
340 feature_id: None,
341 tier: None,
342 message: warn.message.clone(),
343 suggestion: None,
344 },
345 }
346 }
347}
348
349pub fn h2_diagnostic(code: DiagnosticCode, message: impl Into<String>) -> StructuredDiagnostic {
350 StructuredDiagnostic {
351 code,
352 feature_id: Some("h2".to_string()),
353 tier: Some("drop_in".to_string()),
354 message: message.into(),
355 suggestion: None,
356 }
357}
358
359pub fn h2_diagnostic_with_suggestion(
360 code: DiagnosticCode,
361 message: impl Into<String>,
362 suggestion: impl Into<String>,
363) -> StructuredDiagnostic {
364 StructuredDiagnostic {
365 code,
366 feature_id: Some("h2".to_string()),
367 tier: Some("drop_in".to_string()),
368 message: message.into(),
369 suggestion: Some(suggestion.into()),
370 }
371}
372
373impl CompatWarning {
374 pub fn diagnostic_code(&self) -> DiagnosticCode {
376 StructuredDiagnostic::from(self).code
377 }
378}
379
380fn classify_unsupported_feature(
383 feature: &str,
384) -> (DiagnosticCode, &'static str, Option<&'static str>) {
385 match feature {
386 "daemon" | "backward-jump-chain" | "backward-tls" => (
387 DiagnosticCode::UnsupportedFlag,
388 "unsupported",
389 Some("configure this via eggress TOML"),
390 ),
391 "system-proxy" => (
392 DiagnosticCode::UnsupportedFlag,
393 "compatible_with_warning",
394 Some("compatibility mode restores prior settings after --sys"),
395 ),
396 "auth-timeout" => (
397 DiagnosticCode::UnsupportedFlag,
398 "compatible_with_warning",
399 Some("configure listener credentials to enable source-IP reuse"),
400 ),
401 "chain-unsupported-hop" | "chain-backward-composition" => (
402 DiagnosticCode::UnsupportedProtocol,
403 "unsupported",
404 Some("remove unsupported hops or use multi-r flag for alternatives"),
405 ),
406 "ssr-listener" | "ssr-upstream" | "ssr-udp" => (
410 DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature,
411 "intentional_non_parity",
412 Some("use standard Shadowsocks (ss://) with AEAD methods"),
413 ),
414 "trojan-listener" => (
415 DiagnosticCode::UnsupportedProtocol,
416 "unsupported",
417 Some("Trojan listeners require TLS material via --ssl cert,key"),
418 ),
419 "ssh-listener" => (
420 DiagnosticCode::UnsupportedProtocol,
421 "intentional_non_parity",
422 Some("SSH is upstream-only; enable the ssh feature for pproxy-compatible transport or use OpenSSH dynamic forwarding (ssh -D)"),
423 ),
424 "ssh-upstream" => (
425 DiagnosticCode::UnsupportedProtocol,
426 "intentional_non_parity",
427 Some("SSH upstream transport requires the optional ssh feature"),
428 ),
429 "unix-upstream" | "redir-upstream"
430 | "direct-listener" => (DiagnosticCode::UnsupportedProtocol, "unsupported", None),
431 "socks4-bind" => (
432 DiagnosticCode::UnsupportedProtocol,
433 "unsupported",
434 Some("SOCKS4 BIND is not implemented; pproxy also does not implement SOCKS4 BIND"),
435 ),
436 "socks5-bind" => (
437 DiagnosticCode::UnsupportedProtocol,
438 "unsupported",
439 Some("SOCKS5 BIND is not implemented; pproxy also does not implement SOCKS5 BIND"),
440 ),
441 "udp-http-transport" | "udp-https-transport" => (
442 DiagnosticCode::UnsupportedProtocol,
443 "unsupported",
444 Some("use direct://, socks5://, or ss:// for UDP upstreams"),
445 ),
446 "udp-socks4-transport" | "udp-socks4a-transport" => (
447 DiagnosticCode::UnsupportedProtocol,
448 "unsupported",
449 Some("SOCKS4 does not support UDP; use socks5:// for UDP upstreams"),
450 ),
451 "udp-trojan-transport" => (
452 DiagnosticCode::UnsupportedProtocol,
453 "unsupported",
454 Some("Trojan does not support UDP; use direct://, socks5://, or ss://"),
455 ),
456 "udp-multihop" => (
457 DiagnosticCode::UnsupportedProtocol,
458 "unsupported",
459 Some("UDP multi-hop chains are not supported; use single-hop upstreams"),
460 ),
461 "trojan-no-password" => (
462 DiagnosticCode::UnsupportedProtocol,
463 "unsupported",
464 Some("provide a password in the Trojan URI: trojan://password@host:port"),
465 ),
466 "scheme" => (
467 DiagnosticCode::UnsupportedProtocol,
468 "unsupported",
469 Some("use a recognized protocol scheme"),
470 ),
471 "legacy-cipher" => (
472 DiagnosticCode::InvalidCipherMethod,
473 "unsupported",
474 Some("enable the optional legacy-crypto feature or use an AEAD method: aes-128-gcm, aes-192-gcm, aes-256-gcm, chacha20-ietf-poly1305"),
475 ),
476 _ => (DiagnosticCode::UnsupportedFlag, "unsupported", None),
477 }
478}
479
480pub fn classify_unsupported_feature_code(feature: &str) -> DiagnosticCode {
485 classify_unsupported_feature_inner(feature)
486}
487
488pub fn classify_unsupported_feature_tier(feature: &str) -> &'static str {
494 let (_, tier, _) = classify_unsupported_feature(feature);
495 tier
496}
497
498fn classify_unsupported_feature_inner(feature: &str) -> DiagnosticCode {
499 classify_unsupported_feature(feature).0
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::error::CompatError;
506 use crate::warnings::CompatWarning;
507
508 #[test]
509 fn diagnostic_code_display_is_snake_case() {
510 assert_eq!(
511 DiagnosticCode::UnsupportedProtocol.to_string(),
512 "unsupported_protocol"
513 );
514 assert_eq!(
515 DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature.to_string(),
516 "unsupported_security_sensitive_legacy_feature"
517 );
518 assert_eq!(
519 DiagnosticCode::InvalidUriSyntax.to_string(),
520 "invalid_uri_syntax"
521 );
522 assert_eq!(DiagnosticCode::MissingTarget.to_string(), "missing_target");
523 assert_eq!(
524 DiagnosticCode::MissingCredential.to_string(),
525 "missing_credential"
526 );
527 assert_eq!(
528 DiagnosticCode::InvalidCipherMethod.to_string(),
529 "invalid_cipher_method"
530 );
531 assert_eq!(DiagnosticCode::BindFailure.to_string(), "bind_failure");
532 assert_eq!(
533 DiagnosticCode::PrivilegeCapabilityMissing.to_string(),
534 "privilege_capability_missing"
535 );
536 assert_eq!(
537 DiagnosticCode::ExternalDependencyMissing.to_string(),
538 "external_dependency_missing"
539 );
540 }
541
542 #[test]
543 fn diagnostic_code_serializes_to_snake_case() {
544 let code = DiagnosticCode::UnsupportedProtocol;
545 let json = serde_json::to_string(&code).unwrap();
546 assert_eq!(json, "\"unsupported_protocol\"");
547 }
548
549 #[test]
550 fn structured_diagnostic_display_includes_code_and_message() {
551 let diag = StructuredDiagnostic {
552 code: DiagnosticCode::UnsupportedProtocol,
553 feature_id: None,
554 tier: Some("unsupported".to_string()),
555 message: "unsupported protocol: ftp".to_string(),
556 suggestion: None,
557 };
558 let s = diag.to_string();
559 assert!(s.contains("[unsupported_protocol]"));
560 assert!(s.contains("unsupported protocol: ftp"));
561 assert!(s.contains("tier: unsupported"));
562 }
563
564 #[test]
565 fn structured_diagnostic_display_includes_suggestion() {
566 let diag = StructuredDiagnostic {
567 code: DiagnosticCode::InvalidCipherMethod,
568 feature_id: None,
569 tier: Some("intentional_non_parity".to_string()),
570 message: "legacy cipher".to_string(),
571 suggestion: Some("use AEAD".to_string()),
572 };
573 let s = diag.to_string();
574 assert!(s.contains("suggestion: use AEAD"));
575 }
576
577 #[test]
578 fn from_unsupported_protocol_error() {
579 let err = CompatError::UnsupportedProtocol("ftp".to_string());
580 let diag = StructuredDiagnostic::from(err);
581 assert_eq!(diag.code, DiagnosticCode::UnsupportedProtocol);
582 assert_eq!(diag.tier.as_deref(), Some("unsupported"));
583 assert!(diag.suggestion.is_some());
584 }
585
586 #[test]
587 fn from_unsupported_feature_daemon() {
588 let err = CompatError::unsupported("daemon", "--daemon not supported");
589 let diag = StructuredDiagnostic::from(err);
590 assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
591 assert_eq!(diag.feature_id.as_deref(), Some("daemon"));
592 }
593
594 #[test]
595 fn from_unsupported_feature_ssr() {
596 let err = CompatError::unsupported("ssr-listener", "SSR not supported");
597 let diag = StructuredDiagnostic::from(err);
598 assert_eq!(
599 diag.code,
600 DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature
601 );
602 assert_eq!(diag.tier.as_deref(), Some("intentional_non_parity"));
603 }
604
605 #[test]
606 fn from_unsupported_feature_legacy_cipher() {
607 let err = CompatError::unsupported("legacy-cipher", "aes-128-ctr not supported");
608 let diag = StructuredDiagnostic::from(err);
609 assert_eq!(diag.code, DiagnosticCode::InvalidCipherMethod);
610 }
611
612 #[test]
613 fn from_invalid_uri_error() {
614 let err = CompatError::InvalidUri {
615 message: "bad host".to_string(),
616 };
617 let diag = StructuredDiagnostic::from(err);
618 assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
619 }
620
621 #[test]
622 fn from_invalid_args_error() {
623 let err = CompatError::InvalidArgs {
624 message: "no listener".to_string(),
625 };
626 let diag = StructuredDiagnostic::from(err);
627 assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
628 }
629
630 #[test]
631 fn from_config_validation_error() {
632 let err = CompatError::ConfigValidation {
633 message: "conflict".to_string(),
634 };
635 let diag = StructuredDiagnostic::from(err);
636 assert_eq!(diag.code, DiagnosticCode::InvalidChainComposition);
637 }
638
639 #[test]
640 fn from_missing_argument_error() {
641 let err = CompatError::MissingArgument("-l".to_string());
642 let diag = StructuredDiagnostic::from(err);
643 assert_eq!(diag.code, DiagnosticCode::MissingTarget);
644 }
645
646 #[test]
647 fn from_unknown_flag_warning() {
648 let warn = CompatWarning {
649 category: "unknown-flag",
650 message: "unrecognized flag '--foo'".to_string(),
651 };
652 let diag = StructuredDiagnostic::from(&warn);
653 assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
654 }
655
656 #[test]
657 fn from_direct_mode_warning() {
658 let warn = CompatWarning {
659 category: "direct-mode",
660 message: "no upstream".to_string(),
661 };
662 let diag = StructuredDiagnostic::from(&warn);
663 assert_eq!(diag.code, DiagnosticCode::MissingTarget);
664 assert!(diag.suggestion.is_some());
665 }
666
667 #[test]
668 fn from_credential_warning() {
669 let warn = CompatWarning {
670 category: "credential-in-toml",
671 message: "plaintext creds".to_string(),
672 };
673 let diag = StructuredDiagnostic::from(&warn);
674 assert_eq!(diag.code, DiagnosticCode::MissingCredential);
675 }
676
677 #[test]
678 fn from_verbose_warning() {
679 let warn = CompatWarning {
680 category: "verbose-mode",
681 message: "use RUST_LOG".to_string(),
682 };
683 let diag = StructuredDiagnostic::from(&warn);
684 assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
685 assert_eq!(diag.feature_id.as_deref(), Some("verbose"));
686 assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
687 }
688
689 #[test]
690 fn from_debug_warning_uses_debug_feature_and_tier() {
691 let warn = CompatWarning {
692 category: "debug-mode",
693 message: "debug tracing difference".to_string(),
694 };
695 let diag = StructuredDiagnostic::from(&warn);
696 assert_eq!(diag.feature_id.as_deref(), Some("debug"));
697 assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
698 }
699
700 #[test]
701 fn touched_warning_categories_have_consistent_tiers() {
702 let categories = [
703 ("debug-mode", "debug"),
704 ("verbose-mode", "verbose"),
705 ("pac-serving", "pac"),
706 ("get-static-content", "get"),
707 ("test-mode", "test"),
708 ];
709 for (category, feature_id) in categories {
710 let warn = CompatWarning {
711 category,
712 message: "test diagnostic".to_string(),
713 };
714 let diagnostic = StructuredDiagnostic::from(&warn);
715 assert_eq!(diagnostic.feature_id.as_deref(), Some(feature_id));
716 assert_eq!(
717 diagnostic.tier.as_deref(),
718 Some(crate::manifest_tier_for_category(category).as_str()),
719 "tier mismatch for {category}"
720 );
721 }
722 }
723
724 #[test]
725 fn from_unknown_category_warning() {
726 let warn = CompatWarning {
727 category: "some-new-category",
728 message: "something happened".to_string(),
729 };
730 let diag = StructuredDiagnostic::from(&warn);
731 assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
732 }
733
734 #[test]
735 fn warning_diagnostic_code_method() {
736 let warn = CompatWarning {
737 category: "direct-mode",
738 message: "no upstream".to_string(),
739 };
740 assert_eq!(warn.diagnostic_code(), DiagnosticCode::MissingTarget);
741 }
742
743 #[test]
744 fn structured_diagnostic_json_roundtrip() {
745 let diag = StructuredDiagnostic {
746 code: DiagnosticCode::UnsupportedProtocol,
747 feature_id: Some("ssh-upstream".to_string()),
748 tier: Some("unsupported".to_string()),
749 message: "SSH not supported".to_string(),
750 suggestion: None,
751 };
752 let json = serde_json::to_value(&diag).unwrap();
753 assert_eq!(json["code"], "unsupported_protocol");
754 assert_eq!(json["feature_id"], "ssh-upstream");
755 assert_eq!(json["tier"], "unsupported");
756 assert_eq!(json["message"], "SSH not supported");
757 assert!(json.get("suggestion").is_none());
758 }
759
760 #[test]
761 fn all_diagnostic_codes_serialize() {
762 let codes = [
763 DiagnosticCode::UnsupportedProtocol,
764 DiagnosticCode::UnsupportedTransportWrapper,
765 DiagnosticCode::UnsupportedFlag,
766 DiagnosticCode::UnsupportedPlatform,
767 DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature,
768 DiagnosticCode::InvalidUriSyntax,
769 DiagnosticCode::InvalidChainComposition,
770 DiagnosticCode::MissingTarget,
771 DiagnosticCode::MissingCredential,
772 DiagnosticCode::InvalidCipherMethod,
773 DiagnosticCode::BindFailure,
774 DiagnosticCode::PrivilegeCapabilityMissing,
775 DiagnosticCode::ExternalDependencyMissing,
776 DiagnosticCode::RulefileError,
777 DiagnosticCode::InvalidRegexPattern,
778 DiagnosticCode::FancyRegexBackend,
779 DiagnosticCode::UriPreservedUnsupportedComponent,
780 DiagnosticCode::H2HandshakeFailure,
781 DiagnosticCode::H2ConnectRejected,
782 DiagnosticCode::H2StreamReset,
783 DiagnosticCode::H2GoawayReceived,
784 DiagnosticCode::H2PoolExhausted,
785 DiagnosticCode::H2FlowControlStall,
786 DiagnosticCode::H2AuthFailure,
787 DiagnosticCode::H2UnsupportedCleartext,
788 DiagnosticCode::H2TlsAlpnMismatch,
789 ];
790 for code in &codes {
791 let json = serde_json::to_string(code).unwrap();
792 assert!(json.starts_with('"'));
794 assert!(json.ends_with('"'));
795 }
796 }
797
798 #[test]
806 fn structured_diagnostic_from_unsupported_protocol_never_leaks_credentials() {
807 let err = CompatError::UnsupportedProtocol("ssh".to_string());
808 let diag = StructuredDiagnostic::from(err);
809 assert!(!diag.message.contains("@"));
810 assert!(diag.suggestion.is_some());
811 }
812
813 #[test]
814 fn structured_diagnostic_from_unsupported_feature_never_leaks_credentials() {
815 let err = CompatError::unsupported("daemon", "--daemon not supported");
816 let diag = StructuredDiagnostic::from(err);
817 assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
818 assert!(!diag.message.contains("@"));
819 }
820
821 #[test]
822 fn structured_diagnostic_from_invalid_uri_never_leaks_credentials() {
823 let err = CompatError::InvalidUri {
824 message: "missing port in endpoint".to_string(),
825 };
826 let diag = StructuredDiagnostic::from(err);
827 assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
828 assert!(!diag.message.contains("@"));
829 }
830
831 #[test]
832 fn structured_diagnostic_json_excludes_optional_none_fields() {
833 let diag = StructuredDiagnostic {
834 code: DiagnosticCode::UnsupportedProtocol,
835 feature_id: None,
836 tier: None,
837 message: "test".to_string(),
838 suggestion: None,
839 };
840 let json = serde_json::to_value(&diag).unwrap();
841 assert!(json.get("feature_id").is_none());
842 assert!(json.get("tier").is_none());
843 assert!(json.get("suggestion").is_none());
844 }
845
846 #[test]
847 fn compat_warning_display_never_leaks_credentials() {
848 let warn = CompatWarning {
849 category: "credential-in-toml",
850 message: "Listener 'pproxy-local-0' has plaintext credentials in generated TOML"
851 .to_string(),
852 };
853 let display = warn.to_string();
854 assert!(display.contains("[credential-in-toml]"));
855 assert!(!display.contains("@"));
856 }
857
858 #[test]
859 fn h2_diagnostic_code_display() {
860 assert_eq!(
861 DiagnosticCode::H2HandshakeFailure.to_string(),
862 "h2_handshake_failure"
863 );
864 assert_eq!(
865 DiagnosticCode::H2ConnectRejected.to_string(),
866 "h2_connect_rejected"
867 );
868 assert_eq!(DiagnosticCode::H2StreamReset.to_string(), "h2_stream_reset");
869 assert_eq!(
870 DiagnosticCode::H2GoawayReceived.to_string(),
871 "h2_goaway_received"
872 );
873 assert_eq!(
874 DiagnosticCode::H2PoolExhausted.to_string(),
875 "h2_pool_exhausted"
876 );
877 assert_eq!(
878 DiagnosticCode::H2FlowControlStall.to_string(),
879 "h2_flow_control_stall"
880 );
881 assert_eq!(DiagnosticCode::H2AuthFailure.to_string(), "h2_auth_failure");
882 assert_eq!(
883 DiagnosticCode::H2UnsupportedCleartext.to_string(),
884 "h2_unsupported_cleartext"
885 );
886 assert_eq!(
887 DiagnosticCode::H2TlsAlpnMismatch.to_string(),
888 "h2_tls_alpn_mismatch"
889 );
890 }
891
892 #[test]
893 fn h2_diagnostic_helper() {
894 let diag = h2_diagnostic(DiagnosticCode::H2HandshakeFailure, "handshake failed");
895 assert_eq!(diag.code, DiagnosticCode::H2HandshakeFailure);
896 assert_eq!(diag.feature_id.as_deref(), Some("h2"));
897 assert_eq!(diag.tier.as_deref(), Some("drop_in"));
898 assert_eq!(diag.message, "handshake failed");
899 assert!(diag.suggestion.is_none());
900 }
901
902 #[test]
903 fn h2_diagnostic_with_suggestion_helper() {
904 let diag = h2_diagnostic_with_suggestion(
905 DiagnosticCode::H2TlsAlpnMismatch,
906 "ALPN mismatch",
907 "use h2 ALPN",
908 );
909 assert_eq!(diag.code, DiagnosticCode::H2TlsAlpnMismatch);
910 assert_eq!(diag.feature_id.as_deref(), Some("h2"));
911 assert_eq!(diag.tier.as_deref(), Some("drop_in"));
912 assert_eq!(diag.message, "ALPN mismatch");
913 assert_eq!(diag.suggestion.as_deref(), Some("use h2 ALPN"));
914 }
915
916 #[test]
917 fn diagnostic_code_display_never_contains_credentials() {
918 let codes = [
919 DiagnosticCode::UnsupportedProtocol,
920 DiagnosticCode::UnsupportedFlag,
921 DiagnosticCode::InvalidUriSyntax,
922 DiagnosticCode::MissingTarget,
923 DiagnosticCode::MissingCredential,
924 DiagnosticCode::InvalidCipherMethod,
925 DiagnosticCode::BindFailure,
926 ];
927 for code in &codes {
928 let display = code.to_string();
929 assert!(
930 !display.contains("@"),
931 "code display contains @: {}",
932 display
933 );
934 }
935 }
936}