1use crate::capabilities::{BackendCapabilities, BackendCapabilitiesValidationError};
2use crate::claims::{BodyClaim, BodyClaimTier};
3use crate::errors::{format_display_list, EphemerisError, EphemerisErrorKind};
4use crate::identity::{AccuracyClass, BackendFamily, BackendId};
5use crate::request::EphemerisRequest;
6use crate::validation::{validate_non_blank, validate_non_empty_unique, validate_unique_entries};
7use core::fmt;
8use pleiades_types::{
9 CelestialBody, CoordinateFrame, TimeRange, TimeRangeValidationError, TimeScale, ZodiacMode,
10};
11
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct BackendProvenance {
16 pub summary: String,
18 pub data_sources: Vec<String>,
20}
21
22impl BackendProvenance {
23 pub fn new(summary: impl Into<String>) -> Self {
25 Self {
26 summary: summary.into(),
27 data_sources: Vec::new(),
28 }
29 }
30
31 pub fn summary_line(&self) -> String {
33 self.summary.clone()
34 }
35
36 pub fn validate(&self) -> Result<(), BackendProvenanceValidationError> {
43 validate_non_blank("provenance summary", &self.summary)
44 .map_err(|_| BackendProvenanceValidationError::BlankSummary)?;
45
46 for (index, source) in self.data_sources.iter().enumerate() {
47 if source.trim().is_empty() || source.trim() != source {
48 return Err(BackendProvenanceValidationError::BlankDataSource { index });
49 }
50 }
51
52 validate_unique_entries("provenance data sources", &self.data_sources).map_err(|error| {
53 match error {
54 BackendMetadataValidationError::DuplicateEntry { value, .. } => {
55 BackendProvenanceValidationError::DuplicateDataSource { value }
56 }
57 _ => {
58 unreachable!("duplicate provenance sources should only fail via DuplicateEntry")
59 }
60 }
61 })
62 }
63
64 pub fn validated_summary_line(&self) -> Result<String, BackendProvenanceValidationError> {
66 self.validate()?;
67 Ok(self.summary_line())
68 }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Hash)]
73#[non_exhaustive]
74pub enum BackendProvenanceValidationError {
75 BlankSummary,
77 BlankDataSource {
79 index: usize,
81 },
82 DuplicateDataSource {
84 value: String,
86 },
87}
88
89impl BackendProvenanceValidationError {
90 pub fn summary_line(&self) -> String {
92 match self {
93 Self::BlankSummary => {
94 "backend provenance summary must not be blank or whitespace-padded".to_owned()
95 }
96 Self::BlankDataSource { index } => format!(
97 "backend provenance data source at index {index} must not be blank or whitespace-padded"
98 ),
99 Self::DuplicateDataSource { value } => {
100 format!("backend provenance data sources contain duplicate entry `{value}`")
101 }
102 }
103 }
104}
105
106impl fmt::Display for BackendProvenanceValidationError {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.write_str(&self.summary_line())
109 }
110}
111
112impl std::error::Error for BackendProvenanceValidationError {}
113
114impl fmt::Display for BackendProvenance {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.summary)
117 }
118}
119
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122#[derive(Clone, Debug, PartialEq)]
123pub struct BackendMetadata {
124 pub id: BackendId,
126 pub version: String,
128 pub family: BackendFamily,
130 pub provenance: BackendProvenance,
132 pub nominal_range: TimeRange,
134 pub supported_time_scales: Vec<TimeScale>,
136 pub body_claims: Vec<BodyClaim>,
138 pub supported_frames: Vec<CoordinateFrame>,
140 pub capabilities: BackendCapabilities,
142 pub accuracy: AccuracyClass,
144 pub deterministic: bool,
146 pub offline: bool,
148}
149
150impl BackendMetadata {
151 pub fn supported_bodies(&self) -> Vec<CelestialBody> {
153 self.body_claims
154 .iter()
155 .filter(|c| c.tier != BodyClaimTier::Unsupported)
156 .map(|c| c.body.clone())
157 .collect()
158 }
159
160 pub fn claim_for(&self, body: &CelestialBody) -> Option<&BodyClaim> {
162 self.body_claims.iter().find(|c| &c.body == body)
163 }
164
165 pub fn release_grade_bodies(&self) -> Vec<CelestialBody> {
167 self.body_claims
168 .iter()
169 .filter(|c| c.tier == BodyClaimTier::ReleaseGrade)
170 .map(|c| c.body.clone())
171 .collect()
172 }
173
174 pub fn claims_by_tier(&self, tier: BodyClaimTier) -> Vec<&BodyClaim> {
176 self.body_claims.iter().filter(|c| c.tier == tier).collect()
177 }
178
179 pub fn summary_line(&self) -> String {
181 format!(
182 "id={}; version={}; family={}; family posture={}; accuracy={}; deterministic={}; offline={}; nominal range={}; time scales=[{}]; bodies=[{}]; frames=[{}]; capabilities=[{}]; provenance={}",
183 self.id,
184 self.version,
185 self.family,
186 self.family.posture_label(),
187 self.accuracy,
188 self.deterministic,
189 self.offline,
190 self.nominal_range,
191 format_display_list(&self.supported_time_scales),
192 self.body_claims
193 .iter()
194 .map(BodyClaim::summary_line)
195 .collect::<Vec<_>>()
196 .join(", "),
197 format_display_list(&self.supported_frames),
198 self.capabilities.summary_line(),
199 self.provenance.summary_line(),
200 )
201 }
202
203 pub fn validated_summary_line(&self) -> Result<String, BackendMetadataValidationError> {
205 self.validate()?;
206 Ok(self.summary_line())
207 }
208
209 pub fn validate_request(&self, req: &EphemerisRequest) -> Result<(), EphemerisError> {
216 req.validate_custom_definitions()?;
217
218 if !self.family.is_routing() {
219 crate::policy::current::validate_request_policy(
220 req,
221 self.id.as_str(),
222 &self.supported_time_scales,
223 &self.supported_frames,
224 self.capabilities.mean,
225 self.capabilities.apparent,
226 )?;
227
228 if !self.capabilities.native_sidereal {
229 crate::policy::current::validate_zodiac_policy(
230 req,
231 self.id.as_str(),
232 &[ZodiacMode::Tropical],
233 )?;
234 }
235
236 crate::policy::current::validate_request_observer_location(req)?;
237 crate::policy::current::validate_observer_policy(
238 req,
239 self.id.as_str(),
240 self.capabilities.topocentric,
241 )?;
242 } else {
243 crate::policy::current::validate_request_observer_location(req)?;
244 }
245
246 if !self.supported_bodies().contains(&req.body) {
247 return Err(EphemerisError::new(
248 EphemerisErrorKind::UnsupportedBody,
249 format!("{} does not support {}", self.id, req.body),
250 ));
251 }
252
253 Ok(())
254 }
255}
256
257impl fmt::Display for BackendMetadata {
258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259 f.write_str(&self.summary_line())
260 }
261}
262
263#[derive(Clone, Debug, PartialEq, Eq, Hash)]
265#[non_exhaustive]
266pub enum BackendMetadataValidationError {
267 BlankField {
269 field: &'static str,
271 },
272 EmptyField {
274 field: &'static str,
276 },
277 DuplicateEntry {
279 field: &'static str,
281 value: String,
283 },
284 NominalRangeNotFinite,
286 NominalRangeScaleMismatch,
288 NominalRangeOutOfOrder,
290 InvalidCapabilities {
292 field: &'static str,
294 message: &'static str,
296 },
297}
298
299impl BackendMetadataValidationError {
300 pub fn summary_line(&self) -> String {
302 match self {
303 Self::BlankField { field } => {
304 format!("backend metadata field `{field}` is blank or whitespace-padded")
305 }
306 Self::EmptyField { field } => {
307 format!("backend metadata field `{field}` must not be empty")
308 }
309 Self::DuplicateEntry { field, value } => {
310 format!("backend metadata field `{field}` contains duplicate entry `{value}`")
311 }
312 Self::NominalRangeNotFinite => {
313 "backend metadata nominal range must use finite Julian-day bounds".to_owned()
314 }
315 Self::NominalRangeScaleMismatch => {
316 "backend metadata nominal range bounds must use the same time scale".to_owned()
317 }
318 Self::NominalRangeOutOfOrder => {
319 "backend metadata nominal range end must not precede the start".to_owned()
320 }
321 Self::InvalidCapabilities { field, message } => {
322 format!("backend metadata field `{field}` is invalid: {message}")
323 }
324 }
325 }
326}
327
328impl fmt::Display for BackendMetadataValidationError {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 f.write_str(&self.summary_line())
331 }
332}
333
334impl std::error::Error for BackendMetadataValidationError {}
335
336impl BackendMetadata {
337 pub fn validate(&self) -> Result<(), BackendMetadataValidationError> {
345 validate_non_blank("id", self.id.as_str())?;
346 validate_non_blank("version", &self.version)?;
347 self.provenance.validate().map_err(|error| match error {
348 BackendProvenanceValidationError::BlankSummary => {
349 BackendMetadataValidationError::BlankField {
350 field: "provenance summary",
351 }
352 }
353 BackendProvenanceValidationError::BlankDataSource { .. } => {
354 BackendMetadataValidationError::BlankField {
355 field: "provenance data sources",
356 }
357 }
358 BackendProvenanceValidationError::DuplicateDataSource { value } => {
359 BackendMetadataValidationError::DuplicateEntry {
360 field: "provenance data sources",
361 value,
362 }
363 }
364 })?;
365 validate_non_empty_unique("supported time scales", &self.supported_time_scales)?;
366 if self.body_claims.is_empty() {
367 return Err(BackendMetadataValidationError::EmptyField {
368 field: "body claims",
369 });
370 }
371 let mut seen: Vec<CelestialBody> = Vec::new();
372 for claim in &self.body_claims {
373 if seen.contains(&claim.body) {
374 return Err(BackendMetadataValidationError::DuplicateEntry {
375 field: "body claims",
376 value: claim.body.to_string(),
377 });
378 }
379 seen.push(claim.body.clone());
380 }
381 validate_non_empty_unique("supported frames", &self.supported_frames)?;
382 self.capabilities.validate().map_err(|error| match error {
383 BackendCapabilitiesValidationError::MissingPositionMode => {
384 BackendMetadataValidationError::InvalidCapabilities {
385 field: "capabilities",
386 message: error.summary_line(),
387 }
388 }
389 BackendCapabilitiesValidationError::MissingValueMode => {
390 BackendMetadataValidationError::InvalidCapabilities {
391 field: "capabilities",
392 message: error.summary_line(),
393 }
394 }
395 })?;
396 self.validate_nominal_range()?;
397 Ok(())
398 }
399
400 fn validate_nominal_range(&self) -> Result<(), BackendMetadataValidationError> {
401 match self.nominal_range.validate() {
402 Ok(()) => Ok(()),
403 Err(TimeRangeValidationError::NonFiniteBound { .. }) => {
404 Err(BackendMetadataValidationError::NominalRangeNotFinite)
405 }
406 Err(TimeRangeValidationError::ScaleMismatch { .. }) => {
407 Err(BackendMetadataValidationError::NominalRangeScaleMismatch)
408 }
409 Err(TimeRangeValidationError::OutOfOrder { .. }) => {
410 Err(BackendMetadataValidationError::NominalRangeOutOfOrder)
411 }
412 }
413 }
414}
415
416pub fn merge_body_claims(a: &[BodyClaim], b: &[BodyClaim]) -> Vec<BodyClaim> {
418 let mut out: Vec<BodyClaim> = a.to_vec();
419 for claim in b {
420 match out.iter_mut().find(|c| c.body == claim.body) {
421 Some(existing) => {
422 if claim.tier.rank() > existing.tier.rank() {
423 *existing = claim.clone();
424 }
425 }
426 None => out.push(claim.clone()),
427 }
428 }
429 out
430}
431
432#[cfg(test)]
433#[path = "metadata_tests.rs"]
434mod tests;