use crate::exception::{ExceptionKind, SolverException};
use crate::throw;
use crate::types::{Index, Number};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(non_camel_case_types)]
pub enum OptionType {
OT_Number,
OT_Integer,
OT_String,
OT_Unknown,
}
#[derive(Debug, Clone)]
pub struct StringEntry {
pub value: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
None,
Number(Number),
Integer(Index),
String(String),
}
#[derive(Debug, Clone)]
pub struct RegisteredOption {
pub name: String,
pub short_description: String,
pub long_description: String,
pub category: String,
pub counter: Index,
pub advanced: bool,
pub option_type: OptionType,
pub default: DefaultValue,
pub has_lower: bool,
pub lower: Number,
pub lower_strict: bool,
pub has_upper: bool,
pub upper: Number,
pub upper_strict: bool,
pub valid_strings: Vec<StringEntry>,
}
impl RegisteredOption {
fn new(
name: String,
short: String,
long: String,
category: String,
counter: Index,
advanced: bool,
) -> Self {
Self {
name,
short_description: short,
long_description: long,
category,
counter,
advanced,
option_type: OptionType::OT_Unknown,
default: DefaultValue::None,
has_lower: false,
lower: 0.0,
lower_strict: false,
has_upper: false,
upper: 0.0,
upper_strict: false,
valid_strings: Vec::new(),
}
}
pub fn is_valid_number(&self, v: Number) -> bool {
if self.has_lower {
let ok = if self.lower_strict {
v > self.lower
} else {
v >= self.lower
};
if !ok {
return false;
}
}
if self.has_upper {
let ok = if self.upper_strict {
v < self.upper
} else {
v <= self.upper
};
if !ok {
return false;
}
}
true
}
pub fn is_valid_integer(&self, v: Index) -> bool {
self.is_valid_number(v as Number)
}
pub fn is_valid_string(&self, value: &str) -> bool {
let v = value.to_ascii_lowercase();
self.valid_strings
.iter()
.any(|e| e.value == "*" || e.value.eq_ignore_ascii_case(&v))
}
pub fn canonical_string(&self, value: &str) -> Option<&str> {
self.valid_strings
.iter()
.find(|e| e.value.eq_ignore_ascii_case(value))
.map(|e| e.value.as_str())
}
pub fn map_string_to_enum(&self, value: &str) -> Option<Index> {
self.valid_strings
.iter()
.position(|e| e.value.eq_ignore_ascii_case(value))
.map(|i| i as Index)
}
}
#[derive(Debug, Default)]
pub struct RegisteredOptions {
options: RefCell<BTreeMap<String, Rc<RegisteredOption>>>,
order: RefCell<Vec<String>>,
current_category: RefCell<String>,
next_counter: RefCell<Index>,
}
impl RegisteredOptions {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
pub fn set_registering_category(&self, category: impl Into<String>) {
*self.current_category.borrow_mut() = category.into();
}
fn alloc_counter(&self) -> Index {
let mut c = self.next_counter.borrow_mut();
let v = *c;
*c += 1;
v
}
fn register(&self, opt: RegisteredOption) -> Result<Rc<RegisteredOption>, SolverException> {
let key = opt.name.to_ascii_lowercase();
let mut opts = self.options.borrow_mut();
if opts.contains_key(&key) {
throw!(
ExceptionKind::OPTION_ALREADY_REGISTERED,
format!("Option {} already registered.", opt.name)
);
}
let rc = Rc::new(opt);
opts.insert(key.clone(), rc.clone());
self.order.borrow_mut().push(key);
Ok(rc)
}
pub fn add_number_option(
&self,
name: &str,
short_description: &str,
default_value: Number,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Number;
o.default = DefaultValue::Number(default_value);
self.register(o)
}
pub fn add_lower_bounded_number_option(
&self,
name: &str,
short_description: &str,
lower: Number,
strict: bool,
default_value: Number,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Number;
o.default = DefaultValue::Number(default_value);
o.has_lower = true;
o.lower = lower;
o.lower_strict = strict;
self.register(o)
}
#[allow(clippy::too_many_arguments)]
pub fn add_bounded_number_option(
&self,
name: &str,
short_description: &str,
lower: Number,
lower_strict: bool,
upper: Number,
upper_strict: bool,
default_value: Number,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Number;
o.default = DefaultValue::Number(default_value);
o.has_lower = true;
o.lower = lower;
o.lower_strict = lower_strict;
o.has_upper = true;
o.upper = upper;
o.upper_strict = upper_strict;
self.register(o)
}
pub fn add_integer_option(
&self,
name: &str,
short_description: &str,
default_value: Index,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Integer;
o.default = DefaultValue::Integer(default_value);
self.register(o)
}
pub fn add_lower_bounded_integer_option(
&self,
name: &str,
short_description: &str,
lower: Index,
default_value: Index,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Integer;
o.default = DefaultValue::Integer(default_value);
o.has_lower = true;
o.lower = lower as Number;
self.register(o)
}
pub fn add_bounded_integer_option(
&self,
name: &str,
short_description: &str,
lower: Index,
upper: Index,
default_value: Index,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_Integer;
o.default = DefaultValue::Integer(default_value);
o.has_lower = true;
o.lower = lower as Number;
o.has_upper = true;
o.upper = upper as Number;
self.register(o)
}
pub fn add_string_option(
&self,
name: &str,
short_description: &str,
default_value: &str,
valid: &[(&str, &str)],
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
let mut o = RegisteredOption::new(
name.to_string(),
short_description.to_string(),
long_description.to_string(),
self.current_category.borrow().clone(),
self.alloc_counter(),
false,
);
o.option_type = OptionType::OT_String;
o.default = DefaultValue::String(default_value.to_string());
o.valid_strings = valid
.iter()
.map(|(v, d)| StringEntry {
value: v.to_string(),
description: d.to_string(),
})
.collect();
self.register(o)
}
pub fn add_bool_option(
&self,
name: &str,
short_description: &str,
default_yes: bool,
long_description: &str,
) -> Result<Rc<RegisteredOption>, SolverException> {
self.add_string_option(
name,
short_description,
if default_yes { "yes" } else { "no" },
&[("no", ""), ("yes", "")],
long_description,
)
}
pub fn get_option(&self, name: &str) -> Option<Rc<RegisteredOption>> {
let tag_only = match name.rfind('.') {
Some(pos) => &name[pos + 1..],
None => name,
};
self.options
.borrow()
.get(&tag_only.to_ascii_lowercase())
.cloned()
}
pub fn registered_options_in_order(&self) -> Vec<Rc<RegisteredOption>> {
let opts = self.options.borrow();
self.order
.borrow()
.iter()
.filter_map(|k| opts.get(k).cloned())
.collect()
}
pub fn print_options_documentation(
&self,
mode: PrintOptionsMode,
include_advanced: bool,
) -> String {
let opts = self.registered_options_in_order();
let mut out = String::new();
match mode {
PrintOptionsMode::Text => {}
PrintOptionsMode::Latex => {
out.push_str("% pounce: latex output for print_options_mode is not yet implemented; falling through to plain text.\n\n");
}
PrintOptionsMode::Doxygen => {
out.push_str("<!-- pounce: doxygen output for print_options_mode is not yet implemented; falling through to plain text. -->\n\n");
}
}
let mut current_category: Option<String> = None;
for opt in opts.iter() {
if !include_advanced && opt.advanced {
continue;
}
if opt.short_description.is_empty() && opt.long_description.is_empty() {
continue;
}
let category = if opt.category.is_empty() {
"Uncategorized"
} else {
opt.category.as_str()
};
if current_category.as_deref() != Some(category) {
if current_category.is_some() {
out.push('\n');
}
out.push_str("### ");
out.push_str(category);
out.push_str(" ###\n\n");
current_category = Some(category.to_string());
}
format_option_text(&mut out, opt);
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrintOptionsMode {
Text,
Latex,
Doxygen,
}
impl PrintOptionsMode {
pub fn from_tag(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"latex" => Self::Latex,
"doxygen" => Self::Doxygen,
_ => Self::Text,
}
}
}
fn format_option_text(out: &mut String, opt: &RegisteredOption) {
out.push_str(opt.name.as_str());
out.push_str(": ");
if !opt.short_description.is_empty() {
out.push_str(opt.short_description.as_str());
}
out.push('\n');
let type_str = match opt.option_type {
OptionType::OT_Number => "Number",
OptionType::OT_Integer => "Integer",
OptionType::OT_String => "String",
OptionType::OT_Unknown => "Unknown",
};
out.push_str(" type: ");
out.push_str(type_str);
out.push('\n');
out.push_str(" default: ");
match &opt.default {
DefaultValue::None => out.push_str("(none)"),
DefaultValue::Number(v) => out.push_str(&format!("{}", v)),
DefaultValue::Integer(v) => out.push_str(&format!("{}", v)),
DefaultValue::String(v) => {
out.push('"');
out.push_str(v);
out.push('"');
}
}
out.push('\n');
if matches!(
opt.option_type,
OptionType::OT_Number | OptionType::OT_Integer
) && (opt.has_lower || opt.has_upper)
{
out.push_str(" range: ");
if opt.has_lower {
out.push_str(&format!(
"{}{}",
if opt.lower_strict { "(" } else { "[" },
opt.lower
));
} else {
out.push_str("(-inf");
}
out.push_str(", ");
if opt.has_upper {
out.push_str(&format!(
"{}{}",
opt.upper,
if opt.upper_strict { ")" } else { "]" }
));
} else {
out.push_str("inf)");
}
out.push('\n');
}
if !opt.valid_strings.is_empty() {
out.push_str(" values:\n");
for entry in &opt.valid_strings {
if entry.description.is_empty() {
out.push_str(&format!(" - {}\n", entry.value));
} else {
out.push_str(&format!(" - {}: {}\n", entry.value, entry.description));
}
}
}
if !opt.long_description.is_empty() {
out.push('\n');
for line in wrap_paragraph(&opt.long_description, 76) {
out.push_str(" ");
out.push_str(&line);
out.push('\n');
}
}
out.push('\n');
}
fn wrap_paragraph(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for paragraph in text.split('\n') {
let mut line = String::new();
for word in paragraph.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() <= width {
line.push(' ');
line.push_str(word);
} else {
lines.push(std::mem::take(&mut line));
line.push_str(word);
}
}
if !line.is_empty() {
lines.push(line);
} else if paragraph.is_empty() {
lines.push(String::new());
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_and_lookup_case_insensitive() {
let r = RegisteredOptions::new();
r.set_registering_category("Test");
r.add_number_option("Tol", "tolerance", 1e-8, "").unwrap();
assert!(r.get_option("tol").is_some());
assert!(r.get_option("TOL").is_some());
}
#[test]
fn print_options_documentation_renders_categories_and_metadata() {
let r = RegisteredOptions::new();
r.set_registering_category("Catty");
r.add_string_option(
"mode",
"How to do it.",
"auto",
&[("auto", "Decide for me."), ("manual", "I will choose.")],
"Long description that explains the trade-off between auto and manual selection.",
)
.unwrap();
r.add_bounded_number_option("tol", "Tolerance.", 0.0, true, 1.0, false, 1e-8, "")
.unwrap();
r.set_registering_category("Hidden");
r.add_bool_option("internal", "", false, "").unwrap();
let out = r.print_options_documentation(PrintOptionsMode::Text, false);
assert!(out.contains("### Catty ###"), "category header missing");
assert!(out.contains("mode:"), "option name missing");
assert!(out.contains("default: \"auto\""), "default missing");
assert!(
out.contains("- auto: Decide for me."),
"valid string missing"
);
assert!(out.contains("tol:"), "second option missing");
assert!(out.contains("range: (0, 1]"), "range formatting missing");
assert!(
!out.contains("internal:"),
"undocumented option leaked into output: {out}"
);
let latex = r.print_options_documentation(PrintOptionsMode::Latex, false);
assert!(latex.starts_with("% pounce: latex"));
assert!(latex.contains("mode:"));
let dox = r.print_options_documentation(PrintOptionsMode::Doxygen, false);
assert!(dox.starts_with("<!-- pounce: doxygen"));
assert!(dox.contains("mode:"));
}
#[test]
fn print_options_mode_parses_tags() {
assert_eq!(PrintOptionsMode::from_tag("text"), PrintOptionsMode::Text);
assert_eq!(PrintOptionsMode::from_tag("LaTeX"), PrintOptionsMode::Latex);
assert_eq!(
PrintOptionsMode::from_tag("doxygen"),
PrintOptionsMode::Doxygen
);
assert_eq!(PrintOptionsMode::from_tag("html"), PrintOptionsMode::Text);
}
#[test]
fn duplicate_registration_is_error() {
let r = RegisteredOptions::new();
r.add_number_option("alpha", "", 1.0, "").unwrap();
let err = r.add_number_option("ALPHA", "", 2.0, "").unwrap_err();
assert_eq!(err.kind, ExceptionKind::OPTION_ALREADY_REGISTERED);
}
#[test]
fn bounds_check_on_number() {
let r = RegisteredOptions::new();
r.add_lower_bounded_number_option("mu", "", 0.0, true, 0.1, "")
.unwrap();
let opt = r.get_option("mu").unwrap();
assert!(opt.is_valid_number(1e-12));
assert!(!opt.is_valid_number(0.0));
assert!(!opt.is_valid_number(-1.0));
}
#[test]
fn string_enum_lookup() {
let r = RegisteredOptions::new();
r.add_string_option(
"linear_solver",
"",
"mumps",
&[("mumps", "MUMPS"), ("feral", "FERAL")],
"",
)
.unwrap();
let opt = r.get_option("linear_solver").unwrap();
assert!(opt.is_valid_string("MuMpS"));
assert!(!opt.is_valid_string("ma27"));
assert_eq!(opt.map_string_to_enum("feral"), Some(1));
}
#[test]
fn registration_order_preserved() {
let r = RegisteredOptions::new();
r.add_number_option("c", "", 0.0, "").unwrap();
r.add_number_option("a", "", 0.0, "").unwrap();
r.add_number_option("b", "", 0.0, "").unwrap();
let order: Vec<_> = r
.registered_options_in_order()
.iter()
.map(|o| o.name.clone())
.collect();
assert_eq!(order, vec!["c", "a", "b"]);
}
}