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 pproxy flags that require a translation decision, kept as raw
68    /// `key=value` strings for back-compat.
69    ///
70    /// Legacy name: despite `unsupported` in the name, this bucket carries
71    /// supported/native-equivalent options too (`ssl=`, `pac=`,
72    /// `udp-listen=`, ...). It stays populated so existing API readers keep
73    /// working, but authoritative state lives in the structured fields
74    /// below. Internal translation and presentation code must consume the
75    /// structured fields/accessors, never scan these strings.
76    pub known_unsupported: Vec<String>,
77    /// `--ssl CERT,KEY` raw value, if requested. Structured owner of the
78    /// `ssl=` legacy bucket entry; drives TLS translation and the startup
79    /// banner.
80    pub ssl: Option<String>,
81    /// `--pac PATH` raw value, if requested. Structured owner of the `pac=`
82    /// legacy bucket entry; drives admin PAC translation and the banner.
83    pub pac: Option<String>,
84    /// `--test URL` raw value, if this invocation is a diagnostic probe.
85    /// Structured owner of the `test=` legacy bucket entry.
86    pub test_value: Option<String>,
87    /// `-ul` UDP listener values in declaration order. Structured owner of
88    /// the `udp-listen=` legacy bucket entries.
89    pub udp_listen: Vec<String>,
90    /// `-ur` UDP upstream values in declaration order. Structured owner of
91    /// the `udp-remote=` legacy bucket entries.
92    pub udp_remote: Vec<String>,
93    /// `-s` scheduler value, if given. Structured owner of the `scheduler=`
94    /// legacy bucket entry.
95    pub scheduler: Option<String>,
96    /// `-a` alive/health-check interval value, if given. Structured owner of
97    /// the `alive=` legacy bucket entry.
98    pub alive: Option<String>,
99    /// `-b` block pattern values in declaration order. Structured owner of
100    /// the `block=` legacy bucket entries.
101    pub block_values: Vec<String>,
102    /// `--log` values in declaration order. Structured owner of the `log=`
103    /// legacy bucket entries.
104    pub log_values: Vec<String>,
105    /// `--rulefile` values in declaration order. Structured owner of the
106    /// `rulefile=` legacy bucket entries.
107    pub rulefile_values: Vec<String>,
108    /// `--get PATH,FILE` values in declaration order. Structured owner of
109    /// the `get=` legacy bucket entries.
110    pub get_values: Vec<String>,
111    /// Unknown flags that are not recognized.
112    pub unknown_flags: Vec<String>,
113    /// Options accepted by the migration translator but not by the frozen
114    /// pproxy 2.7.9 parser, plus positional arguments and long aliases.
115    /// Compatibility execution treats these as parser errors.
116    pub strict_violations: Vec<String>,
117}
118
119impl PproxyArgs {
120    /// Check whether any arguments were provided.
121    pub fn has_args(raw: &[String]) -> bool {
122        !raw.is_empty()
123    }
124
125    /// Return the target supplied to `--test`, preserving the parser's
126    /// value-taking semantics for both compatibility execution entry points.
127    /// Reads the structured `--test` field, not the legacy string bucket.
128    pub fn test_target(&self) -> Option<&str> {
129        self.test_value.as_deref()
130    }
131
132    /// Authoritative inventory of every option the frozen 2.7.9 parser
133    /// recognizes, adjacent to the parser itself. The standalone help text
134    /// and the help/parser drift test consume this metadata so recognized
135    /// options cannot silently drift away from documented ones.
136    pub fn recognized_option_names() -> &'static [&'static str] {
137        &[
138            "-l",
139            "-r",
140            "-ul",
141            "-ur",
142            "-b",
143            "-a",
144            "-s",
145            "-d",
146            "-v",
147            "--ssl",
148            "--pac",
149            "--test",
150            "--sys",
151            "--reuse",
152            "--auth",
153            "--get",
154            "--daemon",
155            "--version",
156            "-h",
157            "--help",
158        ]
159    }
160
161    /// Structured presentation state for the startup banner and diagnostics.
162    ///
163    /// These accessors read the typed parser fields. The legacy
164    /// `known_unsupported` string bucket stays populated for back-compat but
165    /// must not be scanned by internal code.
166    ///
167    /// Whether each entry blocks startup is decided separately by the
168    /// execution gate over the translation output, never by these
169    /// presentation helpers.
170    ///
171    /// UDP listener addresses from `-ul`, in declaration order.
172    pub fn udp_listen_addrs(&self) -> Vec<&str> {
173        self.udp_listen.iter().map(String::as_str).collect()
174    }
175
176    /// UDP upstream values from `-ur`, in declaration order.
177    pub fn udp_remote_addrs(&self) -> Vec<&str> {
178        self.udp_remote.iter().map(String::as_str).collect()
179    }
180
181    /// Whether TLS was requested on listeners via `--ssl`.
182    pub fn tls_requested(&self) -> bool {
183        self.ssl.is_some()
184    }
185
186    /// Raw `--ssl CERT,KEY` value, if requested.
187    pub fn tls_value(&self) -> Option<&str> {
188        self.ssl.as_deref()
189    }
190
191    /// Whether PAC serving was requested via `--pac`.
192    pub fn pac_requested(&self) -> bool {
193        self.pac.is_some()
194    }
195
196    /// Raw `--pac` value, if requested.
197    pub fn pac_value(&self) -> Option<&str> {
198        self.pac.as_deref()
199    }
200
201    /// Raw `-s` scheduler value, if given.
202    pub fn scheduler_value(&self) -> Option<&str> {
203        self.scheduler.as_deref()
204    }
205
206    /// Raw `-a` alive interval value, if given.
207    pub fn alive_value(&self) -> Option<&str> {
208        self.alive.as_deref()
209    }
210
211    /// Create default pproxy args equivalent to running `pproxy` with no arguments.
212    ///
213    /// Real pproxy defaults to a mixed HTTP/SOCKS4/SOCKS5 listener on `:8080`
214    /// with direct routing.
215    pub fn default_args() -> Self {
216        Self {
217            local: vec!["http+socks4+socks5://:8080".to_string()],
218            remotes: vec![],
219            verbose_level: 0,
220            debug: false,
221            debug_level: 0,
222            daemon: false,
223            reuse_port: false,
224            auth_timeout: None,
225            system_proxy: false,
226            help: false,
227            version: false,
228            known_unsupported: vec![],
229            ssl: None,
230            pac: None,
231            test_value: None,
232            udp_listen: vec![],
233            udp_remote: vec![],
234            scheduler: None,
235            alive: None,
236            block_values: vec![],
237            log_values: vec![],
238            rulefile_values: vec![],
239            get_values: vec![],
240            unknown_flags: vec![],
241            strict_violations: vec![],
242        }
243    }
244
245    /// Parse from raw argument list (excluding argv[0]).
246    pub fn parse(raw: &[String]) -> Result<Self, CompatError> {
247        let mut local = Vec::new();
248        let mut remotes = Vec::new();
249        let mut verbose_level: u8 = 0;
250        let mut debug = false;
251        let mut debug_level: u8 = 0;
252        let mut daemon = false;
253        let mut reuse_port = false;
254        let mut auth_timeout: Option<Duration> = None;
255        let mut system_proxy = false;
256        let mut help = false;
257        let mut version = false;
258        let mut known_unsupported = Vec::new();
259        let mut ssl: Option<String> = None;
260        let mut pac: Option<String> = None;
261        let mut test_value: Option<String> = None;
262        let mut udp_listen: Vec<String> = Vec::new();
263        let mut udp_remote: Vec<String> = Vec::new();
264        let mut scheduler: Option<String> = None;
265        let mut alive: Option<String> = None;
266        let mut block_values: Vec<String> = Vec::new();
267        let mut log_values: Vec<String> = Vec::new();
268        let mut rulefile_values: Vec<String> = Vec::new();
269        let mut get_values: Vec<String> = Vec::new();
270        let mut unknown_flags = Vec::new();
271        let mut strict_violations = Vec::new();
272        let mut i = 0;
273
274        while i < raw.len() {
275            let arg = &raw[i];
276            match arg.as_str() {
277                "-h" | "--help" => {
278                    help = true;
279                }
280                "--version" => {
281                    version = true;
282                }
283                "-l" | "--listen" => {
284                    if arg == "--listen" {
285                        strict_violations.push(arg.clone());
286                    }
287                    local.push(take_required_value(raw, &mut i, arg)?);
288                }
289                "-r" | "--remote" => {
290                    if arg == "--remote" {
291                        strict_violations.push(arg.clone());
292                    }
293                    remotes.push(take_required_value(raw, &mut i, arg)?);
294                }
295                "--daemon" => {
296                    daemon = true;
297                }
298                "-d" => {
299                    debug = true;
300                    debug_level = debug_level.saturating_add(1);
301                }
302                "--log" | "-log" => {
303                    let value = take_required_value(raw, &mut i, arg)?;
304                    known_unsupported.push(format!("log={value}"));
305                    log_values.push(value);
306                    strict_violations.push(arg.clone());
307                }
308                "-ul" | "--udp-listen" => {
309                    if arg == "--udp-listen" {
310                        strict_violations.push(arg.clone());
311                    }
312                    let value = take_required_value(raw, &mut i, arg)?;
313                    known_unsupported.push(format!("udp-listen={value}"));
314                    udp_listen.push(value);
315                }
316                "-ur" | "--udp-remote" => {
317                    if arg == "--udp-remote" {
318                        strict_violations.push(arg.clone());
319                    }
320                    let value = take_required_value(raw, &mut i, arg)?;
321                    known_unsupported.push(format!("udp-remote={value}"));
322                    udp_remote.push(value);
323                }
324                "--rulefile" | "-rulefile" => {
325                    let value = take_required_value(raw, &mut i, arg)?;
326                    known_unsupported.push(format!("rulefile={value}"));
327                    rulefile_values.push(value);
328                    strict_violations.push(arg.clone());
329                }
330                "-v" | "-vv" | "-vvv" => {
331                    verbose_level = verbose_level.saturating_add(arg.len() as u8 - 1);
332                }
333                short
334                    if short.len() > 2
335                        && short.starts_with('-')
336                        && short[1..].chars().all(|c| c == 'd' || c == 'v') =>
337                {
338                    for flag in short[1..].chars() {
339                        if flag == 'd' {
340                            debug = true;
341                            debug_level = debug_level.saturating_add(1);
342                        } else {
343                            verbose_level = verbose_level.saturating_add(1);
344                        }
345                    }
346                }
347                "-s" => {
348                    let value = take_required_value(raw, &mut i, arg)?;
349                    if !matches!(
350                        value.as_str(),
351                        "fa" | "rr"
352                            | "rc"
353                            | "lc"
354                            | "first_available"
355                            | "round_robin"
356                            | "random_choice"
357                            | "least_connection"
358                    ) {
359                        return Err(CompatError::InvalidArgs {
360                            message: format!(
361                                "invalid choice: '{}' (choose from fa, rr, rc, lc)",
362                                value
363                            ),
364                        });
365                    }
366                    if !matches!(value.as_str(), "fa" | "rr" | "rc" | "lc") {
367                        // Keep migration-only scheduler aliases parseable, but
368                        // make strict executable validation reject them.
369                        strict_violations.push(format!("-s {value}"));
370                    }
371                    known_unsupported.push(format!("scheduler={value}"));
372                    scheduler = Some(value.clone());
373                }
374                "-a" => {
375                    let value = take_required_value(raw, &mut i, arg)?;
376                    known_unsupported.push(format!("alive={value}"));
377                    alive = Some(value);
378                }
379                "--ssl" => {
380                    let value = take_required_value(raw, &mut i, arg)?;
381                    known_unsupported.push(format!("ssl={value}"));
382                    ssl = Some(value);
383                }
384                "-b" => {
385                    let value = take_required_value(raw, &mut i, arg)?;
386                    known_unsupported.push(format!("block={value}"));
387                    block_values.push(value);
388                }
389                "--pac" => {
390                    let value = take_required_value(raw, &mut i, arg)?;
391                    known_unsupported.push(format!("pac={value}"));
392                    pac = Some(value);
393                }
394                "--test" => {
395                    let value = take_required_value(raw, &mut i, arg)?;
396                    known_unsupported.push(format!("test={value}"));
397                    test_value = Some(value);
398                }
399                "--sys" => {
400                    system_proxy = true;
401                }
402                "--reuse" => {
403                    reuse_port = true;
404                }
405                "--get" => {
406                    let value = take_required_value(raw, &mut i, arg)?;
407                    known_unsupported.push(format!("get={value}"));
408                    get_values.push(value);
409                }
410                "--auth" => {
411                    let value = take_required_value(raw, &mut i, arg)?;
412                    auth_timeout = Some(parse_auth_duration(&value)?);
413                }
414                other if other.starts_with('-') => {
415                    unknown_flags.push(other.to_string());
416                }
417                other => {
418                    // The migration translator historically accepted positional
419                    // URIs. Keep parsing them for API callers, but mark them so
420                    // strict executable entry points can reject them like
421                    // argparse does.
422                    if local.is_empty() {
423                        local.push(other.to_string());
424                    } else {
425                        remotes.push(other.to_string());
426                    }
427                    strict_violations.push(other.to_string());
428                }
429            }
430            i += 1;
431        }
432
433        Ok(PproxyArgs {
434            local,
435            remotes,
436            verbose_level,
437            debug,
438            debug_level,
439            daemon,
440            reuse_port,
441            auth_timeout,
442            system_proxy,
443            help,
444            version,
445            known_unsupported,
446            ssl,
447            pac,
448            test_value,
449            udp_listen,
450            udp_remote,
451            scheduler,
452            alive,
453            block_values,
454            log_values,
455            rulefile_values,
456            get_values,
457            unknown_flags,
458            strict_violations,
459        })
460    }
461
462    /// Identify unrecognized flags and return diagnostics for them.
463    pub fn unknown_flag_diagnostics(&self) -> Vec<CompatWarning> {
464        let mut warnings = Vec::new();
465        for flag in &self.unknown_flags {
466            warnings.push(CompatWarning {
467                category: "unknown-flag",
468                message: format!("unrecognized option '{}'", flag),
469            });
470        }
471        warnings
472    }
473
474    /// Return a TranslationOutput containing the unknown-flag diagnostics.
475    pub fn unknown_flag_translation_output(&self) -> TranslationOutput {
476        let warnings = self.unknown_flag_diagnostics();
477        TranslationOutput::new(String::new()).with_warnings(warnings)
478    }
479
480    /// Check if there are any unknown or translation-decision flags.
481    ///
482    /// Legacy parser-bucket check: true when unknown flags, the legacy
483    /// `known_unsupported` translation bucket, or `--daemon` are present.
484    /// This is not the execution decision — startup blocking is owned by
485    /// [`crate::gate::evaluate`] over the translation output.
486    pub fn has_unknown_or_unsupported(&self) -> bool {
487        !self.unknown_flags.is_empty() || !self.known_unsupported.is_empty() || self.daemon
488    }
489
490    /// Return parser violations against the exact 2.7.9 command surface.
491    pub fn strict_parser_violations(&self) -> Vec<String> {
492        self.unknown_flags
493            .iter()
494            .cloned()
495            .chain(self.strict_violations.iter().cloned())
496            .collect()
497    }
498
499    /// Version string used by all compatibility entry points.
500    pub fn version_string() -> String {
501        format!("eggress-pproxy-compat {}", env!("CARGO_PKG_VERSION"))
502    }
503
504    /// Validate values whose upstream argparse `type=` callbacks run during
505    /// parsing. URI failures therefore stay in the CLI-parse category rather
506    /// than being reported as a later runtime/configuration failure.
507    /// Reads the structured parser fields, not the legacy string bucket.
508    pub fn validate_strict_values(&self) -> Result<(), CompatError> {
509        self.parse_local_uris()
510            .map_err(|error| CompatError::InvalidArgs {
511                message: format!("invalid -l URI: {error}"),
512            })?;
513        self.parse_remote_chains()
514            .map_err(|error| CompatError::InvalidArgs {
515                message: format!("invalid -r URI: {error}"),
516            })?;
517        if let Some(scheduler) = self.scheduler.as_deref() {
518            if !matches!(scheduler, "fa" | "rr" | "rc" | "lc") {
519                return Err(CompatError::InvalidArgs {
520                    message: format!(
521                        "invalid choice: '{}' (choose from fa, rr, rc, lc)",
522                        scheduler
523                    ),
524                });
525            }
526        }
527        if let Some(interval) = self.alive.as_deref() {
528            interval
529                .parse::<i64>()
530                .map_err(|_| CompatError::InvalidArgs {
531                    message: format!("-a value '{}' is not a valid integer", interval),
532                })?;
533        }
534        for (kind, values) in [
535            ("udp-listen", self.udp_listen.iter()),
536            ("udp-remote", self.udp_remote.iter()),
537        ] {
538            for value in values {
539                crate::uri::parse_pproxy_uri(value).map_err(|error| CompatError::InvalidArgs {
540                    message: format!("invalid {kind} URI: {error}"),
541                })?;
542            }
543        }
544        Ok(())
545    }
546
547    /// Return the pproxy default re-authentication interval when `--auth` was
548    /// omitted. The field remains optional so translation/API callers can
549    /// distinguish an explicit compatibility option from the parser default.
550    pub fn effective_auth_timeout(&self) -> Duration {
551        self.auth_timeout
552            .unwrap_or_else(|| Duration::from_secs(86_400 * 30))
553    }
554
555    /// Default tracing log level chosen from `-d` and `-v` verbosity flags.
556    ///
557    /// Resolution order (highest precedence first):
558    ///
559    /// 1. `-vvv` -> `trace`
560    /// 2. `-v` / `-vv` / `-d` -> `debug`
561    /// 3. otherwise -> `info`
562    ///
563    /// `-d` is independent of `-v` and of `--daemon`. This helper is the
564    /// single source of truth used by the standalone compatibility binary
565    /// (and tests) so the observable behavior of `-d` is testable without
566    /// depending on tracing internals.
567    ///
568    /// An explicit `RUST_LOG` environment variable remains authoritative
569    /// at the `tracing_subscriber` layer; this helper is only consulted when
570    /// `RUST_LOG` is unset or invalid.
571    pub fn default_log_level(&self) -> &'static str {
572        if self.verbose_level >= 3 {
573            "trace"
574        } else if self.verbose_level >= 1 || self.debug {
575            "debug"
576        } else {
577            "info"
578        }
579    }
580
581    /// Parse all local URIs into typed representations.
582    pub fn parse_local_uris(&self) -> Result<Vec<PproxyUri>, CompatError> {
583        self.local
584            .iter()
585            .map(|s| crate::uri::parse_pproxy_uri(s))
586            .collect()
587    }
588
589    /// Parse all remote URIs into typed representations.
590    pub fn parse_remote_uris(&self) -> Result<Vec<PproxyUri>, CompatError> {
591        self.remotes
592            .iter()
593            .map(|s| crate::uri::parse_pproxy_uri(s))
594            .collect()
595    }
596
597    /// Parse all remote URIs into chain representations (supports `__` separators).
598    pub fn parse_remote_chains(&self) -> Result<Vec<PproxyChain>, CompatError> {
599        self.remotes
600            .iter()
601            .map(|s| crate::uri::parse_pproxy_chain(s))
602            .collect()
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_parse_basic() {
612        let args = PproxyArgs::parse(&[
613            "-l".into(),
614            "socks5://127.0.0.1:1080".into(),
615            "-r".into(),
616            "http://proxy:8080".into(),
617        ])
618        .unwrap();
619        assert_eq!(args.local.len(), 1);
620        assert_eq!(args.remotes.len(), 1);
621        assert_eq!(args.local[0], "socks5://127.0.0.1:1080");
622        assert_eq!(args.remotes[0], "http://proxy:8080");
623    }
624
625    #[test]
626    fn test_parse_help_and_version_actions() {
627        let help = PproxyArgs::parse(&["--help".into()]).unwrap();
628        assert!(help.help);
629        assert!(!help.version);
630        assert!(help.strict_parser_violations().is_empty());
631
632        let version = PproxyArgs::parse(&["--version".into()]).unwrap();
633        assert!(version.version);
634        assert!(!version.help);
635        assert_eq!(
636            PproxyArgs::version_string(),
637            format!("eggress-pproxy-compat {}", env!("CARGO_PKG_VERSION"))
638        );
639    }
640
641    #[test]
642    fn test_parse_positional() {
643        let args =
644            PproxyArgs::parse(&["socks5://127.0.0.1:1080".into(), "http://proxy:8080".into()])
645                .unwrap();
646        assert_eq!(args.local.len(), 1);
647        assert_eq!(args.remotes.len(), 1);
648    }
649
650    #[test]
651    fn test_parse_multiple_remotes() {
652        let args = PproxyArgs::parse(&[
653            "-l".into(),
654            "socks5://127.0.0.1:1080".into(),
655            "-r".into(),
656            "http://proxy1:8080".into(),
657            "-r".into(),
658            "socks5://proxy2:1080".into(),
659        ])
660        .unwrap();
661        assert_eq!(args.remotes.len(), 2);
662    }
663
664    #[test]
665    fn test_parse_missing_value() {
666        let result = PproxyArgs::parse(&["-l".into()]);
667        assert!(result.is_err());
668    }
669
670    #[test]
671    fn test_parse_daemon_flag() {
672        let args = PproxyArgs::parse(&[
673            "-l".into(),
674            "socks5://127.0.0.1:1080".into(),
675            "--daemon".into(),
676        ])
677        .unwrap();
678        assert!(args.daemon);
679        assert!(!args.debug);
680    }
681
682    #[test]
683    fn test_parse_debug_flag() {
684        let args = PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-d".into()])
685            .unwrap();
686        assert!(args.debug);
687        assert_eq!(args.debug_level, 1);
688        assert!(!args.daemon);
689    }
690
691    #[test]
692    fn test_count_actions_and_short_clusters() {
693        let args = PproxyArgs::parse(&[
694            "-l".into(),
695            "http://:8080".into(),
696            "-dd".into(),
697            "-v".into(),
698            "-vv".into(),
699        ])
700        .unwrap();
701        assert_eq!(args.debug_level, 2);
702        assert_eq!(args.verbose_level, 3);
703
704        let clustered =
705            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-dv".into()]).unwrap();
706        assert_eq!(clustered.debug_level, 1);
707        assert_eq!(clustered.verbose_level, 1);
708    }
709
710    #[test]
711    fn test_strict_parser_rejects_extensions_and_positionals() {
712        let args = PproxyArgs::parse(&[
713            "--log".into(),
714            "access.log".into(),
715            "proxy://listener".into(),
716        ])
717        .unwrap();
718        assert!(args
719            .strict_parser_violations()
720            .iter()
721            .any(|value| value == "--log"));
722        assert!(args
723            .strict_parser_violations()
724            .iter()
725            .any(|value| value == "proxy://listener"));
726    }
727
728    #[test]
729    fn test_target_preserves_value_taking_option() {
730        let args = PproxyArgs::parse(&[
731            "-l".into(),
732            "http://:8080".into(),
733            "--test".into(),
734            "https://example.invalid/health".into(),
735        ])
736        .unwrap();
737        assert_eq!(args.test_target(), Some("https://example.invalid/health"));
738        assert_eq!(
739            args.test_value.as_deref(),
740            Some("https://example.invalid/health")
741        );
742    }
743
744    #[test]
745    fn structured_fields_match_legacy_bucket() {
746        let args = PproxyArgs::parse(&[
747            "-l".into(),
748            "http://:8080".into(),
749            "--ssl".into(),
750            "cert.pem,key.pem".into(),
751            "--pac".into(),
752            "/proxy.pac".into(),
753            "--test".into(),
754            "http://example.com".into(),
755            "-ul".into(),
756            "socks5://:1081".into(),
757            "-ur".into(),
758            "socks5://proxy:1080".into(),
759            "-s".into(),
760            "rr".into(),
761            "-a".into(),
762            "10".into(),
763            "-b".into(),
764            ".*\\.example\\.com".into(),
765            "--log".into(),
766            "access.log".into(),
767            "--rulefile".into(),
768            "rules.txt".into(),
769            "--get".into(),
770            "/index.html,body.txt".into(),
771        ])
772        .unwrap();
773        // Structured state is authoritative; the banner and translation
774        // consume these accessors, never string scans.
775        assert!(args.tls_requested());
776        assert_eq!(args.tls_value(), Some("cert.pem,key.pem"));
777        assert!(args.pac_requested());
778        assert_eq!(args.pac_value(), Some("/proxy.pac"));
779        assert_eq!(args.test_target(), Some("http://example.com"));
780        assert_eq!(args.udp_listen_addrs(), vec!["socks5://:1081"]);
781        assert_eq!(args.udp_remote_addrs(), vec!["socks5://proxy:1080"]);
782        assert_eq!(args.scheduler_value(), Some("rr"));
783        assert_eq!(args.alive_value(), Some("10"));
784        assert_eq!(args.block_values, vec![".*\\.example\\.com".to_string()]);
785        assert_eq!(args.log_values, vec!["access.log".to_string()]);
786        assert_eq!(args.rulefile_values, vec!["rules.txt".to_string()]);
787        assert_eq!(args.get_values, vec!["/index.html,body.txt".to_string()]);
788        // The legacy bucket stays populated for back-compat readers.
789        for entry in [
790            "ssl=cert.pem,key.pem",
791            "pac=/proxy.pac",
792            "test=http://example.com",
793            "udp-listen=socks5://:1081",
794            "udp-remote=socks5://proxy:1080",
795            "scheduler=rr",
796            "alive=10",
797        ] {
798            assert!(
799                args.known_unsupported.contains(&entry.to_string()),
800                "legacy bucket must still carry '{entry}'"
801            );
802        }
803    }
804
805    #[test]
806    fn test_d_and_daemon_independent() {
807        let args = PproxyArgs::parse(&[
808            "-l".into(),
809            "socks5://127.0.0.1:1080".into(),
810            "-d".into(),
811            "--daemon".into(),
812        ])
813        .unwrap();
814        assert!(args.debug);
815        assert!(args.daemon);
816    }
817
818    #[test]
819    fn test_d_never_sets_daemon() {
820        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-d".into()]).unwrap();
821        assert!(args.debug);
822        assert!(!args.daemon);
823    }
824
825    #[test]
826    fn test_daemon_never_sets_debug() {
827        let args =
828            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
829        assert!(!args.debug);
830        assert!(args.daemon);
831    }
832
833    #[test]
834    fn test_parse_verbose_flag() {
835        let args = PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-v".into()])
836            .unwrap();
837        assert_eq!(args.verbose_level, 1);
838    }
839
840    #[test]
841    fn test_parse_scheduler_flag() {
842        let args = PproxyArgs::parse(&[
843            "-l".into(),
844            "socks5://127.0.0.1:1080".into(),
845            "-s".into(),
846            "rr".into(),
847        ])
848        .unwrap();
849        assert!(args.known_unsupported.contains(&"scheduler=rr".to_string()));
850    }
851
852    #[test]
853    fn test_scheduler_migration_alias_is_not_strict_executable_surface() {
854        let args = PproxyArgs::parse(&[
855            "-l".into(),
856            "socks5://127.0.0.1:1080".into(),
857            "-s".into(),
858            "round_robin".into(),
859        ])
860        .unwrap();
861        assert!(args
862            .strict_parser_violations()
863            .iter()
864            .any(|value| value == "-s round_robin"));
865        assert!(args.validate_strict_values().is_err());
866    }
867
868    #[test]
869    fn test_parse_alive_flag() {
870        let args = PproxyArgs::parse(&[
871            "-l".into(),
872            "socks5://127.0.0.1:1080".into(),
873            "-a".into(),
874            "10".into(),
875        ])
876        .unwrap();
877        assert!(args.known_unsupported.contains(&"alive=10".to_string()));
878    }
879
880    #[test]
881    fn test_parse_ssl_flag() {
882        let args = PproxyArgs::parse(&[
883            "-l".into(),
884            "socks5://127.0.0.1:1080".into(),
885            "--ssl".into(),
886            "cert.pem,key.pem".into(),
887        ])
888        .unwrap();
889        assert!(args
890            .known_unsupported
891            .contains(&"ssl=cert.pem,key.pem".to_string()));
892    }
893
894    #[test]
895    fn test_parse_block_flag() {
896        let args = PproxyArgs::parse(&[
897            "-l".into(),
898            "socks5://127.0.0.1:1080".into(),
899            "-b".into(),
900            ".*\\.example\\.com".into(),
901        ])
902        .unwrap();
903        assert!(args
904            .known_unsupported
905            .contains(&"block=.*\\.example\\.com".to_string()));
906    }
907
908    #[test]
909    fn test_parse_log_flag() {
910        let args = PproxyArgs::parse(&[
911            "-l".into(),
912            "socks5://127.0.0.1:1080".into(),
913            "--log".into(),
914            "access.log".into(),
915        ])
916        .unwrap();
917        assert!(args
918            .known_unsupported
919            .contains(&"log=access.log".to_string()));
920    }
921
922    #[test]
923    fn test_parse_udp_flags() {
924        let args = PproxyArgs::parse(&[
925            "-l".into(),
926            "socks5://127.0.0.1:1080".into(),
927            "-ul".into(),
928            "socks5://:1081".into(),
929            "-ur".into(),
930            "socks5://proxy:1080".into(),
931        ])
932        .unwrap();
933        assert!(args
934            .known_unsupported
935            .contains(&"udp-listen=socks5://:1081".to_string()));
936        assert!(args
937            .known_unsupported
938            .contains(&"udp-remote=socks5://proxy:1080".to_string()));
939    }
940
941    #[test]
942    fn test_parse_rulefile_flag() {
943        let args = PproxyArgs::parse(&[
944            "-l".into(),
945            "socks5://127.0.0.1:1080".into(),
946            "--rulefile".into(),
947            "rules.txt".into(),
948        ])
949        .unwrap();
950        assert!(args
951            .known_unsupported
952            .contains(&"rulefile=rules.txt".to_string()));
953    }
954
955    #[test]
956    fn test_unknown_flag_diagnostics() {
957        let args = PproxyArgs::parse(&[
958            "-l".into(),
959            "socks5://127.0.0.1:1080".into(),
960            "--unknown-flag".into(),
961            "-x".into(),
962        ])
963        .unwrap();
964        let warnings = args.unknown_flag_diagnostics();
965        assert_eq!(warnings.len(), 2);
966        assert!(warnings
967            .iter()
968            .any(|w| w.message.contains("--unknown-flag")));
969        assert!(warnings.iter().any(|w| w.message.contains("-x")));
970    }
971
972    #[test]
973    fn test_known_flags_no_unknown_warnings() {
974        let args = PproxyArgs::parse(&[
975            "-l".into(),
976            "socks5://127.0.0.1:1080".into(),
977            "-v".into(),
978            "-s".into(),
979            "rr".into(),
980            "-a".into(),
981            "10".into(),
982            "--daemon".into(),
983            "--log".into(),
984            "access.log".into(),
985            "-ul".into(),
986            "socks5://:1081".into(),
987            "-ur".into(),
988            "socks5://proxy:1080".into(),
989            "--rulefile".into(),
990            "rules.txt".into(),
991            "--ssl".into(),
992            "cert.pem,key.pem".into(),
993            "-b".into(),
994            ".*\\.example\\.com".into(),
995        ])
996        .unwrap();
997        let warnings = args.unknown_flag_diagnostics();
998        assert!(warnings.is_empty());
999    }
1000
1001    #[test]
1002    fn test_scheduler_missing_value() {
1003        let result =
1004            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-s".into()]);
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    fn test_log_missing_value() {
1010        let result = PproxyArgs::parse(&[
1011            "-l".into(),
1012            "socks5://127.0.0.1:1080".into(),
1013            "--log".into(),
1014        ]);
1015        assert!(result.is_err());
1016    }
1017
1018    #[test]
1019    fn test_udp_listen_missing_value() {
1020        let result =
1021            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-ul".into()]);
1022        assert!(result.is_err());
1023    }
1024
1025    #[test]
1026    fn test_udp_remote_missing_value() {
1027        let result =
1028            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-ur".into()]);
1029        assert!(result.is_err());
1030    }
1031
1032    #[test]
1033    fn test_rulefile_missing_value() {
1034        let result = PproxyArgs::parse(&[
1035            "-l".into(),
1036            "socks5://127.0.0.1:1080".into(),
1037            "--rulefile".into(),
1038        ]);
1039        assert!(result.is_err());
1040    }
1041
1042    #[test]
1043    fn test_alive_missing_value() {
1044        let result =
1045            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-a".into()]);
1046        assert!(result.is_err());
1047    }
1048
1049    #[test]
1050    fn test_ssl_missing_value() {
1051        let result = PproxyArgs::parse(&[
1052            "-l".into(),
1053            "socks5://127.0.0.1:1080".into(),
1054            "--ssl".into(),
1055        ]);
1056        assert!(result.is_err());
1057    }
1058
1059    #[test]
1060    fn test_block_missing_value() {
1061        let result =
1062            PproxyArgs::parse(&["-l".into(), "socks5://127.0.0.1:1080".into(), "-b".into()]);
1063        assert!(result.is_err());
1064    }
1065
1066    #[test]
1067    fn test_parse_known_flags_pac_test_sys_reuse_get() {
1068        let args = PproxyArgs::parse(&[
1069            "-l".into(),
1070            "socks5://127.0.0.1:1080".into(),
1071            "--pac".into(),
1072            "/proxy.pac".into(),
1073            "--test".into(),
1074            "http://example.com".into(),
1075            "--sys".into(),
1076            "--reuse".into(),
1077            "--get".into(),
1078            "/index.html,body.txt".into(),
1079        ])
1080        .unwrap();
1081        assert!(args.system_proxy);
1082        assert!(args.reuse_port);
1083        let warnings = args.unknown_flag_diagnostics();
1084        assert!(warnings.is_empty(), "unexpected warnings: {:?}", warnings);
1085    }
1086
1087    #[test]
1088    fn test_verbose_level_single() {
1089        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-v".into()]).unwrap();
1090        assert_eq!(args.verbose_level, 1);
1091    }
1092
1093    #[test]
1094    fn test_verbose_level_double() {
1095        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vv".into()]).unwrap();
1096        assert_eq!(args.verbose_level, 2);
1097    }
1098
1099    #[test]
1100    fn test_verbose_level_triple() {
1101        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vvv".into()]).unwrap();
1102        assert_eq!(args.verbose_level, 3);
1103    }
1104
1105    #[test]
1106    fn test_verbose_level_default_zero() {
1107        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
1108        assert_eq!(args.verbose_level, 0);
1109    }
1110
1111    #[test]
1112    fn test_verbose_level_max_of_multiple() {
1113        let args = PproxyArgs::parse(&[
1114            "-l".into(),
1115            "http://:8080".into(),
1116            "-v".into(),
1117            "-vvv".into(),
1118        ])
1119        .unwrap();
1120        assert_eq!(args.verbose_level, 4);
1121    }
1122
1123    #[test]
1124    fn test_has_args_true() {
1125        assert!(PproxyArgs::has_args(&["-l".into(), "http://:8080".into()]));
1126    }
1127
1128    #[test]
1129    fn test_has_args_false_empty() {
1130        assert!(!PproxyArgs::has_args(&[]));
1131    }
1132
1133    #[test]
1134    fn test_default_args() {
1135        let args = PproxyArgs::default_args();
1136        assert_eq!(args.local, vec!["http+socks4+socks5://:8080"]);
1137        assert!(args.remotes.is_empty());
1138        assert_eq!(args.verbose_level, 0);
1139        assert!(!args.debug);
1140        assert!(!args.daemon);
1141        assert!(!args.reuse_port);
1142        assert!(args.auth_timeout.is_none());
1143        assert!(!args.system_proxy);
1144    }
1145
1146    #[test]
1147    fn test_default_args_translates() {
1148        let args = PproxyArgs::default_args();
1149        let output = super::super::translate::translate_pproxy_args(&args).unwrap();
1150        assert!(output.toml.contains("8080"));
1151        assert!(output.toml.contains("socks5") || output.toml.contains("http"));
1152        assert!(!output.has_unsupported());
1153    }
1154
1155    #[test]
1156    fn test_parse_reuse_flag() {
1157        let args = PproxyArgs::parse(&[
1158            "-l".into(),
1159            "socks5://127.0.0.1:1080".into(),
1160            "--reuse".into(),
1161        ])
1162        .unwrap();
1163        assert!(args.reuse_port);
1164    }
1165
1166    #[test]
1167    fn test_parse_auth_valid() {
1168        let args = PproxyArgs::parse(&[
1169            "-l".into(),
1170            "socks5://127.0.0.1:1080".into(),
1171            "--auth".into(),
1172            "3600".into(),
1173        ])
1174        .unwrap();
1175        assert_eq!(args.auth_timeout, Some(Duration::from_secs(3600)));
1176    }
1177
1178    #[test]
1179    fn test_parse_auth_zero() {
1180        let args = PproxyArgs::parse(&[
1181            "-l".into(),
1182            "socks5://127.0.0.1:1080".into(),
1183            "--auth".into(),
1184            "0".into(),
1185        ])
1186        .unwrap();
1187        assert_eq!(args.auth_timeout, Some(Duration::from_secs(0)));
1188    }
1189
1190    #[test]
1191    fn test_parse_auth_invalid_non_numeric() {
1192        let result = PproxyArgs::parse(&[
1193            "-l".into(),
1194            "socks5://127.0.0.1:1080".into(),
1195            "--auth".into(),
1196            "abc".into(),
1197        ]);
1198        assert!(result.is_err());
1199    }
1200
1201    #[test]
1202    fn test_parse_auth_overflow() {
1203        let result = PproxyArgs::parse(&[
1204            "-l".into(),
1205            "socks5://127.0.0.1:1080".into(),
1206            "--auth".into(),
1207            "9223372036854775808".into(),
1208        ]);
1209        assert!(result.is_err());
1210    }
1211
1212    #[test]
1213    fn test_parse_auth_negative_matches_argparse_integer() {
1214        let args = PproxyArgs::parse(&[
1215            "-l".into(),
1216            "http://127.0.0.1:0".into(),
1217            "--auth".into(),
1218            "-1".into(),
1219        ])
1220        .unwrap();
1221        assert_eq!(args.auth_timeout, Some(Duration::ZERO));
1222    }
1223
1224    #[test]
1225    fn test_parse_auth_missing_value() {
1226        let result = PproxyArgs::parse(&[
1227            "-l".into(),
1228            "socks5://127.0.0.1:1080".into(),
1229            "--auth".into(),
1230        ]);
1231        assert!(result.is_err());
1232    }
1233
1234    #[test]
1235    fn test_parse_sys_flag() {
1236        let args = PproxyArgs::parse(&[
1237            "-l".into(),
1238            "socks5://127.0.0.1:1080".into(),
1239            "--sys".into(),
1240        ])
1241        .unwrap();
1242        assert!(args.system_proxy);
1243    }
1244
1245    #[test]
1246    fn test_has_unknown_or_unsupported_with_unknown() {
1247        let args =
1248            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--bogus".into()]).unwrap();
1249        assert!(args.has_unknown_or_unsupported());
1250    }
1251
1252    #[test]
1253    fn test_has_unknown_or_unsupported_with_daemon() {
1254        let args =
1255            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
1256        assert!(args.has_unknown_or_unsupported());
1257    }
1258
1259    #[test]
1260    fn test_no_raw_flags_field() {
1261        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
1262        assert!(args.known_unsupported.is_empty());
1263        assert!(args.unknown_flags.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_default_log_level_no_flags() {
1268        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into()]).unwrap();
1269        assert_eq!(args.default_log_level(), "info");
1270    }
1271
1272    #[test]
1273    fn test_default_log_level_d_flag() {
1274        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-d".into()]).unwrap();
1275        assert_eq!(args.default_log_level(), "debug");
1276    }
1277
1278    #[test]
1279    fn test_default_log_level_v_flag() {
1280        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-v".into()]).unwrap();
1281        assert_eq!(args.default_log_level(), "debug");
1282    }
1283
1284    #[test]
1285    fn test_default_log_level_vv_flag() {
1286        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vv".into()]).unwrap();
1287        assert_eq!(args.default_log_level(), "debug");
1288    }
1289
1290    #[test]
1291    fn test_default_log_level_vvv_flag() {
1292        let args = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vvv".into()]).unwrap();
1293        assert_eq!(args.default_log_level(), "trace");
1294    }
1295
1296    #[test]
1297    fn test_default_log_level_d_and_vv() {
1298        let args = PproxyArgs::parse(&[
1299            "-l".into(),
1300            "http://:8080".into(),
1301            "-d".into(),
1302            "-vv".into(),
1303        ])
1304        .unwrap();
1305        assert_eq!(args.default_log_level(), "debug");
1306    }
1307
1308    #[test]
1309    fn test_default_log_level_d_and_vvv() {
1310        let args = PproxyArgs::parse(&[
1311            "-l".into(),
1312            "http://:8080".into(),
1313            "-d".into(),
1314            "-vvv".into(),
1315        ])
1316        .unwrap();
1317        assert_eq!(args.default_log_level(), "trace");
1318    }
1319
1320    #[test]
1321    fn test_default_log_level_daemon_only_keeps_daemon_separate() {
1322        let args =
1323            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "--daemon".into()]).unwrap();
1324        assert!(args.daemon);
1325        assert!(!args.debug);
1326        assert_eq!(args.default_log_level(), "info");
1327    }
1328
1329    #[test]
1330    fn test_default_log_level_d_still_works_with_daemon() {
1331        let args = PproxyArgs::parse(&[
1332            "-l".into(),
1333            "http://:8080".into(),
1334            "-d".into(),
1335            "--daemon".into(),
1336        ])
1337        .unwrap();
1338        assert!(args.debug);
1339        assert_eq!(args.default_log_level(), "debug");
1340    }
1341
1342    #[test]
1343    fn test_default_log_level_default_args() {
1344        let args = PproxyArgs::default_args();
1345        assert_eq!(args.default_log_level(), "info");
1346    }
1347
1348    #[test]
1349    fn test_default_log_level_vvvv_saturates_to_trace() {
1350        for flag in ["-vvvv", "-vvvvv", "-vvvvvvvv"] {
1351            let args =
1352                PproxyArgs::parse(&["-l".into(), "http://:8080".into(), flag.into()]).unwrap();
1353            assert_eq!(
1354                args.default_log_level(),
1355                "trace",
1356                "flag {flag} should saturate to trace"
1357            );
1358        }
1359    }
1360
1361    #[test]
1362    fn test_default_log_level_clustered_mixed_forms() {
1363        // `-dv` / `-vd` carry one debug + one verbose count -> debug.
1364        for flag in ["-dv", "-vd", "-ddv", "-vvd", "-dvv"] {
1365            let args =
1366                PproxyArgs::parse(&["-l".into(), "http://:8080".into(), flag.into()]).unwrap();
1367            assert!(args.debug, "flag {flag} should set debug");
1368            assert!(args.verbose_level >= 1, "flag {flag} should count verbose");
1369        }
1370        let dv = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-dv".into()]).unwrap();
1371        assert_eq!(dv.default_log_level(), "debug");
1372        let vd = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-vd".into()]).unwrap();
1373        assert_eq!(vd.default_log_level(), "debug");
1374        let dvv = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-dvv".into()]).unwrap();
1375        assert_eq!(dvv.verbose_level, 2);
1376        assert_eq!(dvv.default_log_level(), "debug");
1377    }
1378
1379    #[test]
1380    fn test_default_log_level_clustered_trace_forms() {
1381        // Three or more verbose counts via clustered/mixed flags -> trace.
1382        let dvvv =
1383            PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-dvvv".into()]).unwrap();
1384        assert_eq!(dvvv.verbose_level, 3);
1385        assert_eq!(dvvv.default_log_level(), "trace");
1386
1387        let mixed = PproxyArgs::parse(&[
1388            "-l".into(),
1389            "http://:8080".into(),
1390            "-dv".into(),
1391            "-vv".into(),
1392        ])
1393        .unwrap();
1394        assert_eq!(mixed.verbose_level, 3);
1395        assert_eq!(mixed.default_log_level(), "trace");
1396    }
1397
1398    #[test]
1399    fn test_default_log_level_repeated_flags_accumulate() {
1400        let args = PproxyArgs::parse(&[
1401            "-l".into(),
1402            "http://:8080".into(),
1403            "-v".into(),
1404            "-v".into(),
1405            "-v".into(),
1406        ])
1407        .unwrap();
1408        assert_eq!(args.verbose_level, 3);
1409        assert_eq!(args.default_log_level(), "trace");
1410
1411        let dd = PproxyArgs::parse(&["-l".into(), "http://:8080".into(), "-d".into(), "-d".into()])
1412            .unwrap();
1413        assert!(dd.debug);
1414        assert_eq!(dd.debug_level, 2);
1415        assert_eq!(dd.default_log_level(), "debug");
1416    }
1417
1418    /// Table-driven arity test sourced from the checked-in baseline.
1419    /// Ensures that every value-taking option in the baseline correctly
1420    /// requires a value and fails when missing.
1421    #[test]
1422    fn test_baseline_value_arity() {
1423        // Each entry: (flag, expects_value)
1424        // "value" means the flag requires a following argument.
1425        let cases: &[(&[&str], bool)] = &[
1426            // Value-taking options (must have next arg)
1427            (&["-l", "http://:8080"], true),
1428            (&["-r", "http://proxy:8080"], true),
1429            (&["-ul", "socks5://:1081"], true),
1430            (&["-ur", "socks5://proxy:1080"], true),
1431            (&["--ssl", "cert.pem,key.pem"], true),
1432            (&["--pac", "/proxy.pac"], true),
1433            (&["--test", "http://example.com"], true),
1434            (&["--auth", "3600"], true),
1435            (&["--get", "/index.html,body.txt"], true),
1436            (&["-s", "rr"], true),
1437            (&["-a", "10"], true),
1438            (&["-b", ".*\\.example\\.com"], true),
1439            (&["--rulefile", "rules.txt"], true),
1440            (&["--log", "access.log"], true),
1441            // Boolean flags (no value needed)
1442            (&["-v"], false),
1443            (&["-d"], false),
1444            (&["--daemon"], false),
1445            (&["--sys"], false),
1446            (&["--reuse"], false),
1447        ];
1448
1449        for (args_slice, expects_value) in cases {
1450            let raw: Vec<String> = args_slice.iter().map(|s| s.to_string()).collect();
1451            let result = PproxyArgs::parse(&raw);
1452            if *expects_value && raw.len() == 1 {
1453                // Single value-taking flag with no value should fail
1454                assert!(
1455                    result.is_err(),
1456                    "expected error for {:?} (missing value), got Ok",
1457                    args_slice
1458                );
1459            } else {
1460                // Complete flag+value or boolean flag should succeed
1461                assert!(
1462                    result.is_ok(),
1463                    "expected Ok for {:?}, got Err: {:?}",
1464                    args_slice,
1465                    result.err()
1466                );
1467            }
1468        }
1469    }
1470}