Skip to main content

pounce_cli/
cli.rs

1//! Argv parser for the `pounce` binary. Tiny hand-rolled parser so we
2//! avoid pulling in `clap` (and its 100k LOC dependency tree).
3
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub enum ProblemSource {
8    Builtin(String),
9    NlFile(PathBuf),
10}
11
12/// Which options file a run should read, decided from argv (and
13/// `$pounce_options`) *before* any file is opened. See
14/// [`Args::option_file_choice`].
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum OptionFileChoice {
17    /// The user named a file: `--options-file <path>` or
18    /// `option_file_name=<path>`. A named file that does not exist is a
19    /// hard error — silently running at stock defaults is the failure
20    /// mode this whole path exists to remove (gh#518).
21    Named(PathBuf),
22    /// Nobody named one: probe the working directory for the default
23    /// names (`pounce.opt`, then `ipopt.opt`), reading whichever exists.
24    Discover,
25    /// `--no-options-file`: read none, probe nothing.
26    Suppressed,
27}
28
29#[derive(Debug, Clone)]
30pub struct Args {
31    pub problem: ProblemSource,
32    pub options_file: Option<PathBuf>,
33    /// `--no-options-file` — read no options file at all: neither an
34    /// implicit `pounce.opt` / `ipopt.opt` from the working directory nor
35    /// anything named by `option_file_name`. The escape hatch for a
36    /// directory holding an options file written for another run (or for
37    /// Ipopt), since that file is otherwise picked up automatically.
38    pub no_options_file: bool,
39    /// `key=value` options collected from the command line. Forwarded to
40    /// the application's `OptionsList` after the options-file load (so
41    /// CLI args override file values), mirroring upstream ipopt's
42    /// `ipopt problem.nl print_level=8 ...` convention.
43    pub set_options: Vec<(String, String)>,
44    /// `--json-output PATH` — when set, the binary writes a
45    /// machine-readable JSON solve report to PATH after the solve
46    /// completes. See [`crate::solve_report`] (pounce#8).
47    pub json_output: Option<PathBuf>,
48    /// `--json-detail summary|full` — controls how much detail the
49    /// JSON report carries. Defaults to `Summary`. `Full` adds
50    /// per-iteration history and suffix blocks; same scale as
51    /// upstream's `print_level` but on the JSON side.
52    pub json_detail: crate::solve_report::ReportDetail,
53    /// `--sol-output PATH` — write an AMPL `.sol` solution file to
54    /// PATH. When unset, a positional `.nl` input still gets a sibling
55    /// `<stub>.sol` (the AMPL solver convention); `--no-sol` opts out
56    /// of that default. Builtin problems have no stub, so they only
57    /// produce a `.sol` when this flag is given explicitly.
58    pub sol_output: Option<PathBuf>,
59    /// `--no-sol` — suppress the default `<stub>.sol` write for `.nl`
60    /// inputs.
61    pub no_sol: bool,
62    /// `-AMPL` — the AMPL solver-protocol flag. AMPL and Pyomo's ASL
63    /// interface invoke a solver as `solver problem.nl -AMPL`. It needs
64    /// no positional behavior (pounce already reads the `.nl` and
65    /// writes `<stub>.sol`), but it does switch the process exit-code
66    /// contract: in AMPL mode the termination is conveyed through the
67    /// `.sol` file's `solve_result_num`, so the process exits 0 for any
68    /// non-fatal solve outcome (limit reached, infeasible, etc.) rather
69    /// than the non-zero code the plain CLI uses.
70    pub ampl: bool,
71    pub help: bool,
72    pub version: bool,
73    /// `--about`: print build metadata, compiled-in features, available
74    /// linear solvers, and runtime paths. Used for bug reports.
75    pub about: bool,
76    /// `--cite [REPORT.json]`: print the citations a user should include
77    /// when publishing pounce results, then exit. Always lists the static
78    /// core (pounce itself + Wächter-Biegler). When a solve-report JSON
79    /// path follows, adds solve-aware extras for features the run actually
80    /// used (v1: the restoration phase). A terminal mode like `--about` —
81    /// requires no problem.
82    pub cite: bool,
83    /// Optional solve-report path consumed by `--cite` (the immediately
84    /// following argument, iff present and not another flag).
85    pub cite_report: Option<PathBuf>,
86    /// `--bibtex`: render `--cite` output as BibTeX instead of the human
87    /// list. No effect without `--cite`.
88    pub cite_bibtex: bool,
89    /// `--dump <cat>[:<iter-spec>]`, repeatable. Each entry asks the
90    /// solver to dump one diagnostic category at the specified iter
91    /// range (`all`, `N`, `N-M`, `N-`, `-M`); omitting the spec is
92    /// equivalent to `:all`. Forwarded to
93    /// [`pounce_common::diagnostics::DiagnosticsConfig`].
94    pub dump_specs: Vec<(String, String)>,
95    /// `--dump-dir <path>`: override the dump root. Defaults to
96    /// `./pounce-dump-<unix-secs>`, picked at solve-start time.
97    pub dump_dir: Option<PathBuf>,
98    /// `--dump-format <fmt>`: dump file format. Currently only `jsonl`.
99    pub dump_format: Option<String>,
100    /// `--sens-boundcheck` — clamp the perturbed primal `x* + Δx` onto
101    /// the declared `[x_l, x_u]` box after the sensitivity step. Only
102    /// has effect when the `.nl` declares the sIPOPT suffixes. Mirrors
103    /// upstream sIPOPT's `sens_boundcheck`.
104    pub sens_boundcheck: bool,
105    /// `--sens-bound-eps <eps>` — tolerance for `--sens-boundcheck`
106    /// (default `1e-3`). Setting it also enables `--sens-boundcheck`.
107    pub sens_bound_eps: f64,
108    /// `--compute-red-hessian` — after the solve, compute the reduced
109    /// Hessian over the variables tagged by the `red_hessian` integer
110    /// var-suffix in the input `.nl`. Mirrors upstream sIPOPT's
111    /// `compute_red_hessian`.
112    pub compute_red_hessian: bool,
113    /// `--rh-eigendecomp` — also compute the eigendecomposition of the
114    /// reduced Hessian. Implies `--compute-red-hessian`. Mirrors
115    /// upstream `rh_eigendecomp`.
116    pub rh_eigendecomp: bool,
117    /// `--debug` / `--debug-json` — drop into the interactive solver
118    /// debugger at each iteration. `Repl` is the human line-oriented
119    /// front end; `Json` speaks newline-delimited JSON so an LLM agent
120    /// (or any program) can drive the loop. `None` disables it.
121    pub debug: Option<DebugMode>,
122    /// `--debug-on-error` — don't pause every iteration; instead run
123    /// freely and only drop into the debugger at the terminal checkpoint
124    /// *if the solve did not succeed*, for a post-mortem at the failing
125    /// iterate. Implies `--debug` (REPL) when no `--debug*` mode is given.
126    pub debug_on_error: bool,
127    /// `--debug-on-interrupt` — run normally but install a Ctrl-C handler
128    /// that drops into the debugger at the next iteration. No automatic
129    /// pauses. Implies `--debug` (REPL) when no `--debug*` mode is given.
130    pub debug_on_interrupt: bool,
131    /// `--debug-script <file>` — run debugger commands from a file at the
132    /// first pause (e.g. set breakpoints then `continue`). Implies
133    /// `--debug` when no `--debug*` mode is given.
134    pub debug_script: Option<PathBuf>,
135    /// `--minima <method>` (or `--multistart`) — search for multiple local
136    /// minima instead of a single solve. `None` keeps the default
137    /// single-solve behaviour. See [`MinimaArgs`] for the strategy knobs.
138    /// Mirrors `pounce.find_minima` (`python/pounce/_minima.py`).
139    pub minima: Option<MinimaArgs>,
140}
141
142/// Global-search strategy for `--minima`. Mirrors the six methods of
143/// `pounce.find_minima` (`python/pounce/_minima.py`).
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum MinimaMethod {
146    /// Random / Sobol' box sampling (restart).
147    Multistart,
148    /// Multi-Level Single Linkage clustering (Rinnooy Kan & Timmer 1987).
149    Mlsl,
150    /// Metropolis chain over minima (Wales & Doye 1997).
151    Basinhopping,
152    /// Repulsive Gaussian bumps (filled-function; Ge 1990).
153    Flooding,
154    /// Softened `1/‖x−x*‖^p` poles (deflation; Farrell et al. 2015).
155    Deflation,
156    /// Equal-height tunnel between descents (Levy & Montalvo 1985).
157    Tunneling,
158}
159
160impl MinimaMethod {
161    pub fn parse(s: &str) -> Result<Self, String> {
162        Ok(match s {
163            "multistart" => Self::Multistart,
164            "mlsl" => Self::Mlsl,
165            "basinhopping" => Self::Basinhopping,
166            "flooding" => Self::Flooding,
167            "deflation" => Self::Deflation,
168            "tunneling" => Self::Tunneling,
169            other => {
170                return Err(format!(
171                    "unknown --minima method '{other}'; choose from \
172                     multistart, mlsl, basinhopping, flooding, deflation, tunneling"
173                ));
174            }
175        })
176    }
177
178    pub fn as_str(&self) -> &'static str {
179        match self {
180            Self::Multistart => "multistart",
181            Self::Mlsl => "mlsl",
182            Self::Basinhopping => "basinhopping",
183            Self::Flooding => "flooding",
184            Self::Deflation => "deflation",
185            Self::Tunneling => "tunneling",
186        }
187    }
188}
189
190/// Parsed `--minima` configuration. Shared knobs have concrete defaults;
191/// strategy-specific knobs are `Option`s resolved per-method in the driver
192/// (so `"auto"` widths and curvature-based amplitudes match
193/// `pounce.find_minima`). Field semantics mirror `_minima.py` exactly.
194#[derive(Debug, Clone)]
195pub struct MinimaArgs {
196    pub method: MinimaMethod,
197    /// Target: stop once this many distinct minima are found (default 10).
198    pub n_minima: usize,
199    /// Budget: hard cap on solver calls (default `8 * n_minima`).
200    pub max_solves: Option<usize>,
201    /// Give-up: stop after this many solves in a row that find nothing new.
202    pub patience: usize,
203    /// Two minima within this scaled distance are the same (default 1e-4).
204    pub dedup: f64,
205    /// Smallest Hessian eigenvalue tolerated by saddle rejection (1e-6).
206    pub psd_tol: f64,
207    /// Seed for the sampler / Sobol' scramble (default 0; reproducible).
208    pub seed: u64,
209    /// Use a scrambled Sobol' sequence for box sampling (default true).
210    pub sobol: bool,
211    // ---- strategy-specific knobs (None ⇒ per-method default) ----
212    pub sigma: Option<f64>,
213    pub sigma_frac: Option<f64>,
214    pub amplitude: Option<f64>,
215    pub amp_margin: Option<f64>,
216    pub eta: Option<f64>,
217    pub power: Option<f64>,
218    pub soft: Option<f64>,
219    pub length: Option<f64>,
220    pub length_frac: Option<f64>,
221    pub gamma: Option<f64>,
222    pub samples_per_round: Option<usize>,
223    pub step: Option<f64>,
224    pub temperature: Option<f64>,
225    pub restart_jitter: Option<f64>,
226}
227
228impl Default for MinimaArgs {
229    fn default() -> Self {
230        Self {
231            // Matches `find_minima`'s default `method="deflation"`.
232            method: MinimaMethod::Deflation,
233            n_minima: 10,
234            max_solves: None,
235            patience: 8,
236            dedup: 1e-4,
237            psd_tol: 1e-6,
238            seed: 0,
239            sobol: true,
240            sigma: None,
241            sigma_frac: None,
242            amplitude: None,
243            amp_margin: None,
244            eta: None,
245            power: None,
246            soft: None,
247            length: None,
248            length_frac: None,
249            gamma: None,
250            samples_per_round: None,
251            step: None,
252            temperature: None,
253            restart_jitter: None,
254        }
255    }
256}
257
258/// Front end for the interactive solver debugger (`--debug*`).
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub enum DebugMode {
261    /// Human-facing line REPL on stdin/stdout.
262    Repl,
263    /// Newline-delimited JSON protocol for an agent / program.
264    Json,
265}
266
267impl Args {
268    pub fn usage() -> &'static str {
269        "\
270Usage: pounce [OPTIONS] [PATH] [SOL] [KEY=VALUE ...]
271
272PATH is an AMPL .nl file (positional). Equivalent: --nl-file <path>.
273An extensionless AMPL stub is accepted: if PATH is missing but PATH.nl
274exists, PATH.nl is read (the `pounce mystub -AMPL` invocation convention).
275SOL is an optional second positional naming the .sol output file
276(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
277convention.
278
279Options may also be supplied via the `pounce_options` environment
280variable (AMPL's `<solver>_options` convention): a whitespace-separated
281list of KEY=VALUE tokens. Command-line KEY=VALUE options override it.
282
283Options are also read from an ipopt.opt-format options file: the one
284named by --options-file or by option_file_name=<path>, or — when
285neither names one — `pounce.opt` or `ipopt.opt` from the working
286directory, if present. A named file that does not exist is an error.
287KEY=VALUE options (command line or environment) override the file.
288--no-options-file reads none.
289
290Subcommands:
291  pounce verify <problem.nl> <claim.sol> [--feas-tol T] [--json-output P]
292                            independently check that a .sol solution
293                            satisfies the canonical .nl's constraints and
294                            bounds, without trusting the solver/agent that
295                            produced it. Exit 0 = feasible, 20 = violated.
296                            Run `pounce verify --help` for details.
297  pounce check-x0 <problem.nl> [--json] [--json-output P]
298                            starting-point preflight: evaluate the model
299                            once at x0 and report NaN/inf, bound and
300                            constraint violations, interior-clamp
301                            displacement, and derivative scale spread
302                            before any solve. Exit 0 = evaluates cleanly,
303                            21 = NaN/inf at x0.
304                            Run `pounce check-x0 --help` for details.
305
306When the .nl declares the sIPOPT suffixes (sens_state_1,
307sens_state_value_1, sens_init_constr), pounce additionally runs the
308post-optimal parametric sensitivity step and writes the perturbed
309primal back into the .sol as a `sens_sol_state_1` suffix.
310
311Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
312(same syntax/semantics as the ipopt CLI). They override values loaded
313from --options-file. Examples:
314
315  pounce problem.nl print_level=8
316  pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
317
318Required (one of):
319  PATH                      positional .nl file to solve
320  --nl-file <path>          same, as a flag
321  --problem <name>          solve a built-in test problem
322
323Options:
324  --options-file <path>     read solver options from an ipopt.opt-format file
325                            (same as option_file_name=<path>)
326  --no-options-file         read no options file at all — skip the implicit
327                            pounce.opt / ipopt.opt lookup in the working
328                            directory
329  --json-output <path>      write a JSON solve report to PATH after the solve
330                            (pounce#8 — machine-readable, FAIR-aligned)
331  --json-detail LEVEL       summary | full (default: summary). `full` adds
332                            per-iteration history + suffix blocks.
333  --sol-output <path>       write an AMPL .sol solution file to PATH.
334                            A positional .nl input writes <stub>.sol
335                            next to it by default (AMPL convention).
336  --no-sol                  suppress the default <stub>.sol write
337  --sens-boundcheck         clamp the perturbed primal x* + Δx onto the
338                            declared [x_l, x_u] box (sIPOPT sens_boundcheck)
339  --sens-bound-eps EPS      tolerance for --sens-boundcheck (default 1e-3;
340                            setting it also enables --sens-boundcheck)
341  --compute-red-hessian     compute the reduced Hessian over the variables
342                            tagged by the `red_hessian` integer var-suffix
343  --rh-eigendecomp          also compute the reduced-Hessian eigendecomp;
344                            implies --compute-red-hessian
345  --debug                   drop into the interactive solver debugger (a
346                            pdb-for-the-IPM): pause each iteration to
347                            inspect/mutate x, multipliers, mu, set
348                            breakpoints, step/continue. Type `help` at
349                            the pounce-dbg> prompt for commands.
350  --debug-json              same loop, but speak newline-delimited JSON on
351                            stdin/stdout so an LLM agent or program can drive
352                            it. The first line is a self-describing `hello`
353                            handshake (protocol version + every command,
354                            event, checkpoint, metric, and capability), so a
355                            client needs no out-of-band docs; each pause is one
356                            JSON state object. Full spec: docs/src/debugger.md.
357  --debug-on-error          don't pause every iteration; run freely and
358                            drop into the debugger only if the solve fails,
359                            for a post-mortem at the final iterate. Implies
360                            --debug when no --debug* mode is given.
361  --debug-on-interrupt      run normally but install a Ctrl-C handler that
362                            drops into the debugger at the next iteration
363                            (second Ctrl-C aborts). Implies --debug when no
364                            --debug* mode is given.
365  --debug-script <file>     run debugger commands from a file at the first
366                            pause (e.g. set breakpoints then continue).
367                            Implies --debug when no --debug* mode is given.
368  --list-problems           print available built-in problems and exit
369  -AMPL                     AMPL solver-protocol mode (for Pyomo / AMPL
370                            drivers): convey termination via the .sol
371                            file and exit 0 for non-fatal outcomes
372  --help, -h                print this message and exit
373  --version, -v, -V         print version and exit
374  --about                   print version, build info, features,
375                            linear solvers, and runtime paths
376  --cite [REPORT.json]      print the papers to cite when publishing
377                            pounce results, then exit. Always lists pounce
378                            itself + Wächter-Biegler; pass a JSON solve
379                            report (from --json-output) to also list papers
380                            for features the run used (e.g. restoration).
381  --bibtex                  with --cite, emit BibTeX instead of a text list
382  --dump <cat>[:<spec>]     dump diagnostic category to per-iter files.
383                            Repeatable. Categories: kkt, iterate(s), step,
384                            mu, ls, resto, convergence, timing.
385                            Iter-spec grammar: all | N | N-M | N- | -M
386                            (default: all). The `iterates` category also
387                            accepts a `:summary` (default) or `:full`
388                            variant suffix and streams one JSONL row
389                            per iter to <dump-dir>/iterates.jsonl. The
390                            `kkt` category accepts `+L` / `+L+Lvals`
391                            suffixes that add the LDLᵀ factor's
392                            strict-lower pattern (and optional values)
393                            plus the fill-reducing permutation to each
394                            kkt_solve_NNN.jsonl record (feral backend
395                            only; MA57 silently omits the L fields).
396                            Examples:
397                              --dump kkt:5
398                              --dump kkt:2-10 --dump iterate:all
399                              --dump kkt:5-10+L
400                              --dump kkt:5-10+L+Lvals
401                              --dump iterates:summary
402                              --dump iterates:5-:full
403  --dump-dir <path>         override dump root (default ./pounce-dump-<ts>)
404  --dump-format <fmt>       dump format (default: jsonl)
405
406Multistart / find-minima (search for several local minima, not one):
407  --minima <method>         enable multistart with the given strategy:
408                            multistart | mlsl | basinhopping |
409                            flooding | deflation | tunneling
410  --multistart              shorthand for --minima multistart
411  --n-minima <N>            target number of distinct minima (default 10)
412  --max-solves <N>          hard cap on solver calls (default 8*n_minima)
413  --patience <N>            stop after N solves in a row that find nothing
414                            new (default 8)
415  --dedup <d>               minima within this per-dimension-scaled distance
416                            are the same (default 1e-4)
417  --psd-tol <t>             smallest Hessian eigenvalue tolerated by the
418                            saddle-rejection check (default 1e-6)
419  --seed <S>                seed for sampling / Sobol' scramble (default 0)
420  --sobol / --no-sobol      use a scrambled Sobol' sequence for box
421                            sampling (default: on)
422  Strategy knobs (used only by the relevant --minima method; all optional):
423    --sigma, --sigma-frac, --amplitude, --amp-margin   (flooding)
424    --eta, --power, --soft, --length, --length-frac    (deflation/tunneling)
425    --gamma, --samples-per-round                       (mlsl)
426    --step, --temperature                              (basinhopping)
427    --restart-jitter                                   (all restart fallbacks)
428
429  When --minima is set, the global best minimum is written to <stub>.sol
430  (the usual AMPL output), and the remaining minima, ranked by objective,
431  to siblings <stub>.min001.sol, <stub>.min002.sol, ….  The JSON report
432  (--json-output) gains a `minima` section listing every found minimum.
433"
434    }
435
436    pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
437        let mut problem: Option<ProblemSource> = None;
438        let mut options_file: Option<PathBuf> = None;
439        let mut no_options_file = false;
440        let mut set_options: Vec<(String, String)> = Vec::new();
441        let mut json_output: Option<PathBuf> = None;
442        let mut json_detail = crate::solve_report::ReportDetail::Summary;
443        let mut sol_output: Option<PathBuf> = None;
444        let mut no_sol = false;
445        let mut ampl = false;
446        let mut help = false;
447        let mut version = false;
448        let mut about = false;
449        let mut cite = false;
450        let mut cite_report: Option<PathBuf> = None;
451        let mut cite_bibtex = false;
452        let mut list_problems = false;
453        let mut dump_specs: Vec<(String, String)> = Vec::new();
454        let mut dump_dir: Option<PathBuf> = None;
455        let mut dump_format: Option<String> = None;
456        let mut sens_boundcheck = false;
457        let mut sens_bound_eps: f64 = 1e-3;
458        let mut compute_red_hessian = false;
459        let mut rh_eigendecomp = false;
460        let mut debug: Option<DebugMode> = None;
461        let mut debug_on_error = false;
462        let mut debug_on_interrupt = false;
463        let mut debug_script: Option<PathBuf> = None;
464        let mut minima: Option<MinimaArgs> = None;
465        // Global search is enabled ONLY by an explicit method selector
466        // (`--minima <m>` / `--multistart`). The tuning knobs below
467        // (`--seed`, `--patience`, …) populate the config but must not, on
468        // their own, switch the run into multistart mode — track whether a
469        // method was explicitly chosen and which lone knob (if any) was seen
470        // so we can reject a knob-without-method invocation after parsing.
471        let mut minima_method_explicit = false;
472        let mut minima_knob: Option<&'static str> = None;
473
474        let mut it = argv.into_iter().skip(1).peekable();
475        // Shorthand: fetch the value for a flag that requires one.
476        macro_rules! flag_val {
477            ($flag:expr_2021) => {
478                it.next()
479                    .ok_or_else(|| format!("{} requires a value", $flag))?
480            };
481        }
482        // Parse a numeric value for a `--minima` knob, lazily creating the
483        // config (default method = deflation, overridden by `--minima <m>`).
484        macro_rules! minima_num {
485            ($flag:expr_2021, $ty:ty, $field:ident) => {{
486                let v = flag_val!($flag);
487                let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
488                minima.get_or_insert_with(MinimaArgs::default).$field = parsed;
489                if minima_knob.is_none() {
490                    minima_knob = Some($flag);
491                }
492            }};
493            ($flag:expr_2021, $ty:ty, $field:ident, opt) => {{
494                let v = flag_val!($flag);
495                let parsed: $ty = v.parse().map_err(|e| format!("{}: {}", $flag, e))?;
496                minima.get_or_insert_with(MinimaArgs::default).$field = Some(parsed);
497                if minima_knob.is_none() {
498                    minima_knob = Some($flag);
499                }
500            }};
501        }
502        while let Some(arg) = it.next() {
503            match arg.as_str() {
504                "-h" | "--help" => help = true,
505                "-v" | "-V" | "--version" => version = true,
506                "--about" => about = true,
507                "--cite" => {
508                    cite = true;
509                    // Optional value: consume the next argument as the
510                    // solve-report path only if it's present and is not
511                    // itself a flag (so `--cite --bibtex` doesn't swallow
512                    // the modifier, and bare `--cite` stays report-less).
513                    if let Some(next) = it.peek() {
514                        if !next.starts_with('-') {
515                            cite_report = Some(PathBuf::from(it.next().unwrap()));
516                        }
517                    }
518                }
519                "--bibtex" => cite_bibtex = true,
520                // AMPL solver-protocol flag — see `Args::ampl`.
521                "-AMPL" => ampl = true,
522                "--list-problems" => list_problems = true,
523                "--problem" => {
524                    let v = it
525                        .next()
526                        .ok_or_else(|| "--problem requires a value".to_string())?;
527                    problem = Some(ProblemSource::Builtin(v));
528                }
529                "--nl-file" => {
530                    let v = it
531                        .next()
532                        .ok_or_else(|| "--nl-file requires a value".to_string())?;
533                    problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
534                }
535                "--options-file" => {
536                    let v = it
537                        .next()
538                        .ok_or_else(|| "--options-file requires a value".to_string())?;
539                    options_file = Some(PathBuf::from(v));
540                }
541                "--no-options-file" => no_options_file = true,
542                "--dump" => {
543                    let v = it
544                        .next()
545                        .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
546                    let (cat, spec) = match v.split_once(':') {
547                        Some((c, s)) => (c.to_string(), s.to_string()),
548                        None => (v, "all".to_string()),
549                    };
550                    dump_specs.push((cat, spec));
551                }
552                "--dump-dir" => {
553                    let v = it
554                        .next()
555                        .ok_or_else(|| "--dump-dir requires a value".to_string())?;
556                    dump_dir = Some(PathBuf::from(v));
557                }
558                "--dump-format" => {
559                    let v = it
560                        .next()
561                        .ok_or_else(|| "--dump-format requires a value".to_string())?;
562                    dump_format = Some(v);
563                }
564                "--json-output" => {
565                    let v = it
566                        .next()
567                        .ok_or_else(|| "--json-output requires a value".to_string())?;
568                    json_output = Some(PathBuf::from(v));
569                }
570                "--json-detail" => {
571                    let v = it
572                        .next()
573                        .ok_or_else(|| "--json-detail requires a value".to_string())?;
574                    json_detail = crate::solve_report::ReportDetail::parse(&v)?;
575                }
576                "--sol-output" => {
577                    let v = it
578                        .next()
579                        .ok_or_else(|| "--sol-output requires a value".to_string())?;
580                    sol_output = Some(PathBuf::from(v));
581                }
582                "--no-sol" => no_sol = true,
583                "--sens-boundcheck" => sens_boundcheck = true,
584                "--sens-bound-eps" => {
585                    let v = it
586                        .next()
587                        .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
588                    sens_bound_eps = v
589                        .parse::<f64>()
590                        .map_err(|e| format!("--sens-bound-eps: {e}"))?;
591                    sens_boundcheck = true;
592                }
593                "--debug" => debug = Some(DebugMode::Repl),
594                "--debug-json" => debug = Some(DebugMode::Json),
595                "--debug-on-error" => debug_on_error = true,
596                "--debug-on-interrupt" => debug_on_interrupt = true,
597                "--debug-script" => {
598                    let v = it
599                        .next()
600                        .ok_or_else(|| "--debug-script requires a value".to_string())?;
601                    debug_script = Some(PathBuf::from(v));
602                }
603                "--compute-red-hessian" => compute_red_hessian = true,
604                "--rh-eigendecomp" => {
605                    rh_eigendecomp = true;
606                    compute_red_hessian = true;
607                }
608                // ---- multistart / find-minima (`--minima`) ----
609                "--minima" => {
610                    let v = flag_val!("--minima");
611                    let method = MinimaMethod::parse(&v)?;
612                    minima.get_or_insert_with(MinimaArgs::default).method = method;
613                    minima_method_explicit = true;
614                }
615                "--multistart" => {
616                    minima.get_or_insert_with(MinimaArgs::default).method =
617                        MinimaMethod::Multistart;
618                    minima_method_explicit = true;
619                }
620                "--n-minima" => minima_num!("--n-minima", usize, n_minima),
621                "--max-solves" => minima_num!("--max-solves", usize, max_solves, opt),
622                "--patience" => minima_num!("--patience", usize, patience),
623                "--dedup" => minima_num!("--dedup", f64, dedup),
624                "--psd-tol" => minima_num!("--psd-tol", f64, psd_tol),
625                "--seed" => minima_num!("--seed", u64, seed),
626                "--sobol" => {
627                    minima.get_or_insert_with(MinimaArgs::default).sobol = true;
628                    if minima_knob.is_none() {
629                        minima_knob = Some("--sobol");
630                    }
631                }
632                "--no-sobol" => {
633                    minima.get_or_insert_with(MinimaArgs::default).sobol = false;
634                    if minima_knob.is_none() {
635                        minima_knob = Some("--no-sobol");
636                    }
637                }
638                "--sigma" => minima_num!("--sigma", f64, sigma, opt),
639                "--sigma-frac" => minima_num!("--sigma-frac", f64, sigma_frac, opt),
640                "--amplitude" => minima_num!("--amplitude", f64, amplitude, opt),
641                "--amp-margin" => minima_num!("--amp-margin", f64, amp_margin, opt),
642                "--eta" => minima_num!("--eta", f64, eta, opt),
643                "--power" => minima_num!("--power", f64, power, opt),
644                "--soft" => minima_num!("--soft", f64, soft, opt),
645                "--length" => minima_num!("--length", f64, length, opt),
646                "--length-frac" => minima_num!("--length-frac", f64, length_frac, opt),
647                "--gamma" => minima_num!("--gamma", f64, gamma, opt),
648                "--samples-per-round" => {
649                    minima_num!("--samples-per-round", usize, samples_per_round, opt)
650                }
651                "--step" => minima_num!("--step", f64, step, opt),
652                "--temperature" => minima_num!("--temperature", f64, temperature, opt),
653                "--restart-jitter" => minima_num!("--restart-jitter", f64, restart_jitter, opt),
654                other if !other.starts_with('-') => {
655                    // `key=value` forms an option pair (matches upstream
656                    // ipopt CLI). Otherwise the first bare arg is the
657                    // positional .nl path, and a second bare arg is the
658                    // .sol output (AMPL `solver in.nl out.sol`).
659                    if let Some((k, v)) = parse_kv(other) {
660                        set_options.push((k, v));
661                    } else if problem.is_none() {
662                        problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
663                    } else if sol_output.is_none() {
664                        sol_output = Some(PathBuf::from(other));
665                    } else {
666                        return Err(format!(
667                            "unexpected positional argument '{other}' (expected KEY=VALUE)"
668                        ));
669                    }
670                }
671                other => return Err(format!("unrecognized argument '{other}'")),
672            }
673        }
674
675        if list_problems {
676            println!("{}", crate::builtin::list().join("\n"));
677            std::process::exit(0);
678        }
679
680        // `--debug-on-error` / `--debug-on-interrupt` / `--debug-script`
681        // without an explicit mode imply the REPL.
682        if (debug_on_error || debug_on_interrupt || debug_script.is_some()) && debug.is_none() {
683            debug = Some(DebugMode::Repl);
684        }
685
686        if !help && !version && !about && !cite {
687            // A `--minima` *tuning* knob on its own used to lazily create a
688            // config and silently reroute the whole run into multistart
689            // (deflation) mode — different console output and a dual-free
690            // `.sol`. Global search must be opted into explicitly; reject a
691            // lone knob with a message pointing at the method selectors.
692            if let Some(knob) = minima_knob {
693                if !minima_method_explicit {
694                    return Err(format!(
695                        "{knob} is a --minima tuning knob and has no effect on its own; \
696                         enable global search with --minima <method> or --multistart"
697                    ));
698                }
699            }
700            let problem = problem.ok_or_else(|| {
701                "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
702            })?;
703            return Ok(Self {
704                problem,
705                options_file,
706                no_options_file,
707                set_options,
708                json_output,
709                json_detail,
710                sol_output,
711                no_sol,
712                ampl,
713                help,
714                version,
715                about,
716                cite,
717                cite_report,
718                cite_bibtex,
719                dump_specs,
720                dump_dir,
721                dump_format,
722                sens_boundcheck,
723                sens_bound_eps,
724                compute_red_hessian,
725                rh_eigendecomp,
726                debug,
727                debug_on_error,
728                debug_on_interrupt,
729                debug_script,
730                minima,
731            });
732        }
733
734        Ok(Self {
735            problem: ProblemSource::Builtin(String::new()),
736            options_file,
737            no_options_file,
738            set_options,
739            json_output,
740            json_detail,
741            sol_output,
742            no_sol,
743            ampl,
744            help,
745            version,
746            about,
747            cite,
748            cite_report,
749            cite_bibtex,
750            dump_specs,
751            dump_dir,
752            dump_format,
753            sens_boundcheck,
754            sens_bound_eps,
755            compute_red_hessian,
756            rh_eigendecomp,
757            debug,
758            debug_on_error,
759            debug_on_interrupt,
760            debug_script,
761            minima,
762        })
763    }
764}
765
766impl Args {
767    /// Which options file this run should read.
768    ///
769    /// Decided before any file is opened, because the file has to be read
770    /// *before* the `key=value` overrides are applied — command-line
771    /// options beat file options, not the other way round — and
772    /// `option_file_name` is itself one of those overrides.
773    ///
774    /// `--options-file <path>` and `option_file_name=<path>` are two
775    /// spellings of one thing; `option_file_name=` may repeat, in which
776    /// case the last wins as it does for every other `key=value`. With
777    /// no file named, the working directory is probed. Upstream reads
778    /// `option_file_name` out of the option store at the same point for
779    /// the same reason.
780    ///
781    /// Every way of asking for *two different things at once* is an
782    /// error rather than a precedence rule — `--no-options-file` beside
783    /// a named file, or the two spellings naming different files. A
784    /// precedence rule here would mean one of the two files the user
785    /// named was quietly not read, which is the failure gh#518 reports,
786    /// reintroduced by the fix for it.
787    pub fn option_file_choice(&self) -> Result<OptionFileChoice, String> {
788        let named = match (&self.options_file, self.option_file_name_override()) {
789            (Some(flag), Some(kv)) if *flag != kv => {
790                return Err(format!(
791                    "--options-file '{}' and option_file_name={} name different \
792                     options files; only one is read, so pass one or the other",
793                    flag.display(),
794                    kv.display()
795                ));
796            }
797            (Some(flag), _) => Some(flag.clone()),
798            (None, kv) => kv,
799        };
800        match (self.no_options_file, named) {
801            (true, Some(p)) => Err(format!(
802                "--no-options-file conflicts with the options file '{}' named on \
803                 the command line; pass one or the other",
804                p.display()
805            )),
806            (true, None) => Ok(OptionFileChoice::Suppressed),
807            (false, Some(p)) => Ok(OptionFileChoice::Named(p)),
808            (false, None) => Ok(OptionFileChoice::Discover),
809        }
810    }
811
812    /// The last `option_file_name=<path>` among the `key=value` options,
813    /// if any. Last-wins matches the order they are applied in.
814    fn option_file_name_override(&self) -> Option<PathBuf> {
815        self.set_options
816            .iter()
817            .rev()
818            .find(|(k, _)| k.eq_ignore_ascii_case("option_file_name"))
819            .map(|(_, v)| PathBuf::from(v))
820    }
821}
822
823/// Parse `key=value` (or `key:=value`, ipopt-compatible). Returns
824/// `None` if the token does not contain `=`. Whitespace around the
825/// separator is trimmed; empty key or value yields `None`.
826///
827/// A value wrapped in a matching pair of quotes has them stripped. No
828/// shell is involved when a driver `exec`s us directly, so quotes the
829/// driver added for its own quoting rules arrive as literal characters:
830/// Pyomo's v2 solver interface builds every option as
831/// `option_file_name="/tmp/…/x.opt"` (`_option_to_cmd` in
832/// `pyomo/contrib/solver/solvers/ipopt.py`) and passes it as one argv
833/// entry. Ipopt's ASL option parser strips those quotes; until we did
834/// too, POUNCE looked for a file whose name literally began with `"`,
835/// failed to load it, and aborted the run — so `SolverFactory('ipopt_v2',
836/// executable=<pounce>)`, the interface that is becoming Pyomo's default
837/// `ipopt`, could not drive POUNCE at all whenever any option was set.
838fn parse_kv(s: &str) -> Option<(String, String)> {
839    let (k, v) = s.split_once('=')?;
840    let k = k.trim().trim_end_matches(':');
841    let v = unquote(v.trim());
842    if k.is_empty() || v.is_empty() {
843        return None;
844    }
845    Some((k.to_string(), v.to_string()))
846}
847
848/// Strip one matching pair of surrounding `"` or `'` quotes. A lone
849/// quote on one side is left alone — it is more likely part of the value
850/// than a quoting artifact.
851fn unquote(v: &str) -> &str {
852    let b = v.as_bytes();
853    if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
854        &v[1..v.len() - 1]
855    } else {
856        v
857    }
858}
859
860/// Parse the AMPL `pounce_options` environment variable into
861/// `(key, value)` option pairs.
862///
863/// AMPL passes solver directives through a `<solver>_options` env var
864/// (here `pounce_options`): a whitespace-separated list of `key=value`
865/// tokens — the same `key=value` grammar pounce accepts as positional CLI
866/// options. Tokens without an `=` (AMPL's rarer `keyword value` spelling)
867/// are skipped rather than guessed at, matching the CLI parser, which has
868/// no `key value` form either. The caller applies these *before* the
869/// command-line `key=value` options so an explicit CLI flag wins.
870pub fn options_from_env(value: &str) -> Vec<(String, String)> {
871    value.split_whitespace().filter_map(parse_kv).collect()
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    fn argv(args: &[&str]) -> Vec<String> {
879        std::iter::once("pounce")
880            .chain(args.iter().copied())
881            .map(String::from)
882            .collect()
883    }
884
885    #[test]
886    fn help_short_and_long() {
887        assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
888        assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
889    }
890
891    #[test]
892    fn version_short_and_long() {
893        assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
894        assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
895        assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
896    }
897
898    #[test]
899    fn ampl_flag_sets_mode_and_keeps_positional() {
900        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
901        assert!(a.ampl);
902        match a.problem {
903            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
904            _ => panic!("expected positional .nl"),
905        }
906    }
907
908    #[test]
909    fn ampl_flag_defaults_off() {
910        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
911        assert!(!a.ampl);
912    }
913
914    #[test]
915    fn ampl_flag_with_options() {
916        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
917        assert!(a.ampl);
918        assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
919    }
920
921    #[test]
922    fn about_flag_does_not_require_problem() {
923        let a = Args::parse_argv(argv(&["--about"])).unwrap();
924        assert!(a.about);
925    }
926
927    #[test]
928    fn cite_flag_alone_needs_no_problem_or_report() {
929        let a = Args::parse_argv(argv(&["--cite"])).unwrap();
930        assert!(a.cite);
931        assert!(a.cite_report.is_none());
932        assert!(!a.cite_bibtex);
933    }
934
935    #[test]
936    fn cite_consumes_following_report_path() {
937        let a = Args::parse_argv(argv(&["--cite", "run.json"])).unwrap();
938        assert!(a.cite);
939        assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
940    }
941
942    #[test]
943    fn cite_does_not_swallow_a_following_flag() {
944        let a = Args::parse_argv(argv(&["--cite", "--bibtex"])).unwrap();
945        assert!(a.cite);
946        assert!(a.cite_report.is_none());
947        assert!(a.cite_bibtex);
948    }
949
950    #[test]
951    fn cite_with_report_and_bibtex() {
952        let a = Args::parse_argv(argv(&["--cite", "run.json", "--bibtex"])).unwrap();
953        assert!(a.cite);
954        assert_eq!(a.cite_report.unwrap().to_str(), Some("run.json"));
955        assert!(a.cite_bibtex);
956    }
957
958    #[test]
959    fn problem_flag_captures_name() {
960        let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
961        match a.problem {
962            ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
963            _ => panic!("expected builtin"),
964        }
965    }
966
967    #[test]
968    fn nl_file_captured() {
969        let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
970        match a.problem {
971            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
972            _ => panic!("expected nl file"),
973        }
974    }
975
976    #[test]
977    fn positional_nl_path() {
978        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
979        match a.problem {
980            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
981            _ => panic!("expected positional .nl"),
982        }
983    }
984
985    #[test]
986    fn positional_with_options_file() {
987        let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
988        match a.problem {
989            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
990            _ => panic!("expected positional .nl"),
991        }
992        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
993    }
994
995    #[test]
996    fn options_file_captured() {
997        let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
998        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
999    }
1000
1001    /// gh#518: which options file a run reads, decided from argv alone.
1002    /// The filesystem end of it (the implicit lookup, a named file that
1003    /// is missing) lives in `tests/issue_518_option_files.rs`.
1004    #[test]
1005    fn option_file_choice_defaults_to_discovery() {
1006        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1007        assert_eq!(a.option_file_choice(), Ok(OptionFileChoice::Discover));
1008    }
1009
1010    #[test]
1011    fn option_file_choice_from_option_file_name() {
1012        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "option_file_name=tiny.opt"])).unwrap();
1013        assert_eq!(
1014            a.option_file_choice(),
1015            Ok(OptionFileChoice::Named(PathBuf::from("tiny.opt")))
1016        );
1017    }
1018
1019    /// `set_options` are applied last-wins, so the file they name is too
1020    /// — including a command-line value landing on top of one from
1021    /// `$pounce_options` (which is merged in ahead of them).
1022    #[test]
1023    fn option_file_choice_takes_the_last_option_file_name() {
1024        let a = Args::parse_argv(argv(&[
1025            "/tmp/foo.nl",
1026            "option_file_name=first.opt",
1027            "option_file_name=second.opt",
1028        ]))
1029        .unwrap();
1030        assert_eq!(
1031            a.option_file_choice(),
1032            Ok(OptionFileChoice::Named(PathBuf::from("second.opt")))
1033        );
1034    }
1035
1036    /// The two spellings agreeing is fine; disagreeing is an error, not
1037    /// a precedence rule — picking a winner would silently not read the
1038    /// other file the user named.
1039    #[test]
1040    fn the_two_spellings_may_agree() {
1041        let a = Args::parse_argv(argv(&[
1042            "/tmp/foo.nl",
1043            "--options-file",
1044            "same.opt",
1045            "option_file_name=same.opt",
1046        ]))
1047        .unwrap();
1048        assert_eq!(
1049            a.option_file_choice(),
1050            Ok(OptionFileChoice::Named(PathBuf::from("same.opt")))
1051        );
1052    }
1053
1054    #[test]
1055    fn the_two_spellings_disagreeing_is_rejected() {
1056        let a = Args::parse_argv(argv(&[
1057            "/tmp/foo.nl",
1058            "--options-file",
1059            "flag.opt",
1060            "option_file_name=kv.opt",
1061        ]))
1062        .unwrap();
1063        let err = a.option_file_choice().unwrap_err();
1064        assert!(err.contains("flag.opt") && err.contains("kv.opt"), "{err}");
1065    }
1066
1067    #[test]
1068    fn no_options_file_suppresses_discovery() {
1069        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-options-file"])).unwrap();
1070        assert!(a.no_options_file);
1071        assert_eq!(a.option_file_choice(), Ok(OptionFileChoice::Suppressed));
1072    }
1073
1074    #[test]
1075    fn no_options_file_plus_a_named_file_is_rejected() {
1076        let a = Args::parse_argv(argv(&[
1077            "/tmp/foo.nl",
1078            "--no-options-file",
1079            "--options-file",
1080            "tiny.opt",
1081        ]))
1082        .unwrap();
1083        assert!(a.option_file_choice().is_err());
1084    }
1085
1086    #[test]
1087    fn missing_value_for_flag() {
1088        assert!(Args::parse_argv(argv(&["--problem"])).is_err());
1089    }
1090
1091    #[test]
1092    fn missing_problem() {
1093        assert!(Args::parse_argv(argv(&[])).is_err());
1094    }
1095
1096    #[test]
1097    fn unknown_arg() {
1098        assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
1099    }
1100
1101    #[test]
1102    fn key_value_options_collected() {
1103        let a = Args::parse_argv(argv(&[
1104            "/tmp/foo.nl",
1105            "print_level=8",
1106            "max_iter=500",
1107            "tol=1e-10",
1108        ]))
1109        .unwrap();
1110        assert_eq!(
1111            a.set_options,
1112            vec![
1113                ("print_level".into(), "8".into()),
1114                ("max_iter".into(), "500".into()),
1115                ("tol".into(), "1e-10".into()),
1116            ]
1117        );
1118    }
1119
1120    #[test]
1121    fn key_value_before_path() {
1122        let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
1123        match a.problem {
1124            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
1125            _ => panic!("expected positional .nl"),
1126        }
1127        assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
1128    }
1129
1130    #[test]
1131    fn dump_flag_captures_cat_and_spec() {
1132        let a = Args::parse_argv(argv(&[
1133            "--problem",
1134            "x",
1135            "--dump",
1136            "kkt:2-10",
1137            "--dump",
1138            "iterate",
1139        ]))
1140        .unwrap();
1141        assert_eq!(
1142            a.dump_specs,
1143            vec![
1144                ("kkt".into(), "2-10".into()),
1145                ("iterate".into(), "all".into()),
1146            ]
1147        );
1148    }
1149
1150    #[test]
1151    fn dump_dir_and_format_captured() {
1152        let a = Args::parse_argv(argv(&[
1153            "--problem",
1154            "x",
1155            "--dump",
1156            "kkt",
1157            "--dump-dir",
1158            "/tmp/d",
1159            "--dump-format",
1160            "jsonl",
1161        ]))
1162        .unwrap();
1163        assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
1164        assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
1165    }
1166
1167    #[test]
1168    fn sol_output_captured() {
1169        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
1170        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
1171        assert!(!a.no_sol);
1172    }
1173
1174    #[test]
1175    fn no_sol_flag() {
1176        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
1177        assert!(a.no_sol);
1178        assert!(a.sol_output.is_none());
1179    }
1180
1181    #[test]
1182    fn sol_output_defaults_unset() {
1183        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1184        assert!(a.sol_output.is_none());
1185        assert!(!a.no_sol);
1186    }
1187
1188    #[test]
1189    fn sol_output_missing_value() {
1190        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
1191    }
1192
1193    #[test]
1194    fn second_positional_is_sol_output() {
1195        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
1196        match a.problem {
1197            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
1198            _ => panic!("expected positional .nl"),
1199        }
1200        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
1201    }
1202
1203    #[test]
1204    fn third_positional_is_an_error() {
1205        assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
1206    }
1207
1208    #[test]
1209    fn sens_flags_default_off() {
1210        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1211        assert!(!a.sens_boundcheck);
1212        assert!(!a.compute_red_hessian);
1213        assert!(!a.rh_eigendecomp);
1214        assert_eq!(a.sens_bound_eps, 1e-3);
1215    }
1216
1217    #[test]
1218    fn sens_boundcheck_flag() {
1219        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
1220        assert!(a.sens_boundcheck);
1221    }
1222
1223    #[test]
1224    fn sens_bound_eps_sets_value_and_enables_boundcheck() {
1225        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
1226        assert_eq!(a.sens_bound_eps, 1e-6);
1227        assert!(a.sens_boundcheck);
1228    }
1229
1230    #[test]
1231    fn rh_eigendecomp_implies_compute_red_hessian() {
1232        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
1233        assert!(a.rh_eigendecomp);
1234        assert!(a.compute_red_hessian);
1235    }
1236
1237    #[test]
1238    fn minima_absent_by_default() {
1239        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
1240        assert!(a.minima.is_none());
1241    }
1242
1243    #[test]
1244    fn minima_method_and_shared_knobs() {
1245        let a = Args::parse_argv(argv(&[
1246            "/tmp/foo.nl",
1247            "--minima",
1248            "flooding",
1249            "--n-minima",
1250            "5",
1251            "--max-solves",
1252            "42",
1253            "--patience",
1254            "3",
1255            "--dedup",
1256            "1e-2",
1257            "--psd-tol",
1258            "1e-8",
1259            "--seed",
1260            "7",
1261            "--no-sobol",
1262        ]))
1263        .unwrap();
1264        let m = a.minima.expect("minima parsed");
1265        assert_eq!(m.method, MinimaMethod::Flooding);
1266        assert_eq!(m.n_minima, 5);
1267        assert_eq!(m.max_solves, Some(42));
1268        assert_eq!(m.patience, 3);
1269        assert_eq!(m.dedup, 1e-2);
1270        assert_eq!(m.psd_tol, 1e-8);
1271        assert_eq!(m.seed, 7);
1272        assert!(!m.sobol);
1273    }
1274
1275    #[test]
1276    fn multistart_shorthand_selects_multistart() {
1277        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--multistart"])).unwrap();
1278        assert_eq!(a.minima.unwrap().method, MinimaMethod::Multistart);
1279    }
1280
1281    #[test]
1282    fn minima_strategy_knobs_are_optional_and_parsed() {
1283        let a = Args::parse_argv(argv(&[
1284            "/tmp/foo.nl",
1285            "--minima",
1286            "deflation",
1287            "--eta",
1288            "2.5",
1289            "--power",
1290            "3",
1291            "--soft",
1292            "1e-4",
1293            "--length",
1294            "0.2",
1295            "--restart-jitter",
1296            "0.9",
1297        ]))
1298        .unwrap();
1299        let m = a.minima.unwrap();
1300        assert_eq!(m.method, MinimaMethod::Deflation);
1301        assert_eq!(m.eta, Some(2.5));
1302        assert_eq!(m.power, Some(3.0));
1303        assert_eq!(m.soft, Some(1e-4));
1304        assert_eq!(m.length, Some(0.2));
1305        assert_eq!(m.restart_jitter, Some(0.9));
1306        // Untouched knobs stay None.
1307        assert_eq!(m.sigma, None);
1308        assert_eq!(m.gamma, None);
1309    }
1310
1311    #[test]
1312    fn minima_unknown_method_errors() {
1313        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--minima", "nope"])).is_err());
1314    }
1315
1316    /// Code-review 2026-06 item M14: a `--minima` tuning knob (`--seed`,
1317    /// `--patience`, `--no-sobol`, …) on its own used to lazily build a
1318    /// `MinimaArgs` and silently reroute the whole run into multistart
1319    /// (deflation) mode. It must now be rejected with a message pointing
1320    /// at the method selectors.
1321    #[test]
1322    fn lone_minima_knob_without_method_is_rejected() {
1323        let err = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"]))
1324            .expect_err("lone --seed should be rejected");
1325        assert!(
1326            err.contains("--seed") && err.contains("--minima"),
1327            "error should name the knob and the method selectors; got: {err}"
1328        );
1329        // A no-value knob (`--no-sobol`) is rejected the same way.
1330        let err2 = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sobol"]))
1331            .expect_err("lone --no-sobol should be rejected");
1332        assert!(err2.contains("--no-sobol"), "got: {err2}");
1333        // And a lone knob does NOT leave the run in minima mode.
1334        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "42"])).is_err());
1335    }
1336
1337    /// The same knob is accepted once global search is explicitly enabled,
1338    /// regardless of flag order (knob before the method selector).
1339    #[test]
1340    fn minima_knob_with_explicit_method_is_accepted() {
1341        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--seed", "7", "--multistart"])).unwrap();
1342        let m = a.minima.expect("minima parsed");
1343        assert_eq!(m.method, MinimaMethod::Multistart);
1344        assert_eq!(m.seed, 7);
1345    }
1346
1347    #[test]
1348    fn parse_kv_basic() {
1349        assert_eq!(
1350            parse_kv("print_level=8"),
1351            Some(("print_level".into(), "8".into()))
1352        );
1353        assert_eq!(
1354            parse_kv("tol = 1e-10"),
1355            Some(("tol".into(), "1e-10".into()))
1356        );
1357        assert_eq!(parse_kv("plain_path.nl"), None);
1358        assert_eq!(parse_kv("=value"), None);
1359        assert_eq!(parse_kv("key="), None);
1360    }
1361
1362    /// Pyomo's v2 solver interface emits every option as `key="value"`
1363    /// and `exec`s the solver directly, so with no shell in between the
1364    /// quotes reach us as literal characters. Ipopt's ASL parser strips
1365    /// them; so must we, or `option_file_name` names a file that cannot
1366    /// exist and the run aborts.
1367    #[test]
1368    fn parse_kv_strips_the_quotes_a_driver_added() {
1369        assert_eq!(
1370            parse_kv(r#"option_file_name="/tmp/pyomo/x.opt""#),
1371            Some(("option_file_name".into(), "/tmp/pyomo/x.opt".into()))
1372        );
1373        assert_eq!(
1374            parse_kv("option_file_name='/tmp/has space/x.opt'"),
1375            Some(("option_file_name".into(), "/tmp/has space/x.opt".into()))
1376        );
1377        // A quote on one side only is content, not quoting.
1378        assert_eq!(
1379            parse_kv(r#"msg="unbalanced"#),
1380            Some(("msg".into(), r#""unbalanced"#.into()))
1381        );
1382        // An empty quoted value is still empty.
1383        assert_eq!(parse_kv(r#"key="""#), None);
1384    }
1385
1386    #[test]
1387    fn options_from_env_parses_whitespace_separated_pairs() {
1388        // AMPL `<solver>_options` convention: a whitespace-separated list
1389        // of key=value tokens. Code review 2026-06 item M15.
1390        assert_eq!(
1391            options_from_env("max_iter=100 tol=1e-8"),
1392            vec![
1393                ("max_iter".into(), "100".into()),
1394                ("tol".into(), "1e-8".into()),
1395            ]
1396        );
1397        // Multiple spaces / tabs / newlines all split.
1398        assert_eq!(
1399            options_from_env("a=1\tb=2\n c=3"),
1400            vec![
1401                ("a".into(), "1".into()),
1402                ("b".into(), "2".into()),
1403                ("c".into(), "3".into()),
1404            ]
1405        );
1406    }
1407
1408    #[test]
1409    fn options_from_env_skips_non_kv_tokens_and_empty() {
1410        // Tokens without `=` (AMPL's `keyword value` spelling) are skipped,
1411        // matching the CLI grammar, which has no `key value` form either.
1412        assert_eq!(
1413            options_from_env("max_iter=100 bareword tol=1e-8"),
1414            vec![
1415                ("max_iter".into(), "100".into()),
1416                ("tol".into(), "1e-8".into()),
1417            ]
1418        );
1419        assert!(options_from_env("").is_empty());
1420        assert!(options_from_env("   \t\n ").is_empty());
1421        assert!(options_from_env("just some words").is_empty());
1422    }
1423}