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#[derive(Debug, Clone)]
13pub struct Args {
14    pub problem: ProblemSource,
15    pub options_file: Option<PathBuf>,
16    /// `key=value` options collected from the command line. Forwarded to
17    /// the application's `OptionsList` after the options-file load (so
18    /// CLI args override file values), mirroring upstream ipopt's
19    /// `ipopt problem.nl print_level=8 ...` convention.
20    pub set_options: Vec<(String, String)>,
21    /// `--json-output PATH` — when set, the binary writes a
22    /// machine-readable JSON solve report to PATH after the solve
23    /// completes. See [`crate::solve_report`] (pounce#8).
24    pub json_output: Option<PathBuf>,
25    /// `--json-detail summary|full` — controls how much detail the
26    /// JSON report carries. Defaults to `Summary`. `Full` adds
27    /// per-iteration history and suffix blocks; same scale as
28    /// upstream's `print_level` but on the JSON side.
29    pub json_detail: crate::solve_report::ReportDetail,
30    /// `--sol-output PATH` — write an AMPL `.sol` solution file to
31    /// PATH. When unset, a positional `.nl` input still gets a sibling
32    /// `<stub>.sol` (the AMPL solver convention); `--no-sol` opts out
33    /// of that default. Builtin problems have no stub, so they only
34    /// produce a `.sol` when this flag is given explicitly.
35    pub sol_output: Option<PathBuf>,
36    /// `--no-sol` — suppress the default `<stub>.sol` write for `.nl`
37    /// inputs.
38    pub no_sol: bool,
39    /// `-AMPL` — the AMPL solver-protocol flag. AMPL and Pyomo's ASL
40    /// interface invoke a solver as `solver problem.nl -AMPL`. It needs
41    /// no positional behavior (pounce already reads the `.nl` and
42    /// writes `<stub>.sol`), but it does switch the process exit-code
43    /// contract: in AMPL mode the termination is conveyed through the
44    /// `.sol` file's `solve_result_num`, so the process exits 0 for any
45    /// non-fatal solve outcome (limit reached, infeasible, etc.) rather
46    /// than the non-zero code the plain CLI uses.
47    pub ampl: bool,
48    pub help: bool,
49    pub version: bool,
50    /// `--about`: print build metadata, compiled-in features, available
51    /// linear solvers, and runtime paths. Used for bug reports.
52    pub about: bool,
53    /// `--dump <cat>[:<iter-spec>]`, repeatable. Each entry asks the
54    /// solver to dump one diagnostic category at the specified iter
55    /// range (`all`, `N`, `N-M`, `N-`, `-M`); omitting the spec is
56    /// equivalent to `:all`. Forwarded to
57    /// [`pounce_common::diagnostics::DiagnosticsConfig`].
58    pub dump_specs: Vec<(String, String)>,
59    /// `--dump-dir <path>`: override the dump root. Defaults to
60    /// `./pounce-dump-<unix-secs>`, picked at solve-start time.
61    pub dump_dir: Option<PathBuf>,
62    /// `--dump-format <fmt>`: dump file format. Currently only `jsonl`.
63    pub dump_format: Option<String>,
64    /// `--sens-boundcheck` — clamp the perturbed primal `x* + Δx` onto
65    /// the declared `[x_l, x_u]` box after the sensitivity step. Only
66    /// has effect when the `.nl` declares the sIPOPT suffixes. Mirrors
67    /// upstream sIPOPT's `sens_boundcheck`.
68    pub sens_boundcheck: bool,
69    /// `--sens-bound-eps <eps>` — tolerance for `--sens-boundcheck`
70    /// (default `1e-3`). Setting it also enables `--sens-boundcheck`.
71    pub sens_bound_eps: f64,
72    /// `--compute-red-hessian` — after the solve, compute the reduced
73    /// Hessian over the variables tagged by the `red_hessian` integer
74    /// var-suffix in the input `.nl`. Mirrors upstream sIPOPT's
75    /// `compute_red_hessian`.
76    pub compute_red_hessian: bool,
77    /// `--rh-eigendecomp` — also compute the eigendecomposition of the
78    /// reduced Hessian. Implies `--compute-red-hessian`. Mirrors
79    /// upstream `rh_eigendecomp`.
80    pub rh_eigendecomp: bool,
81}
82
83impl Args {
84    pub fn usage() -> &'static str {
85        "\
86Usage: pounce [OPTIONS] [PATH] [SOL] [KEY=VALUE ...]
87
88PATH is an AMPL .nl file (positional). Equivalent: --nl-file <path>.
89SOL is an optional second positional naming the .sol output file
90(equivalent to --sol-output <path>); the AMPL `solver in.nl out.sol`
91convention.
92
93When the .nl declares the sIPOPT suffixes (sens_state_1,
94sens_state_value_1, sens_init_constr), pounce additionally runs the
95post-optimal parametric sensitivity step and writes the perturbed
96primal back into the .sol as a `sens_sol_state_1` suffix.
97
98Trailing KEY=VALUE pairs are forwarded to the solver's OptionsList
99(same syntax/semantics as the ipopt CLI). They override values loaded
100from --options-file. Examples:
101
102  pounce problem.nl print_level=8
103  pounce problem.nl max_iter=500 tol=1e-10 linear_solver=ma57
104
105Required (one of):
106  PATH                      positional .nl file to solve
107  --nl-file <path>          same, as a flag
108  --problem <name>          solve a built-in test problem
109
110Options:
111  --options-file <path>     read solver options from an ipopt.opt-format file
112  --json-output <path>      write a JSON solve report to PATH after the solve
113                            (pounce#8 — machine-readable, FAIR-aligned)
114  --json-detail LEVEL       summary | full (default: summary). `full` adds
115                            per-iteration history + suffix blocks.
116  --sol-output <path>       write an AMPL .sol solution file to PATH.
117                            A positional .nl input writes <stub>.sol
118                            next to it by default (AMPL convention).
119  --no-sol                  suppress the default <stub>.sol write
120  --sens-boundcheck         clamp the perturbed primal x* + Δx onto the
121                            declared [x_l, x_u] box (sIPOPT sens_boundcheck)
122  --sens-bound-eps EPS      tolerance for --sens-boundcheck (default 1e-3;
123                            setting it also enables --sens-boundcheck)
124  --compute-red-hessian     compute the reduced Hessian over the variables
125                            tagged by the `red_hessian` integer var-suffix
126  --rh-eigendecomp          also compute the reduced-Hessian eigendecomp;
127                            implies --compute-red-hessian
128  --list-problems           print available built-in problems and exit
129  -AMPL                     AMPL solver-protocol mode (for Pyomo / AMPL
130                            drivers): convey termination via the .sol
131                            file and exit 0 for non-fatal outcomes
132  --help, -h                print this message and exit
133  --version, -v, -V         print version and exit
134  --about                   print version, build info, features,
135                            linear solvers, and runtime paths
136  --dump <cat>[:<spec>]     dump diagnostic category to per-iter files.
137                            Repeatable. Categories: kkt, iterate(s), step,
138                            mu, ls, resto, convergence, timing.
139                            Iter-spec grammar: all | N | N-M | N- | -M
140                            (default: all). The `iterates` category also
141                            accepts a `:summary` (default) or `:full`
142                            variant suffix and streams one JSONL row
143                            per iter to <dump-dir>/iterates.jsonl. The
144                            `kkt` category accepts `+L` / `+L+Lvals`
145                            suffixes that add the LDLᵀ factor's
146                            strict-lower pattern (and optional values)
147                            plus the fill-reducing permutation to each
148                            kkt_solve_NNN.jsonl record (feral backend
149                            only; MA57 silently omits the L fields).
150                            Examples:
151                              --dump kkt:5
152                              --dump kkt:2-10 --dump iterate:all
153                              --dump kkt:5-10+L
154                              --dump kkt:5-10+L+Lvals
155                              --dump iterates:summary
156                              --dump iterates:5-:full
157  --dump-dir <path>         override dump root (default ./pounce-dump-<ts>)
158  --dump-format <fmt>       dump format (default: jsonl)
159"
160    }
161
162    pub fn parse_argv(argv: Vec<String>) -> Result<Self, String> {
163        let mut problem: Option<ProblemSource> = None;
164        let mut options_file: Option<PathBuf> = None;
165        let mut set_options: Vec<(String, String)> = Vec::new();
166        let mut json_output: Option<PathBuf> = None;
167        let mut json_detail = crate::solve_report::ReportDetail::Summary;
168        let mut sol_output: Option<PathBuf> = None;
169        let mut no_sol = false;
170        let mut ampl = false;
171        let mut help = false;
172        let mut version = false;
173        let mut about = false;
174        let mut list_problems = false;
175        let mut dump_specs: Vec<(String, String)> = Vec::new();
176        let mut dump_dir: Option<PathBuf> = None;
177        let mut dump_format: Option<String> = None;
178        let mut sens_boundcheck = false;
179        let mut sens_bound_eps: f64 = 1e-3;
180        let mut compute_red_hessian = false;
181        let mut rh_eigendecomp = false;
182
183        let mut it = argv.into_iter().skip(1);
184        while let Some(arg) = it.next() {
185            match arg.as_str() {
186                "-h" | "--help" => help = true,
187                "-v" | "-V" | "--version" => version = true,
188                "--about" => about = true,
189                // AMPL solver-protocol flag — see `Args::ampl`.
190                "-AMPL" => ampl = true,
191                "--list-problems" => list_problems = true,
192                "--problem" => {
193                    let v = it
194                        .next()
195                        .ok_or_else(|| "--problem requires a value".to_string())?;
196                    problem = Some(ProblemSource::Builtin(v));
197                }
198                "--nl-file" => {
199                    let v = it
200                        .next()
201                        .ok_or_else(|| "--nl-file requires a value".to_string())?;
202                    problem = Some(ProblemSource::NlFile(PathBuf::from(v)));
203                }
204                "--options-file" => {
205                    let v = it
206                        .next()
207                        .ok_or_else(|| "--options-file requires a value".to_string())?;
208                    options_file = Some(PathBuf::from(v));
209                }
210                "--dump" => {
211                    let v = it
212                        .next()
213                        .ok_or_else(|| "--dump requires a value (cat[:spec])".to_string())?;
214                    let (cat, spec) = match v.split_once(':') {
215                        Some((c, s)) => (c.to_string(), s.to_string()),
216                        None => (v, "all".to_string()),
217                    };
218                    dump_specs.push((cat, spec));
219                }
220                "--dump-dir" => {
221                    let v = it
222                        .next()
223                        .ok_or_else(|| "--dump-dir requires a value".to_string())?;
224                    dump_dir = Some(PathBuf::from(v));
225                }
226                "--dump-format" => {
227                    let v = it
228                        .next()
229                        .ok_or_else(|| "--dump-format requires a value".to_string())?;
230                    dump_format = Some(v);
231                }
232                "--json-output" => {
233                    let v = it
234                        .next()
235                        .ok_or_else(|| "--json-output requires a value".to_string())?;
236                    json_output = Some(PathBuf::from(v));
237                }
238                "--json-detail" => {
239                    let v = it
240                        .next()
241                        .ok_or_else(|| "--json-detail requires a value".to_string())?;
242                    json_detail = crate::solve_report::ReportDetail::parse(&v)?;
243                }
244                "--sol-output" => {
245                    let v = it
246                        .next()
247                        .ok_or_else(|| "--sol-output requires a value".to_string())?;
248                    sol_output = Some(PathBuf::from(v));
249                }
250                "--no-sol" => no_sol = true,
251                "--sens-boundcheck" => sens_boundcheck = true,
252                "--sens-bound-eps" => {
253                    let v = it
254                        .next()
255                        .ok_or_else(|| "--sens-bound-eps requires a value".to_string())?;
256                    sens_bound_eps = v
257                        .parse::<f64>()
258                        .map_err(|e| format!("--sens-bound-eps: {e}"))?;
259                    sens_boundcheck = true;
260                }
261                "--compute-red-hessian" => compute_red_hessian = true,
262                "--rh-eigendecomp" => {
263                    rh_eigendecomp = true;
264                    compute_red_hessian = true;
265                }
266                other if !other.starts_with('-') => {
267                    // `key=value` forms an option pair (matches upstream
268                    // ipopt CLI). Otherwise the first bare arg is the
269                    // positional .nl path, and a second bare arg is the
270                    // .sol output (AMPL `solver in.nl out.sol`).
271                    if let Some((k, v)) = parse_kv(other) {
272                        set_options.push((k, v));
273                    } else if problem.is_none() {
274                        problem = Some(ProblemSource::NlFile(PathBuf::from(other)));
275                    } else if sol_output.is_none() {
276                        sol_output = Some(PathBuf::from(other));
277                    } else {
278                        return Err(format!(
279                            "unexpected positional argument '{other}' (expected KEY=VALUE)"
280                        ));
281                    }
282                }
283                other => return Err(format!("unrecognized argument '{other}'")),
284            }
285        }
286
287        if list_problems {
288            println!("{}", crate::builtin::list().join("\n"));
289            std::process::exit(0);
290        }
291
292        if !help && !version && !about {
293            let problem = problem.ok_or_else(|| {
294                "missing problem: pass a positional .nl path, --nl-file, or --problem".to_string()
295            })?;
296            return Ok(Self {
297                problem,
298                options_file,
299                set_options,
300                json_output,
301                json_detail,
302                sol_output,
303                no_sol,
304                ampl,
305                help,
306                version,
307                about,
308                dump_specs,
309                dump_dir,
310                dump_format,
311                sens_boundcheck,
312                sens_bound_eps,
313                compute_red_hessian,
314                rh_eigendecomp,
315            });
316        }
317
318        Ok(Self {
319            problem: ProblemSource::Builtin(String::new()),
320            options_file,
321            set_options,
322            json_output,
323            json_detail,
324            sol_output,
325            no_sol,
326            ampl,
327            help,
328            version,
329            about,
330            dump_specs,
331            dump_dir,
332            dump_format,
333            sens_boundcheck,
334            sens_bound_eps,
335            compute_red_hessian,
336            rh_eigendecomp,
337        })
338    }
339}
340
341/// Parse `key=value` (or `key:=value`, ipopt-compatible). Returns
342/// `None` if the token does not contain `=`. Whitespace around the
343/// separator is trimmed; empty key or value yields `None`.
344fn parse_kv(s: &str) -> Option<(String, String)> {
345    let (k, v) = s.split_once('=')?;
346    let k = k.trim().trim_end_matches(':');
347    let v = v.trim();
348    if k.is_empty() || v.is_empty() {
349        return None;
350    }
351    Some((k.to_string(), v.to_string()))
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn argv(args: &[&str]) -> Vec<String> {
359        std::iter::once("pounce")
360            .chain(args.iter().copied())
361            .map(String::from)
362            .collect()
363    }
364
365    #[test]
366    fn help_short_and_long() {
367        assert!(Args::parse_argv(argv(&["-h"])).unwrap().help);
368        assert!(Args::parse_argv(argv(&["--help"])).unwrap().help);
369    }
370
371    #[test]
372    fn version_short_and_long() {
373        assert!(Args::parse_argv(argv(&["-v"])).unwrap().version);
374        assert!(Args::parse_argv(argv(&["-V"])).unwrap().version);
375        assert!(Args::parse_argv(argv(&["--version"])).unwrap().version);
376    }
377
378    #[test]
379    fn ampl_flag_sets_mode_and_keeps_positional() {
380        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL"])).unwrap();
381        assert!(a.ampl);
382        match a.problem {
383            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
384            _ => panic!("expected positional .nl"),
385        }
386    }
387
388    #[test]
389    fn ampl_flag_defaults_off() {
390        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
391        assert!(!a.ampl);
392    }
393
394    #[test]
395    fn ampl_flag_with_options() {
396        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "-AMPL", "max_iter=500"])).unwrap();
397        assert!(a.ampl);
398        assert_eq!(a.set_options, vec![("max_iter".into(), "500".into())]);
399    }
400
401    #[test]
402    fn about_flag_does_not_require_problem() {
403        let a = Args::parse_argv(argv(&["--about"])).unwrap();
404        assert!(a.about);
405    }
406
407    #[test]
408    fn problem_flag_captures_name() {
409        let a = Args::parse_argv(argv(&["--problem", "rosenbrock"])).unwrap();
410        match a.problem {
411            ProblemSource::Builtin(s) => assert_eq!(s, "rosenbrock"),
412            _ => panic!("expected builtin"),
413        }
414    }
415
416    #[test]
417    fn nl_file_captured() {
418        let a = Args::parse_argv(argv(&["--nl-file", "/tmp/foo.nl"])).unwrap();
419        match a.problem {
420            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
421            _ => panic!("expected nl file"),
422        }
423    }
424
425    #[test]
426    fn positional_nl_path() {
427        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
428        match a.problem {
429            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
430            _ => panic!("expected positional .nl"),
431        }
432    }
433
434    #[test]
435    fn positional_with_options_file() {
436        let a = Args::parse_argv(argv(&["--options-file", "ipopt.opt", "/tmp/foo.nl"])).unwrap();
437        match a.problem {
438            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
439            _ => panic!("expected positional .nl"),
440        }
441        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
442    }
443
444    #[test]
445    fn options_file_captured() {
446        let a = Args::parse_argv(argv(&["--problem", "x", "--options-file", "ipopt.opt"])).unwrap();
447        assert_eq!(a.options_file.unwrap().to_str(), Some("ipopt.opt"));
448    }
449
450    #[test]
451    fn missing_value_for_flag() {
452        assert!(Args::parse_argv(argv(&["--problem"])).is_err());
453    }
454
455    #[test]
456    fn missing_problem() {
457        assert!(Args::parse_argv(argv(&[])).is_err());
458    }
459
460    #[test]
461    fn unknown_arg() {
462        assert!(Args::parse_argv(argv(&["--bogus"])).is_err());
463    }
464
465    #[test]
466    fn key_value_options_collected() {
467        let a = Args::parse_argv(argv(&[
468            "/tmp/foo.nl",
469            "print_level=8",
470            "max_iter=500",
471            "tol=1e-10",
472        ]))
473        .unwrap();
474        assert_eq!(
475            a.set_options,
476            vec![
477                ("print_level".into(), "8".into()),
478                ("max_iter".into(), "500".into()),
479                ("tol".into(), "1e-10".into()),
480            ]
481        );
482    }
483
484    #[test]
485    fn key_value_before_path() {
486        let a = Args::parse_argv(argv(&["print_level=8", "/tmp/foo.nl"])).unwrap();
487        match a.problem {
488            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
489            _ => panic!("expected positional .nl"),
490        }
491        assert_eq!(a.set_options, vec![("print_level".into(), "8".into())]);
492    }
493
494    #[test]
495    fn dump_flag_captures_cat_and_spec() {
496        let a = Args::parse_argv(argv(&[
497            "--problem",
498            "x",
499            "--dump",
500            "kkt:2-10",
501            "--dump",
502            "iterate",
503        ]))
504        .unwrap();
505        assert_eq!(
506            a.dump_specs,
507            vec![
508                ("kkt".into(), "2-10".into()),
509                ("iterate".into(), "all".into()),
510            ]
511        );
512    }
513
514    #[test]
515    fn dump_dir_and_format_captured() {
516        let a = Args::parse_argv(argv(&[
517            "--problem",
518            "x",
519            "--dump",
520            "kkt",
521            "--dump-dir",
522            "/tmp/d",
523            "--dump-format",
524            "jsonl",
525        ]))
526        .unwrap();
527        assert_eq!(a.dump_dir.unwrap().to_str(), Some("/tmp/d"));
528        assert_eq!(a.dump_format.as_deref(), Some("jsonl"));
529    }
530
531    #[test]
532    fn sol_output_captured() {
533        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output", "/tmp/out.sol"])).unwrap();
534        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
535        assert!(!a.no_sol);
536    }
537
538    #[test]
539    fn no_sol_flag() {
540        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--no-sol"])).unwrap();
541        assert!(a.no_sol);
542        assert!(a.sol_output.is_none());
543    }
544
545    #[test]
546    fn sol_output_defaults_unset() {
547        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
548        assert!(a.sol_output.is_none());
549        assert!(!a.no_sol);
550    }
551
552    #[test]
553    fn sol_output_missing_value() {
554        assert!(Args::parse_argv(argv(&["/tmp/foo.nl", "--sol-output"])).is_err());
555    }
556
557    #[test]
558    fn second_positional_is_sol_output() {
559        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "/tmp/out.sol"])).unwrap();
560        match a.problem {
561            ProblemSource::NlFile(p) => assert_eq!(p.to_str(), Some("/tmp/foo.nl")),
562            _ => panic!("expected positional .nl"),
563        }
564        assert_eq!(a.sol_output.unwrap().to_str(), Some("/tmp/out.sol"));
565    }
566
567    #[test]
568    fn third_positional_is_an_error() {
569        assert!(Args::parse_argv(argv(&["/tmp/a.nl", "/tmp/b.sol", "/tmp/c"])).is_err());
570    }
571
572    #[test]
573    fn sens_flags_default_off() {
574        let a = Args::parse_argv(argv(&["/tmp/foo.nl"])).unwrap();
575        assert!(!a.sens_boundcheck);
576        assert!(!a.compute_red_hessian);
577        assert!(!a.rh_eigendecomp);
578        assert_eq!(a.sens_bound_eps, 1e-3);
579    }
580
581    #[test]
582    fn sens_boundcheck_flag() {
583        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-boundcheck"])).unwrap();
584        assert!(a.sens_boundcheck);
585    }
586
587    #[test]
588    fn sens_bound_eps_sets_value_and_enables_boundcheck() {
589        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--sens-bound-eps", "1e-6"])).unwrap();
590        assert_eq!(a.sens_bound_eps, 1e-6);
591        assert!(a.sens_boundcheck);
592    }
593
594    #[test]
595    fn rh_eigendecomp_implies_compute_red_hessian() {
596        let a = Args::parse_argv(argv(&["/tmp/foo.nl", "--rh-eigendecomp"])).unwrap();
597        assert!(a.rh_eigendecomp);
598        assert!(a.compute_red_hessian);
599    }
600
601    #[test]
602    fn parse_kv_basic() {
603        assert_eq!(
604            parse_kv("print_level=8"),
605            Some(("print_level".into(), "8".into()))
606        );
607        assert_eq!(
608            parse_kv("tol = 1e-10"),
609            Some(("tol".into(), "1e-10".into()))
610        );
611        assert_eq!(parse_kv("plain_path.nl"), None);
612        assert_eq!(parse_kv("=value"), None);
613        assert_eq!(parse_kv("key="), None);
614    }
615}