Skip to main content

pleiades_backend/
metadata.rs

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/// Provenance summary for a backend.
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct BackendProvenance {
16    /// Short human-readable summary of the backend's source material.
17    pub summary: String,
18    /// External data or reference sources used by the backend.
19    pub data_sources: Vec<String>,
20}
21
22impl BackendProvenance {
23    /// Creates a new provenance summary.
24    pub fn new(summary: impl Into<String>) -> Self {
25        Self {
26            summary: summary.into(),
27            data_sources: Vec::new(),
28        }
29    }
30
31    /// Returns a compact one-line rendering of the provenance summary.
32    pub fn summary_line(&self) -> String {
33        self.summary.clone()
34    }
35
36    /// Returns `Ok(())` when the provenance summary is internally consistent.
37    ///
38    /// The shared check keeps backend provenance metadata from silently
39    /// carrying blank summary text or duplicate/whitespace-padded source
40    /// labels. Empty source lists are allowed for synthesized or routing
41    /// backends that do not have external data provenance to list.
42    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    /// Returns the compact provenance summary after validating it.
65    pub fn validated_summary_line(&self) -> Result<String, BackendProvenanceValidationError> {
66        self.validate()?;
67        Ok(self.summary_line())
68    }
69}
70
71/// Errors returned when backend provenance metadata fails the shared consistency checks.
72#[derive(Clone, Debug, PartialEq, Eq, Hash)]
73#[non_exhaustive]
74pub enum BackendProvenanceValidationError {
75    /// The summary text was blank or whitespace-padded.
76    BlankSummary,
77    /// A provenance source entry was blank or whitespace-padded.
78    BlankDataSource {
79        /// Zero-based position of the invalid source entry.
80        index: usize,
81    },
82    /// A provenance source entry appeared more than once.
83    DuplicateDataSource {
84        /// The duplicated source label.
85        value: String,
86    },
87}
88
89impl BackendProvenanceValidationError {
90    /// Returns a compact validation summary string.
91    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/// Nominal backend metadata.
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122#[derive(Clone, Debug, PartialEq)]
123pub struct BackendMetadata {
124    /// Stable backend identifier.
125    pub id: BackendId,
126    /// Human-readable backend version.
127    pub version: String,
128    /// Backend family.
129    pub family: BackendFamily,
130    /// Provenance summary.
131    pub provenance: BackendProvenance,
132    /// Nominal supported time range.
133    pub nominal_range: TimeRange,
134    /// Time scales the backend can accept.
135    pub supported_time_scales: Vec<TimeScale>,
136    /// Supported body coverage and per-body release claims.
137    pub body_claims: Vec<BodyClaim>,
138    /// Supported coordinate frames.
139    pub supported_frames: Vec<CoordinateFrame>,
140    /// Declared capabilities.
141    pub capabilities: BackendCapabilities,
142    /// Published accuracy class.
143    pub accuracy: AccuracyClass,
144    /// Whether repeated queries are deterministic.
145    pub deterministic: bool,
146    /// Whether the backend runs fully offline.
147    pub offline: bool,
148}
149
150impl BackendMetadata {
151    /// Returns the bodies the backend serves (every tier except `Unsupported`).
152    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    /// Returns the claim for a body, if declared.
161    pub fn claim_for(&self, body: &CelestialBody) -> Option<&BodyClaim> {
162        self.body_claims.iter().find(|c| &c.body == body)
163    }
164
165    /// Returns the bodies claimed `ReleaseGrade`.
166    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    /// Returns claims at a given tier.
175    pub fn claims_by_tier(&self, tier: BodyClaimTier) -> Vec<&BodyClaim> {
176        self.body_claims.iter().filter(|c| c.tier == tier).collect()
177    }
178
179    /// Returns a compact one-line rendering of the backend metadata posture.
180    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    /// Returns the compact backend metadata summary after validating the stored fields.
204    pub fn validated_summary_line(&self) -> Result<String, BackendMetadataValidationError> {
205        self.validate()?;
206        Ok(self.summary_line())
207    }
208
209    /// Validates a request shape against this metadata before backend computation.
210    ///
211    /// Routing backends still defer frame, time-scale, value-mode, and zodiac
212    /// checks to the selected provider, but they continue to validate the
213    /// request's custom definitions, observer syntax, and body coverage here so
214    /// unsupported shapes fail closed before execution.
215    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/// Errors returned when backend metadata fails the shared consistency checks.
264#[derive(Clone, Debug, PartialEq, Eq, Hash)]
265#[non_exhaustive]
266pub enum BackendMetadataValidationError {
267    /// A required metadata field is blank or whitespace-padded.
268    BlankField {
269        /// Name of the offending metadata field.
270        field: &'static str,
271    },
272    /// A required list field is empty.
273    EmptyField {
274        /// Name of the offending metadata list field.
275        field: &'static str,
276    },
277    /// A catalog-style list field contains a duplicate entry.
278    DuplicateEntry {
279        /// Name of the metadata list field that held the duplicate.
280        field: &'static str,
281        /// The duplicated entry value.
282        value: String,
283    },
284    /// The nominal range contains a non-finite Julian-day bound.
285    NominalRangeNotFinite,
286    /// The nominal range bounds use different time scales.
287    NominalRangeScaleMismatch,
288    /// The nominal range end precedes the start.
289    NominalRangeOutOfOrder,
290    /// The declared capability flags are internally inconsistent.
291    InvalidCapabilities {
292        /// The invalid field name.
293        field: &'static str,
294        /// A short description of the capability mismatch.
295        message: &'static str,
296    },
297}
298
299impl BackendMetadataValidationError {
300    /// Returns a compact validation summary string.
301    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    /// Returns `Ok(())` when the metadata is internally consistent.
338    ///
339    /// The shared check keeps the release-facing backend inventory from
340    /// silently advertising blank identifiers, duplicate coverage entries, or
341    /// an invalid nominal range. It does not attempt to validate source-specific
342    /// accuracy claims; those still belong to the backend crate that owns the
343    /// data.
344    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
416/// Merges two claim lists, keeping the stronger-ranked tier on body collisions.
417pub 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;