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