use crate::diagnostic::{DiagnosticError, codes, err};
use crate::simulate::{DEFAULT_ABSTOL, DEFAULT_RELTOL};
use serde_json::Value;
use std::path::Path;
pub fn reject_solver_pre_v11(view: &Value) -> Result<(), DiagnosticError> {
let Some(obj) = view.as_object() else {
return Ok(());
};
if !obj.contains_key("solver") {
return Ok(());
}
let Some(esm) = obj.get("esm").and_then(|v| v.as_str()) else {
return Ok(());
};
let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
return Ok(());
};
if (major, minor) >= (1, 1) {
return Ok(());
}
Err(err(
codes::SOLVER_VERSION_TOO_OLD,
format!(
"the top-level `solver` block requires esm >= 1.1.0; file declares {esm}. \
Offending path: /solver"
),
))
}
pub fn reject_solver_external_pre_v13(view: &Value) -> Result<(), DiagnosticError> {
let Some(obj) = view.as_object() else {
return Ok(());
};
let carries_external = obj
.get("solver")
.and_then(Value::as_object)
.is_some_and(|s| s.contains_key("external"));
if !carries_external {
return Ok(());
}
let Some(esm) = obj.get("esm").and_then(|v| v.as_str()) else {
return Ok(());
};
let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
return Ok(());
};
if (major, minor) >= (1, 3) {
return Ok(());
}
Err(err(
codes::SOLVER_EXTERNAL_VERSION_TOO_OLD,
format!(
"`solver.external` requires esm >= 1.3.0; file declares {esm}. \
Offending path: /solver/external"
),
))
}
fn refuse_unexpanded(external: &str) -> Result<(), DiagnosticError> {
if !external.contains("${") {
return Ok(());
}
Err(err(
codes::SOLVER_EXTERNAL_UNRESOLVED,
format!(
"/solver/external: \"{external}\" carries an unexpanded '${{...}}' variable. \
esm-spec §2.2.6 never expands environment variables into a run-configuration \
reference; write a path relative to this document, or pass the run \
configuration at the call site."
),
))
}
pub(crate) fn reject_solver_external_unresolved(view: &Value) -> Result<(), DiagnosticError> {
match view.pointer("/solver/external").and_then(Value::as_str) {
Some(external) => refuse_unexpanded(external),
None => Ok(()),
}
}
fn is_drive_path(path: &str) -> bool {
let b = path.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\')
}
fn is_absolute_location_path(path: &str) -> bool {
path.starts_with('/') || is_drive_path(path)
}
fn remove_dot_segments(path: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
format!("/{}", out.join("/"))
}
fn lexical_absolute(path: &str) -> String {
if is_drive_path(path) {
format!(
"{}{}",
&path[..2],
remove_dot_segments(&path[2..].replace('\\', "/"))
)
} else {
remove_dot_segments(path)
}
}
fn slashed(path: &Path) -> Option<String> {
let s = path.to_str()?;
Some(if std::path::MAIN_SEPARATOR == '/' {
s.to_string()
} else {
s.replace(std::path::MAIN_SEPARATOR, "/")
})
}
pub(crate) fn absolute_directory(dir: &Path) -> Option<String> {
let d = slashed(dir)?;
let d = if d.is_empty() { ".".to_string() } else { d };
if is_absolute_location_path(&d) {
return Some(lexical_absolute(&d));
}
let cwd = std::env::current_dir()
.ok()
.and_then(|c| slashed(&c))
.unwrap_or_else(|| "/".to_string());
Some(lexical_absolute(&format!("{cwd}/{d}")))
}
fn file_location_url(path: &str) -> String {
use std::fmt::Write as _;
let mut url = String::from("file://");
if !path.starts_with('/') {
url.push('/');
}
for &b in path.as_bytes() {
if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) {
url.push(b as char);
} else {
let _ = write!(url, "%{b:02X}");
}
}
url
}
pub fn solver_external_location(
file: &crate::EsmFile,
base: Option<&Path>,
) -> Result<Option<String>, DiagnosticError> {
let Some(external) = file.solver.as_ref().and_then(|s| s.external.as_deref()) else {
return Ok(None);
};
refuse_unexpanded(external)?;
if crate::data_source_urls::is_scheme_led(external) {
return Ok(Some(external.to_string()));
}
if is_absolute_location_path(external) {
return Ok(Some(file_location_url(&lexical_absolute(external))));
}
let dir = match (base, file.document_base.as_deref()) {
(Some(b), _) => absolute_directory(b),
(None, Some(recorded)) => Some(recorded.to_string()),
(None, None) => absolute_directory(Path::new(".")),
};
let Some(dir) = dir else {
return Err(err(
codes::SOLVER_EXTERNAL_UNRESOLVED,
format!(
"/solver/external: \"{external}\" is relative, and the directory it \
resolves against is not valid UTF-8, so it cannot be spelled as a URL \
(esm-spec §2.2.6)."
),
));
};
Ok(Some(file_location_url(&lexical_absolute(&format!(
"{dir}/{external}"
)))))
}
pub fn reject_solver_time_span_pre_v13(view: &Value) -> Result<(), DiagnosticError> {
let Some(obj) = view.as_object() else {
return Ok(());
};
let carries_time_span = obj
.get("solver")
.and_then(Value::as_object)
.is_some_and(|s| s.contains_key("time_span"));
if !carries_time_span {
return Ok(());
}
let Some(esm) = obj.get("esm").and_then(|v| v.as_str()) else {
return Ok(());
};
let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
return Ok(());
};
if (major, minor) >= (1, 3) {
return Ok(());
}
Err(err(
codes::SOLVER_TIME_SPAN_VERSION_TOO_OLD,
format!(
"`solver.time_span` requires esm >= 1.3.0; file declares {esm}. \
Offending path: /solver/time_span"
),
))
}
enum StampError {
Form,
NoDay,
}
#[derive(Clone, Copy)]
struct Instant {
whole: i64,
frac: f64,
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn days_in_month(y: i64, m: i64) -> i64 {
match m {
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
fn digits(b: &[u8]) -> Option<i64> {
if b.is_empty() || !b.iter().all(u8::is_ascii_digit) {
return None;
}
Some(b.iter().fold(0, |acc, &c| acc * 10 + i64::from(c - b'0')))
}
fn parse_date_time(s: &str, utc_only: bool) -> Result<Instant, StampError> {
let b = s.as_bytes();
if b.len() < 20
|| b[4] != b'-'
|| b[7] != b'-'
|| !(b[10] == b'T' || (!utc_only && b[10] == b't'))
|| b[13] != b':'
|| b[16] != b':'
{
return Err(StampError::Form);
}
let field = |r: std::ops::Range<usize>| digits(&b[r]).ok_or(StampError::Form);
let (year, month, day) = (field(0..4)?, field(5..7)?, field(8..10)?);
let (hour, minute, second) = (field(11..13)?, field(14..16)?, field(17..19)?);
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 59
{
return Err(StampError::Form);
}
let mut i = 19;
let mut frac = 0.0;
if b[i] == b'.' {
let start = i + 1;
let mut j = start;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j == start {
return Err(StampError::Form);
}
frac = format!("0.{}", &s[start..j])
.parse::<f64>()
.map_err(|_| StampError::Form)?;
i = j;
}
let offset = match &b[i..] {
[b'Z'] => 0,
[b'z'] if !utc_only => 0,
[sign @ (b'+' | b'-'), h1, h2, b':', m1, m2] if !utc_only => {
let oh = digits(&[*h1, *h2]).ok_or(StampError::Form)?;
let om = digits(&[*m1, *m2]).ok_or(StampError::Form)?;
if oh > 23 || om > 59 {
return Err(StampError::Form);
}
let off = oh * 3600 + om * 60;
if *sign == b'-' { -off } else { off }
}
_ => return Err(StampError::Form),
};
if day > days_in_month(year, month) {
return Err(StampError::NoDay);
}
let whole =
days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second - offset;
Ok(Instant { whole, frac })
}
#[derive(Clone, Copy)]
enum Bound<'a> {
Seconds(f64),
Timestamp(&'a str),
}
impl<'a> Bound<'a> {
fn of_point(p: &'a crate::types::TimePoint) -> Self {
match p {
crate::types::TimePoint::Seconds(n) => Bound::Seconds(n.as_f64().unwrap_or(f64::NAN)),
crate::types::TimePoint::Timestamp(s) => Bound::Timestamp(s),
}
}
}
fn time_span_invalid(why: impl std::fmt::Display) -> DiagnosticError {
err(
codes::SOLVER_TIME_SPAN_INVALID,
format!("/solver/time_span: {why} (esm-spec §2.2.7)."),
)
}
fn time_origin(temporal: Option<&Value>) -> Result<Instant, DiagnosticError> {
let epoch = Instant {
whole: 0,
frac: 0.0,
};
let Some(temporal) = temporal.and_then(Value::as_object) else {
return Ok(epoch);
};
for key in ["reference_time", "start"] {
let Some(v) = temporal.get(key) else {
continue;
};
let pointer = format!("/domain/temporal/{key}");
let Some(text) = v.as_str() else {
return Err(time_span_invalid(format_args!(
"a timestamp bound is counted from the time origin {pointer}, \
which is not an RFC 3339 date-time"
)));
};
return match parse_date_time(text, false) {
Ok(origin) => Ok(origin),
Err(StampError::Form) => Err(time_span_invalid(format_args!(
"a timestamp bound is counted from the time origin {pointer} = \"{text}\", \
which is not an RFC 3339 date-time"
))),
Err(StampError::NoDay) => Err(time_span_invalid(format_args!(
"a timestamp bound is counted from the time origin {pointer} = \"{text}\", \
which names no calendar day"
))),
};
}
Ok(epoch)
}
fn time_span_seconds(
start: Bound<'_>,
end: Bound<'_>,
temporal: Option<&Value>,
) -> Result<(f64, f64), DiagnosticError> {
let mut stamps = [None, None];
for (slot, (name, bound)) in stamps.iter_mut().zip([("start", start), ("end", end)]) {
if let Bound::Timestamp(text) = bound {
*slot = Some(parse_date_time(text, true).map_err(|e| match e {
StampError::NoDay => {
time_span_invalid(format_args!("{name} \"{text}\" names no calendar day"))
}
StampError::Form => time_span_invalid(format_args!(
"{name} \"{text}\" is not an RFC 3339 UTC timestamp \
(YYYY-MM-DDThh:mm:ss[.f]Z)"
)),
})?);
}
}
let origin = if stamps.iter().any(Option::is_some) {
Some(time_origin(temporal)?)
} else {
None
};
let seconds = |bound: Bound<'_>, stamp: Option<Instant>| match (bound, stamp, origin) {
(_, Some(t), Some(o)) => (t.whole - o.whole) as f64 + (t.frac - o.frac),
(Bound::Seconds(v), ..) => v,
(Bound::Timestamp(_), ..) => f64::NAN,
};
let (s, e) = (seconds(start, stamps[0]), seconds(end, stamps[1]));
if e.partial_cmp(&s) != Some(std::cmp::Ordering::Greater) {
return Err(time_span_invalid(format_args!(
"end ({e} s) is not after start ({s} s); the interval is empty or reversed"
)));
}
Ok((s, e))
}
fn raw_bound(v: &Value) -> Option<Bound<'_>> {
match v {
Value::Number(n) => n.as_f64().map(Bound::Seconds),
Value::String(s) => match parse_date_time(s, true) {
Ok(_) | Err(StampError::NoDay) => Some(Bound::Timestamp(s)),
Err(StampError::Form) => None,
},
_ => None,
}
}
pub(crate) fn reject_solver_time_span_invalid(view: &Value) -> Result<(), DiagnosticError> {
let Some(span) = view.pointer("/solver/time_span").and_then(Value::as_object) else {
return Ok(());
};
let (Some(start), Some(end)) = (
span.get("start").and_then(raw_bound),
span.get("end").and_then(raw_bound),
) else {
return Ok(());
};
time_span_seconds(start, end, view.pointer("/domain/temporal")).map(|_| ())
}
pub fn resolve_time_span(
file: &crate::EsmFile,
tspan: Option<(f64, f64)>,
) -> Result<Option<(f64, f64)>, DiagnosticError> {
if tspan.is_some() {
return Ok(tspan);
}
let Some(span) = file.solver.as_ref().and_then(|s| s.time_span.as_ref()) else {
return Ok(None);
};
let temporal = file.domain.as_ref().and_then(|d| d.temporal.as_ref());
time_span_seconds(
Bound::of_point(&span.start),
Bound::of_point(&span.end),
temporal,
)
.map(Some)
}
pub fn resolve_tolerances(
solver: Option<&crate::types::Solver>,
abstol: Option<f64>,
reltol: Option<f64>,
) -> (f64, f64) {
(
abstol
.or_else(|| solver.and_then(|s| s.abstol))
.unwrap_or(DEFAULT_ABSTOL),
reltol
.or_else(|| solver.and_then(|s| s.reltol))
.unwrap_or(DEFAULT_RELTOL),
)
}
pub(crate) fn normalize_empty(
solver: Option<crate::types::Solver>,
) -> Option<crate::types::Solver> {
let s = solver?;
if s.stiffness.is_none()
&& s.abstol.is_none()
&& s.reltol.is_none()
&& s.splitting.is_none()
&& s.external.is_none()
&& s.time_span.is_none()
{
return None;
}
Some(s)
}