1use crate::{
4 ActivityId, ActivityKind, ActivityRole, AssetId, Error, ErrorKind, ExternalIdentifier,
5 LocatorId, MediaRootId, MetadataProperty, ObjectRef, RepresentationId, ResourceId, Result,
6 RevisionId, Timestamp, ToolIdentity, TransactionId,
7};
8
9pub const MAX_REVISION_MESSAGE_BYTES: usize = 4_096;
11pub const MAX_REVISION_PAGE_SIZE: u32 = 1_000;
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct OriginIdentity(ToolIdentity);
17
18impl OriginIdentity {
19 pub fn new(
26 name: impl Into<String>,
27 version: Option<String>,
28 uri: Option<String>,
29 ) -> Result<Self> {
30 ToolIdentity::new(name, version, uri).map(Self)
31 }
32
33 #[must_use]
35 pub fn name(&self) -> &str {
36 self.0.name()
37 }
38
39 #[must_use]
41 pub fn version(&self) -> Option<&str> {
42 self.0.version()
43 }
44
45 #[must_use]
47 pub fn uri(&self) -> Option<&str> {
48 self.0.uri()
49 }
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct Revision {
55 id: RevisionId,
56 sequence: u64,
57 transaction_id: TransactionId,
58 committed_at: Timestamp,
59 origin: Option<OriginIdentity>,
60 message: Option<String>,
61}
62
63#[derive(Clone, Debug, Default, Eq, PartialEq)]
65pub struct RevisionContext {
66 origin: Option<OriginIdentity>,
67 message: Option<String>,
68}
69
70impl RevisionContext {
71 pub fn new(origin: Option<OriginIdentity>, message: Option<String>) -> Result<Self> {
78 validate_revision_message(message.as_deref())?;
79 Ok(Self { origin, message })
80 }
81
82 #[must_use]
84 pub const fn origin(&self) -> Option<&OriginIdentity> {
85 self.origin.as_ref()
86 }
87
88 #[must_use]
90 pub fn message(&self) -> Option<&str> {
91 self.message.as_deref()
92 }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
97#[non_exhaustive]
98pub enum RevisionEventKind {
99 AssetImported {
101 asset_id: AssetId,
103 },
104 RepresentationAdded {
106 asset_id: AssetId,
108 representation_id: RepresentationId,
110 },
111 ResourceAdded {
113 resource_id: ResourceId,
115 },
116 RepresentationResourceAdded {
118 representation_id: RepresentationId,
120 resource_id: ResourceId,
122 position: u32,
124 },
125 LocatorAdded {
127 resource_id: ResourceId,
129 locator_id: LocatorId,
131 },
132 LocatorRetired {
134 resource_id: ResourceId,
136 locator_id: LocatorId,
138 },
139 MediaRootAdded {
141 media_root_id: MediaRootId,
143 },
144 MediaRootEnabledChanged {
146 media_root_id: MediaRootId,
148 enabled: bool,
150 },
151 MediaRootRemoved {
153 media_root_id: MediaRootId,
155 },
156 ExternalIdentifierAdded {
158 target: ObjectRef,
160 identifier: ExternalIdentifier,
162 },
163 ExternalIdentifierRemoved {
165 target: ObjectRef,
167 identifier: ExternalIdentifier,
169 },
170 MetadataAddedOrReplaced {
172 target: ObjectRef,
174 property: MetadataProperty,
176 },
177 MetadataRemoved {
179 target: ObjectRef,
181 property: MetadataProperty,
183 },
184 ActivityCreated {
186 activity_id: ActivityId,
188 kind: ActivityKind,
190 },
191 ActivityInputAdded {
193 activity_id: ActivityId,
195 representation_id: RepresentationId,
197 role: Option<ActivityRole>,
199 },
200 ActivityOutputAdded {
202 activity_id: ActivityId,
204 representation_id: RepresentationId,
206 role: Option<ActivityRole>,
208 },
209}
210
211#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct RevisionEvent {
214 revision_id: RevisionId,
215 position: u32,
216 kind: RevisionEventKind,
217}
218
219impl RevisionEvent {
220 #[must_use]
222 pub const fn new(revision_id: RevisionId, position: u32, kind: RevisionEventKind) -> Self {
223 Self {
224 revision_id,
225 position,
226 kind,
227 }
228 }
229
230 #[must_use]
232 pub const fn revision_id(&self) -> RevisionId {
233 self.revision_id
234 }
235
236 #[must_use]
238 pub const fn position(&self) -> u32 {
239 self.position
240 }
241
242 #[must_use]
244 pub const fn kind(&self) -> &RevisionEventKind {
245 &self.kind
246 }
247}
248
249impl Revision {
250 pub fn new(
257 id: RevisionId,
258 sequence: u64,
259 transaction_id: TransactionId,
260 committed_at: Timestamp,
261 origin: Option<OriginIdentity>,
262 message: Option<String>,
263 ) -> Result<Self> {
264 if sequence == 0 {
265 return Err(Error::new(
266 ErrorKind::InvalidArgument,
267 "revision sequence must be greater than zero",
268 ));
269 }
270 validate_revision_message(message.as_deref())?;
271 Ok(Self {
272 id,
273 sequence,
274 transaction_id,
275 committed_at,
276 origin,
277 message,
278 })
279 }
280
281 #[must_use]
283 pub const fn id(&self) -> RevisionId {
284 self.id
285 }
286
287 #[must_use]
289 pub const fn sequence(&self) -> u64 {
290 self.sequence
291 }
292
293 #[must_use]
295 pub const fn transaction_id(&self) -> TransactionId {
296 self.transaction_id
297 }
298
299 #[must_use]
301 pub const fn committed_at(&self) -> Timestamp {
302 self.committed_at
303 }
304
305 #[must_use]
307 pub const fn origin(&self) -> Option<&OriginIdentity> {
308 self.origin.as_ref()
309 }
310
311 #[must_use]
313 pub fn message(&self) -> Option<&str> {
314 self.message.as_deref()
315 }
316}
317
318fn validate_revision_message(message: Option<&str>) -> Result<()> {
319 if message.is_some_and(|message| {
320 message.is_empty() || message.len() > MAX_REVISION_MESSAGE_BYTES || message.contains('\0')
321 }) {
322 return Err(Error::new(
323 ErrorKind::InvalidArgument,
324 format!(
325 "revision message must contain 1-{MAX_REVISION_MESSAGE_BYTES} UTF-8 bytes without NUL"
326 ),
327 ));
328 }
329 Ok(())
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn revision_preserves_transaction_context() {
338 let origin = OriginIdentity::new(
339 "Editorial host",
340 Some("2.4.1".to_owned()),
341 Some("https://example.com/editor".to_owned()),
342 )
343 .expect("valid origin");
344 let revision = Revision::new(
345 RevisionId::new(),
346 7,
347 TransactionId::new(),
348 Timestamp::from_unix_micros(42),
349 Some(origin),
350 Some("Import camera original".to_owned()),
351 )
352 .expect("valid revision");
353
354 assert_eq!(revision.sequence(), 7);
355 assert_eq!(revision.committed_at().as_unix_micros(), 42);
356 assert_eq!(revision.origin().unwrap().name(), "Editorial host");
357 assert_eq!(revision.message(), Some("Import camera original"));
358 }
359
360 #[test]
361 fn revision_rejects_ambiguous_context() {
362 let create = |sequence, message| {
363 Revision::new(
364 RevisionId::new(),
365 sequence,
366 TransactionId::new(),
367 Timestamp::from_unix_micros(0),
368 None,
369 message,
370 )
371 };
372
373 assert!(create(0, None).is_err());
374 assert!(create(1, Some(String::new())).is_err());
375 assert!(create(1, Some("bad\0message".to_owned())).is_err());
376 assert!(create(1, Some("x".repeat(MAX_REVISION_MESSAGE_BYTES + 1))).is_err());
377 }
378
379 #[test]
380 fn events_identify_semantic_targets_in_stable_order() {
381 let revision_id = RevisionId::new();
382 let asset_id = AssetId::new();
383 let representation_id = RepresentationId::new();
384 let events = [
385 RevisionEvent::new(
386 revision_id,
387 0,
388 RevisionEventKind::AssetImported { asset_id },
389 ),
390 RevisionEvent::new(
391 revision_id,
392 1,
393 RevisionEventKind::RepresentationAdded {
394 asset_id,
395 representation_id,
396 },
397 ),
398 ];
399
400 assert_eq!(events[0].revision_id(), revision_id);
401 assert_eq!(events[1].position(), 1);
402 assert!(matches!(
403 events[1].kind(),
404 RevisionEventKind::RepresentationAdded {
405 representation_id: id,
406 ..
407 } if *id == representation_id
408 ));
409 }
410}