1use crate::ModelError;
4
5#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct SourceId(String);
8
9impl SourceId {
10 pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
17 let value = value.into();
18 validate_text("source identity", &value)?;
19 Ok(Self(value))
20 }
21
22 #[must_use]
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27}
28
29#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
31pub struct SourceSpan {
32 start: usize,
33 end: usize,
34}
35
36impl SourceSpan {
37 pub const fn new(start: usize, end: usize) -> Result<Self, ModelError> {
43 if end < start {
44 return Err(ModelError::ReversedSpan { start, end });
45 }
46 Ok(Self { start, end })
47 }
48
49 #[must_use]
51 pub const fn start(self) -> usize {
52 self.start
53 }
54
55 #[must_use]
57 pub const fn end(self) -> usize {
58 self.end
59 }
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct Provenance {
65 kind: ProvenanceKind,
66 source: SourceId,
67 span: Option<SourceSpan>,
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72#[non_exhaustive]
73pub enum ProvenanceKind {
74 SourceDocument,
76 RuntimeObservation,
78 UserOverride,
80 ImplementationDefault,
82 ConversionDecision,
84}
85
86impl Provenance {
87 #[must_use]
89 pub const fn source(source: SourceId) -> Self {
90 Self {
91 kind: ProvenanceKind::SourceDocument,
92 source,
93 span: None,
94 }
95 }
96
97 #[must_use]
99 pub const fn spanned(source: SourceId, span: SourceSpan) -> Self {
100 Self {
101 kind: ProvenanceKind::SourceDocument,
102 source,
103 span: Some(span),
104 }
105 }
106
107 #[must_use]
109 pub const fn runtime_observation(source: SourceId) -> Self {
110 Self {
111 kind: ProvenanceKind::RuntimeObservation,
112 source,
113 span: None,
114 }
115 }
116
117 #[must_use]
119 pub const fn user_override(source: SourceId) -> Self {
120 Self {
121 kind: ProvenanceKind::UserOverride,
122 source,
123 span: None,
124 }
125 }
126
127 #[must_use]
129 pub const fn implementation_default(source: SourceId) -> Self {
130 Self {
131 kind: ProvenanceKind::ImplementationDefault,
132 source,
133 span: None,
134 }
135 }
136
137 #[must_use]
139 pub const fn conversion_decision(source: SourceId) -> Self {
140 Self {
141 kind: ProvenanceKind::ConversionDecision,
142 source,
143 span: None,
144 }
145 }
146
147 #[must_use]
149 pub const fn kind(&self) -> ProvenanceKind {
150 self.kind
151 }
152
153 #[must_use]
155 pub const fn source_id(&self) -> &SourceId {
156 &self.source
157 }
158
159 #[must_use]
161 pub const fn span(&self) -> Option<SourceSpan> {
162 self.span
163 }
164}
165
166#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct Sourced<T> {
169 value: T,
170 origins: Vec<Provenance>,
171}
172
173impl<T> Sourced<T> {
174 #[must_use]
176 pub const fn generated(value: T) -> Self {
177 Self {
178 value,
179 origins: Vec::new(),
180 }
181 }
182
183 #[must_use]
185 pub fn from_source(value: T, origin: Provenance) -> Self {
186 Self {
187 value,
188 origins: vec![origin],
189 }
190 }
191
192 pub fn add_origin(&mut self, origin: Provenance) {
194 self.origins.push(origin);
195 }
196
197 #[must_use]
199 pub const fn value(&self) -> &T {
200 &self.value
201 }
202
203 #[must_use]
205 pub fn origins(&self) -> &[Provenance] {
206 &self.origins
207 }
208
209 #[must_use]
211 pub fn into_parts(self) -> (T, Vec<Provenance>) {
212 (self.value, self.origins)
213 }
214}
215
216fn validate_text(kind: &'static str, value: &str) -> Result<(), ModelError> {
217 if value.is_empty() {
218 return Err(ModelError::EmptyValue(kind));
219 }
220 if value.contains('\0') {
221 return Err(ModelError::ContainsNul(kind));
222 }
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::{Provenance, ProvenanceKind, SourceId, SourceSpan, Sourced};
229 use crate::ModelError;
230
231 #[test]
232 fn retains_multiple_origins_in_discovery_order() -> Result<(), String> {
233 let first_source = SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
234 let second_source = SourceId::new("compose.override.yaml").map_err(|error| error.to_string())?;
235 let first_span = SourceSpan::new(10, 20).map_err(|error| error.to_string())?;
236 let second_span = SourceSpan::new(30, 40).map_err(|error| error.to_string())?;
237 let mut value = Sourced::from_source("image", Provenance::spanned(first_source, first_span));
238 value.add_origin(Provenance::spanned(second_source, second_span));
239
240 assert_eq!(value.origins()[0].span(), Some(first_span));
241 assert_eq!(value.origins()[1].span(), Some(second_span));
242 Ok(())
243 }
244
245 #[test]
246 fn rejects_reversed_source_spans() {
247 assert!(matches!(
248 SourceSpan::new(20, 10),
249 Err(ModelError::ReversedSpan { start: 20, end: 10 })
250 ));
251 }
252
253 #[test]
254 fn distinguishes_runtime_observations_from_authored_sources_and_decisions() -> Result<(), String> {
255 let runtime = SourceId::new("runtime:container/web").map_err(|error| error.to_string())?;
256 let decision = SourceId::new("decision:working-directory").map_err(|error| error.to_string())?;
257 let runtime = Provenance::runtime_observation(runtime);
258 let decision = Provenance::conversion_decision(decision);
259
260 assert_eq!(runtime.kind(), ProvenanceKind::RuntimeObservation);
261 assert_eq!(runtime.span(), None);
262 assert_eq!(decision.kind(), ProvenanceKind::ConversionDecision);
263 Ok(())
264 }
265}