1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{
7 Array, FixedSizeBinaryBuilder, StringArray, TimestampMicrosecondArray, UInt32Array, UInt64Array,
8};
9use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
10use arrow::record_batch::RecordBatch;
11use graphforge_core::canonical::{
12 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
13};
14use uuid::Uuid;
15
16use crate::{
17 KNOWLEDGE_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
18 check_limit, fixed_column, invalid, optional_fixed_32, optional_text, require_schema,
19 require_uuid, require_v7, required_i64, required_text, required_u32, string_column,
20 timestamp_column, u32_column, uuid_at, uuid_field,
21};
22
23pub const ARTIFACT_CONTRACT_VERSION: u32 = 1;
25pub const ARTIFACT_KIND_REGISTRY_VERSION: u32 = 1;
27pub const ARTIFACT_PAYLOAD_KIND_REGISTRY_VERSION: u32 = 1;
29pub const ARTIFACT_AVAILABILITY_REGISTRY_VERSION: u32 = 1;
31pub const MAX_ARTIFACT_MEDIA_TYPE_BYTES: usize = 256;
33pub const MAX_ARTIFACT_EXTERNAL_URI_BYTES: usize = 4_096;
35
36pub static ARTIFACT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
38 Arc::new(Schema::new(vec![
39 uuid_field("artifact_uuid", false),
40 uuid_field("source_uuid", false),
41 Field::new("artifact_kind", DataType::Utf8, false),
42 Field::new("media_type", DataType::Utf8, false),
43 Field::new("payload_kind", DataType::Utf8, false),
44 Field::new("content_sha256", DataType::FixedSizeBinary(32), true),
45 Field::new("content_length", DataType::UInt64, true),
46 Field::new("external_uri", DataType::Utf8, true),
47 Field::new("external_fingerprint", DataType::FixedSizeBinary(32), true),
48 Field::new("availability", DataType::Utf8, false),
49 uuid_field("run_uuid", true),
50 uuid_field("provenance_uuid", false),
51 Field::new(
52 "recorded_at",
53 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
54 false,
55 ),
56 Field::new("contract_version", DataType::UInt32, false),
57 ]))
58});
59
60static ARTIFACT_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
61 fingerprint(
62 CanonicalDomain::Schema,
63 CANONICAL_CONTRACT_VERSION,
64 b"artifact/1|artifact_uuid:fixed[16]:required|source_uuid:fixed[16]:required|artifact_kind:utf8:required|media_type:utf8:required|payload_kind:utf8:required|content_sha256:fixed[32]:nullable|content_length:u64:nullable|external_uri:utf8:nullable|external_fingerprint:fixed[32]:nullable|availability:utf8:required|run_uuid:fixed[16]:nullable|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
65 )
66 .expect("registered artifact schema is within canonical bounds")
67});
68
69#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ArtifactKind {
73 RawScan,
75 ProcessedScan,
77 OcrText,
79 NormalizedText,
81 PassageExtract,
83 Other,
85}
86
87impl ArtifactKind {
88 #[must_use]
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 Self::RawScan => "raw_scan",
93 Self::ProcessedScan => "processed_scan",
94 Self::OcrText => "ocr_text",
95 Self::NormalizedText => "normalized_text",
96 Self::PassageExtract => "passage_extract",
97 Self::Other => "other",
98 }
99 }
100
101 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
102 match value {
103 "raw_scan" => Ok(Self::RawScan),
104 "processed_scan" => Ok(Self::ProcessedScan),
105 "ocr_text" => Ok(Self::OcrText),
106 "normalized_text" => Ok(Self::NormalizedText),
107 "passage_extract" => Ok(Self::PassageExtract),
108 "other" => Ok(Self::Other),
109 _ => Err(invalid("artifact_kind", "unknown closed value")),
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ArtifactPayloadKind {
118 LocalSha256,
120 ExternalReference,
122 Absent,
124}
125
126impl ArtifactPayloadKind {
127 #[must_use]
129 pub const fn as_str(self) -> &'static str {
130 match self {
131 Self::LocalSha256 => "local_sha256",
132 Self::ExternalReference => "external_reference",
133 Self::Absent => "absent",
134 }
135 }
136
137 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
138 match value {
139 "local_sha256" => Ok(Self::LocalSha256),
140 "external_reference" => Ok(Self::ExternalReference),
141 "absent" => Ok(Self::Absent),
142 _ => Err(invalid("payload_kind", "unknown closed value")),
143 }
144 }
145}
146
147#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ArtifactAvailability {
151 LocalVerified,
153 ExternalOnly,
155 MissingLocal,
157 Unverifiable,
159}
160
161impl ArtifactAvailability {
162 #[must_use]
164 pub const fn as_str(self) -> &'static str {
165 match self {
166 Self::LocalVerified => "local_verified",
167 Self::ExternalOnly => "external_only",
168 Self::MissingLocal => "missing_local",
169 Self::Unverifiable => "unverifiable",
170 }
171 }
172
173 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
174 match value {
175 "local_verified" => Ok(Self::LocalVerified),
176 "external_only" => Ok(Self::ExternalOnly),
177 "missing_local" => Ok(Self::MissingLocal),
178 "unverifiable" => Ok(Self::Unverifiable),
179 _ => Err(invalid("availability", "unknown closed value")),
180 }
181 }
182}
183
184#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct Artifact {
187 pub artifact_uuid: Uuid,
189 pub source_uuid: Uuid,
191 pub artifact_kind: ArtifactKind,
193 pub media_type: String,
195 pub payload_kind: ArtifactPayloadKind,
197 pub content_sha256: Option<[u8; 32]>,
199 pub content_length: Option<u64>,
201 pub external_uri: Option<String>,
203 pub external_fingerprint: Option<[u8; 32]>,
205 pub availability: ArtifactAvailability,
207 pub run_uuid: Option<Uuid>,
209 pub provenance_uuid: Uuid,
211 pub recorded_at_micros: i64,
213 pub contract_version: u32,
215}
216
217impl Artifact {
218 #[allow(clippy::too_many_arguments)]
220 pub fn new(
221 artifact_uuid: Uuid,
222 source_uuid: Uuid,
223 artifact_kind: ArtifactKind,
224 media_type: String,
225 payload_kind: ArtifactPayloadKind,
226 content_sha256: Option<[u8; 32]>,
227 content_length: Option<u64>,
228 external_uri: Option<String>,
229 external_fingerprint: Option<[u8; 32]>,
230 availability: ArtifactAvailability,
231 run_uuid: Option<Uuid>,
232 provenance_uuid: Uuid,
233 recorded_at_micros: i64,
234 ) -> Result<Self, KnowledgeError> {
235 let row = Self {
236 artifact_uuid,
237 source_uuid,
238 artifact_kind,
239 media_type,
240 payload_kind,
241 content_sha256,
242 content_length,
243 external_uri,
244 external_fingerprint,
245 availability,
246 run_uuid,
247 provenance_uuid,
248 recorded_at_micros,
249 contract_version: ARTIFACT_CONTRACT_VERSION,
250 };
251 validate_artifact(&row)?;
252 Ok(row)
253 }
254}
255
256#[derive(Clone, Debug, Default, Eq, PartialEq)]
258pub struct ArtifactLedger {
259 pub artifacts: Vec<Artifact>,
261}
262
263impl ArtifactLedger {
264 pub fn new(mut artifacts: Vec<Artifact>) -> Result<Self, KnowledgeError> {
266 artifacts.sort_by_key(|row| (row.recorded_at_micros, row.artifact_uuid));
267 validate_artifacts(&artifacts)?;
268 Ok(Self { artifacts })
269 }
270
271 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
273 let mut merged = self.artifacts.clone();
274 for row in &staged.artifacts {
275 if let Some(existing) = merged
276 .iter()
277 .find(|candidate| candidate.artifact_uuid == row.artifact_uuid)
278 {
279 if existing != row {
280 return Err(KnowledgeError::Conflict("artifact_uuid"));
281 }
282 continue;
283 }
284 merged.push(row.clone());
285 }
286 Self::new(merged)
287 }
288
289 pub fn artifact_fingerprint(&self, artifact_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
294 let row = self
295 .artifacts
296 .iter()
297 .find(|row| row.artifact_uuid == artifact_uuid)
298 .ok_or(KnowledgeError::Dangling("artifact_uuid"))?;
299 let mut writer = CanonicalWriter::new();
300 writer.raw(b"GFAR")?;
301 writer.u32(ARTIFACT_CONTRACT_VERSION)?;
302 writer.raw(row.artifact_uuid.as_bytes())?;
303 writer.raw(row.source_uuid.as_bytes())?;
304 writer.text(row.artifact_kind.as_str())?;
305 writer.text(&row.media_type)?;
306 writer.text(row.payload_kind.as_str())?;
307 match row.content_sha256 {
308 None => writer.u8(0)?,
309 Some(hash) => {
310 writer.u8(1)?;
311 writer.raw(&hash)?;
312 }
313 }
314 match row.content_length {
315 None => writer.u8(0)?,
316 Some(len) => {
317 writer.u8(1)?;
318 writer.u64(len)?;
319 }
320 }
321 match &row.external_uri {
322 None => writer.u8(0)?,
323 Some(uri) => {
324 writer.u8(1)?;
325 writer.text(uri)?;
326 }
327 }
328 match row.external_fingerprint {
329 None => writer.u8(0)?,
330 Some(fp) => {
331 writer.u8(1)?;
332 writer.raw(&fp)?;
333 }
334 }
335 writer.text(row.availability.as_str())?;
336 match row.run_uuid {
337 None => writer.u8(0)?,
338 Some(uuid) => {
339 writer.u8(1)?;
340 writer.raw(uuid.as_bytes())?;
341 }
342 }
343 Ok(fingerprint(
344 CanonicalDomain::ResearchArtifact,
345 CANONICAL_CONTRACT_VERSION,
346 &writer.finish(),
347 )?)
348 }
349
350 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
352 artifact_batch(&self.artifacts)
353 }
354
355 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
357 let mut artifacts = Vec::new();
358 for batch in batches {
359 require_schema(batch, &ARTIFACT_SCHEMA, "artifacts.schema")?;
360 let ids = fixed_column(batch, "artifact_uuid")?;
361 let sources = fixed_column(batch, "source_uuid")?;
362 let kinds = string_column(batch, "artifact_kind")?;
363 let media_types = string_column(batch, "media_type")?;
364 let payload_kinds = string_column(batch, "payload_kind")?;
365 let content_hashes = fixed_column(batch, "content_sha256")?;
366 let content_lengths = batch
367 .column_by_name("content_length")
368 .ok_or_else(|| invalid("content_length", "missing column"))?
369 .as_any()
370 .downcast_ref::<UInt64Array>()
371 .ok_or_else(|| invalid("content_length", "invalid column type"))?;
372 let external_uris = string_column(batch, "external_uri")?;
373 let external_fingerprints = fixed_column(batch, "external_fingerprint")?;
374 let availability = string_column(batch, "availability")?;
375 let runs = fixed_column(batch, "run_uuid")?;
376 let provenance = fixed_column(batch, "provenance_uuid")?;
377 let recorded = timestamp_column(batch, "recorded_at")?;
378 let versions = u32_column(batch, "contract_version")?;
379 for row in 0..batch.num_rows() {
380 artifacts.push(Artifact {
381 artifact_uuid: uuid_at(ids, row, "artifact_uuid")?,
382 source_uuid: uuid_at(sources, row, "source_uuid")?,
383 artifact_kind: ArtifactKind::parse(required_text(
384 kinds,
385 row,
386 "artifact_kind",
387 )?)?,
388 media_type: required_text(media_types, row, "media_type")?.to_string(),
389 payload_kind: ArtifactPayloadKind::parse(required_text(
390 payload_kinds,
391 row,
392 "payload_kind",
393 )?)?,
394 content_sha256: optional_fixed_32(content_hashes, row, "content_sha256")?,
395 content_length: (!content_lengths.is_null(row))
396 .then(|| content_lengths.value(row)),
397 external_uri: optional_text(external_uris, row),
398 external_fingerprint: optional_fixed_32(
399 external_fingerprints,
400 row,
401 "external_fingerprint",
402 )?,
403 availability: ArtifactAvailability::parse(required_text(
404 availability,
405 row,
406 "availability",
407 )?)?,
408 run_uuid: optional_uuid_at(runs, row, "run_uuid")?,
409 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
410 recorded_at_micros: required_i64(recorded, row, "recorded_at")?,
411 contract_version: required_u32(versions, row, "contract_version")?,
412 });
413 }
414 }
415 Self::new(artifacts)
416 }
417
418 #[must_use]
420 pub fn by_source(&self) -> HashMap<Uuid, Vec<&Artifact>> {
421 let mut grouped = HashMap::new();
422 for artifact in &self.artifacts {
423 grouped
424 .entry(artifact.source_uuid)
425 .or_insert_with(Vec::new)
426 .push(artifact);
427 }
428 grouped
429 }
430}
431
432pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
433 SchemaRegistryEntry {
434 capability_id: "knowledge",
435 capability_version: KNOWLEDGE_CAPABILITY_VERSION,
436 record_family: "artifacts",
437 record_version: ARTIFACT_CONTRACT_VERSION,
438 schema: Arc::clone(&ARTIFACT_SCHEMA),
439 schema_fingerprint: *ARTIFACT_SCHEMA_FINGERPRINT,
440 enum_registry_versions: &[
441 ("artifact_kind", ARTIFACT_KIND_REGISTRY_VERSION),
442 ("payload_kind", ARTIFACT_PAYLOAD_KIND_REGISTRY_VERSION),
443 ("availability", ARTIFACT_AVAILABILITY_REGISTRY_VERSION),
444 ],
445 sort_key: &["recorded_at", "artifact_uuid"],
446 diff_identity_fields: &["artifact_uuid"],
447 diff_record_uuid_field: Some("artifact_uuid"),
448 fingerprint_domain: CanonicalDomain::ResearchArtifact,
449 owner: "graphforge-knowledge",
450 implementation_issue: 1349,
451 max_rows: MAX_KNOWLEDGE_ROWS,
452 }
453}
454
455fn optional_uuid_at(
456 array: &arrow::array::FixedSizeBinaryArray,
457 row: usize,
458 field: &'static str,
459) -> Result<Option<Uuid>, KnowledgeError> {
460 if array.is_null(row) {
461 Ok(None)
462 } else {
463 Ok(Some(uuid_at(array, row, field)?))
464 }
465}
466
467fn validate_artifact(row: &Artifact) -> Result<(), KnowledgeError> {
468 if row.contract_version != ARTIFACT_CONTRACT_VERSION {
469 return Err(invalid("artifact.contract_version", "unsupported version"));
470 }
471 require_v7(row.artifact_uuid, "artifact_uuid")?;
472 require_v7(row.source_uuid, "source_uuid")?;
473 require_uuid(row.provenance_uuid, "provenance_uuid")?;
474 if let Some(run_uuid) = row.run_uuid {
475 require_v7(run_uuid, "run_uuid")?;
476 }
477 validate_bounded_text(
478 "artifact.media_type",
479 &row.media_type,
480 MAX_ARTIFACT_MEDIA_TYPE_BYTES,
481 )?;
482 if let Some(uri) = &row.external_uri {
483 validate_bounded_text(
484 "artifact.external_uri",
485 uri,
486 MAX_ARTIFACT_EXTERNAL_URI_BYTES,
487 )?;
488 }
489 match row.payload_kind {
490 ArtifactPayloadKind::LocalSha256 => {
491 if row.content_sha256.is_none() || row.content_length.is_none() {
492 return Err(invalid(
493 "payload_kind",
494 "local_sha256 requires content_sha256 and content_length",
495 ));
496 }
497 if row.external_uri.is_some() {
498 return Err(invalid(
499 "external_uri",
500 "local_sha256 artifacts cannot retain external_uri",
501 ));
502 }
503 }
504 ArtifactPayloadKind::ExternalReference => {
505 if row.external_uri.is_none() {
506 return Err(invalid(
507 "external_uri",
508 "external_reference requires external_uri",
509 ));
510 }
511 if row.content_sha256.is_some() || row.content_length.is_some() {
512 return Err(invalid(
513 "content_sha256",
514 "external_reference cannot retain local bytes",
515 ));
516 }
517 }
518 ArtifactPayloadKind::Absent => {
519 if row.content_sha256.is_some()
520 || row.content_length.is_some()
521 || row.external_uri.is_some()
522 || row.external_fingerprint.is_some()
523 {
524 return Err(invalid(
525 "payload_kind",
526 "absent cannot retain payload fields",
527 ));
528 }
529 }
530 }
531 Ok(())
532}
533
534fn validate_artifacts(artifacts: &[Artifact]) -> Result<(), KnowledgeError> {
535 check_limit("artifacts", artifacts.len())?;
536 let mut ids = HashSet::with_capacity(artifacts.len());
537 for row in artifacts {
538 validate_artifact(row)?;
539 if !ids.insert(row.artifact_uuid) {
540 return Err(KnowledgeError::Duplicate("artifact_uuid"));
541 }
542 }
543 Ok(())
544}
545
546fn validate_bounded_text(
547 field: &'static str,
548 value: &str,
549 limit: usize,
550) -> Result<(), KnowledgeError> {
551 if value.is_empty() {
552 return Err(invalid(field, "value must not be empty"));
553 }
554 if value.len() > limit {
555 return Err(KnowledgeError::Limit {
556 participant: field,
557 observed: value.len(),
558 limit,
559 });
560 }
561 Ok(())
562}
563
564fn artifact_batch(rows: &[Artifact]) -> Result<RecordBatch, KnowledgeError> {
565 let mut ids = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
566 let mut sources = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
567 let mut content_hashes = FixedSizeBinaryBuilder::with_capacity(rows.len(), 32);
568 let mut external_fingerprints = FixedSizeBinaryBuilder::with_capacity(rows.len(), 32);
569 let mut runs = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
570 let mut provenance = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
571 let mut content_lengths = UInt64Array::builder(rows.len());
572 for row in rows {
573 ids.append_value(row.artifact_uuid.as_bytes())?;
574 sources.append_value(row.source_uuid.as_bytes())?;
575 match row.content_sha256 {
576 Some(value) => content_hashes.append_value(value)?,
577 None => content_hashes.append_null(),
578 }
579 match row.content_length {
580 Some(value) => content_lengths.append_value(value),
581 None => content_lengths.append_null(),
582 }
583 match row.external_fingerprint {
584 Some(value) => external_fingerprints.append_value(value)?,
585 None => external_fingerprints.append_null(),
586 }
587 match row.run_uuid {
588 Some(value) => runs.append_value(value.as_bytes())?,
589 None => runs.append_null(),
590 }
591 provenance.append_value(row.provenance_uuid.as_bytes())?;
592 }
593 RecordBatch::try_new(
594 Arc::clone(&ARTIFACT_SCHEMA),
595 vec![
596 Arc::new(ids.finish()),
597 Arc::new(sources.finish()),
598 Arc::new(StringArray::from_iter_values(
599 rows.iter().map(|row| row.artifact_kind.as_str()),
600 )),
601 Arc::new(StringArray::from_iter_values(
602 rows.iter().map(|row| row.media_type.as_str()),
603 )),
604 Arc::new(StringArray::from_iter_values(
605 rows.iter().map(|row| row.payload_kind.as_str()),
606 )),
607 Arc::new(content_hashes.finish()),
608 Arc::new(content_lengths.finish()),
609 Arc::new(StringArray::from(
610 rows.iter()
611 .map(|row| row.external_uri.as_deref())
612 .collect::<Vec<_>>(),
613 )),
614 Arc::new(external_fingerprints.finish()),
615 Arc::new(StringArray::from_iter_values(
616 rows.iter().map(|row| row.availability.as_str()),
617 )),
618 Arc::new(runs.finish()),
619 Arc::new(provenance.finish()),
620 Arc::new(
621 TimestampMicrosecondArray::from_iter_values(
622 rows.iter().map(|row| row.recorded_at_micros),
623 )
624 .with_timezone("UTC"),
625 ),
626 Arc::new(UInt32Array::from_iter_values(
627 rows.iter().map(|row| row.contract_version),
628 )),
629 ],
630 )
631 .map_err(Into::into)
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::tests::uuid7;
638
639 #[test]
640 fn artifact_round_trip_and_payload_invariants() {
641 let local = Artifact::new(
642 uuid7(1),
643 uuid7(2),
644 ArtifactKind::RawScan,
645 "image/tiff".into(),
646 ArtifactPayloadKind::LocalSha256,
647 Some([7; 32]),
648 Some(1024),
649 None,
650 None,
651 ArtifactAvailability::LocalVerified,
652 None,
653 uuid7(3),
654 100,
655 )
656 .unwrap();
657 let external = Artifact::new(
658 uuid7(4),
659 uuid7(2),
660 ArtifactKind::OcrText,
661 "text/plain".into(),
662 ArtifactPayloadKind::ExternalReference,
663 None,
664 None,
665 Some("https://example.org/historical".into()),
666 None,
667 ArtifactAvailability::ExternalOnly,
668 Some(uuid7(5)),
669 uuid7(6),
670 200,
671 )
672 .unwrap();
673 let ledger = ArtifactLedger::new(vec![local.clone(), external]).unwrap();
674 let reopened = ArtifactLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
675 assert_eq!(reopened, ledger);
676 assert!(matches!(
677 Artifact::new(
678 uuid7(7),
679 uuid7(2),
680 ArtifactKind::Other,
681 "text/plain".into(),
682 ArtifactPayloadKind::LocalSha256,
683 None,
684 Some(1),
685 None,
686 None,
687 ArtifactAvailability::MissingLocal,
688 None,
689 uuid7(8),
690 1,
691 ),
692 Err(KnowledgeError::Invalid { .. })
693 ));
694 }
695
696 #[test]
697 fn artifact_registries_round_trip_and_reject_unknown_tokens() {
698 for kind in [
699 ArtifactKind::RawScan,
700 ArtifactKind::ProcessedScan,
701 ArtifactKind::OcrText,
702 ArtifactKind::NormalizedText,
703 ArtifactKind::PassageExtract,
704 ArtifactKind::Other,
705 ] {
706 assert_eq!(ArtifactKind::parse(kind.as_str()).unwrap(), kind);
707 }
708 for payload in [
709 ArtifactPayloadKind::LocalSha256,
710 ArtifactPayloadKind::ExternalReference,
711 ArtifactPayloadKind::Absent,
712 ] {
713 assert_eq!(
714 ArtifactPayloadKind::parse(payload.as_str()).unwrap(),
715 payload
716 );
717 }
718 for availability in [
719 ArtifactAvailability::LocalVerified,
720 ArtifactAvailability::ExternalOnly,
721 ArtifactAvailability::MissingLocal,
722 ArtifactAvailability::Unverifiable,
723 ] {
724 assert_eq!(
725 ArtifactAvailability::parse(availability.as_str()).unwrap(),
726 availability
727 );
728 }
729 assert!(ArtifactKind::parse("scan").is_err());
730 }
731}