Skip to main content

pounce_common/
reg_options.rs

1//! Registered options registry.
2//!
3//! Mirrors `Common/IpRegOptions.{hpp,cpp}`. Each option that the
4//! solver consults must be registered first with type, default, valid
5//! range, and description. The registry is shared (via `Rc`) by the
6//! `OptionsList` and by code that prints help.
7//!
8//! Insertion order is preserved (each option carries a `counter`), so
9//! generated help text can be byte-identical to upstream when the
10//! same registration order is used.
11
12use crate::exception::{ExceptionKind, SolverException};
13use crate::throw;
14use crate::types::{Index, Number};
15use std::cell::RefCell;
16use std::collections::BTreeMap;
17use std::rc::Rc;
18
19/// Mirrors `RegisteredOptionType`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[allow(non_camel_case_types)]
22pub enum OptionType {
23    OT_Number,
24    OT_Integer,
25    OT_String,
26    OT_Unknown,
27}
28
29/// Mirrors `RegisteredOption::string_entry`.
30#[derive(Debug, Clone)]
31pub struct StringEntry {
32    pub value: String,
33    pub description: String,
34}
35
36#[derive(Debug, Clone)]
37pub enum DefaultValue {
38    None,
39    Number(Number),
40    Integer(Index),
41    String(String),
42}
43
44/// Mirrors `RegisteredOption`. Holds metadata for one option.
45#[derive(Debug, Clone)]
46pub struct RegisteredOption {
47    pub name: String,
48    pub short_description: String,
49    pub long_description: String,
50    pub category: String,
51    pub counter: Index,
52    pub advanced: bool,
53    pub option_type: OptionType,
54    pub default: DefaultValue,
55    pub has_lower: bool,
56    pub lower: Number,
57    pub lower_strict: bool,
58    pub has_upper: bool,
59    pub upper: Number,
60    pub upper_strict: bool,
61    pub valid_strings: Vec<StringEntry>,
62}
63
64impl RegisteredOption {
65    fn new(
66        name: String,
67        short: String,
68        long: String,
69        category: String,
70        counter: Index,
71        advanced: bool,
72    ) -> Self {
73        Self {
74            name,
75            short_description: short,
76            long_description: long,
77            category,
78            counter,
79            advanced,
80            option_type: OptionType::OT_Unknown,
81            default: DefaultValue::None,
82            has_lower: false,
83            lower: 0.0,
84            lower_strict: false,
85            has_upper: false,
86            upper: 0.0,
87            upper_strict: false,
88            valid_strings: Vec::new(),
89        }
90    }
91
92    /// Equivalent to `IsValidNumberSetting` — checks bounds.
93    pub fn is_valid_number(&self, v: Number) -> bool {
94        if self.has_lower {
95            let ok = if self.lower_strict {
96                v > self.lower
97            } else {
98                v >= self.lower
99            };
100            if !ok {
101                return false;
102            }
103        }
104        if self.has_upper {
105            let ok = if self.upper_strict {
106                v < self.upper
107            } else {
108                v <= self.upper
109            };
110            if !ok {
111                return false;
112            }
113        }
114        true
115    }
116
117    pub fn is_valid_integer(&self, v: Index) -> bool {
118        self.is_valid_number(v as Number)
119    }
120
121    /// Equivalent to `IsValidStringSetting`. A registered entry of
122    /// `"*"` is treated as a wildcard: any string is accepted. This
123    /// mirrors upstream Ipopt's behavior for free-form options like
124    /// `output_file` and `linear_solver_options`.
125    pub fn is_valid_string(&self, value: &str) -> bool {
126        let v = value.to_ascii_lowercase();
127        self.valid_strings
128            .iter()
129            .any(|e| e.value == "*" || e.value.eq_ignore_ascii_case(&v))
130    }
131
132    /// Returns the canonical (lowercase) form recorded at registration
133    /// for the given enum value, or `None` if not allowed.
134    pub fn canonical_string(&self, value: &str) -> Option<&str> {
135        self.valid_strings
136            .iter()
137            .find(|e| e.value.eq_ignore_ascii_case(value))
138            .map(|e| e.value.as_str())
139    }
140
141    /// Index of `value` in `valid_strings`, used for `GetEnumValue`.
142    pub fn map_string_to_enum(&self, value: &str) -> Option<Index> {
143        self.valid_strings
144            .iter()
145            .position(|e| e.value.eq_ignore_ascii_case(value))
146            .map(|i| i as Index)
147    }
148}
149
150/// Mirrors `RegisteredOptions`. Insertion-ordered registry of options.
151#[derive(Debug, Default)]
152pub struct RegisteredOptions {
153    /// All registered options, keyed on lowercase name.
154    options: RefCell<BTreeMap<String, Rc<RegisteredOption>>>,
155    /// Insertion order used for printing.
156    order: RefCell<Vec<String>>,
157    /// Active category for `add_*` calls — set with `set_registering_category`.
158    current_category: RefCell<String>,
159    next_counter: RefCell<Index>,
160}
161
162impl RegisteredOptions {
163    pub fn new() -> Rc<Self> {
164        Rc::new(Self::default())
165    }
166
167    pub fn set_registering_category(&self, category: impl Into<String>) {
168        *self.current_category.borrow_mut() = category.into();
169    }
170
171    fn alloc_counter(&self) -> Index {
172        let mut c = self.next_counter.borrow_mut();
173        let v = *c;
174        *c += 1;
175        v
176    }
177
178    fn register(&self, opt: RegisteredOption) -> Result<Rc<RegisteredOption>, SolverException> {
179        let key = opt.name.to_ascii_lowercase();
180        let mut opts = self.options.borrow_mut();
181        if opts.contains_key(&key) {
182            throw!(
183                ExceptionKind::OPTION_ALREADY_REGISTERED,
184                format!("Option {} already registered.", opt.name)
185            );
186        }
187        let rc = Rc::new(opt);
188        opts.insert(key.clone(), rc.clone());
189        self.order.borrow_mut().push(key);
190        Ok(rc)
191    }
192
193    pub fn add_number_option(
194        &self,
195        name: &str,
196        short_description: &str,
197        default_value: Number,
198        long_description: &str,
199    ) -> Result<Rc<RegisteredOption>, SolverException> {
200        let mut o = RegisteredOption::new(
201            name.to_string(),
202            short_description.to_string(),
203            long_description.to_string(),
204            self.current_category.borrow().clone(),
205            self.alloc_counter(),
206            false,
207        );
208        o.option_type = OptionType::OT_Number;
209        o.default = DefaultValue::Number(default_value);
210        self.register(o)
211    }
212
213    pub fn add_lower_bounded_number_option(
214        &self,
215        name: &str,
216        short_description: &str,
217        lower: Number,
218        strict: bool,
219        default_value: Number,
220        long_description: &str,
221    ) -> Result<Rc<RegisteredOption>, SolverException> {
222        let mut o = RegisteredOption::new(
223            name.to_string(),
224            short_description.to_string(),
225            long_description.to_string(),
226            self.current_category.borrow().clone(),
227            self.alloc_counter(),
228            false,
229        );
230        o.option_type = OptionType::OT_Number;
231        o.default = DefaultValue::Number(default_value);
232        o.has_lower = true;
233        o.lower = lower;
234        o.lower_strict = strict;
235        self.register(o)
236    }
237
238    #[allow(clippy::too_many_arguments)]
239    pub fn add_bounded_number_option(
240        &self,
241        name: &str,
242        short_description: &str,
243        lower: Number,
244        lower_strict: bool,
245        upper: Number,
246        upper_strict: bool,
247        default_value: Number,
248        long_description: &str,
249    ) -> Result<Rc<RegisteredOption>, SolverException> {
250        let mut o = RegisteredOption::new(
251            name.to_string(),
252            short_description.to_string(),
253            long_description.to_string(),
254            self.current_category.borrow().clone(),
255            self.alloc_counter(),
256            false,
257        );
258        o.option_type = OptionType::OT_Number;
259        o.default = DefaultValue::Number(default_value);
260        o.has_lower = true;
261        o.lower = lower;
262        o.lower_strict = lower_strict;
263        o.has_upper = true;
264        o.upper = upper;
265        o.upper_strict = upper_strict;
266        self.register(o)
267    }
268
269    pub fn add_integer_option(
270        &self,
271        name: &str,
272        short_description: &str,
273        default_value: Index,
274        long_description: &str,
275    ) -> Result<Rc<RegisteredOption>, SolverException> {
276        let mut o = RegisteredOption::new(
277            name.to_string(),
278            short_description.to_string(),
279            long_description.to_string(),
280            self.current_category.borrow().clone(),
281            self.alloc_counter(),
282            false,
283        );
284        o.option_type = OptionType::OT_Integer;
285        o.default = DefaultValue::Integer(default_value);
286        self.register(o)
287    }
288
289    pub fn add_lower_bounded_integer_option(
290        &self,
291        name: &str,
292        short_description: &str,
293        lower: Index,
294        default_value: Index,
295        long_description: &str,
296    ) -> Result<Rc<RegisteredOption>, SolverException> {
297        let mut o = RegisteredOption::new(
298            name.to_string(),
299            short_description.to_string(),
300            long_description.to_string(),
301            self.current_category.borrow().clone(),
302            self.alloc_counter(),
303            false,
304        );
305        o.option_type = OptionType::OT_Integer;
306        o.default = DefaultValue::Integer(default_value);
307        o.has_lower = true;
308        o.lower = lower as Number;
309        self.register(o)
310    }
311
312    pub fn add_bounded_integer_option(
313        &self,
314        name: &str,
315        short_description: &str,
316        lower: Index,
317        upper: Index,
318        default_value: Index,
319        long_description: &str,
320    ) -> Result<Rc<RegisteredOption>, SolverException> {
321        let mut o = RegisteredOption::new(
322            name.to_string(),
323            short_description.to_string(),
324            long_description.to_string(),
325            self.current_category.borrow().clone(),
326            self.alloc_counter(),
327            false,
328        );
329        o.option_type = OptionType::OT_Integer;
330        o.default = DefaultValue::Integer(default_value);
331        o.has_lower = true;
332        o.lower = lower as Number;
333        o.has_upper = true;
334        o.upper = upper as Number;
335        self.register(o)
336    }
337
338    pub fn add_string_option(
339        &self,
340        name: &str,
341        short_description: &str,
342        default_value: &str,
343        valid: &[(&str, &str)],
344        long_description: &str,
345    ) -> Result<Rc<RegisteredOption>, SolverException> {
346        let mut o = RegisteredOption::new(
347            name.to_string(),
348            short_description.to_string(),
349            long_description.to_string(),
350            self.current_category.borrow().clone(),
351            self.alloc_counter(),
352            false,
353        );
354        o.option_type = OptionType::OT_String;
355        o.default = DefaultValue::String(default_value.to_string());
356        o.valid_strings = valid
357            .iter()
358            .map(|(v, d)| StringEntry {
359                value: v.to_string(),
360                description: d.to_string(),
361            })
362            .collect();
363        self.register(o)
364    }
365
366    /// Convenience: yes/no option, default `default_yes` ? "yes" : "no".
367    pub fn add_bool_option(
368        &self,
369        name: &str,
370        short_description: &str,
371        default_yes: bool,
372        long_description: &str,
373    ) -> Result<Rc<RegisteredOption>, SolverException> {
374        self.add_string_option(
375            name,
376            short_description,
377            if default_yes { "yes" } else { "no" },
378            &[("no", ""), ("yes", "")],
379            long_description,
380        )
381    }
382
383    /// Mirrors `GetOption(name)`. If `name` contains a `.`, only the
384    /// suffix after the last `.` is looked up — this is how upstream
385    /// validates prefixed option-file lines like `resto.tol`.
386    pub fn get_option(&self, name: &str) -> Option<Rc<RegisteredOption>> {
387        let tag_only = match name.rfind('.') {
388            Some(pos) => &name[pos + 1..],
389            None => name,
390        };
391        self.options
392            .borrow()
393            .get(&tag_only.to_ascii_lowercase())
394            .cloned()
395    }
396
397    /// Returns options in registration order.
398    pub fn registered_options_in_order(&self) -> Vec<Rc<RegisteredOption>> {
399        let opts = self.options.borrow();
400        self.order
401            .borrow()
402            .iter()
403            .filter_map(|k| opts.get(k).cloned())
404            .collect()
405    }
406
407    /// Render the registry as human-readable text grouped by category.
408    /// The output mirrors the categories produced by Ipopt's
409    /// `RegisteredOptions::OutputOptionDocumentation`. Options whose
410    /// short and long descriptions are both empty are dropped from
411    /// the listing. `include_advanced` controls whether options
412    /// flagged `advanced` appear (none in pounce today, but the gate
413    /// is honored so the flag stays meaningful for future additions).
414    ///
415    /// Set `mode` via [`PrintOptionsMode`]; `Text` is the default.
416    /// `Latex` / `Doxygen` are accepted (they prepend a one-line note
417    /// that they currently fall through to the text formatter) so
418    /// scripts that pass them through unconditionally still produce
419    /// readable output.
420    pub fn print_options_documentation(
421        &self,
422        mode: PrintOptionsMode,
423        include_advanced: bool,
424    ) -> String {
425        let opts = self.registered_options_in_order();
426        let mut out = String::new();
427        match mode {
428            PrintOptionsMode::Text => {}
429            PrintOptionsMode::Latex => {
430                out.push_str("% pounce: latex output for print_options_mode is not yet implemented; falling through to plain text.\n\n");
431            }
432            PrintOptionsMode::Doxygen => {
433                out.push_str("<!-- pounce: doxygen output for print_options_mode is not yet implemented; falling through to plain text. -->\n\n");
434            }
435        }
436        // Group by category in registration order.
437        let mut current_category: Option<String> = None;
438        for opt in opts.iter() {
439            if !include_advanced && opt.advanced {
440                continue;
441            }
442            // Skip undocumented stubs (Ipopt does the same — an option
443            // with neither short nor long description is internal-only).
444            if opt.short_description.is_empty() && opt.long_description.is_empty() {
445                continue;
446            }
447            let category = if opt.category.is_empty() {
448                "Uncategorized"
449            } else {
450                opt.category.as_str()
451            };
452            if current_category.as_deref() != Some(category) {
453                if current_category.is_some() {
454                    out.push('\n');
455                }
456                out.push_str("### ");
457                out.push_str(category);
458                out.push_str(" ###\n\n");
459                current_category = Some(category.to_string());
460            }
461            format_option_text(&mut out, opt);
462        }
463        out
464    }
465}
466
467/// Format selector for [`RegisteredOptions::print_options_documentation`].
468/// Matches the `print_options_mode` registered string option.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum PrintOptionsMode {
471    Text,
472    Latex,
473    Doxygen,
474}
475
476impl PrintOptionsMode {
477    /// Parse the `print_options_mode` tag (case-insensitive). Falls
478    /// back to [`PrintOptionsMode::Text`] for unrecognized values so
479    /// the option dump still appears.
480    pub fn from_tag(s: &str) -> Self {
481        match s.trim().to_ascii_lowercase().as_str() {
482            "latex" => Self::Latex,
483            "doxygen" => Self::Doxygen,
484            _ => Self::Text,
485        }
486    }
487}
488
489fn format_option_text(out: &mut String, opt: &RegisteredOption) {
490    out.push_str(opt.name.as_str());
491    out.push_str(": ");
492    if !opt.short_description.is_empty() {
493        out.push_str(opt.short_description.as_str());
494    }
495    out.push('\n');
496    let type_str = match opt.option_type {
497        OptionType::OT_Number => "Number",
498        OptionType::OT_Integer => "Integer",
499        OptionType::OT_String => "String",
500        OptionType::OT_Unknown => "Unknown",
501    };
502    out.push_str("   type:    ");
503    out.push_str(type_str);
504    out.push('\n');
505    out.push_str("   default: ");
506    match &opt.default {
507        DefaultValue::None => out.push_str("(none)"),
508        DefaultValue::Number(v) => out.push_str(&format!("{}", v)),
509        DefaultValue::Integer(v) => out.push_str(&format!("{}", v)),
510        DefaultValue::String(v) => {
511            out.push('"');
512            out.push_str(v);
513            out.push('"');
514        }
515    }
516    out.push('\n');
517    if matches!(
518        opt.option_type,
519        OptionType::OT_Number | OptionType::OT_Integer
520    ) && (opt.has_lower || opt.has_upper)
521    {
522        out.push_str("   range:   ");
523        if opt.has_lower {
524            out.push_str(&format!(
525                "{}{}",
526                if opt.lower_strict { "(" } else { "[" },
527                opt.lower
528            ));
529        } else {
530            out.push_str("(-inf");
531        }
532        out.push_str(", ");
533        if opt.has_upper {
534            out.push_str(&format!(
535                "{}{}",
536                opt.upper,
537                if opt.upper_strict { ")" } else { "]" }
538            ));
539        } else {
540            out.push_str("inf)");
541        }
542        out.push('\n');
543    }
544    if !opt.valid_strings.is_empty() {
545        out.push_str("   values:\n");
546        for entry in &opt.valid_strings {
547            if entry.description.is_empty() {
548                out.push_str(&format!("     - {}\n", entry.value));
549            } else {
550                out.push_str(&format!("     - {}: {}\n", entry.value, entry.description));
551            }
552        }
553    }
554    if !opt.long_description.is_empty() {
555        out.push('\n');
556        for line in wrap_paragraph(&opt.long_description, 76) {
557            out.push_str("   ");
558            out.push_str(&line);
559            out.push('\n');
560        }
561    }
562    out.push('\n');
563}
564
565/// Soft-wrap on word boundaries to keep the dumped doc readable in a
566/// terminal. Doesn't break mid-word; long single tokens overflow the
567/// width rather than splitting.
568fn wrap_paragraph(text: &str, width: usize) -> Vec<String> {
569    let mut lines = Vec::new();
570    for paragraph in text.split('\n') {
571        let mut line = String::new();
572        for word in paragraph.split_whitespace() {
573            if line.is_empty() {
574                line.push_str(word);
575            } else if line.len() + 1 + word.len() <= width {
576                line.push(' ');
577                line.push_str(word);
578            } else {
579                lines.push(std::mem::take(&mut line));
580                line.push_str(word);
581            }
582        }
583        if !line.is_empty() {
584            lines.push(line);
585        } else if paragraph.is_empty() {
586            lines.push(String::new());
587        }
588    }
589    lines
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn register_and_lookup_case_insensitive() {
598        let r = RegisteredOptions::new();
599        r.set_registering_category("Test");
600        r.add_number_option("Tol", "tolerance", 1e-8, "").unwrap();
601        assert!(r.get_option("tol").is_some());
602        assert!(r.get_option("TOL").is_some());
603    }
604
605    #[test]
606    fn print_options_documentation_renders_categories_and_metadata() {
607        let r = RegisteredOptions::new();
608        r.set_registering_category("Catty");
609        r.add_string_option(
610            "mode",
611            "How to do it.",
612            "auto",
613            &[("auto", "Decide for me."), ("manual", "I will choose.")],
614            "Long description that explains the trade-off between auto and manual selection.",
615        )
616        .unwrap();
617        r.add_bounded_number_option("tol", "Tolerance.", 0.0, true, 1.0, false, 1e-8, "")
618            .unwrap();
619        r.set_registering_category("Hidden");
620        r.add_bool_option("internal", "", false, "").unwrap();
621
622        let out = r.print_options_documentation(PrintOptionsMode::Text, false);
623        assert!(out.contains("### Catty ###"), "category header missing");
624        assert!(out.contains("mode:"), "option name missing");
625        assert!(out.contains("default: \"auto\""), "default missing");
626        assert!(
627            out.contains("- auto: Decide for me."),
628            "valid string missing"
629        );
630        assert!(out.contains("tol:"), "second option missing");
631        assert!(out.contains("range:   (0, 1]"), "range formatting missing");
632        // The undocumented `internal` stub (empty short+long) is skipped.
633        assert!(
634            !out.contains("internal:"),
635            "undocumented option leaked into output: {out}"
636        );
637
638        // Latex/Doxygen prepend a one-line note and fall through.
639        let latex = r.print_options_documentation(PrintOptionsMode::Latex, false);
640        assert!(latex.starts_with("% pounce: latex"));
641        assert!(latex.contains("mode:"));
642        let dox = r.print_options_documentation(PrintOptionsMode::Doxygen, false);
643        assert!(dox.starts_with("<!-- pounce: doxygen"));
644        assert!(dox.contains("mode:"));
645    }
646
647    #[test]
648    fn print_options_mode_parses_tags() {
649        assert_eq!(PrintOptionsMode::from_tag("text"), PrintOptionsMode::Text);
650        assert_eq!(PrintOptionsMode::from_tag("LaTeX"), PrintOptionsMode::Latex);
651        assert_eq!(
652            PrintOptionsMode::from_tag("doxygen"),
653            PrintOptionsMode::Doxygen
654        );
655        // Unknown falls back to text.
656        assert_eq!(PrintOptionsMode::from_tag("html"), PrintOptionsMode::Text);
657    }
658
659    #[test]
660    fn duplicate_registration_is_error() {
661        let r = RegisteredOptions::new();
662        r.add_number_option("alpha", "", 1.0, "").unwrap();
663        let err = r.add_number_option("ALPHA", "", 2.0, "").unwrap_err();
664        assert_eq!(err.kind, ExceptionKind::OPTION_ALREADY_REGISTERED);
665    }
666
667    #[test]
668    fn bounds_check_on_number() {
669        let r = RegisteredOptions::new();
670        r.add_lower_bounded_number_option("mu", "", 0.0, true, 0.1, "")
671            .unwrap();
672        let opt = r.get_option("mu").unwrap();
673        assert!(opt.is_valid_number(1e-12));
674        assert!(!opt.is_valid_number(0.0));
675        assert!(!opt.is_valid_number(-1.0));
676    }
677
678    #[test]
679    fn string_enum_lookup() {
680        let r = RegisteredOptions::new();
681        r.add_string_option(
682            "linear_solver",
683            "",
684            "mumps",
685            &[("mumps", "MUMPS"), ("feral", "FERAL")],
686            "",
687        )
688        .unwrap();
689        let opt = r.get_option("linear_solver").unwrap();
690        assert!(opt.is_valid_string("MuMpS"));
691        assert!(!opt.is_valid_string("ma27"));
692        assert_eq!(opt.map_string_to_enum("feral"), Some(1));
693    }
694
695    #[test]
696    fn registration_order_preserved() {
697        let r = RegisteredOptions::new();
698        r.add_number_option("c", "", 0.0, "").unwrap();
699        r.add_number_option("a", "", 0.0, "").unwrap();
700        r.add_number_option("b", "", 0.0, "").unwrap();
701        let order: Vec<_> = r
702            .registered_options_in_order()
703            .iter()
704            .map(|o| o.name.clone())
705            .collect();
706        assert_eq!(order, vec!["c", "a", "b"]);
707    }
708}