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
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn register_and_lookup_case_insensitive() {
414        let r = RegisteredOptions::new();
415        r.set_registering_category("Test");
416        r.add_number_option("Tol", "tolerance", 1e-8, "").unwrap();
417        assert!(r.get_option("tol").is_some());
418        assert!(r.get_option("TOL").is_some());
419    }
420
421    #[test]
422    fn duplicate_registration_is_error() {
423        let r = RegisteredOptions::new();
424        r.add_number_option("alpha", "", 1.0, "").unwrap();
425        let err = r.add_number_option("ALPHA", "", 2.0, "").unwrap_err();
426        assert_eq!(err.kind, ExceptionKind::OPTION_ALREADY_REGISTERED);
427    }
428
429    #[test]
430    fn bounds_check_on_number() {
431        let r = RegisteredOptions::new();
432        r.add_lower_bounded_number_option("mu", "", 0.0, true, 0.1, "")
433            .unwrap();
434        let opt = r.get_option("mu").unwrap();
435        assert!(opt.is_valid_number(1e-12));
436        assert!(!opt.is_valid_number(0.0));
437        assert!(!opt.is_valid_number(-1.0));
438    }
439
440    #[test]
441    fn string_enum_lookup() {
442        let r = RegisteredOptions::new();
443        r.add_string_option(
444            "linear_solver",
445            "",
446            "mumps",
447            &[("mumps", "MUMPS"), ("feral", "FERAL")],
448            "",
449        )
450        .unwrap();
451        let opt = r.get_option("linear_solver").unwrap();
452        assert!(opt.is_valid_string("MuMpS"));
453        assert!(!opt.is_valid_string("ma27"));
454        assert_eq!(opt.map_string_to_enum("feral"), Some(1));
455    }
456
457    #[test]
458    fn registration_order_preserved() {
459        let r = RegisteredOptions::new();
460        r.add_number_option("c", "", 0.0, "").unwrap();
461        r.add_number_option("a", "", 0.0, "").unwrap();
462        r.add_number_option("b", "", 0.0, "").unwrap();
463        let order: Vec<_> = r
464            .registered_options_in_order()
465            .iter()
466            .map(|o| o.name.clone())
467            .collect();
468        assert_eq!(order, vec!["c", "a", "b"]);
469    }
470}