use std::time::Duration;
use chrono::NaiveDate;
use geo::{LineString, Point};
use serde_json::Value;
use crate::errors::ProjectionError;
use crate::models::{
NetRelation, Netelement, RinfNavigability, RinfNetelementRow, RinfNetrelationRow,
};
pub trait SparqlClient: Send + Sync {
fn query(&self, endpoint_url: &str, sparql: &str) -> Result<Value, ProjectionError>;
}
pub struct UreqSparqlClient {
timeout: Duration,
}
impl Default for UreqSparqlClient {
fn default() -> Self {
Self {
timeout: Duration::from_secs(60),
}
}
}
impl UreqSparqlClient {
pub fn new(timeout: Duration) -> Self {
Self { timeout }
}
}
impl SparqlClient for UreqSparqlClient {
fn query(&self, endpoint_url: &str, sparql: &str) -> Result<Value, ProjectionError> {
let agent = ureq::AgentBuilder::new()
.timeout(self.timeout)
.user_agent("tp-lib/006-rinf-retrieval")
.build();
let response = agent
.post(endpoint_url)
.set("Accept", "application/sparql-results+json")
.set("Content-Type", "application/sparql-query")
.send_string(sparql)
.map_err(|e| ProjectionError::RinfRetrievalFailed(format!("HTTP error: {e}")))?;
let json: Value = response
.into_json()
.map_err(|e| ProjectionError::RinfRetrievalFailed(format!("JSON parse error: {e}")))?;
Ok(json)
}
}
pub fn build_netelements_query(polygon_wkt: &str) -> String {
format!(
r#"PREFIX era: <http://data.europa.eu/949/>
PREFIX gsp: <http://www.opengis.net/ont/geosparql#>
PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
PREFIX time: <http://www.w3.org/2006/time#>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?netelement ?netelement_wkt ?valid_from_date ?valid_to_date
WHERE {{
?netelement a era:LinearElement ;
era:validity/time:hasBeginning/time:inXSDDate ?valid_from_date ;
gsp:hasGeometry/gsp:asWKT ?netelement_wkt .
FILTER(geof:sfIntersects(
?netelement_wkt,
"{polygon}"^^gsp:wktLiteral
))
OPTIONAL {{
?netelement era:validity/time:hasEnd/time:inXSDDate ?valid_to_date .
FILTER (xsd:date(now()) >= ?valid_to_date)
}}
FILTER (xsd:date(now()) >= ?valid_from_date && !BOUND(?valid_to_date))
}}"#,
polygon = polygon_wkt
)
}
pub fn build_netrelations_query(seed_iris: &[String]) -> String {
let values = seed_iris
.iter()
.map(|iri| format!("<{}>", iri))
.collect::<Vec<_>>()
.join(" ");
format!(
r#"PREFIX era: <http://data.europa.eu/949/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
PREFIX time: <http://www.w3.org/2006/time#>
SELECT ?netrelation ?netelementA ?netelementB ?isOnOriginOfElementA ?isOnOriginOfElementB ?navigability ?valid_from_date ?valid_to_date
WHERE {{
VALUES ?seed_element {{ {values} }}
{{
BIND(?seed_element AS ?netelementA)
?netrelation a era:NetRelation ;
era:elementA ?netelementA ;
era:elementB ?netelementB ;
era:isOnOriginOfElementA ?isOnOriginOfElementA ;
era:isOnOriginOfElementB ?isOnOriginOfElementB ;
era:navigability ?navigability ;
era:validity/time:hasBeginning/time:inXSDDate ?valid_from_date .
OPTIONAL {{
?netrelation era:validity/time:hasEnd/time:inXSDDate ?valid_to_date .
FILTER (xsd:date(now()) >= ?valid_to_date)
}}
FILTER (xsd:date(now()) >= ?valid_from_date && !BOUND(?valid_to_date))
}}
UNION
{{
BIND(?seed_element AS ?netelementB)
?netrelation a era:NetRelation ;
era:elementA ?netelementA ;
era:elementB ?netelementB ;
era:isOnOriginOfElementA ?isOnOriginOfElementA ;
era:isOnOriginOfElementB ?isOnOriginOfElementB ;
era:navigability ?navigability ;
era:validity/time:hasBeginning/time:inXSDDate ?valid_from_date .
OPTIONAL {{
?netrelation era:validity/time:hasEnd/time:inXSDDate ?valid_to_date .
FILTER (xsd:date(now()) >= ?valid_to_date)
}}
FILTER (xsd:date(now()) >= ?valid_from_date && !BOUND(?valid_to_date))
}}
}}"#
)
}
pub fn parse_wkt_linestring(wkt: &str) -> Result<LineString<f64>, ProjectionError> {
let trimmed = wkt.trim();
let upper = trimmed.to_ascii_uppercase();
let body = if let Some(rest) = upper.strip_prefix("LINESTRING") {
rest
} else {
return Err(ProjectionError::RinfIncompleteTopology(format!(
"WKT is not a LINESTRING: {trimmed}"
)));
};
let body = body.trim();
let inner = body
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.ok_or_else(|| {
ProjectionError::RinfIncompleteTopology(format!(
"Malformed LINESTRING parentheses: {trimmed}"
))
})?;
let mut coords: Vec<(f64, f64)> = Vec::new();
for pair in inner.split(',') {
let mut nums = pair.split_whitespace();
let lon = nums
.next()
.and_then(|s| s.parse::<f64>().ok())
.ok_or_else(|| {
ProjectionError::RinfIncompleteTopology(format!(
"Missing or invalid longitude in WKT: {trimmed}"
))
})?;
let lat = nums
.next()
.and_then(|s| s.parse::<f64>().ok())
.ok_or_else(|| {
ProjectionError::RinfIncompleteTopology(format!(
"Missing or invalid latitude in WKT: {trimmed}"
))
})?;
coords.push((lon, lat));
}
if coords.len() < 2 {
return Err(ProjectionError::RinfIncompleteTopology(format!(
"LINESTRING needs >=2 points: {trimmed}"
)));
}
Ok(LineString::from(coords))
}
pub fn linestring_length_meters(ls: &LineString<f64>) -> f64 {
let pts: Vec<Point<f64>> = ls.points().collect();
let mut total = 0.0;
for w in pts.windows(2) {
let (a, b) = (w[0], w[1]);
let lat_mid = (a.y() + b.y()) / 2.0;
let dx = (b.x() - a.x()) * 111_320.0 * lat_mid.to_radians().cos();
let dy = (b.y() - a.y()) * 111_320.0;
total += (dx * dx + dy * dy).sqrt();
}
total
}
fn iri_to_id(iri: &str) -> String {
iri.rsplit(['/', '#']).next().unwrap_or(iri).to_string()
}
fn binding_value<'a>(row: &'a Value, key: &str) -> Option<&'a str> {
row.get(key)?.get("value")?.as_str()
}
fn parse_bool(s: &str) -> bool {
matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "1")
}
fn parse_navigability(iri_or_label: &str) -> RinfNavigability {
let tail = iri_to_id(iri_or_label).to_ascii_lowercase();
match tail.as_str() {
"both" => RinfNavigability::Both,
"ab" | "atob" | "anbi" => RinfNavigability::AB,
"ba" | "btoa" | "binba" => RinfNavigability::BA,
"none" | "non-navigable" => RinfNavigability::None,
_ => RinfNavigability::Both,
}
}
pub fn parse_netelements_response(json: &Value) -> Result<Vec<RinfNetelementRow>, ProjectionError> {
let bindings = json
.get("results")
.and_then(|r| r.get("bindings"))
.and_then(|b| b.as_array())
.ok_or_else(|| {
ProjectionError::RinfRetrievalFailed(
"Netelements response missing results.bindings array".to_string(),
)
})?;
let mut out = Vec::with_capacity(bindings.len());
for row in bindings {
let iri = binding_value(row, "netelement").ok_or_else(|| {
ProjectionError::RinfRetrievalFailed("Missing ?netelement binding".to_string())
})?;
let wkt = binding_value(row, "netelement_wkt").ok_or_else(|| {
ProjectionError::RinfRetrievalFailed("Missing ?netelement_wkt binding".to_string())
})?;
let ls = parse_wkt_linestring(wkt)?;
let count = ls.coords().count();
let length = linestring_length_meters(&ls);
out.push(RinfNetelementRow {
netelement_iri: iri.to_string(),
netelement_id: iri_to_id(iri),
wkt: wkt.to_string(),
geometry_point_count: count,
length_meters: length,
});
}
Ok(out)
}
pub fn parse_netrelations_response(
json: &Value,
) -> Result<Vec<RinfNetrelationRow>, ProjectionError> {
let bindings = json
.get("results")
.and_then(|r| r.get("bindings"))
.and_then(|b| b.as_array())
.ok_or_else(|| {
ProjectionError::RinfRetrievalFailed(
"Netrelations response missing results.bindings array".to_string(),
)
})?;
let today = chrono::Utc::now().date_naive();
let mut out = Vec::with_capacity(bindings.len());
for row in bindings {
let iri = binding_value(row, "netrelation").ok_or_else(|| {
ProjectionError::RinfRetrievalFailed("Missing ?netrelation binding".to_string())
})?;
let a = binding_value(row, "netelementA").ok_or_else(|| {
ProjectionError::RinfRetrievalFailed("Missing ?netelementA binding".to_string())
})?;
let b = binding_value(row, "netelementB").ok_or_else(|| {
ProjectionError::RinfRetrievalFailed("Missing ?netelementB binding".to_string())
})?;
let on_a = binding_value(row, "isOnOriginOfElementA")
.map(parse_bool)
.unwrap_or(false);
let on_b = binding_value(row, "isOnOriginOfElementB")
.map(parse_bool)
.unwrap_or(false);
let nav = binding_value(row, "navigability")
.map(parse_navigability)
.unwrap_or(RinfNavigability::Both);
let valid_on_date = binding_value(row, "valid_from_date")
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
.unwrap_or(today);
out.push(RinfNetrelationRow {
netrelation_iri: iri.to_string(),
element_a_id: iri_to_id(a),
element_b_id: iri_to_id(b),
is_on_origin_of_element_a: on_a,
is_on_origin_of_element_b: on_b,
navigability: nav,
valid_on_date,
});
}
Ok(out)
}
#[allow(clippy::type_complexity)]
pub fn map_netelements_to_core(
rows: &[RinfNetelementRow],
) -> Result<(Vec<Netelement>, Vec<(String, f64, usize)>), ProjectionError> {
let mut nes = Vec::with_capacity(rows.len());
let mut lengths = Vec::with_capacity(rows.len());
for r in rows {
let ls = parse_wkt_linestring(&r.wkt)?;
let length = linestring_length_meters(&ls);
let count = ls.coords().count();
let ne = Netelement::new(r.netelement_id.clone(), ls, "EPSG:4326".to_string())?;
lengths.push((r.netelement_id.clone(), length, count));
nes.push(ne);
}
Ok((nes, lengths))
}
pub fn map_netrelations_to_core(
rows: &[RinfNetrelationRow],
netelements: &[Netelement],
) -> Result<Vec<NetRelation>, ProjectionError> {
use std::collections::HashSet;
let known: HashSet<&str> = netelements.iter().map(|n| n.id.as_str()).collect();
let mut out = Vec::with_capacity(rows.len());
for r in rows {
if !known.contains(r.element_a_id.as_str()) || !known.contains(r.element_b_id.as_str()) {
continue;
}
if r.element_a_id == r.element_b_id {
continue;
}
let (fwd, bwd) = match r.navigability {
RinfNavigability::Both => (true, true),
RinfNavigability::AB => (true, false),
RinfNavigability::BA => (false, true),
RinfNavigability::None => (false, false),
};
let pos_a: u8 = if r.is_on_origin_of_element_a { 0 } else { 1 };
let pos_b: u8 = if r.is_on_origin_of_element_b { 0 } else { 1 };
let id = iri_to_id(&r.netrelation_iri);
let nr = NetRelation::new(
id,
r.element_a_id.clone(),
r.element_b_id.clone(),
pos_a,
pos_b,
fwd,
bwd,
)?;
out.push(nr);
}
Ok(out)
}
pub fn fetch_netelements(
client: &dyn SparqlClient,
endpoint_url: &str,
polygon_wkt: &str,
) -> Result<Vec<RinfNetelementRow>, ProjectionError> {
let query = build_netelements_query(polygon_wkt);
let json = client.query(endpoint_url, &query)?;
parse_netelements_response(&json)
}
pub fn fetch_netrelations(
client: &dyn SparqlClient,
endpoint_url: &str,
seed_iris: &[String],
) -> Result<Vec<RinfNetrelationRow>, ProjectionError> {
if seed_iris.is_empty() {
return Ok(Vec::new());
}
let query = build_netrelations_query(seed_iris);
let json = client.query(endpoint_url, &query)?;
parse_netrelations_response(&json)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn wkt_linestring_parses_basic() {
let ls = parse_wkt_linestring("LINESTRING(11.0 60.0, 11.1 60.1)").unwrap();
assert_eq!(ls.coords().count(), 2);
}
#[test]
fn wkt_linestring_rejects_single_point() {
assert!(parse_wkt_linestring("LINESTRING(11.0 60.0)").is_err());
}
#[test]
fn iri_tail_is_used_as_id() {
assert_eq!(
iri_to_id("http://data.europa.eu/949/linearElement/SMOKE-A"),
"SMOKE-A"
);
}
#[test]
fn netelements_query_contains_polygon() {
let q = build_netelements_query("POLYGON((1 2, 3 4))");
assert!(q.contains("POLYGON((1 2, 3 4))"));
assert!(q.contains("sfIntersects"));
}
#[test]
fn wkt_linestring_rejects_non_linestring() {
let err = parse_wkt_linestring("POINT(11.0 60.0)").unwrap_err();
assert!(err.to_string().contains("not a LINESTRING"));
}
#[test]
fn wkt_linestring_rejects_malformed_coordinates() {
let err = parse_wkt_linestring("LINESTRING(11.0, 11.1 60.1)").unwrap_err();
assert!(err.to_string().contains("longitude") || err.to_string().contains("latitude"));
}
#[test]
fn parse_netelements_response_rejects_missing_bindings() {
let err = parse_netelements_response(&json!({"results": {}})).unwrap_err();
assert!(err.to_string().contains("results.bindings"));
}
#[test]
fn parse_netelements_response_maps_rows() {
let input = json!({
"results": {
"bindings": [
{
"netelement": {"value": "http://example/linearElement/NE-A"},
"netelement_wkt": {"value": "LINESTRING(11.0 60.0, 11.1 60.1)"}
}
]
}
});
let rows = parse_netelements_response(&input).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].netelement_id, "NE-A");
assert_eq!(rows[0].geometry_point_count, 2);
assert!(rows[0].length_meters > 0.0);
}
#[test]
fn parse_netrelations_response_uses_defaults() {
let input = json!({
"results": {
"bindings": [
{
"netrelation": {"value": "http://example/netRelation/NR-1"},
"netelementA": {"value": "http://example/linearElement/NE-A"},
"netelementB": {"value": "http://example/linearElement/NE-B"}
}
]
}
});
let rows = parse_netrelations_response(&input).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].element_a_id, "NE-A");
assert_eq!(rows[0].element_b_id, "NE-B");
assert_eq!(rows[0].navigability, RinfNavigability::Both);
assert!(!rows[0].is_on_origin_of_element_a);
assert!(!rows[0].is_on_origin_of_element_b);
}
#[test]
fn map_netrelations_to_core_filters_unknown_and_self_loops() {
let ne_a = Netelement::new(
"NE-A".to_string(),
parse_wkt_linestring("LINESTRING(11.0 60.0, 11.1 60.1)").unwrap(),
"EPSG:4326".to_string(),
)
.unwrap();
let ne_b = Netelement::new(
"NE-B".to_string(),
parse_wkt_linestring("LINESTRING(11.1 60.1, 11.2 60.2)").unwrap(),
"EPSG:4326".to_string(),
)
.unwrap();
let rows = vec![
RinfNetrelationRow {
netrelation_iri: "http://example/netRelation/NR-valid".to_string(),
element_a_id: "NE-A".to_string(),
element_b_id: "NE-B".to_string(),
is_on_origin_of_element_a: true,
is_on_origin_of_element_b: false,
navigability: RinfNavigability::AB,
valid_on_date: chrono::Utc::now().date_naive(),
},
RinfNetrelationRow {
netrelation_iri: "http://example/netRelation/NR-unknown".to_string(),
element_a_id: "NE-A".to_string(),
element_b_id: "NE-X".to_string(),
is_on_origin_of_element_a: true,
is_on_origin_of_element_b: false,
navigability: RinfNavigability::Both,
valid_on_date: chrono::Utc::now().date_naive(),
},
RinfNetrelationRow {
netrelation_iri: "http://example/netRelation/NR-self".to_string(),
element_a_id: "NE-A".to_string(),
element_b_id: "NE-A".to_string(),
is_on_origin_of_element_a: true,
is_on_origin_of_element_b: false,
navigability: RinfNavigability::Both,
valid_on_date: chrono::Utc::now().date_naive(),
},
];
let mapped = map_netrelations_to_core(&rows, &[ne_a, ne_b]).unwrap();
assert_eq!(mapped.len(), 1);
assert_eq!(mapped[0].id, "NR-valid");
assert!(mapped[0].navigable_forward);
assert!(!mapped[0].navigable_backward);
}
struct CountingClient {
calls: AtomicUsize,
payload: Value,
}
impl SparqlClient for CountingClient {
fn query(&self, _endpoint_url: &str, _sparql: &str) -> Result<Value, ProjectionError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.payload.clone())
}
}
#[test]
fn fetch_netrelations_with_empty_seeds_short_circuits() {
let client = CountingClient {
calls: AtomicUsize::new(0),
payload: json!({"results": {"bindings": []}}),
};
let out = fetch_netrelations(&client, "https://example.invalid", &[]).unwrap();
assert!(out.is_empty());
assert_eq!(client.calls.load(Ordering::SeqCst), 0);
}
#[test]
fn fetch_netelements_executes_query_and_parses() {
let client = CountingClient {
calls: AtomicUsize::new(0),
payload: json!({
"results": {
"bindings": [
{
"netelement": {"value": "http://example/linearElement/NE-A"},
"netelement_wkt": {"value": "LINESTRING(11.0 60.0, 11.1 60.1)"}
}
]
}
}),
};
let out = fetch_netelements(
&client,
"https://example.invalid",
"POLYGON((0 0,1 0,1 1,0 1,0 0))",
)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].netelement_id, "NE-A");
assert_eq!(client.calls.load(Ordering::SeqCst), 1);
}
}