Skip to main content

pounce_common/
options_list.rs

1//! User-set options list.
2//!
3//! Mirrors `Common/IpOptionsList.{hpp,cpp}`. Stores name → value
4//! string mappings; lookup is case-insensitive and prefix-aware. The
5//! prefix mechanism is what gives the restoration sub-algorithm its
6//! own option scope: looking up `tol` with prefix `"resto."` first
7//! tries `resto.tol`, then falls back to `tol`.
8//!
9//! Internal value representation is always a `String`, exactly as in
10//! upstream — typed accessors parse on each call, matching Ipopt
11//! behavior.
12
13use crate::exception::{ExceptionKind, SolverException};
14use crate::reg_options::{DefaultValue, OptionType, RegisteredOptions};
15use crate::throw;
16use crate::types::{Index, Number};
17use std::collections::BTreeMap;
18use std::io::Read;
19use std::rc::Rc;
20
21#[derive(Debug, Clone)]
22struct OptionValue {
23    value: String,
24    counter: std::cell::Cell<Index>,
25    allow_clobber: bool,
26    dont_print: bool,
27}
28
29impl OptionValue {
30    fn new(value: String, allow_clobber: bool, dont_print: bool) -> Self {
31        Self {
32            value,
33            counter: std::cell::Cell::new(0),
34            allow_clobber,
35            dont_print,
36        }
37    }
38    fn get_value(&self) -> &str {
39        self.counter.set(self.counter.get() + 1);
40        &self.value
41    }
42}
43
44/// Mirrors `Ipopt::OptionsList`.
45#[derive(Debug, Default, Clone)]
46pub struct OptionsList {
47    options: BTreeMap<String, OptionValue>,
48    reg_options: Option<Rc<RegisteredOptions>>,
49}
50
51impl OptionsList {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn with_registered(reg: Rc<RegisteredOptions>) -> Self {
57        Self {
58            options: BTreeMap::new(),
59            reg_options: Some(reg),
60        }
61    }
62
63    pub fn set_registered_options(&mut self, reg: Rc<RegisteredOptions>) {
64        self.reg_options = Some(reg);
65    }
66
67    pub fn registered_options(&self) -> Option<Rc<RegisteredOptions>> {
68        self.reg_options.clone()
69    }
70
71    pub fn clear(&mut self) {
72        self.options.clear();
73    }
74
75    fn key(name: &str) -> String {
76        name.to_ascii_lowercase()
77    }
78
79    /// Mirrors `OptionsList::find_tag` — try `prefix+tag` first, then
80    /// bare `tag`. Returns the stored string and bumps its read counter.
81    fn find_tag(&self, tag: &str, prefix: &str) -> Option<&OptionValue> {
82        if !prefix.is_empty() {
83            let key = Self::key(&format!("{prefix}{tag}"));
84            if let Some(v) = self.options.get(&key) {
85                return Some(v);
86            }
87        }
88        self.options.get(&Self::key(tag))
89    }
90
91    fn will_allow_clobber(&self, tag: &str) -> bool {
92        match self.options.get(&Self::key(tag)) {
93            Some(v) => v.allow_clobber,
94            None => true,
95        }
96    }
97
98    /// Mirrors `SetStringValue`.
99    pub fn set_string_value(
100        &mut self,
101        tag: &str,
102        value: &str,
103        allow_clobber: bool,
104        dont_print: bool,
105    ) -> Result<bool, SolverException> {
106        if let Some(reg) = &self.reg_options {
107            let opt = reg.get_option(tag).ok_or_else(|| {
108                SolverException::new(
109                    ExceptionKind::OPTION_INVALID,
110                    format!("Unknown option \"{tag}\"."),
111                    file!(),
112                    line!() as Index,
113                )
114            })?;
115            if opt.option_type != OptionType::OT_String {
116                throw!(
117                    ExceptionKind::OPTION_INVALID,
118                    format!("Option \"{tag}\" is not a string option.")
119                );
120            }
121            if !opt.is_valid_string(value) {
122                throw!(
123                    ExceptionKind::OPTION_INVALID,
124                    format!("Invalid value \"{value}\" for string option \"{tag}\".")
125                );
126            }
127        }
128        if !self.will_allow_clobber(tag) {
129            return Ok(false);
130        }
131        let stored = value.to_ascii_lowercase();
132        self.options.insert(
133            Self::key(tag),
134            OptionValue::new(stored, allow_clobber, dont_print),
135        );
136        Ok(true)
137    }
138
139    /// Mirrors `SetNumericValue`.
140    pub fn set_numeric_value(
141        &mut self,
142        tag: &str,
143        value: Number,
144        allow_clobber: bool,
145        dont_print: bool,
146    ) -> Result<bool, SolverException> {
147        if let Some(reg) = &self.reg_options {
148            let opt = reg.get_option(tag).ok_or_else(|| {
149                SolverException::new(
150                    ExceptionKind::OPTION_INVALID,
151                    format!("Unknown option \"{tag}\"."),
152                    file!(),
153                    line!() as Index,
154                )
155            })?;
156            if opt.option_type != OptionType::OT_Number {
157                throw!(
158                    ExceptionKind::OPTION_INVALID,
159                    format!("Option \"{tag}\" is not a numeric option.")
160                );
161            }
162            if !opt.is_valid_number(value) {
163                throw!(
164                    ExceptionKind::OPTION_INVALID,
165                    format!("Numeric value {value} for option \"{tag}\" out of range.")
166                );
167            }
168        }
169        if !self.will_allow_clobber(tag) {
170            return Ok(false);
171        }
172        // Print with full precision so round-trip preserves the value.
173        let s = format!("{value:.18e}");
174        self.options.insert(
175            Self::key(tag),
176            OptionValue::new(s, allow_clobber, dont_print),
177        );
178        Ok(true)
179    }
180
181    /// Mirrors `SetIntegerValue`.
182    pub fn set_integer_value(
183        &mut self,
184        tag: &str,
185        value: Index,
186        allow_clobber: bool,
187        dont_print: bool,
188    ) -> Result<bool, SolverException> {
189        if let Some(reg) = &self.reg_options {
190            let opt = reg.get_option(tag).ok_or_else(|| {
191                SolverException::new(
192                    ExceptionKind::OPTION_INVALID,
193                    format!("Unknown option \"{tag}\"."),
194                    file!(),
195                    line!() as Index,
196                )
197            })?;
198            if opt.option_type != OptionType::OT_Integer {
199                throw!(
200                    ExceptionKind::OPTION_INVALID,
201                    format!("Option \"{tag}\" is not an integer option.")
202                );
203            }
204            if !opt.is_valid_integer(value) {
205                throw!(
206                    ExceptionKind::OPTION_INVALID,
207                    format!("Integer value {value} for option \"{tag}\" out of range.")
208                );
209            }
210        }
211        if !self.will_allow_clobber(tag) {
212            return Ok(false);
213        }
214        self.options.insert(
215            Self::key(tag),
216            OptionValue::new(value.to_string(), allow_clobber, dont_print),
217        );
218        Ok(true)
219    }
220
221    /// Mirrors `SetBoolValue`.
222    pub fn set_bool_value(
223        &mut self,
224        tag: &str,
225        value: bool,
226        allow_clobber: bool,
227        dont_print: bool,
228    ) -> Result<bool, SolverException> {
229        self.set_string_value(
230            tag,
231            if value { "yes" } else { "no" },
232            allow_clobber,
233            dont_print,
234        )
235    }
236
237    /// Mirrors `UnsetValue`. Returns true if the value was removed.
238    pub fn unset_value(&mut self, tag: &str) -> bool {
239        let key = Self::key(tag);
240        if let Some(v) = self.options.get(&key) {
241            if !v.allow_clobber {
242                return false;
243            }
244            self.options.remove(&key);
245            true
246        } else {
247            false
248        }
249    }
250
251    /// Mirrors `GetStringValue`. Returns true if found in the list.
252    /// Falls back to the registered default when not found.
253    pub fn get_string_value(
254        &self,
255        tag: &str,
256        prefix: &str,
257    ) -> Result<(String, bool), SolverException> {
258        if let Some(v) = self.find_tag(tag, prefix) {
259            return Ok((v.get_value().to_string(), true));
260        }
261        if let Some(reg) = &self.reg_options {
262            if let Some(opt) = reg.get_option(tag) {
263                if let DefaultValue::String(d) = &opt.default {
264                    return Ok((d.clone(), false));
265                }
266                throw!(
267                    ExceptionKind::OPTION_INVALID,
268                    format!("Option \"{tag}\" is not a string option.")
269                );
270            }
271        }
272        Ok((String::new(), false))
273    }
274
275    /// Mirrors `GetNumericValue`.
276    pub fn get_numeric_value(
277        &self,
278        tag: &str,
279        prefix: &str,
280    ) -> Result<(Number, bool), SolverException> {
281        if let Some(v) = self.find_tag(tag, prefix) {
282            let s = v.get_value().to_string();
283            let parsed = parse_ipopt_number(&s).ok_or_else(|| {
284                SolverException::new(
285                    ExceptionKind::OPTION_INVALID,
286                    format!("Option \"{tag}\": cannot parse value \"{s}\" as Number."),
287                    file!(),
288                    line!() as Index,
289                )
290            })?;
291            return Ok((parsed, true));
292        }
293        if let Some(reg) = &self.reg_options {
294            if let Some(opt) = reg.get_option(tag) {
295                if let DefaultValue::Number(d) = &opt.default {
296                    return Ok((*d, false));
297                }
298                throw!(
299                    ExceptionKind::OPTION_INVALID,
300                    format!("Option \"{tag}\" is not a numeric option.")
301                );
302            }
303        }
304        Ok((0.0, false))
305    }
306
307    /// Mirrors `GetIntegerValue`.
308    pub fn get_integer_value(
309        &self,
310        tag: &str,
311        prefix: &str,
312    ) -> Result<(Index, bool), SolverException> {
313        if let Some(v) = self.find_tag(tag, prefix) {
314            let s = v.get_value().to_string();
315            let parsed: Index = s.trim().parse().map_err(|_| {
316                SolverException::new(
317                    ExceptionKind::OPTION_INVALID,
318                    format!("Option \"{tag}\": cannot parse value \"{s}\" as Integer."),
319                    file!(),
320                    line!() as Index,
321                )
322            })?;
323            return Ok((parsed, true));
324        }
325        if let Some(reg) = &self.reg_options {
326            if let Some(opt) = reg.get_option(tag) {
327                if let DefaultValue::Integer(d) = &opt.default {
328                    return Ok((*d, false));
329                }
330                throw!(
331                    ExceptionKind::OPTION_INVALID,
332                    format!("Option \"{tag}\" is not an integer option.")
333                );
334            }
335        }
336        Ok((0, false))
337    }
338
339    /// Mirrors `GetBoolValue`. Accepts `"yes"`/`"no"`.
340    pub fn get_bool_value(&self, tag: &str, prefix: &str) -> Result<(bool, bool), SolverException> {
341        let (s, found) = self.get_string_value(tag, prefix)?;
342        let v = match s.to_ascii_lowercase().as_str() {
343            "yes" => true,
344            "no" => false,
345            other => throw!(
346                ExceptionKind::OPTION_INVALID,
347                format!("Option \"{tag}\" has non-boolean value \"{other}\".")
348            ),
349        };
350        Ok((v, found))
351    }
352
353    /// Mirrors `GetEnumValue`. Returns the index of the value in the
354    /// registered string list.
355    pub fn get_enum_value(
356        &self,
357        tag: &str,
358        prefix: &str,
359    ) -> Result<(Index, bool), SolverException> {
360        let (s, found) = self.get_string_value(tag, prefix)?;
361        let reg = self.reg_options.as_ref().ok_or_else(|| {
362            SolverException::new(
363                ExceptionKind::OPTION_INVALID,
364                "GetEnumValue requires a RegisteredOptions registry.".to_string(),
365                file!(),
366                line!() as Index,
367            )
368        })?;
369        let opt = reg.get_option(tag).ok_or_else(|| {
370            SolverException::new(
371                ExceptionKind::OPTION_INVALID,
372                format!("Unknown option \"{tag}\"."),
373                file!(),
374                line!() as Index,
375            )
376        })?;
377        let idx = opt.map_string_to_enum(&s).ok_or_else(|| {
378            SolverException::new(
379                ExceptionKind::ERROR_CONVERTING_STRING_TO_ENUM,
380                format!("Cannot map \"{s}\" to enum for option \"{tag}\"."),
381                file!(),
382                line!() as Index,
383            )
384        })?;
385        Ok((idx, found))
386    }
387
388    /// Mirrors `ReadFromStream`. Parses an `ipopt.opt`-style file:
389    /// whitespace-separated `tag value` pairs, `#` line comments,
390    /// double-quoted tokens permitted.
391    pub fn read_from_stream<R: Read>(
392        &mut self,
393        mut r: R,
394        allow_clobber: bool,
395    ) -> Result<(), SolverException> {
396        let mut s = String::new();
397        r.read_to_string(&mut s).map_err(|e| {
398            SolverException::new(
399                ExceptionKind::OPTION_INVALID,
400                format!("I/O error reading options: {e}"),
401                file!(),
402                line!() as Index,
403            )
404        })?;
405        self.read_from_str(&s, allow_clobber)
406    }
407
408    pub fn read_from_str(&mut self, s: &str, allow_clobber: bool) -> Result<(), SolverException> {
409        let mut tokens = Tokenizer::new(s);
410        loop {
411            let Some(tag) = tokens.next_token()? else {
412                return Ok(());
413            };
414            let Some(value) = tokens.next_token()? else {
415                throw!(
416                    ExceptionKind::OPTION_INVALID,
417                    format!("Error reading value for tag {tag} from option file.")
418                );
419            };
420            self.set_from_text(&tag, &value, allow_clobber)?;
421        }
422    }
423
424    fn set_from_text(
425        &mut self,
426        tag: &str,
427        value: &str,
428        allow_clobber: bool,
429    ) -> Result<(), SolverException> {
430        if let Some(reg) = self.reg_options.clone() {
431            let opt = reg.get_option(tag).ok_or_else(|| SolverException::new(
432                ExceptionKind::OPTION_INVALID,
433                format!("Read Option: \"{tag}\". It is not a valid option. Check the list of available options."),
434                file!(), line!() as Index,
435            ))?;
436            match opt.option_type {
437                OptionType::OT_String => {
438                    let ok = self.set_string_value(tag, value, allow_clobber, false)?;
439                    if !ok {
440                        throw!(
441                            ExceptionKind::OPTION_INVALID,
442                            "Error setting string value read from option file.".to_string()
443                        );
444                    }
445                }
446                OptionType::OT_Number => {
447                    let v = parse_ipopt_number(value).ok_or_else(|| SolverException::new(
448                        ExceptionKind::OPTION_INVALID,
449                        format!("Option \"{tag}\": Double value expected, but non-numeric option value \"{value}\" found.\n"),
450                        file!(), line!() as Index,
451                    ))?;
452                    let ok = self.set_numeric_value(tag, v, allow_clobber, false)?;
453                    if !ok {
454                        throw!(
455                            ExceptionKind::OPTION_INVALID,
456                            "Error setting numeric value read from file.".to_string()
457                        );
458                    }
459                }
460                OptionType::OT_Integer => {
461                    let v: Index = value.parse().map_err(|_| SolverException::new(
462                        ExceptionKind::OPTION_INVALID,
463                        format!("Option \"{tag}\": Integer value expected, but non-integer option value \"{value}\" found.\n"),
464                        file!(), line!() as Index,
465                    ))?;
466                    let ok = self.set_integer_value(tag, v, allow_clobber, false)?;
467                    if !ok {
468                        throw!(
469                            ExceptionKind::OPTION_INVALID,
470                            "Error setting integer value read from option file.".to_string()
471                        );
472                    }
473                }
474                OptionType::OT_Unknown => {
475                    throw!(
476                        ExceptionKind::OPTION_INVALID,
477                        format!("Option \"{tag}\" has unknown type.")
478                    );
479                }
480            }
481        } else {
482            self.set_string_value(tag, value, allow_clobber, false)?;
483        }
484        Ok(())
485    }
486
487    /// Mirrors `PrintList`. One option per line: `name value # used N times`.
488    pub fn print_list(&self) -> String {
489        let mut out = String::new();
490        out.push_str("                                    Name   Value           # times used\n");
491        for (k, v) in &self.options {
492            out.push_str(&format!(
493                "{:>40} = {:<30} # {}\n",
494                k,
495                v.value,
496                v.counter.get()
497            ));
498        }
499        out
500    }
501
502    /// Mirrors `PrintUserOptions`.
503    pub fn print_user_options(&self) -> String {
504        let mut out = String::new();
505        for (k, v) in &self.options {
506            if v.dont_print {
507                continue;
508            }
509            let used = if v.counter.get() > 0 {
510                "used"
511            } else {
512                "notused"
513            };
514            out.push_str(&format!("{} {} ({})\n", k, v.value, used));
515        }
516        out
517    }
518}
519
520/// Parse a number, allowing Fortran-style `d`/`D` exponents (matching
521/// `IpOptionsList::ReadFromStream`).
522fn parse_ipopt_number(s: &str) -> Option<Number> {
523    let mut buf = String::with_capacity(s.len());
524    for c in s.chars() {
525        if c == 'd' || c == 'D' {
526            buf.push('e');
527        } else {
528            buf.push(c);
529        }
530    }
531    buf.trim().parse().ok()
532}
533
534/// Tokeniser matching `OptionsList::readnexttoken` semantics:
535/// whitespace splits tokens; `#` introduces a line comment; double
536/// quotes group whitespace into a single token.
537struct Tokenizer<'a> {
538    chars: std::str::Chars<'a>,
539    peeked: Option<char>,
540}
541
542impl<'a> Tokenizer<'a> {
543    fn new(s: &'a str) -> Self {
544        Self {
545            chars: s.chars(),
546            peeked: None,
547        }
548    }
549
550    fn next_char(&mut self) -> Option<char> {
551        self.peeked.take().or_else(|| self.chars.next())
552    }
553
554    fn next_token(&mut self) -> Result<Option<String>, SolverException> {
555        let mut c = match self.next_char() {
556            Some(c) => c,
557            None => return Ok(None),
558        };
559        loop {
560            if c.is_whitespace() { /* skip */
561            } else if c == '#' {
562                // skip until newline
563                loop {
564                    match self.next_char() {
565                        Some('\n') | None => break,
566                        _ => {}
567                    }
568                }
569            } else {
570                break;
571            }
572            c = match self.next_char() {
573                Some(c) => c,
574                None => return Ok(None),
575            };
576        }
577        let inside_quotes = c == '"';
578        let mut tok = String::new();
579        if inside_quotes {
580            c = match self.next_char() {
581                Some(c) => c,
582                None => throw!(
583                    ExceptionKind::OPTION_INVALID,
584                    "Unterminated quoted string in option file.".to_string()
585                ),
586            };
587        }
588        loop {
589            if !inside_quotes && c.is_whitespace() {
590                return Ok(Some(tok));
591            }
592            if inside_quotes && c == '"' {
593                return Ok(Some(tok));
594            }
595            tok.push(c);
596            c = match self.next_char() {
597                Some(c) => c,
598                None => {
599                    if inside_quotes {
600                        throw!(
601                            ExceptionKind::OPTION_INVALID,
602                            "Unterminated quoted string in option file.".to_string()
603                        );
604                    }
605                    return Ok(Some(tok));
606                }
607            };
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn registry_with_basic() -> Rc<RegisteredOptions> {
617        let r = RegisteredOptions::new();
618        r.set_registering_category("Test");
619        r.add_lower_bounded_number_option("tol", "Convergence tolerance", 0.0, true, 1e-8, "")
620            .unwrap();
621        r.add_string_option(
622            "linear_solver",
623            "Linear solver",
624            "mumps",
625            &[("mumps", ""), ("feral", "")],
626            "",
627        )
628        .unwrap();
629        r.add_lower_bounded_integer_option("max_iter", "Maximum iterations", 0, 3000, "")
630            .unwrap();
631        r.add_bool_option("print_user_options", "", false, "")
632            .unwrap();
633        r
634    }
635
636    #[test]
637    fn prefix_lookup_overrides() {
638        let reg = registry_with_basic();
639        let mut o = OptionsList::with_registered(reg);
640        o.set_numeric_value("tol", 1e-6, true, false).unwrap();
641        o.set_numeric_value("resto.tol", 1e-3, true, false).unwrap();
642        let (v_main, _) = o.get_numeric_value("tol", "").unwrap();
643        let (v_resto, _) = o.get_numeric_value("tol", "resto.").unwrap();
644        let (v_other, _) = o.get_numeric_value("tol", "noprefix.").unwrap();
645        assert!((v_main - 1e-6).abs() < 1e-20);
646        assert!((v_resto - 1e-3).abs() < 1e-20);
647        assert!((v_other - 1e-6).abs() < 1e-20);
648    }
649
650    #[test]
651    fn defaults_returned_when_unset() {
652        let reg = registry_with_basic();
653        let o = OptionsList::with_registered(reg);
654        let (v, found) = o.get_numeric_value("tol", "").unwrap();
655        assert!((v - 1e-8).abs() < 1e-20);
656        assert!(!found);
657    }
658
659    #[test]
660    fn read_options_file_text() {
661        let reg = registry_with_basic();
662        let mut o = OptionsList::with_registered(reg);
663        let opt_file = "
664# A comment line
665tol  1.0e-7
666max_iter 500
667linear_solver mumps
668print_user_options yes
669";
670        o.read_from_str(opt_file, false).unwrap();
671        assert_eq!(o.get_numeric_value("tol", "").unwrap().0, 1e-7);
672        assert_eq!(o.get_integer_value("max_iter", "").unwrap().0, 500);
673        assert_eq!(o.get_string_value("linear_solver", "").unwrap().0, "mumps");
674        assert!(o.get_bool_value("print_user_options", "").unwrap().0);
675    }
676
677    #[test]
678    fn fortran_d_exponent_accepted() {
679        let reg = registry_with_basic();
680        let mut o = OptionsList::with_registered(reg);
681        o.read_from_str("tol 1.0d-9\n", false).unwrap();
682        assert!((o.get_numeric_value("tol", "").unwrap().0 - 1e-9).abs() < 1e-30);
683    }
684
685    #[test]
686    fn unknown_option_in_file_is_error() {
687        let reg = registry_with_basic();
688        let mut o = OptionsList::with_registered(reg);
689        let err = o.read_from_str("nonsense_option 1.0\n", false).unwrap_err();
690        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
691    }
692
693    #[test]
694    fn invalid_string_value_rejected() {
695        let reg = registry_with_basic();
696        let mut o = OptionsList::with_registered(reg);
697        let err = o
698            .set_string_value("linear_solver", "ma27", true, false)
699            .unwrap_err();
700        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
701    }
702
703    #[test]
704    fn out_of_range_number_rejected() {
705        let reg = registry_with_basic();
706        let mut o = OptionsList::with_registered(reg);
707        let err = o.set_numeric_value("tol", 0.0, true, false).unwrap_err();
708        assert_eq!(err.kind, ExceptionKind::OPTION_INVALID);
709    }
710
711    #[test]
712    fn enum_value_index() {
713        let reg = registry_with_basic();
714        let mut o = OptionsList::with_registered(reg);
715        o.set_string_value("linear_solver", "feral", true, false)
716            .unwrap();
717        assert_eq!(o.get_enum_value("linear_solver", "").unwrap().0, 1);
718    }
719
720    #[test]
721    fn get_value_increments_use_counter() {
722        let reg = registry_with_basic();
723        let mut o = OptionsList::with_registered(reg);
724        o.set_numeric_value("tol", 1e-6, true, false).unwrap();
725        let _ = o.get_numeric_value("tol", "").unwrap();
726        let _ = o.get_numeric_value("tol", "").unwrap();
727        let listing = o.print_list();
728        assert!(listing.contains("# 2"));
729    }
730}