Skip to main content

eggress_pproxy_compat/
args.rs

1use std::time::Duration;
2
3use crate::error::CompatError;
4use crate::uri::{PproxyChain, PproxyUri};
5use crate::warnings::{CompatWarning, TranslationOutput};
6
7fn take_required_value(
8    raw: &[String],
9    index: &mut usize,
10    flag: &str,
11) -> Result<String, CompatError> {
12    *index += 1;
13    let value = raw
14        .get(*index)
15        .cloned()
16        .ok_or_else(|| CompatError::MissingArgument(format!("{flag} requires a value")))?;
17    if value.starts_with('-') && !(matches!(flag, "-a" | "--auth") && value.parse::<i64>().is_ok())
18    {
19        return Err(CompatError::MissingArgument(format!(
20            "{flag} requires a value"
21        )));
22    }
23    Ok(value)
24}
25
26fn parse_auth_duration(value: &str) -> Result<Duration, CompatError> {
27    let trimmed = value.trim();
28    if trimmed.is_empty() {
29        return Err(CompatError::InvalidArgs {
30            message: "--auth requires a non-empty numeric value".to_string(),
31        });
32    }
33    let seconds: i64 = trimmed.parse().map_err(|_| CompatError::InvalidArgs {
34        message: format!("--auth value '{}' is not a valid integer", trimmed),
35    })?;
36    // argparse accepts negative integers. pproxy's AuthTable then expires
37    // those entries immediately, so preserve that behavior as a zero-length
38    // Rust duration instead of inventing a parser-side upper/lower bound.
39    Ok(Duration::from_secs(seconds.max(0) as u64))
40}
41
42/// Parsed pproxy-compatible CLI arguments.
43#[derive(Debug, Clone)]
44pub struct PproxyArgs {
45    /// Local listener URIs (from `-l` flags).
46    pub local: Vec<String>,
47    /// Remote/upstream URIs (from `-r` flags).
48    pub remotes: Vec<String>,
49    /// Verbosity level derived from `-v`/`-vv`/`-vvv` flags.
50    pub verbose_level: u8,
51    /// `-d` flag: debug-level compatibility diagnostics.
52    pub debug: bool,
53    /// Number of `-d` occurrences, including clustered short options.
54    pub debug_level: u8,
55    /// `--daemon` flag: daemon mode request (unsupported).
56    pub daemon: bool,
57    /// `--reuse` flag: listener SO_REUSEPORT.
58    pub reuse_port: bool,
59    /// `--auth <seconds>`: per-client authentication reuse interval.
60    pub auth_timeout: Option<Duration>,
61    /// `--sys` flag: apply the selected compatibility listener as the system proxy.
62    pub system_proxy: bool,
63    /// `-h`/`--help` was requested.
64    pub help: bool,
65    /// `--version` was requested.
66    pub version: bool,
67    /// Known-but-unsupported flags that require a translation decision.
68    pub known_unsupported: Vec<String>,
69    /// Unknown flags that are not recognized.
70    pub unknown_flags: Vec<String>,
71    /// Options accepted by the migration translator but not by the frozen
72    /// pproxy 2.7.9 parser, plus positional arguments and long aliases.
73    /// Compatibility execution treats these as parser errors.
74    pub strict_violations: Vec<String>,
75}
76
77impl PproxyArgs {
78    /// Check whether any arguments were provided.
79    pub fn has_args(raw: &[String]) -> bool {
80        !raw.is_empty()
81    }
82
83    /// Return the target supplied to `--test`, preserving the parser's
84    /// value-taking semantics for both compatibility execution entry points.
85    pub fn test_target(&self) -> Option<&str> {
86        self.known_unsupported
87            .iter()
88            .find_map(|flag| flag.strip_prefix("test="))
89    }
90
91    /// Create default pproxy args equivalent to running `pproxy` with no arguments.
92    ///
93    /// Real pproxy defaults to a mixed HTTP/SOCKS4/SOCKS5 listener on `:8080`
94    /// with direct routing.
95    pub fn default_args() -> Self {
96        Self {
97            local: vec!["http+socks4+socks5://:8080".to_string()],
98            remotes: vec![],
99            verbose_level: 0,
100            debug: false,
101            debug_level: 0,
102            daemon: false,
103            reuse_port: false,
104            auth_timeout: None,
105            system_proxy: false,
106            help: false,
107            version: false,
108            known_unsupported: vec![],
109            unknown_flags: vec![],
110            strict_violations: vec![],
111        }
112    }
113
114    /// Parse from raw argument list (excluding argv[0]).
115    pub fn parse(raw: &[String]) -> Result<Self, CompatError> {
116        let mut local = Vec::new();
117        let mut remotes = Vec::new();
118        let mut verbose_level: u8 = 0;
119        let mut debug = false;
120        let mut debug_level: u8 = 0;
121        let mut daemon = false;
122        let mut reuse_port = false;
123        let mut auth_timeout: Option<Duration> = None;
124        let mut system_proxy = false;
125        let mut help = false;
126        let mut version = false;
127        let mut known_unsupported = Vec::new();
128        let mut unknown_flags = Vec::new();
129        let mut strict_violations = Vec::new();
130        let mut i = 0;
131
132        while i < raw.len() {
133            let arg = &raw[i];
134            match arg.as_str() {
135                "-h" | "--help" => {
136                    help = true;
137                }
138                "--version" => {
139                    version = true;
140                }
141                "-l" | "--listen" => {
142                    if arg == "--listen" {
143                        strict_violations.push(arg.clone());
144                    }
145                    local.push(take_required_value(raw, &mut i, arg)?);
146                }
147                "-r" | "--remote" => {
148                    if arg == "--remote" {
149                        strict_violations.push(arg.clone());
150                    }
151                    remotes.push(take_required_value(raw, &mut i, arg)?);
152                }
153                "--daemon" => {
154                    daemon = true;
155                }
156                "-d" => {
157                    debug = true;
158                    debug_level = debug_level.saturating_add(1);
159                }
160                "--log" | "-log" => {
161                    let value = take_required_value(raw, &mut i, arg)?;
162                    known_unsupported.push(format!("log={value}"));
163                    strict_violations.push(arg.clone());
164                }
165                "-ul" | "--udp-listen" => {
166                    if arg == "--udp-listen" {
167                        strict_violations.push(arg.clone());
168                    }
169                    let value = take_required_value(raw, &mut i, arg)?;
170                    known_unsupported.push(format!("udp-listen={value}"));
171                }
172                "-ur" | "--udp-remote" => {
173                    if arg == "--udp-remote" {
174                        strict_violations.push(arg.clone());
175                    }
176                    let value = take_required_value(raw, &mut i, arg)?;
177                    known_unsupported.push(format!("udp-remote={value}"));
178                }
179                "--rulefile" | "-rulefile" => {
180                    let value = take_required_value(raw, &mut i, arg)?;
181                    known_unsupported.push(format!("rulefile={value}"));
182                    strict_violations.push(arg.clone());
183                }
184                "-v" | "-vv" | "-vvv" => {
185                    verbose_level = verbose_level.saturating_add(arg.len() as u8 - 1);
186                }
187                short
188                    if short.len() > 2
189                        && short.starts_with('-')
190                        && short[1..].chars().all(|c| c == 'd' || c == 'v') =>
191                {
192                    for flag in short[1..].chars() {
193                        if flag == 'd' {
194                            debug = true;
195                            debug_level = debug_level.saturating_add(1);
196                        } else {
197                            verbose_level = verbose_level.saturating_add(1);
198                        }
199                    }
200                }
201                "-s" => {
202                    let value = take_required_value(raw, &mut i, arg)?;
203                    if !matches!(
204                        value.as_str(),
205                        "fa" | "rr"
206                            | "rc"
207                            | "lc"
208                            | "first_available"
209                            | "round_robin"
210                            | "random_choice"
211                            | "least_connection"
212                    ) {
213                        return Err(CompatError::InvalidArgs {
214                            message: format!(
215                                "invalid choice: '{}' (choose from fa, rr, rc, lc)",
216                                value
217                            ),
218                        });
219                    }
220                    if !matches!(value.as_str(), "fa" | "rr" | "rc" | "lc") {
221                        // Keep migration-only scheduler aliases parseable, but
222                        // make strict executable validation reject them.
223                        strict_violations.push(format!("-s {value}"));
224                    }
225                    known_unsupported.push(format!("scheduler={value}"));
226                }
227                "-a" => {
228                    let value = take_required_value(raw, &mut i, arg)?;
229                    known_unsupported.push(format!("alive={value}"));
230                }
231                "--ssl" => {
232                    let value = take_required_value(raw, &mut i, arg)?;
233                    known_unsupported.push(format!("ssl={value}"));
234                }
235                "-b" => {
236                    let value = take_required_value(raw, &mut i, arg)?;
237                    known_unsupported.push(format!("block={value}"));
238                }
239                "--pac" => {
240                    let value = take_required_value(raw, &mut i, arg)?;
241                    known_unsupported.push(format!("pac={value}"));
242                }
243                "--test" => {
244                    let value = take_required_value(raw, &mut i, arg)?;
245                    known_unsupported.push(format!("test={value}"));
246                }
247                "--sys" => {
248                    system_proxy = true;
249                }
250                "--reuse" => {
251                    reuse_port = true;
252                }
253                "--get" => {
254                    let value = take_required_value(raw, &mut i, arg)?;
255                    known_unsupported.push(format!("get={value}"));
256                }
257                "--auth" => {
258                    let value = take_required_value(raw, &mut i, arg)?;
259                    auth_timeout = Some(parse_auth_duration(&value)?);
260                }
261                other if other.starts_with('-') => {
262                    unknown_flags.push(other.to_string());
263                }
264                other => {
265                    // The migration translator historically accepted positional
266                    // URIs. Keep parsing them for API callers, but mark them so
267                    // strict executable entry points can reject them like
268                    // argparse does.
269                    if local.is_empty() {
270                        local.push(other.to_string());
271                    } else {
272                        remotes.push(other.to_string());
273                    }
274                    strict_violations.push(other.to_string());
275                }
276            }
277            i += 1;
278        }
279
280        Ok(PproxyArgs {
281            local,
282            remotes,
283            verbose_level,
284            debug,
285            debug_level,
286            daemon,
287            reuse_port,
288            auth_timeout,
289            system_proxy,
290            help,
291            version,
292            known_unsupported,
293            unknown_flags,
294            strict_violations,
295        })
296    }
297
298    /// Identify unrecognized flags and return diagnostics for them.
299    pub fn unknown_flag_diagnostics(&self) -> Vec<CompatWarning> {
300        let mut warnings = Vec::new();
301        for flag in &self.unknown_flags {
302            warnings.push(CompatWarning {
303                category: "unknown-flag",
304                message: format!("unrecognized option '{}'", flag),
305            });
306        }
307        warnings
308    }
309
310    /// Return a TranslationOutput containing the unknown-flag diagnostics.
311    pub fn unknown_flag_translation_output(&self) -> TranslationOutput {
312        let warnings = self.unknown_flag_diagnostics();
313        TranslationOutput::new(String::new()).with_warnings(warnings)
314    }
315
316    /// Check if there are any unknown or unsupported flags.
317    pub fn has_unknown_or_unsupported(&self) -> bool {
318        !self.unknown_flags.is_empty() || !self.known_unsupported.is_empty() || self.daemon
319    }
320
321    /// Return parser violations against the exact 2.7.9 command surface.
322    pub fn strict_parser_violations(&self) -> Vec<String> {
323        self.unknown_flags
324            .iter()
325            .cloned()
326            .chain(self.strict_violations.iter().cloned())
327            .collect()
328    }
329
330    /// Version string used by all compatibility entry points.
331    pub fn version_string() -> String {
332        format!("eggress-pproxy-compat {}", env!("CARGO_PKG_VERSION"))
333    }
334
335    /// Validate values whose upstream argparse `type=` callbacks run during
336    /// parsing. URI failures therefore stay in the CLI-parse category rather
337    /// than being reported as a later runtime/configuration failure.
338    pub fn validate_strict_values(&self) -> Result<(), CompatError> {
339        self.parse_local_uris()
340            .map_err(|error| CompatError::InvalidArgs {
341                message: format!("invalid -l URI: {error}"),
342            })?;
343        self.parse_remote_chains()
344            .map_err(|error| CompatError::InvalidArgs {
345                message: format!("invalid -r URI: {error}"),
346            })?;
347        if let Some(scheduler) = self
348            .known_unsupported
349            .iter()
350            .find_map(|flag| flag.strip_prefix("scheduler="))
351        {
352            if !matches!(scheduler, "fa" | "rr" | "rc" | "lc") {
353                return Err(CompatError::InvalidArgs {
354                    message: format!(
355                        "invalid choice: '{}' (choose from fa, rr, rc, lc)",
356                        scheduler
357                    ),
358                });
359            }
360        }
361        if let Some(interval) = self
362            .known_unsupported
363            .iter()
364            .find_map(|flag| flag.strip_prefix("alive="))
365        {
366            interval
367                .parse::<i64>()
368                .map_err(|_| CompatError::InvalidArgs {
369                    message: format!("-a value '{}' is not a valid integer", interval),
370                })?;
371        }
372        for kind in ["udp-listen=", "udp-remote="] {
373            for value in self
374                .known_unsupported
375                .iter()
376                .filter_map(|flag| flag.strip_prefix(kind))
377            {
378                crate::uri::parse_pproxy_uri(value).map_err(|error| CompatError::InvalidArgs {
379                    message: format!("invalid {} URI: {error}", kind.trim_end_matches('=')),
380                })?;
381            }
382        }
383        Ok(())
384    }
385
386    /// Return the pproxy default re-authentication interval when `--auth` was
387    /// omitted. The field remains optional so translation/API callers can
388    /// distinguish an explicit compatibility option from the parser default.
389    pub fn effective_auth_timeout(&self) -> Duration {
390        self.auth_timeout
391            .unwrap_or_else(|| Duration::from_secs(86_400 * 30))
392    }
393
394    /// Default tracing log level chosen from `-d` and `-v` verbosity flags.
395    ///
396    /// Resolution order (highest precedence first):
397    ///
398    /// 1. `-vvv` -> `trace`
399    /// 2. `-v` / `-vv` / `-d` -> `debug`
400    /// 3. otherwise -> `info`
401    ///
402    /// `-d` is independent of `-v` and of `--daemon`. This helper is the
403    /// single source of truth used by the standalone compatibility binary
404    /// (and tests) so the observable behavior of `-d` is testable without
405    /// depending on tracing internals.
406    ///
407    /// An explicit `RUST_LOG` environment variable remains authoritative
408    /// at the `tracing_subscriber` layer; this helper is only consulted when
409    /// `RUST_LOG` is unset or invalid.
410    pub fn default_log_level(&self) -> &'static str {
411        if self.verbose_level >= 3 {
412            "trace"
413        } else if self.verbose_level >= 1 || self.debug {
414            "debug"
415        } else {
416            "info"
417        }
418    }
419
420    /// Parse all local URIs into typed representations.
421    pub fn parse_local_uris(&self) -> Result<Vec<PproxyUri>, CompatError> {
422        self.local
423            .iter()
424            .map(|s| crate::uri::parse_pproxy_uri(s))
425            .collect()
426    }
427
428    /// Parse all remote URIs into typed representations.
429    pub fn parse_remote_uris(&self) -> Result<Vec<PproxyUri>, CompatError> {
430        self.remotes
431            .iter()
432            .map(|s| crate::uri::parse_pproxy_uri(s))
433            .collect()
434    }
435
436    /// Parse all remote URIs into chain representations (supports `__` separators).
437    pub fn parse_remote_chains(&self) -> Result<Vec<PproxyChain>, CompatError> {
438        self.remotes
439            .iter()
440            .map(|s| crate::uri::parse_pproxy_chain(s))
441            .collect()
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_parse_basic() {
451        let args = PproxyArgs::parse(&[
452            "-l".into(),
453            "socks5://127.0.0.1:1080".into(),
454            "-r".into(),
455            "http://proxy:8080".into(),
456        ])
457        .unwrap();
458        assert_eq!(args.local.len(), 1);
459        assert_eq!(args.remotes.len(), 1);
460        assert_eq!(args.local[0], "socks5://127.0.0.1:1080");
461        assert_eq!(args.remotes[0], "http://proxy:8080");
462    }
463
464    #[test]
465    fn test_parse_help_and_version_actions() {
466        let help = PproxyArgs::parse(&["--help".into()]).unwrap();
467        assert!(help.help);
468        assert!(!help.version);
469        assert!(help.strict_parser_violations().is_empty());
470
471        let version = PproxyArgs::parse(&["--version".into()]).unwrap();
472        assert!(version.version);
473        assert!(!version.help);
474        assert_eq!(
475            PproxyArgs::version_string(),
476            format!("eggress-pproxy-compat {}", env!("CARGO_PKG_VERSION"))
477        );
478    }
479
480    #[test]
481    fn test_parse_positional() {
482        let args =
483            PproxyArgs::parse(&["socks5://127.0.0.1:1080".into(), "http://proxy:8080".into()])
484                .unwrap();
485        assert_eq!(args.local.len(), 1);
486        assert_eq!(args.remotes.len(), 1);
487    }
488
489    #[test]
490    fn test_parse_multiple_remotes() {
491        let args = PproxyArgs::parse(&[
492            "-l".into(),
493            "socks5://127.0.0.1:1080".into(),
494            "-r".into(),
495            "http://proxy1:8080".into(),
496            "-r".into(),
497            "socks5://proxy2:1080".into(),
498        ])
499        .unwrap();
500        assert_eq!(args.remotes.len(), 2);
501    }
502
503    #[test]
504    fn test_parse_missing_value() {
505        let result = PproxyArgs::parse(&["-l".into()]);
506        assert!(result.is_err());
507    }
508
509    #[test]
510    fn test_parse_daemon_flag() {
511        let args = PproxyArgs::parse(&[
512            "-l".into(),
513            "socks5://127.0.0.1:1080".into(),
514            "--daemon".into(),
515        ])
516        .unwrap();
517        assert!(args.daemon);
518        assert!(!args.debug);
519    }
520
521    #[test]
522    fn test_parse_debug_flag() {
523        let args = PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-d".into()])
524            .unwrap();
525        assert!(args.debug);
526        assert_eq!(args.debug_level, 1);
527        assert!(!args.daemon);
528    }
529
530    #[test]
531    fn test_count_actions_and_short_clusters() {
532        let args = PproxyArgs::parse(&[
533            "-l".into(),
534            "http://:8080".into(),
535            "-dd".into(),
536            "-v".into(),
537            "-vv".into(),
538        ])
539        .unwrap();
540        assert_eq!(args.debug_level, 2);
541        assert_eq!(args.verbose_level, 3);
542
543        let clustered =
544            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-dv".into()]).unwrap();
545        assert_eq!(clustered.debug_level, 1);
546        assert_eq!(clustered.verbose_level, 1);
547    }
548
549    #[test]
550    fn test_strict_parser_rejects_extensions_and_positionals() {
551        let args = PproxyArgs::parse(&[
552            "--log".into(),
553            "access.log".into(),
554            "proxy://listener".into(),
555        ])
556        .unwrap();
557        assert!(args
558            .strict_parser_violations()
559            .iter()
560            .any(|value| value == "--log"));
561        assert!(args
562            .strict_parser_violations()
563            .iter()
564            .any(|value| value == "proxy://listener"));
565    }
566
567    #[test]
568    fn test_target_preserves_value_taking_option() {
569        let args = PproxyArgs::parse(&[
570            "-l".into(),
571            "http://:8080".into(),
572            "--test".into(),
573            "https://example.invalid/health".into(),
574        ])
575        .unwrap();
576        assert_eq!(args.test_target(), Some("https://example.invalid/health"));
577    }
578
579    #[test]
580    fn test_d_and_daemon_independent() {
581        let args = PproxyArgs::parse(&[
582            "-l".into(),
583            "socks5://127.0.0.1:1080".into(),
584            "-d".into(),
585            "--daemon".into(),
586        ])
587        .unwrap();
588        assert!(args.debug);
589        assert!(args.daemon);
590    }
591
592    #[test]
593    fn test_d_never_sets_daemon() {
594        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-d".into()]).unwrap();
595        assert!(args.debug);
596        assert!(!args.daemon);
597    }
598
599    #[test]
600    fn test_daemon_never_sets_debug() {
601        let args =
602            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
603        assert!(!args.debug);
604        assert!(args.daemon);
605    }
606
607    #[test]
608    fn test_parse_verbose_flag() {
609        let args = PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-v".into()])
610            .unwrap();
611        assert_eq!(args.verbose_level, 1);
612    }
613
614    #[test]
615    fn test_parse_scheduler_flag() {
616        let args = PproxyArgs::parse(&[
617            "-l".into(),
618            "socks5://127.0.0.1:1080".into(),
619            "-s".into(),
620            "rr".into(),
621        ])
622        .unwrap();
623        assert!(args.known_unsupported.contains(&"scheduler=rr".to_string()));
624    }
625
626    #[test]
627    fn test_scheduler_migration_alias_is_not_strict_executable_surface() {
628        let args = PproxyArgs::parse(&[
629            "-l".into(),
630            "socks5://127.0.0.1:1080".into(),
631            "-s".into(),
632            "round_robin".into(),
633        ])
634        .unwrap();
635        assert!(args
636            .strict_parser_violations()
637            .iter()
638            .any(|value| value == "-s round_robin"));
639        assert!(args.validate_strict_values().is_err());
640    }
641
642    #[test]
643    fn test_parse_alive_flag() {
644        let args = PproxyArgs::parse(&[
645            "-l".into(),
646            "socks5://127.0.0.1:1080".into(),
647            "-a".into(),
648            "10".into(),
649        ])
650        .unwrap();
651        assert!(args.known_unsupported.contains(&"alive=10".to_string()));
652    }
653
654    #[test]
655    fn test_parse_ssl_flag() {
656        let args = PproxyArgs::parse(&[
657            "-l".into(),
658            "socks5://127.0.0.1:1080".into(),
659            "--ssl".into(),
660            "cert.pem,key.pem".into(),
661        ])
662        .unwrap();
663        assert!(args
664            .known_unsupported
665            .contains(&"ssl=cert.pem,key.pem".to_string()));
666    }
667
668    #[test]
669    fn test_parse_block_flag() {
670        let args = PproxyArgs::parse(&[
671            "-l".into(),
672            "socks5://127.0.0.1:1080".into(),
673            "-b".into(),
674            ".*\\.example\\.com".into(),
675        ])
676        .unwrap();
677        assert!(args
678            .known_unsupported
679            .contains(&"block=.*\\.example\\.com".to_string()));
680    }
681
682    #[test]
683    fn test_parse_log_flag() {
684        let args = PproxyArgs::parse(&[
685            "-l".into(),
686            "socks5://127.0.0.1:1080".into(),
687            "--log".into(),
688            "access.log".into(),
689        ])
690        .unwrap();
691        assert!(args
692            .known_unsupported
693            .contains(&"log=access.log".to_string()));
694    }
695
696    #[test]
697    fn test_parse_udp_flags() {
698        let args = PproxyArgs::parse(&[
699            "-l".into(),
700            "socks5://127.0.0.1:1080".into(),
701            "-ul".into(),
702            "socks5://:1081".into(),
703            "-ur".into(),
704            "socks5://proxy:1080".into(),
705        ])
706        .unwrap();
707        assert!(args
708            .known_unsupported
709            .contains(&"udp-listen=socks5://:1081".to_string()));
710        assert!(args
711            .known_unsupported
712            .contains(&"udp-remote=socks5://proxy:1080".to_string()));
713    }
714
715    #[test]
716    fn test_parse_rulefile_flag() {
717        let args = PproxyArgs::parse(&[
718            "-l".into(),
719            "socks5://127.0.0.1:1080".into(),
720            "--rulefile".into(),
721            "rules.txt".into(),
722        ])
723        .unwrap();
724        assert!(args
725            .known_unsupported
726            .contains(&"rulefile=rules.txt".to_string()));
727    }
728
729    #[test]
730    fn test_unknown_flag_diagnostics() {
731        let args = PproxyArgs::parse(&[
732            "-l".into(),
733            "socks5://127.0.0.1:1080".into(),
734            "--unknown-flag".into(),
735            "-x".into(),
736        ])
737        .unwrap();
738        let warnings = args.unknown_flag_diagnostics();
739        assert_eq!(warnings.len(), 2);
740        assert!(warnings
741            .iter()
742            .any(|w| w.message.contains("--unknown-flag")));
743        assert!(warnings.iter().any(|w| w.message.contains("-x")));
744    }
745
746    #[test]
747    fn test_known_flags_no_unknown_warnings() {
748        let args = PproxyArgs::parse(&[
749            "-l".into(),
750            "socks5://127.0.0.1:1080".into(),
751            "-v".into(),
752            "-s".into(),
753            "rr".into(),
754            "-a".into(),
755            "10".into(),
756            "--daemon".into(),
757            "--log".into(),
758            "access.log".into(),
759            "-ul".into(),
760            "socks5://:1081".into(),
761            "-ur".into(),
762            "socks5://proxy:1080".into(),
763            "--rulefile".into(),
764            "rules.txt".into(),
765            "--ssl".into(),
766            "cert.pem,key.pem".into(),
767            "-b".into(),
768            ".*\\.example\\.com".into(),
769        ])
770        .unwrap();
771        let warnings = args.unknown_flag_diagnostics();
772        assert!(warnings.is_empty());
773    }
774
775    #[test]
776    fn test_scheduler_missing_value() {
777        let result =
778            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-s".into()]);
779        assert!(result.is_err());
780    }
781
782    #[test]
783    fn test_log_missing_value() {
784        let result = PproxyArgs::parse(&[
785            "-l".into(),
786            "socks5://127.0.0.1:1080".into(),
787            "--log".into(),
788        ]);
789        assert!(result.is_err());
790    }
791
792    #[test]
793    fn test_udp_listen_missing_value() {
794        let result =
795            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-ul".into()]);
796        assert!(result.is_err());
797    }
798
799    #[test]
800    fn test_udp_remote_missing_value() {
801        let result =
802            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-ur".into()]);
803        assert!(result.is_err());
804    }
805
806    #[test]
807    fn test_rulefile_missing_value() {
808        let result = PproxyArgs::parse(&[
809            "-l".into(),
810            "socks5://127.0.0.1:1080".into(),
811            "--rulefile".into(),
812        ]);
813        assert!(result.is_err());
814    }
815
816    #[test]
817    fn test_alive_missing_value() {
818        let result =
819            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-a".into()]);
820        assert!(result.is_err());
821    }
822
823    #[test]
824    fn test_ssl_missing_value() {
825        let result = PproxyArgs::parse(&[
826            "-l".into(),
827            "socks5://127.0.0.1:1080".into(),
828            "--ssl".into(),
829        ]);
830        assert!(result.is_err());
831    }
832
833    #[test]
834    fn test_block_missing_value() {
835        let result =
836            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-b".into()]);
837        assert!(result.is_err());
838    }
839
840    #[test]
841    fn test_parse_known_flags_pac_test_sys_reuse_get() {
842        let args = PproxyArgs::parse(&[
843            "-l".into(),
844            "socks5://127.0.0.1:1080".into(),
845            "--pac".into(),
846            "/proxy.pac".into(),
847            "--test".into(),
848            "http://example.com".into(),
849            "--sys".into(),
850            "--reuse".into(),
851            "--get".into(),
852            "/index.html,body.txt".into(),
853        ])
854        .unwrap();
855        assert!(args.system_proxy);
856        assert!(args.reuse_port);
857        let warnings = args.unknown_flag_diagnostics();
858        assert!(warnings.is_empty(), "unexpected warnings: {:?}", warnings);
859    }
860
861    #[test]
862    fn test_verbose_level_single() {
863        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-v".into()]).unwrap();
864        assert_eq!(args.verbose_level, 1);
865    }
866
867    #[test]
868    fn test_verbose_level_double() {
869        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vv".into()]).unwrap();
870        assert_eq!(args.verbose_level, 2);
871    }
872
873    #[test]
874    fn test_verbose_level_triple() {
875        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vvv".into()]).unwrap();
876        assert_eq!(args.verbose_level, 3);
877    }
878
879    #[test]
880    fn test_verbose_level_default_zero() {
881        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
882        assert_eq!(args.verbose_level, 0);
883    }
884
885    #[test]
886    fn test_verbose_level_max_of_multiple() {
887        let args = PproxyArgs::parse(&[
888            "-l".into(),
889            "http://:8080".into(),
890            "-v".into(),
891            "-vvv".into(),
892        ])
893        .unwrap();
894        assert_eq!(args.verbose_level, 4);
895    }
896
897    #[test]
898    fn test_has_args_true() {
899        assert!(PproxyArgs::has_args(&["-l".into(), "http://:8080".into()]));
900    }
901
902    #[test]
903    fn test_has_args_false_empty() {
904        assert!(!PproxyArgs::has_args(&[]));
905    }
906
907    #[test]
908    fn test_default_args() {
909        let args = PproxyArgs::default_args();
910        assert_eq!(args.local, vec!["http+socks4+socks5://:8080"]);
911        assert!(args.remotes.is_empty());
912        assert_eq!(args.verbose_level, 0);
913        assert!(!args.debug);
914        assert!(!args.daemon);
915        assert!(!args.reuse_port);
916        assert!(args.auth_timeout.is_none());
917        assert!(!args.system_proxy);
918    }
919
920    #[test]
921    fn test_default_args_translates() {
922        let args = PproxyArgs::default_args();
923        let output = super::super::translate::translate_pproxy_args(&args).unwrap();
924        assert!(output.toml.contains("8080"));
925        assert!(output.toml.contains("socks5") || output.toml.contains("http"));
926        assert!(!output.has_unsupported());
927    }
928
929    #[test]
930    fn test_parse_reuse_flag() {
931        let args = PproxyArgs::parse(&[
932            "-l".into(),
933            "socks5://127.0.0.1:1080".into(),
934            "--reuse".into(),
935        ])
936        .unwrap();
937        assert!(args.reuse_port);
938    }
939
940    #[test]
941    fn test_parse_auth_valid() {
942        let args = PproxyArgs::parse(&[
943            "-l".into(),
944            "socks5://127.0.0.1:1080".into(),
945            "--auth".into(),
946            "3600".into(),
947        ])
948        .unwrap();
949        assert_eq!(args.auth_timeout, Some(Duration::from_secs(3600)));
950    }
951
952    #[test]
953    fn test_parse_auth_zero() {
954        let args = PproxyArgs::parse(&[
955            "-l".into(),
956            "socks5://127.0.0.1:1080".into(),
957            "--auth".into(),
958            "0".into(),
959        ])
960        .unwrap();
961        assert_eq!(args.auth_timeout, Some(Duration::from_secs(0)));
962    }
963
964    #[test]
965    fn test_parse_auth_invalid_non_numeric() {
966        let result = PproxyArgs::parse(&[
967            "-l".into(),
968            "socks5://127.0.0.1:1080".into(),
969            "--auth".into(),
970            "abc".into(),
971        ]);
972        assert!(result.is_err());
973    }
974
975    #[test]
976    fn test_parse_auth_overflow() {
977        let result = PproxyArgs::parse(&[
978            "-l".into(),
979            "socks5://127.0.0.1:1080".into(),
980            "--auth".into(),
981            "9223372036854775808".into(),
982        ]);
983        assert!(result.is_err());
984    }
985
986    #[test]
987    fn test_parse_auth_negative_matches_argparse_integer() {
988        let args = PproxyArgs::parse(&[
989            "-l".into(),
990            "http://127.0.0.1:0".into(),
991            "--auth".into(),
992            "-1".into(),
993        ])
994        .unwrap();
995        assert_eq!(args.auth_timeout, Some(Duration::ZERO));
996    }
997
998    #[test]
999    fn test_parse_auth_missing_value() {
1000        let result = PproxyArgs::parse(&[
1001            "-l".into(),
1002            "socks5://127.0.0.1:1080".into(),
1003            "--auth".into(),
1004        ]);
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    fn test_parse_sys_flag() {
1010        let args = PproxyArgs::parse(&[
1011            "-l".into(),
1012            "socks5://127.0.0.1:1080".into(),
1013            "--sys".into(),
1014        ])
1015        .unwrap();
1016        assert!(args.system_proxy);
1017    }
1018
1019    #[test]
1020    fn test_has_unknown_or_unsupported_with_unknown() {
1021        let args =
1022            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--bogus".into()]).unwrap();
1023        assert!(args.has_unknown_or_unsupported());
1024    }
1025
1026    #[test]
1027    fn test_has_unknown_or_unsupported_with_daemon() {
1028        let args =
1029            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
1030        assert!(args.has_unknown_or_unsupported());
1031    }
1032
1033    #[test]
1034    fn test_no_raw_flags_field() {
1035        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
1036        assert!(args.known_unsupported.is_empty());
1037        assert!(args.unknown_flags.is_empty());
1038    }
1039
1040    #[test]
1041    fn test_default_log_level_no_flags() {
1042        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
1043        assert_eq!(args.default_log_level(), "info");
1044    }
1045
1046    #[test]
1047    fn test_default_log_level_d_flag() {
1048        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-d".into()]).unwrap();
1049        assert_eq!(args.default_log_level(), "debug");
1050    }
1051
1052    #[test]
1053    fn test_default_log_level_v_flag() {
1054        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-v".into()]).unwrap();
1055        assert_eq!(args.default_log_level(), "debug");
1056    }
1057
1058    #[test]
1059    fn test_default_log_level_vv_flag() {
1060        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vv".into()]).unwrap();
1061        assert_eq!(args.default_log_level(), "debug");
1062    }
1063
1064    #[test]
1065    fn test_default_log_level_vvv_flag() {
1066        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vvv".into()]).unwrap();
1067        assert_eq!(args.default_log_level(), "trace");
1068    }
1069
1070    #[test]
1071    fn test_default_log_level_d_and_vv() {
1072        let args = PproxyArgs::parse(&[
1073            "-l".into(),
1074            "http://:8080".into(),
1075            "-d".into(),
1076            "-vv".into(),
1077        ])
1078        .unwrap();
1079        assert_eq!(args.default_log_level(), "debug");
1080    }
1081
1082    #[test]
1083    fn test_default_log_level_d_and_vvv() {
1084        let args = PproxyArgs::parse(&[
1085            "-l".into(),
1086            "http://:8080".into(),
1087            "-d".into(),
1088            "-vvv".into(),
1089        ])
1090        .unwrap();
1091        assert_eq!(args.default_log_level(), "trace");
1092    }
1093
1094    #[test]
1095    fn test_default_log_level_daemon_only_keeps_daemon_separate() {
1096        let args =
1097            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
1098        assert!(args.daemon);
1099        assert!(!args.debug);
1100        assert_eq!(args.default_log_level(), "info");
1101    }
1102
1103    #[test]
1104    fn test_default_log_level_d_still_works_with_daemon() {
1105        let args = PproxyArgs::parse(&[
1106            "-l".into(),
1107            "http://:8080".into(),
1108            "-d".into(),
1109            "--daemon".into(),
1110        ])
1111        .unwrap();
1112        assert!(args.debug);
1113        assert_eq!(args.default_log_level(), "debug");
1114    }
1115
1116    #[test]
1117    fn test_default_log_level_default_args() {
1118        let args = PproxyArgs::default_args();
1119        assert_eq!(args.default_log_level(), "info");
1120    }
1121
1122    /// Table-driven arity test sourced from the checked-in baseline.
1123    /// Ensures that every value-taking option in the baseline correctly
1124    /// requires a value and fails when missing.
1125    #[test]
1126    fn test_baseline_value_arity() {
1127        // Each entry: (flag, expects_value)
1128        // "value" means the flag requires a following argument.
1129        let cases: &[(&[&str], bool)] = &[
1130            // Value-taking options (must have next arg)
1131            (&["-l", "http://:8080"], true),
1132            (&["-r", "http://proxy:8080"], true),
1133            (&["-ul", "socks5://:1081"], true),
1134            (&["-ur", "socks5://proxy:1080"], true),
1135            (&["--ssl", "cert.pem,key.pem"], true),
1136            (&["--pac", "/proxy.pac"], true),
1137            (&["--test", "http://example.com"], true),
1138            (&["--auth", "3600"], true),
1139            (&["--get", "/index.html,body.txt"], true),
1140            (&["-s", "rr"], true),
1141            (&["-a", "10"], true),
1142            (&["-b", ".*\\.example\\.com"], true),
1143            (&["--rulefile", "rules.txt"], true),
1144            (&["--log", "access.log"], true),
1145            // Boolean flags (no value needed)
1146            (&["-v"], false),
1147            (&["-d"], false),
1148            (&["--daemon"], false),
1149            (&["--sys"], false),
1150            (&["--reuse"], false),
1151        ];
1152
1153        for (args_slice, expects_value) in cases {
1154            let raw: Vec<String> = args_slice.iter().map(|s| s.to_string()).collect();
1155            let result = PproxyArgs::parse(&raw);
1156            if *expects_value && raw.len() == 1 {
1157                // Single value-taking flag with no value should fail
1158                assert!(
1159                    result.is_err(),
1160                    "expected error for {:?} (missing value), got Ok",
1161                    args_slice
1162                );
1163            } else {
1164                // Complete flag+value or boolean flag should succeed
1165                assert!(
1166                    result.is_ok(),
1167                    "expected Ok for {:?}, got Err: {:?}",
1168                    args_slice,
1169                    result.err()
1170                );
1171            }
1172        }
1173    }
1174}