1use std::collections::BTreeMap;
6use std::path::Path;
7
8use chrono::{DateTime, FixedOffset};
9use geojson::{GeoJson, Geometry, Value};
10use serde_json::{Map as JsonMap, Value as Json};
11
12use crate::detections::error::DetectionError;
13use crate::models::{
14 Detection, DetectionKind, GeographicLocation, LinearDetection, PunctualDetection,
15 TopologicalLocation,
16};
17
18const PUNCTUAL_RESERVED: &[&str] = &[
20 "kind",
21 "timestamp",
22 "netelement_id",
23 "intrinsic",
24 "crs",
25 "id",
26 "source",
27];
28
29const LINEAR_RESERVED: &[&str] = &[
30 "kind",
31 "t_from",
32 "t_to",
33 "netelement_id",
34 "start_intrinsic",
35 "end_intrinsic",
36 "id",
37 "source",
38];
39
40pub fn load(path: &Path, expected_kind: DetectionKind) -> Result<Vec<Detection>, DetectionError> {
42 let source_file = path.display().to_string();
43 let raw = std::fs::read_to_string(path)?;
44 load_str(&raw, &source_file, expected_kind)
45}
46
47pub fn load_str(
50 raw: &str,
51 source_file: &str,
52 expected_kind: DetectionKind,
53) -> Result<Vec<Detection>, DetectionError> {
54 let gj: GeoJson = raw.parse().map_err(|e: geojson::Error| {
55 DetectionError::InvalidSchema(format!("invalid GeoJSON: {e}"))
56 })?;
57 let fc = match gj {
58 GeoJson::FeatureCollection(fc) => fc,
59 _ => {
60 return Err(DetectionError::InvalidSchema(
61 "top-level must be a FeatureCollection".to_string(),
62 ))
63 }
64 };
65
66 let mut out = Vec::with_capacity(fc.features.len());
67 for (idx, feature) in fc.features.into_iter().enumerate() {
68 let source_row = idx;
69 let props = feature.properties.ok_or_else(|| {
70 DetectionError::InvalidSchema(format!("feature[{idx}]: missing 'properties'"))
71 })?;
72
73 let kind_str = require_str(&props, "kind", source_file, source_row)?;
74 let actual_kind = match kind_str.as_str() {
75 "punctual" => DetectionKind::Punctual,
76 "linear" => DetectionKind::Linear,
77 other => {
78 return Err(DetectionError::InvalidSchema(format!(
79 "feature[{idx}]: unknown kind '{other}'"
80 )))
81 }
82 };
83 if actual_kind != expected_kind {
84 return Err(DetectionError::InvalidSchema(format!(
85 "feature[{idx}]: kind '{kind_str}' does not match expected"
86 )));
87 }
88
89 let detection = match expected_kind {
90 DetectionKind::Punctual => {
91 parse_punctual(&props, feature.geometry.as_ref(), source_file, source_row)?
92 }
93 DetectionKind::Linear => parse_linear(&props, source_file, source_row)?,
94 };
95 out.push(detection);
96 }
97 Ok(out)
98}
99
100fn require_str(
101 props: &JsonMap<String, Json>,
102 key: &str,
103 source_file: &str,
104 source_row: usize,
105) -> Result<String, DetectionError> {
106 match props.get(key) {
107 Some(Json::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
108 Some(_) => Err(DetectionError::InvalidSchema(format!(
109 "feature[{source_row}]: property '{key}' must be a non-empty string"
110 ))),
111 None => Err(DetectionError::InvalidSchema(format!(
112 "feature[{source_row}]: missing required property '{key}' in {source_file}"
113 ))),
114 }
115}
116
117fn opt_str(props: &JsonMap<String, Json>, key: &str) -> Option<String> {
118 match props.get(key) {
119 Some(Json::String(s)) if !s.trim().is_empty() => Some(s.clone()),
120 _ => None,
121 }
122}
123
124fn opt_intrinsic(
125 props: &JsonMap<String, Json>,
126 key: &str,
127 source_file: &str,
128 source_row: usize,
129) -> Result<Option<f64>, DetectionError> {
130 let Some(v) = props.get(key) else {
131 return Ok(None);
132 };
133 let n = v.as_f64().ok_or_else(|| DetectionError::Parse {
134 source_file: source_file.to_string(),
135 source_row,
136 message: format!("'{key}' must be a number"),
137 })?;
138 if !(0.0..=1.0).contains(&n) {
139 return Err(DetectionError::InvalidIntrinsic {
140 source_file: source_file.to_string(),
141 source_row,
142 value: n,
143 });
144 }
145 Ok(Some(n))
146}
147
148fn parse_ts(
149 s: &str,
150 source_file: &str,
151 source_row: usize,
152) -> Result<DateTime<FixedOffset>, DetectionError> {
153 crate::temporal::parse_timestamp_flexible_str(s).map_err(|e| DetectionError::InvalidTimestamp {
154 source_file: source_file.to_string(),
155 source_row,
156 message: format!("'{s}': {e}"),
157 })
158}
159
160fn collect_metadata(props: &JsonMap<String, Json>, reserved: &[&str]) -> BTreeMap<String, String> {
161 let mut map = BTreeMap::new();
162 for (k, v) in props.iter() {
163 if reserved.iter().any(|r| r == k) {
164 continue;
165 }
166 let s = match v {
167 Json::String(s) => s.clone(),
168 Json::Null => continue,
169 other => other.to_string(),
170 };
171 map.insert(k.clone(), s);
172 }
173 map
174}
175
176fn parse_punctual(
177 props: &JsonMap<String, Json>,
178 geom: Option<&Geometry>,
179 source_file: &str,
180 source_row: usize,
181) -> Result<Detection, DetectionError> {
182 let timestamp_s = require_str(props, "timestamp", source_file, source_row)?;
183 let timestamp = parse_ts(×tamp_s, source_file, source_row)?;
184
185 let netelement_id = opt_str(props, "netelement_id");
186 let intrinsic_value = opt_intrinsic(props, "intrinsic", source_file, source_row)?;
187
188 let coordinates = match geom {
189 None => None,
190 Some(g) => match &g.value {
191 Value::Point(coords) => {
192 if coords.len() < 2 {
193 return Err(DetectionError::InvalidSchema(format!(
194 "feature[{source_row}]: Point must have [lon, lat]"
195 )));
196 }
197 let crs = opt_str(props, "crs").unwrap_or_else(|| "EPSG:4326".to_string());
198 Some(GeographicLocation {
199 latitude: coords[1],
200 longitude: coords[0],
201 crs,
202 })
203 }
204 _ => {
205 return Err(DetectionError::InvalidSchema(format!(
206 "feature[{source_row}]: punctual geometry must be Point or null"
207 )))
208 }
209 },
210 };
211
212 let has_topo = netelement_id.is_some();
213 let has_coord = coordinates.is_some();
214 if has_topo && has_coord {
215 return Err(DetectionError::InvalidSchema(format!(
216 "feature[{source_row}]: cannot specify both 'netelement_id' and Point geometry"
217 )));
218 }
219 if !has_topo && !has_coord {
220 return Err(DetectionError::InvalidSchema(format!(
221 "feature[{source_row}]: must specify either 'netelement_id' or Point geometry"
222 )));
223 }
224
225 let location = netelement_id.map(|id| TopologicalLocation {
226 netelement_id: id,
227 intrinsic: intrinsic_value.unwrap_or(0.5),
228 });
229
230 Ok(Detection::Punctual(PunctualDetection {
231 timestamp,
232 location,
233 coordinates,
234 intrinsic: intrinsic_value,
235 id: opt_str(props, "id"),
236 source: opt_str(props, "source"),
237 source_file: source_file.to_string(),
238 source_row,
239 metadata: collect_metadata(props, PUNCTUAL_RESERVED),
240 }))
241}
242
243fn parse_linear(
244 props: &JsonMap<String, Json>,
245 source_file: &str,
246 source_row: usize,
247) -> Result<Detection, DetectionError> {
248 let t_from = parse_ts(
249 &require_str(props, "t_from", source_file, source_row)?,
250 source_file,
251 source_row,
252 )?;
253 let t_to = parse_ts(
254 &require_str(props, "t_to", source_file, source_row)?,
255 source_file,
256 source_row,
257 )?;
258 let netelement_id = require_str(props, "netelement_id", source_file, source_row)?;
259 let start_intrinsic =
260 opt_intrinsic(props, "start_intrinsic", source_file, source_row)?.unwrap_or(0.0);
261 let end_intrinsic =
262 opt_intrinsic(props, "end_intrinsic", source_file, source_row)?.unwrap_or(1.0);
263
264 Ok(Detection::Linear(LinearDetection {
265 t_from,
266 t_to,
267 netelement_id,
268 start_intrinsic,
269 end_intrinsic,
270 id: opt_str(props, "id"),
271 source: opt_str(props, "source"),
272 source_file: source_file.to_string(),
273 source_row,
274 metadata: collect_metadata(props, LINEAR_RESERVED),
275 }))
276}