use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::Path;
use std::{env, fs};
fn main() {
let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
let snap_dir = Path::new(&manifest).join("option-snapshots");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=option-snapshots");
let mut files: Vec<_> = fs::read_dir(&snap_dir)
.expect("option-snapshots directory")
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("txt"))
.collect();
files.sort();
let solvers: Vec<Solver> = files
.iter()
.map(|path| {
let text = fs::read_to_string(path).expect("read snapshot");
let stem = path.file_stem().and_then(|s| s.to_str()).expect("utf8 stem");
Solver::new(stem, &text)
})
.collect();
let mut out = String::new();
out.push_str("// @generated by build.rs from option-snapshots/*.txt - do not edit.\n\n");
for s in &solvers {
writeln!(
out,
"gams_params! {{\n #[doc = \"Options for GAMS/{name}. \
Reference: <https://www.gams.com/latest/docs/S_{name}.html>\"]\n \
{struct_}, {sep:?},",
name = s.gams_name,
struct_ = s.struct_ident,
sep = s.sep,
)
.unwrap();
out.push_str(" options: [\n");
for o in s.options.iter().filter(|o| o.values.is_empty()) {
writeln!(out, " ({}, {}, {:?}),", o.kind, o.method, o.key).unwrap();
}
out.push_str(" ],\n enums: [\n");
for o in s.options.iter().filter(|o| !o.values.is_empty()) {
let variants = o
.values
.iter()
.map(|(ident, value)| format!("{ident} = {value:?}"))
.collect::<Vec<_>>()
.join(", ");
writeln!(
out,
" ({}, {}, {:?}, [{}]),",
o.enum_ident.as_ref().unwrap(),
o.method,
o.key,
variants
)
.unwrap();
}
out.push_str(" ],\n}\n\n");
}
emit_solver_config(&mut out, &solvers);
let dest = Path::new(&env::var("OUT_DIR").unwrap()).join("gams_generated.rs");
fs::write(&dest, out).expect("write generated file");
}
fn emit_solver_config(out: &mut String, solvers: &[Solver]) {
out.push_str(
"/// Selects a GAMS sub-solver and optionally carries typed options written\n\
/// to a `<solver>.opt` file before invoking GAMS.\n\
///\n\
/// One variant per solver oximo supports from a modelling standpoint\n\
/// (generated from `option-snapshots/`), plus [`Named`](Self::Named) to\n\
/// pick any solver by name and [`Raw`](Self::Raw) to write verbatim\n\
/// option lines for a named solver.\n\
#[derive(Clone, Debug)]\n\
pub enum GamsSolverConfig {\n",
);
for s in solvers {
writeln!(out, " {}({}),", s.variant, s.struct_ident).unwrap();
}
out.push_str(
" /// Any solver selectable by name with no typed option file.\n\
\x20 Named(GamsSolver),\n\
\x20 /// A solver selected by name with raw option-file lines written verbatim.\n\
\x20 Raw(GamsSolver, Vec<String>),\n\
}\n\n",
);
out.push_str("impl GamsSolverConfig {\n");
out.push_str(
" /// GAMS solver keyword for `option {LP|MIP} = ...;` and the\n\
\x20 /// `<name>.opt` file name.\n\
\x20 #[must_use]\n\
\x20 pub fn gams_name(&self) -> &str {\n match self {\n",
);
for s in solvers {
writeln!(out, " Self::{}(_) => {:?},", s.variant, s.gams_name).unwrap();
}
out.push_str(" Self::Named(s) | Self::Raw(s, _) => s.name(),\n }\n }\n\n");
out.push_str(
" /// Write the `.opt` file body into `buf`. Returns `true` if anything\n\
\x20 /// was written (the caller then sets `model.optfile = 1`).\n\
\x20 #[must_use]\n\
\x20 pub fn write_opt_file(&self, buf: &mut String) -> bool {\n \
use std::fmt::Write as _;\n match self {\n",
);
for s in solvers {
writeln!(out, " Self::{}(o) => o.render(buf),", s.variant).unwrap();
}
out.push_str(
" Self::Named(_) => false,\n\
\x20 Self::Raw(_, lines) => {\n\
\x20 for line in lines {\n\
\x20 let _ = writeln!(buf, \"{line}\");\n\
\x20 }\n\
\x20 !lines.is_empty()\n\
\x20 }\n }\n }\n}\n\n",
);
out.push_str(
"impl From<GamsSolver> for GamsSolverConfig {\n\
\x20 fn from(s: GamsSolver) -> Self {\n Self::Named(s)\n }\n}\n\n",
);
out.push_str(
"/// `(gams_name, separator, option_count)` for every generated solver.\n\
/// Test-only: lets the suite cross-check codegen against the snapshots.\n\
#[cfg(test)]\n\
pub(crate) const GENERATED_SOLVERS: &[(&str, &str, usize)] = &[\n",
);
for s in solvers {
writeln!(out, " ({:?}, {:?}, {}),", s.gams_name, s.sep, s.options.len()).unwrap();
}
out.push_str("];\n");
}
struct Option_ {
kind: String,
method: String,
key: String,
values: Vec<(String, String)>,
enum_ident: Option<String>,
}
struct Solver {
gams_name: String,
variant: String,
struct_ident: String,
sep: &'static str,
options: Vec<Option_>,
}
impl Solver {
fn new(stem: &str, text: &str) -> Self {
let mut lines = text.lines();
let header = lines.next().unwrap_or("");
let gams_name = header.trim_start_matches("//").trim().to_string();
let gams_name = if gams_name.is_empty() { stem.to_uppercase() } else { gams_name };
let variant = capitalize(stem);
let struct_ident = format!("Gams{variant}Options");
let sep = match gams_name.as_str() {
"HIGHS" | "SCIP" | "SOPLEX" | "SHOT" => " = ",
_ => " ",
};
let options = lines
.filter(|l| l.trim_start().starts_with('('))
.map(|l| parse_line(l.trim(), &variant))
.collect();
Self { gams_name, variant, struct_ident, sep, options }
}
}
fn parse_line(line: &str, solver_variant: &str) -> Option_ {
let body = line.trim_start_matches('(').trim_end_matches(',').trim_end_matches(')');
let (kind, rest) = body.split_once(',').expect("kind");
let (method, rest) = rest.split_once(',').expect("method");
let kind = kind.trim().to_string();
let method = method.trim().to_string();
let rest = rest.trim();
let (key, list) = match rest.strip_prefix('"').and_then(|r| r.find('"').map(|i| (r, i))) {
Some((r, end)) => (r[..end].to_string(), r[end + 1..].trim().to_string()),
None => (rest.trim_matches('"').to_string(), String::new()),
};
let mut values: Vec<(String, String)> = Vec::new();
if let Some(inner) = list.trim_start_matches(',').trim().strip_prefix('[') {
let inner = inner.trim_end_matches(']');
let mut seen: HashSet<String> = HashSet::new();
let mut ok = true;
for raw in inner.split(',') {
let value = raw.trim().trim_matches('"');
if value.is_empty() {
continue;
}
let ident = variant_ident(value);
if ident.is_empty() || !seen.insert(ident.clone()) {
ok = false;
break;
}
values.push((ident, value.to_string()));
}
if !ok || values.len() < 2 {
values.clear();
}
}
let enum_ident =
(!values.is_empty()).then(|| format!("Gams{solver_variant}{}", pascal(&method)));
Option_ { kind, method, key, values, enum_ident }
}
fn variant_ident(value: &str) -> String {
let cleaned: String =
value.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
let ident = pascal(&cleaned);
if ident.is_empty() {
return String::new();
}
if ident.starts_with(|c: char| c.is_ascii_digit()) { format!("V{ident}") } else { ident }
}
fn pascal(s: &str) -> String {
s.split('_').filter(|p| !p.is_empty()).map(capitalize_lower).collect()
}
fn capitalize_lower(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_ascii_uppercase().to_string() + &c.as_str().to_ascii_lowercase(),
None => String::new(),
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_ascii_uppercase().to_string() + c.as_str(),
None => String::new(),
}
}