1use std::collections::BTreeSet;
4
5use crate::{
6 ActivityId, Error, ErrorKind, ExternalIdentifier, RepresentationId, Result, Timestamp,
7 uri::normalize_uri,
8};
9
10pub const MAX_ACTIVITY_KIND_BYTES: usize = 128;
12pub const MAX_ACTIVITY_ROLE_BYTES: usize = 128;
14pub const MAX_PROVENANCE_NAME_BYTES: usize = 256;
16pub const MAX_TOOL_VERSION_BYTES: usize = 128;
18pub const MAX_PROVENANCE_URI_BYTES: usize = 4_096;
20pub const MAX_ACTIVITY_EDGES: usize = 100_000;
22
23#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
25pub struct ActivityKind(String);
26
27impl ActivityKind {
28 pub fn new(value: impl Into<String>) -> Result<Self> {
35 validate_namespaced_identifier("activity kind", value.into(), MAX_ACTIVITY_KIND_BYTES)
36 .map(Self)
37 }
38
39 #[must_use]
41 pub fn as_str(&self) -> &str {
42 &self.0
43 }
44}
45
46#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
48pub struct ActivityRole(String);
49
50impl ActivityRole {
51 pub fn new(value: impl Into<String>) -> Result<Self> {
58 validate_namespaced_identifier("activity role", value.into(), MAX_ACTIVITY_ROLE_BYTES)
59 .map(Self)
60 }
61
62 #[must_use]
64 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct ToolIdentity {
72 name: String,
73 version: Option<String>,
74 uri: Option<String>,
75}
76
77impl ToolIdentity {
78 pub fn new(
85 name: impl Into<String>,
86 version: Option<String>,
87 uri: Option<String>,
88 ) -> Result<Self> {
89 let name = name.into();
90 validate_text("tool name", &name, MAX_PROVENANCE_NAME_BYTES)?;
91 if let Some(version) = version.as_deref() {
92 validate_text("tool version", version, MAX_TOOL_VERSION_BYTES)?;
93 }
94 let uri = uri.map(|value| normalize_uri(value, "tool")).transpose()?;
95 if uri
96 .as_ref()
97 .is_some_and(|value| value.len() > MAX_PROVENANCE_URI_BYTES)
98 {
99 return Err(Error::new(
100 ErrorKind::InvalidArgument,
101 format!("tool URI must not exceed {MAX_PROVENANCE_URI_BYTES} UTF-8 bytes"),
102 ));
103 }
104 Ok(Self { name, version, uri })
105 }
106
107 #[must_use]
109 pub fn name(&self) -> &str {
110 &self.name
111 }
112
113 #[must_use]
115 pub fn version(&self) -> Option<&str> {
116 self.version.as_deref()
117 }
118
119 #[must_use]
121 pub fn uri(&self) -> Option<&str> {
122 self.uri.as_deref()
123 }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct AgentIdentity {
129 name: Option<String>,
130 identifier: Option<ExternalIdentifier>,
131}
132
133#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
135pub struct ActivityInput {
136 representation_id: RepresentationId,
137 role: Option<ActivityRole>,
138}
139
140impl ActivityInput {
141 #[must_use]
143 pub const fn new(representation_id: RepresentationId, role: Option<ActivityRole>) -> Self {
144 Self {
145 representation_id,
146 role,
147 }
148 }
149
150 #[must_use]
152 pub const fn representation_id(&self) -> RepresentationId {
153 self.representation_id
154 }
155
156 #[must_use]
158 pub const fn role(&self) -> Option<&ActivityRole> {
159 self.role.as_ref()
160 }
161}
162
163#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
165pub struct ActivityOutput {
166 representation_id: RepresentationId,
167 role: Option<ActivityRole>,
168}
169
170impl ActivityOutput {
171 #[must_use]
173 pub const fn new(representation_id: RepresentationId, role: Option<ActivityRole>) -> Self {
174 Self {
175 representation_id,
176 role,
177 }
178 }
179
180 #[must_use]
182 pub const fn representation_id(&self) -> RepresentationId {
183 self.representation_id
184 }
185
186 #[must_use]
188 pub const fn role(&self) -> Option<&ActivityRole> {
189 self.role.as_ref()
190 }
191}
192
193#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct Activity {
196 id: ActivityId,
197 kind: ActivityKind,
198 started_at: Option<Timestamp>,
199 finished_at: Option<Timestamp>,
200 tool: Option<ToolIdentity>,
201 agent: Option<AgentIdentity>,
202 inputs: Vec<ActivityInput>,
203 outputs: Vec<ActivityOutput>,
204}
205
206impl Activity {
207 pub fn new(
218 id: ActivityId,
219 kind: ActivityKind,
220 mut inputs: Vec<ActivityInput>,
221 mut outputs: Vec<ActivityOutput>,
222 ) -> Result<Self> {
223 validate_edge_count("input", inputs.len(), true)?;
224 validate_edge_count("output", outputs.len(), false)?;
225 inputs.sort();
226 outputs.sort();
227 if inputs.windows(2).any(|pair| pair[0] == pair[1]) {
228 return Err(Error::new(
229 ErrorKind::InvalidArgument,
230 "activity contains a duplicate input edge",
231 ));
232 }
233 if outputs.windows(2).any(|pair| pair[0] == pair[1]) {
234 return Err(Error::new(
235 ErrorKind::InvalidArgument,
236 "activity contains a duplicate output edge",
237 ));
238 }
239 let input_representations: BTreeSet<_> = inputs
240 .iter()
241 .map(ActivityInput::representation_id)
242 .collect();
243 if outputs
244 .iter()
245 .any(|output| input_representations.contains(&output.representation_id()))
246 {
247 return Err(Error::new(
248 ErrorKind::InvalidArgument,
249 "an activity cannot consume and produce the same representation",
250 ));
251 }
252 Ok(Self {
253 id,
254 kind,
255 started_at: None,
256 finished_at: None,
257 tool: None,
258 agent: None,
259 inputs,
260 outputs,
261 })
262 }
263
264 pub fn with_timing(
271 mut self,
272 started_at: Option<Timestamp>,
273 finished_at: Option<Timestamp>,
274 ) -> Result<Self> {
275 if started_at
276 .zip(finished_at)
277 .is_some_and(|(started, finished)| finished < started)
278 {
279 return Err(Error::new(
280 ErrorKind::InvalidArgument,
281 "activity finish time must not precede its start time",
282 ));
283 }
284 self.started_at = started_at;
285 self.finished_at = finished_at;
286 Ok(self)
287 }
288
289 #[must_use]
291 pub fn with_tool(mut self, tool: ToolIdentity) -> Self {
292 self.tool = Some(tool);
293 self
294 }
295
296 #[must_use]
298 pub fn with_agent(mut self, agent: AgentIdentity) -> Self {
299 self.agent = Some(agent);
300 self
301 }
302
303 #[must_use]
305 pub const fn id(&self) -> ActivityId {
306 self.id
307 }
308
309 #[must_use]
311 pub const fn kind(&self) -> &ActivityKind {
312 &self.kind
313 }
314
315 #[must_use]
317 pub const fn started_at(&self) -> Option<Timestamp> {
318 self.started_at
319 }
320
321 #[must_use]
323 pub const fn finished_at(&self) -> Option<Timestamp> {
324 self.finished_at
325 }
326
327 #[must_use]
329 pub const fn tool(&self) -> Option<&ToolIdentity> {
330 self.tool.as_ref()
331 }
332
333 #[must_use]
335 pub const fn agent(&self) -> Option<&AgentIdentity> {
336 self.agent.as_ref()
337 }
338
339 #[must_use]
341 pub fn inputs(&self) -> &[ActivityInput] {
342 &self.inputs
343 }
344
345 #[must_use]
347 pub fn outputs(&self) -> &[ActivityOutput] {
348 &self.outputs
349 }
350}
351
352impl AgentIdentity {
353 pub fn new(name: Option<String>, identifier: Option<ExternalIdentifier>) -> Result<Self> {
360 if let Some(name) = name.as_deref() {
361 validate_text("agent name", name, MAX_PROVENANCE_NAME_BYTES)?;
362 }
363 if name.is_none() && identifier.is_none() {
364 return Err(Error::new(
365 ErrorKind::InvalidArgument,
366 "agent identity requires a name or external identifier",
367 ));
368 }
369 Ok(Self { name, identifier })
370 }
371
372 #[must_use]
374 pub fn name(&self) -> Option<&str> {
375 self.name.as_deref()
376 }
377
378 #[must_use]
380 pub const fn identifier(&self) -> Option<&ExternalIdentifier> {
381 self.identifier.as_ref()
382 }
383}
384
385fn validate_namespaced_identifier(label: &str, value: String, maximum: usize) -> Result<String> {
386 let valid_bytes = value
387 .bytes()
388 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'));
389 let valid_namespace = value
390 .split_once(':')
391 .is_some_and(|(namespace, local)| !namespace.is_empty() && !local.is_empty());
392 if value.len() > maximum || !valid_bytes || !valid_namespace {
393 return Err(Error::new(
394 ErrorKind::InvalidArgument,
395 format!("{label} must be a namespaced identifier of at most {maximum} ASCII bytes"),
396 ));
397 }
398 Ok(value)
399}
400
401fn validate_text(label: &str, value: &str, maximum: usize) -> Result<()> {
402 if value.is_empty() || value.len() > maximum || value.contains('\0') {
403 return Err(Error::new(
404 ErrorKind::InvalidArgument,
405 format!("{label} must contain 1-{maximum} UTF-8 bytes without NUL"),
406 ));
407 }
408 Ok(())
409}
410
411fn validate_edge_count(label: &str, count: usize, may_be_empty: bool) -> Result<()> {
412 if count > MAX_ACTIVITY_EDGES || (!may_be_empty && count == 0) {
413 let minimum = usize::from(!may_be_empty);
414 return Err(Error::new(
415 ErrorKind::InvalidArgument,
416 format!("activity must have {minimum}-{MAX_ACTIVITY_EDGES} {label} edges"),
417 ));
418 }
419 Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::IdentifierScheme;
426
427 #[test]
428 fn activity_vocabulary_is_extensible_and_namespaced() {
429 let kind = ActivityKind::new("vendor.example:vfx-render").expect("valid kind");
430 let role = ActivityRole::new("vendor.example:input.plate").expect("valid role");
431
432 assert_eq!(kind.as_str(), "vendor.example:vfx-render");
433 assert_eq!(role.as_str(), "vendor.example:input.plate");
434 assert!(ActivityKind::new("transcode").is_err());
435 assert!(ActivityRole::new("vendor.example:bad role").is_err());
436 }
437
438 #[test]
439 fn tool_and_agent_identity_preserve_supplied_detail() {
440 let tool = ToolIdentity::new(
441 "FFmpeg",
442 Some("8.0-custom".to_owned()),
443 Some("https://ffmpeg.org".to_owned()),
444 )
445 .expect("valid tool");
446 let identifier = ExternalIdentifier::new(
447 IdentifierScheme::new("com.example.worker").expect("valid scheme"),
448 "worker-42",
449 None,
450 )
451 .expect("valid identifier");
452 let agent = AgentIdentity::new(Some("Render worker".to_owned()), Some(identifier.clone()))
453 .expect("valid agent");
454
455 assert_eq!(tool.name(), "FFmpeg");
456 assert_eq!(tool.version(), Some("8.0-custom"));
457 assert_eq!(tool.uri(), Some("https://ffmpeg.org/"));
458 assert_eq!(agent.name(), Some("Render worker"));
459 assert_eq!(agent.identifier(), Some(&identifier));
460 }
461
462 #[test]
463 fn participant_identity_is_bounded() {
464 assert!(ToolIdentity::new("", None, None).is_err());
465 assert!(ToolIdentity::new("tool", Some(String::new()), None).is_err());
466 assert!(ToolIdentity::new("tool", None, Some("relative".to_owned())).is_err());
467 let oversized_uri = format!(
468 "https://example.com/{}",
469 "x".repeat(MAX_PROVENANCE_URI_BYTES)
470 );
471 assert!(ToolIdentity::new("tool", None, Some(oversized_uri)).is_err());
472 assert!(AgentIdentity::new(None, None).is_err());
473 assert!(AgentIdentity::new(Some("bad\0name".to_owned()), None).is_err());
474 }
475
476 #[test]
477 fn activity_supports_canonical_fan_in_and_fan_out() {
478 let first_input = RepresentationId::from_bytes([2; 16]);
479 let second_input = RepresentationId::from_bytes([1; 16]);
480 let first_output = RepresentationId::from_bytes([4; 16]);
481 let second_output = RepresentationId::from_bytes([3; 16]);
482 let activity = Activity::new(
483 ActivityId::new(),
484 ActivityKind::new("org.postproject:transcode").expect("valid kind"),
485 vec![
486 ActivityInput::new(first_input, None),
487 ActivityInput::new(second_input, None),
488 ],
489 vec![
490 ActivityOutput::new(first_output, None),
491 ActivityOutput::new(second_output, None),
492 ],
493 )
494 .expect("valid activity")
495 .with_timing(
496 Some(Timestamp::from_unix_micros(10)),
497 Some(Timestamp::from_unix_micros(20)),
498 )
499 .expect("valid timing");
500
501 assert_eq!(activity.inputs()[0].representation_id(), second_input);
502 assert_eq!(activity.inputs()[1].representation_id(), first_input);
503 assert_eq!(activity.outputs()[0].representation_id(), second_output);
504 assert_eq!(activity.outputs()[1].representation_id(), first_output);
505 }
506
507 #[test]
508 fn activity_rejects_incomplete_or_ambiguous_graph_facts() {
509 let representation = RepresentationId::new();
510 let kind = || ActivityKind::new("org.postproject:vfx-render").expect("valid kind");
511 let input = || ActivityInput::new(representation, None);
512 let output = || ActivityOutput::new(representation, None);
513
514 let no_outputs = Activity::new(ActivityId::new(), kind(), vec![input()], Vec::new());
515 assert!(no_outputs.is_err());
516
517 let duplicate = Activity::new(
518 ActivityId::new(),
519 kind(),
520 Vec::new(),
521 vec![output(), output()],
522 );
523 assert!(duplicate.is_err());
524
525 let self_edge = Activity::new(ActivityId::new(), kind(), vec![input()], vec![output()]);
526 assert!(self_edge.is_err());
527
528 let reversed_time = Activity::new(
529 ActivityId::new(),
530 kind(),
531 Vec::new(),
532 vec![ActivityOutput::new(RepresentationId::new(), None)],
533 )
534 .expect("valid activity")
535 .with_timing(
536 Some(Timestamp::from_unix_micros(2)),
537 Some(Timestamp::from_unix_micros(1)),
538 );
539 assert!(reversed_time.is_err());
540 }
541}