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