Skip to main content

spatialrust_records/
provenance.rs

1//! Source lineage attached to versioned spatial records.
2
3use crate::{RecordsError, RecordsResult};
4
5/// Current version of the record-provenance contract.
6pub const RECORD_PROVENANCE_VERSION: u32 = 1;
7
8/// Generic source lineage for one [`crate::SpatialRecord`].
9///
10/// The contract deliberately stays independent of ROS, MCAP, Arrow, and
11/// model runtimes. Adapters may identify their storage in `source_uri` and
12/// their logical channel in `stream_id`; chunk-producing adapters can attach a
13/// deterministic source sequence without changing the point schema.
14#[derive(Clone, Debug, PartialEq, Eq)]
15#[cfg_attr(feature = "receipt-json", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "receipt-json", serde(deny_unknown_fields))]
17pub struct RecordProvenance {
18    /// Contract version for this provenance envelope.
19    pub version: u32,
20    /// Stable logical source identity.
21    pub source_id: String,
22    /// Optional local path or URI from which the record was read.
23    #[cfg_attr(feature = "receipt-json", serde(skip_serializing_if = "Option::is_none"))]
24    pub source_uri: Option<String>,
25    /// Optional logical stream, topic, or channel within the source.
26    #[cfg_attr(feature = "receipt-json", serde(skip_serializing_if = "Option::is_none"))]
27    pub stream_id: Option<String>,
28    /// Optional deterministic source sequence for this record or chunk.
29    #[cfg_attr(feature = "receipt-json", serde(skip_serializing_if = "Option::is_none"))]
30    pub sequence: Option<u64>,
31}
32
33impl RecordProvenance {
34    /// Creates an unknown-source envelope for synthetic or legacy records.
35    #[must_use]
36    pub fn unknown() -> Self {
37        Self {
38            version: RECORD_PROVENANCE_VERSION,
39            source_id: "unknown".to_owned(),
40            source_uri: None,
41            stream_id: None,
42            sequence: None,
43        }
44    }
45
46    /// Creates a validated envelope with a non-empty source identity.
47    pub fn try_new(source_id: impl Into<String>) -> RecordsResult<Self> {
48        let source_id = source_id.into();
49        let provenance = Self {
50            version: RECORD_PROVENANCE_VERSION,
51            source_id,
52            source_uri: None,
53            stream_id: None,
54            sequence: None,
55        };
56        provenance.validate()?;
57        Ok(provenance)
58    }
59
60    /// Validates the version and required identity fields.
61    pub fn validate(&self) -> RecordsResult<()> {
62        if self.version != RECORD_PROVENANCE_VERSION {
63            return Err(RecordsError::InvalidConfiguration(format!(
64                "unsupported record provenance version {}; expected {}",
65                self.version, RECORD_PROVENANCE_VERSION
66            )));
67        }
68        if self.source_id.trim().is_empty() {
69            return Err(RecordsError::InvalidConfiguration(
70                "record provenance source_id must not be empty".into(),
71            ));
72        }
73        if self.source_uri.as_deref().is_some_and(|value| value.trim().is_empty()) {
74            return Err(RecordsError::InvalidConfiguration(
75                "record provenance source_uri must not be empty".into(),
76            ));
77        }
78        if self.stream_id.as_deref().is_some_and(|value| value.trim().is_empty()) {
79            return Err(RecordsError::InvalidConfiguration(
80                "record provenance stream_id must not be empty".into(),
81            ));
82        }
83        Ok(())
84    }
85
86    /// Attaches a source path or URI.
87    #[must_use]
88    pub fn with_source_uri(mut self, source_uri: impl Into<String>) -> Self {
89        self.source_uri = Some(source_uri.into());
90        self
91    }
92
93    /// Attaches a logical stream, topic, or channel.
94    #[must_use]
95    pub fn with_stream_id(mut self, stream_id: impl Into<String>) -> Self {
96        self.stream_id = Some(stream_id.into());
97        self
98    }
99
100    /// Attaches a deterministic source sequence.
101    #[must_use]
102    pub const fn with_sequence(mut self, sequence: Option<u64>) -> Self {
103        self.sequence = sequence;
104        self
105    }
106
107    /// Removes a source sequence when an operation aggregates multiple inputs.
108    #[must_use]
109    pub const fn without_sequence(mut self) -> Self {
110        self.sequence = None;
111        self
112    }
113}
114
115impl Default for RecordProvenance {
116    fn default() -> Self {
117        Self::unknown()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::{RecordProvenance, RECORD_PROVENANCE_VERSION};
124
125    #[test]
126    fn builds_lineage_without_protocol_specific_types() {
127        let provenance = RecordProvenance::try_new("bag-01")
128            .unwrap()
129            .with_source_uri("/media/input/bag.db3")
130            .with_stream_id("/lidar/points")
131            .with_sequence(Some(4));
132        assert_eq!(provenance.version, RECORD_PROVENANCE_VERSION);
133        assert_eq!(provenance.source_id, "bag-01");
134        assert_eq!(provenance.stream_id.as_deref(), Some("/lidar/points"));
135        assert_eq!(provenance.sequence, Some(4));
136        assert_eq!(provenance.without_sequence().sequence, None);
137    }
138
139    #[test]
140    fn rejects_empty_source_identity() {
141        assert!(RecordProvenance::try_new(" ").is_err());
142    }
143
144    #[test]
145    fn rejects_unknown_version_and_empty_optional_identifiers() {
146        let mut provenance = RecordProvenance::unknown();
147        provenance.version = RECORD_PROVENANCE_VERSION + 1;
148        assert!(provenance.validate().is_err());
149
150        let mut provenance = RecordProvenance::unknown();
151        provenance.source_uri = Some(String::new());
152        assert!(provenance.validate().is_err());
153    }
154}