1use crate::{Dependencies, Description, PluginName, SchemaError, TriggerType};
4use serde::Serialize as _;
5use serde::ser::Error as _;
6use std::fmt;
7use std::str::FromStr;
8use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
9use unicode_normalization::UnicodeNormalization;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct IndexSchemaVersion {
15 major: u32,
16 minor: u32,
17}
18
19impl IndexSchemaVersion {
20 pub const CURRENT_MAJOR: u32 = 2;
21 pub const CURRENT_MINOR: u32 = 0;
22 pub const CURRENT: Self = Self {
23 major: Self::CURRENT_MAJOR,
24 minor: Self::CURRENT_MINOR,
25 };
26
27 pub fn new(major: u32, minor: u32) -> Self {
28 Self { major, minor }
29 }
30 pub fn major(&self) -> u32 {
31 self.major
32 }
33 pub fn minor(&self) -> u32 {
34 self.minor
35 }
36}
37
38impl fmt::Display for IndexSchemaVersion {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 write!(f, "{}.{}", self.major, self.minor)
41 }
42}
43
44impl FromStr for IndexSchemaVersion {
45 type Err = SchemaError;
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 let malformed = || SchemaError::MalformedSchemaVersion {
48 value: s.to_owned(),
49 };
50 let (major_str, minor_str) = s.split_once('.').ok_or_else(malformed)?;
51 if major_str.is_empty() || minor_str.is_empty() || minor_str.contains('.') {
52 return Err(malformed());
53 }
54 let major: u32 = major_str.parse().map_err(|_| malformed())?;
55 let minor: u32 = minor_str.parse().map_err(|_| malformed())?;
56 if major != Self::CURRENT_MAJOR {
57 return Err(SchemaError::UnsupportedIndexMajor {
58 found: s.to_owned(),
59 supported: Self::CURRENT_MAJOR,
60 });
61 }
62 Ok(Self { major, minor })
63 }
64}
65
66impl<'de> serde::Deserialize<'de> for IndexSchemaVersion {
67 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
68 where
69 D: serde::Deserializer<'de>,
70 {
71 let raw = String::deserialize(deserializer)?;
72 Self::from_str(&raw).map_err(serde::de::Error::custom)
73 }
74}
75
76impl serde::Serialize for IndexSchemaVersion {
77 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
78 where
79 S: serde::Serializer,
80 {
81 serializer.collect_str(self)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct ArtifactsUrl(url::Url);
89
90impl ArtifactsUrl {
91 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
92 let url = url::Url::parse(raw).map_err(|source| SchemaError::InvalidUrl {
93 url: raw.to_owned(),
94 source,
95 })?;
96 match url.scheme() {
97 "https" | "http" | "file" => Ok(Self(url)),
98 other => Err(SchemaError::UnsupportedArtifactScheme {
99 url: raw.to_owned(),
100 scheme: other.to_owned(),
101 }),
102 }
103 }
104
105 pub fn as_url(&self) -> &url::Url {
106 &self.0
107 }
108
109 pub fn artifact_url(&self, name: &PluginName, version: &semver::Version) -> url::Url {
119 let filename = format!("{}-{}.tar.gz", name.as_str(), version);
120 let mut url = self.0.clone();
121 url.path_segments_mut()
122 .expect("ArtifactsUrl schemes (http/https/file) are always base-able")
123 .pop_if_empty()
124 .push(&filename);
125 url
126 }
127}
128
129impl fmt::Display for ArtifactsUrl {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 self.0.fmt(f)
132 }
133}
134
135impl<'de> serde::Deserialize<'de> for ArtifactsUrl {
136 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137 where
138 D: serde::Deserializer<'de>,
139 {
140 let raw = String::deserialize(deserializer)?;
141 Self::try_new(&raw).map_err(serde::de::Error::custom)
142 }
143}
144
145impl serde::Serialize for ArtifactsUrl {
146 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
147 where
148 S: serde::Serializer,
149 {
150 serializer.collect_str(&self.0)
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub struct ArtifactHash(String);
158
159impl ArtifactHash {
160 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
161 const PREFIX: &str = "sha256:";
162 let invalid = || SchemaError::InvalidHash {
163 value: raw.to_owned(),
164 };
165 let Some(hex) = raw.strip_prefix(PREFIX) else {
166 return Err(invalid());
167 };
168 if hex.len() != 64
169 || !hex
170 .chars()
171 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
172 {
173 return Err(invalid());
174 }
175 Ok(Self(raw.to_owned()))
176 }
177
178 pub fn as_str(&self) -> &str {
179 &self.0
180 }
181}
182
183impl fmt::Display for ArtifactHash {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 f.write_str(&self.0)
186 }
187}
188
189impl<'de> serde::Deserialize<'de> for ArtifactHash {
190 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191 where
192 D: serde::Deserializer<'de>,
193 {
194 let raw = String::deserialize(deserializer)?;
195 Self::try_new(&raw).map_err(serde::de::Error::custom)
196 }
197}
198
199impl serde::Serialize for ArtifactHash {
200 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
201 where
202 S: serde::Serializer,
203 {
204 serializer.serialize_str(&self.0)
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
213pub struct PublishedAt(String);
214
215impl PublishedAt {
216 const LEN: usize = "YYYY-MM-DDTHH:MM:SSZ".len();
217
218 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
219 if !has_cargo_pubtime_shape(raw) {
220 return Err(SchemaError::InvalidPublishedAt {
221 value: raw.to_owned(),
222 });
223 }
224
225 let year = raw[0..4].parse::<i32>().expect("shape checked digits");
226 let month = raw[5..7].parse::<u8>().expect("shape checked digits");
227 let day = raw[8..10].parse::<u8>().expect("shape checked digits");
228 let hour = raw[11..13].parse::<u8>().expect("shape checked digits");
229 let minute = raw[14..16].parse::<u8>().expect("shape checked digits");
230 let second = raw[17..19].parse::<u8>().expect("shape checked digits");
231
232 let month = Month::try_from(month).map_err(|_| SchemaError::InvalidPublishedAt {
233 value: raw.to_owned(),
234 })?;
235 let date = Date::from_calendar_date(year, month, day).map_err(|_| {
236 SchemaError::InvalidPublishedAt {
237 value: raw.to_owned(),
238 }
239 })?;
240 let time =
241 Time::from_hms(hour, minute, second).map_err(|_| SchemaError::InvalidPublishedAt {
242 value: raw.to_owned(),
243 })?;
244 let _ = PrimitiveDateTime::new(date, time).assume_utc();
245
246 Ok(Self(raw.to_owned()))
247 }
248
249 pub fn now_utc() -> Self {
250 Self::from_utc_datetime(OffsetDateTime::now_utc())
251 .expect("current UTC timestamp must fit Cargo pubtime format")
252 }
253
254 pub fn from_utc_datetime(timestamp: OffsetDateTime) -> Result<Self, SchemaError> {
255 let timestamp = timestamp.to_offset(UtcOffset::UTC);
256 let year = timestamp.year();
257 if !(0..=9999).contains(&year) {
258 return Err(SchemaError::InvalidPublishedAt {
259 value: year.to_string(),
260 });
261 }
262
263 let value = format!(
264 "{year:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
265 u8::from(timestamp.month()),
266 timestamp.day(),
267 timestamp.hour(),
268 timestamp.minute(),
269 timestamp.second()
270 );
271 Ok(Self(value))
272 }
273
274 pub fn as_str(&self) -> &str {
275 &self.0
276 }
277}
278
279fn has_cargo_pubtime_shape(value: &str) -> bool {
280 let bytes = value.as_bytes();
281 bytes.len() == PublishedAt::LEN
282 && bytes[4] == b'-'
283 && bytes[7] == b'-'
284 && bytes[10] == b'T'
285 && bytes[13] == b':'
286 && bytes[16] == b':'
287 && bytes[19] == b'Z'
288 && bytes
289 .iter()
290 .enumerate()
291 .all(|(idx, byte)| matches!(idx, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit())
292}
293
294impl fmt::Display for PublishedAt {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 f.write_str(&self.0)
297 }
298}
299
300impl FromStr for PublishedAt {
301 type Err = SchemaError;
302
303 fn from_str(s: &str) -> Result<Self, Self::Err> {
304 Self::try_new(s)
305 }
306}
307
308impl<'de> serde::Deserialize<'de> for PublishedAt {
309 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
310 where
311 D: serde::Deserializer<'de>,
312 {
313 let raw = String::deserialize(deserializer)?;
314 Self::try_new(&raw).map_err(serde::de::Error::custom)
315 }
316}
317
318impl serde::Serialize for PublishedAt {
319 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
320 where
321 S: serde::Serializer,
322 {
323 serializer.serialize_str(&self.0)
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
329pub struct Index {
330 pub index_schema_version: IndexSchemaVersion,
331 pub artifacts_url: ArtifactsUrl,
332 pub plugins: Vec<IndexEntry>,
333}
334
335#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
337pub struct IndexEntry {
338 pub name: PluginName,
339 pub version: semver::Version,
340 pub published_at: PublishedAt,
341 pub description: Description,
342 pub triggers: Vec<TriggerType>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub homepage: Option<url::Url>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub repository: Option<url::Url>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub documentation: Option<url::Url>,
349 pub dependencies: Dependencies,
350 pub hash: ArtifactHash,
351 #[serde(default, skip_serializing_if = "is_false")]
352 pub yanked: bool,
353}
354
355impl IndexEntry {
356 pub fn from_manifest(manifest: crate::Manifest, hash: ArtifactHash) -> Self {
357 Self::from_manifest_with_published_at(manifest, hash, PublishedAt::now_utc())
358 }
359
360 pub fn from_manifest_with_published_at(
361 manifest: crate::Manifest,
362 hash: ArtifactHash,
363 published_at: PublishedAt,
364 ) -> Self {
365 let plugin = manifest.plugin;
366 Self {
367 name: plugin.name,
368 version: plugin.version,
369 published_at,
370 description: plugin.description,
371 triggers: plugin.triggers,
372 homepage: plugin.homepage,
373 repository: plugin.repository,
374 documentation: plugin.documentation,
375 dependencies: manifest.dependencies,
376 hash,
377 yanked: false,
378 }
379 }
380}
381
382fn is_false(b: &bool) -> bool {
383 !*b
384}
385
386impl Index {
387 pub fn parse_json(input: &str) -> Result<Self, crate::SchemaErrors> {
408 use crate::raw::RawIndex;
409 use crate::{FieldPath, ReportedError, SchemaErrors};
410 use std::collections::HashMap;
411 use std::str::FromStr;
412
413 let raw: RawIndex = serde_json::from_str(input)
414 .map_err(|source| SchemaErrors::single_at_root(SchemaError::JsonParse { source }))?;
415
416 let schema_version =
417 IndexSchemaVersion::from_str(&raw.index_schema_version).map_err(|e| {
418 SchemaErrors::new(vec![ReportedError::new(
419 FieldPath::root().field("index_schema_version"),
420 e,
421 )])
422 })?;
423
424 let mut errors = Vec::new();
425 let root = FieldPath::root();
426
427 let artifacts_url = match ArtifactsUrl::try_new(&raw.artifacts_url) {
428 Ok(u) => Some(u),
429 Err(e) => {
430 errors.push(ReportedError::new(root.field("artifacts_url"), e));
431 None
432 }
433 };
434
435 let mut entries_ok: Vec<IndexEntry> = Vec::with_capacity(raw.plugins.len());
436
437 let mut seen_by_canonical: HashMap<String, Vec<(String, String)>> = HashMap::new();
443
444 for (i, raw_entry) in raw.plugins.iter().enumerate() {
445 let entry_path = root.field("plugins").index(i);
446 let validated = validate_raw_entry(raw_entry, &entry_path, &mut errors);
447
448 let canonical = crate::identity::canonical_name(&raw_entry.name);
449 let existing = seen_by_canonical.entry(canonical.clone()).or_default();
450
451 let exact_dup = existing
452 .iter()
453 .any(|(n, v)| n == &raw_entry.name && v == &raw_entry.version);
454 if exact_dup {
455 errors.push(ReportedError::new(
456 entry_path.clone(),
457 SchemaError::DuplicateIndexEntry {
458 name: raw_entry.name.clone(),
459 version: raw_entry.version.clone(),
460 },
461 ));
462 } else if existing.iter().any(|(n, _)| n != &raw_entry.name) {
463 errors.push(ReportedError::new(
467 entry_path.clone(),
468 SchemaError::CanonicalCollision {
469 name: raw_entry.name.clone(),
470 canonical: canonical.clone(),
471 existing: existing.clone(),
472 },
473 ));
474 }
475
476 existing.push((raw_entry.name.clone(), raw_entry.version.clone()));
477
478 if let Some(entry) = validated {
479 entries_ok.push(entry);
480 }
481 }
482
483 if !errors.is_empty() {
484 return Err(SchemaErrors::new(errors));
485 }
486
487 Ok(Index {
488 index_schema_version: schema_version,
489 artifacts_url: artifacts_url.unwrap(),
490 plugins: entries_ok,
491 })
492 }
493
494 pub fn to_canonical_json(&self) -> Result<String, SchemaError> {
505 let mut normalized = self.clone();
506 normalized.plugins.sort_by(|a, b| {
509 a.name
510 .as_str()
511 .cmp(b.name.as_str())
512 .then_with(|| a.version.cmp_precedence(&b.version))
513 });
514 for entry in &mut normalized.plugins {
517 let nfc = normalize_nfc(entry.description.as_str());
518 entry.description = Description::try_new(&nfc)?;
519 }
520
521 let mut buf = Vec::with_capacity(256);
522 let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
523 let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
524 normalized
525 .serialize(&mut ser)
526 .map_err(|source| SchemaError::JsonSerialize { source })?;
527 buf.push(b'\n');
528 String::from_utf8(buf).map_err(|e| SchemaError::JsonSerialize {
529 source: serde_json::Error::custom(e.to_string()),
530 })
531 }
532
533 pub fn check_entry_insert(&self, entry: &IndexEntry) -> Result<(), crate::IndexInsertError> {
541 use crate::IndexInsertError;
542
543 let new_canonical = entry.name.canonical();
544
545 let existing_canonical: Vec<(String, semver::Version)> = self
546 .plugins
547 .iter()
548 .filter(|e| e.name.canonical() == new_canonical)
549 .map(|e| (e.name.as_str().to_owned(), e.version.clone()))
550 .collect();
551
552 let any_spelling_differs = existing_canonical
553 .iter()
554 .any(|(n, _)| n != entry.name.as_str());
555 if any_spelling_differs {
556 return Err(IndexInsertError::CanonicalCollision {
557 name: entry.name.as_str().to_owned(),
558 canonical: new_canonical,
559 existing: existing_canonical,
560 });
561 }
562
563 let same_version_dup = existing_canonical.iter().any(|(_, v)| v == &entry.version);
564 if same_version_dup {
565 let existing_versions: Vec<semver::Version> =
566 existing_canonical.iter().map(|(_, v)| v.clone()).collect();
567 return Err(IndexInsertError::Duplicate {
568 name: entry.name.as_str().to_owned(),
569 version: entry.version.clone(),
570 existing_versions,
571 });
572 }
573
574 Ok(())
575 }
576
577 pub fn push_entry(&mut self, entry: IndexEntry) -> Result<(), crate::IndexInsertError> {
583 self.check_entry_insert(&entry)?;
584 self.plugins.push(entry);
585 Ok(())
586 }
587}
588
589fn normalize_nfc(s: &str) -> String {
590 s.nfc().collect()
591}
592
593fn validate_raw_entry(
596 raw: &crate::raw::RawIndexEntry,
597 path: &crate::FieldPath,
598 errors: &mut Vec<crate::ReportedError>,
599) -> Option<IndexEntry> {
600 use crate::manifest::parse_optional_http_url_from_path;
601 use crate::{Description, PluginName, PythonRequirement, ReportedError, TriggerType};
602 use std::str::FromStr;
603
604 let local_err_count = errors.len();
605
606 let name = match PluginName::from_str(&raw.name) {
607 Ok(n) => Some(n),
608 Err(e) => {
609 errors.push(ReportedError::new(path.field("name"), e));
610 None
611 }
612 };
613
614 let version = match semver::Version::parse(&raw.version) {
615 Ok(v) => Some(v),
616 Err(source) => {
617 errors.push(ReportedError::new(
618 path.field("version"),
619 SchemaError::InvalidVersion {
620 version: raw.version.clone(),
621 source,
622 },
623 ));
624 None
625 }
626 };
627
628 let published_at = match &raw.published_at {
629 Some(serde_json::Value::String(value)) => match PublishedAt::try_new(value) {
630 Ok(published_at) => Some(published_at),
631 Err(e) => {
632 errors.push(ReportedError::new(path.field("published_at"), e));
633 None
634 }
635 },
636 Some(value) => {
637 errors.push(ReportedError::new(
638 path.field("published_at"),
639 SchemaError::InvalidPublishedAt {
640 value: value.to_string(),
641 },
642 ));
643 None
644 }
645 None => {
646 errors.push(ReportedError::new(
647 path.field("published_at"),
648 SchemaError::InvalidPublishedAt {
649 value: "<missing>".to_owned(),
650 },
651 ));
652 None
653 }
654 };
655
656 let description = match Description::try_new(&raw.description) {
657 Ok(d) => Some(d),
658 Err(e) => {
659 errors.push(ReportedError::new(path.field("description"), e));
660 None
661 }
662 };
663
664 let mut triggers_ok: Vec<TriggerType> = Vec::with_capacity(raw.triggers.len());
665 if raw.triggers.is_empty() {
666 errors.push(ReportedError::new(
667 path.field("triggers"),
668 SchemaError::EmptyTriggers,
669 ));
670 } else {
671 for (i, t) in raw.triggers.iter().enumerate() {
672 match TriggerType::from_str(t) {
673 Ok(tt) => triggers_ok.push(tt),
674 Err(e) => errors.push(ReportedError::new(path.field("triggers").index(i), e)),
675 }
676 }
677 }
678
679 let homepage = parse_optional_http_url_from_path(&raw.homepage, errors, path, "homepage");
680 let repository = parse_optional_http_url_from_path(&raw.repository, errors, path, "repository");
681 let documentation =
682 parse_optional_http_url_from_path(&raw.documentation, errors, path, "documentation");
683
684 let database_version = match semver::VersionReq::parse(&raw.dependencies.database_version) {
685 Ok(r) => Some(r),
686 Err(source) => {
687 errors.push(ReportedError::new(
688 path.field("dependencies").field("database_version"),
689 SchemaError::InvalidDatabaseVersion {
690 range: raw.dependencies.database_version.clone(),
691 source,
692 },
693 ));
694 None
695 }
696 };
697
698 let mut python_ok: Vec<PythonRequirement> = Vec::with_capacity(raw.dependencies.python.len());
699 for (i, p) in raw.dependencies.python.iter().enumerate() {
700 match PythonRequirement::try_new(p) {
701 Ok(pr) => python_ok.push(pr),
702 Err(e) => errors.push(ReportedError::new(
703 path.field("dependencies").field("python").index(i),
704 e,
705 )),
706 }
707 }
708
709 let hash = match ArtifactHash::try_new(&raw.hash) {
710 Ok(h) => Some(h),
711 Err(e) => {
712 errors.push(ReportedError::new(path.field("hash"), e));
713 None
714 }
715 };
716
717 if errors.len() > local_err_count {
718 return None;
719 }
720
721 Some(IndexEntry {
722 name: name.unwrap(),
723 version: version.unwrap(),
724 published_at: published_at.unwrap(),
725 description: description.unwrap(),
726 triggers: triggers_ok,
727 homepage,
728 repository,
729 documentation,
730 dependencies: crate::Dependencies {
731 database_version: database_version.unwrap(),
732 python: python_ok,
733 },
734 hash: hash.unwrap(),
735 yanked: raw.yanked,
736 })
737}
738
739#[cfg(test)]
740mod schema_version_tests {
741 use super::*;
742 use assert_matches::assert_matches;
743
744 #[test]
745 fn parses_supported_major() {
746 let v: IndexSchemaVersion = "2.0".parse().unwrap();
747 assert_eq!(v.major(), 2);
748 assert_eq!(v.minor(), 0);
749 }
750
751 #[test]
752 fn rejects_unsupported_major() {
753 let err = "3.0".parse::<IndexSchemaVersion>().unwrap_err();
754 assert_matches!(err, SchemaError::UnsupportedIndexMajor { .. });
755 }
756
757 #[test]
758 fn rejects_malformed() {
759 assert_matches!(
760 "abc".parse::<IndexSchemaVersion>(),
761 Err(SchemaError::MalformedSchemaVersion { .. })
762 );
763 }
764
765 #[test]
766 fn current_uses_declared_major_and_minor_constants() {
767 assert_eq!(
768 IndexSchemaVersion::CURRENT.major(),
769 IndexSchemaVersion::CURRENT_MAJOR
770 );
771 assert_eq!(
772 IndexSchemaVersion::CURRENT.minor(),
773 IndexSchemaVersion::CURRENT_MINOR
774 );
775 }
776
777 #[test]
778 fn current_to_string_round_trips() {
779 let s = IndexSchemaVersion::CURRENT.to_string();
780 let parsed: IndexSchemaVersion = s.parse().unwrap();
781 assert_eq!(parsed, IndexSchemaVersion::CURRENT);
782 }
783
784 #[test]
785 fn current_serializes_as_schema_two_zero() {
786 assert_eq!(IndexSchemaVersion::CURRENT.to_string(), "2.0");
787 }
788}
789
790#[cfg(test)]
791mod artifacts_url_tests {
792 use super::*;
793 use assert_matches::assert_matches;
794 use rstest::rstest;
795
796 #[rstest]
797 #[case("https://plugins.example/artifacts")]
798 #[case("http://localhost:8080/artifacts")]
799 #[case("file:///srv/plugins")]
800 fn valid_schemes_accepted(#[case] input: &str) {
801 assert!(ArtifactsUrl::try_new(input).is_ok());
802 }
803
804 #[rstest]
805 #[case("oci://registry.example")]
806 #[case("s3://bucket/plugins")]
807 #[case("git://example/plugins")]
808 #[case("git+https://example/plugins")]
809 #[case("git+ssh://example/plugins")]
810 #[case("ftp://example/plugins")]
811 #[case("sftp://example/plugins")]
812 fn rejected_schemes(#[case] input: &str) {
813 let err = ArtifactsUrl::try_new(input).unwrap_err();
814 assert_matches!(err, SchemaError::UnsupportedArtifactScheme { .. });
815 }
816
817 #[test]
818 fn malformed_url_rejected() {
819 let err = ArtifactsUrl::try_new("not a url").unwrap_err();
820 assert_matches!(err, SchemaError::InvalidUrl { .. });
821 }
822
823 fn name(input: &str) -> PluginName {
824 use std::str::FromStr;
825 PluginName::from_str(input).expect("valid plugin name in test")
826 }
827
828 fn version(input: &str) -> semver::Version {
829 semver::Version::parse(input).expect("valid semver in test")
830 }
831
832 #[rstest]
833 #[case(
835 "https://example.com/artifacts",
836 "n",
837 "1.2.3",
838 "https://example.com/artifacts/n-1.2.3.tar.gz"
839 )]
840 #[case(
841 "https://example.com/artifacts/",
842 "n",
843 "1.2.3",
844 "https://example.com/artifacts/n-1.2.3.tar.gz"
845 )]
846 #[case(
848 "file:///path/to/registry",
849 "n",
850 "1.2.3",
851 "file:///path/to/registry/n-1.2.3.tar.gz"
852 )]
853 #[case(
854 "file:///path/to/registry/",
855 "n",
856 "1.2.3",
857 "file:///path/to/registry/n-1.2.3.tar.gz"
858 )]
859 #[case(
861 "https://host/a/b/c",
862 "n",
863 "1.2.3",
864 "https://host/a/b/c/n-1.2.3.tar.gz"
865 )]
866 #[case(
867 "https://host/a/b/c/",
868 "n",
869 "1.2.3",
870 "https://host/a/b/c/n-1.2.3.tar.gz"
871 )]
872 #[case(
874 "https://example.com/artifacts",
875 "foo-bar",
876 "1.2.3",
877 "https://example.com/artifacts/foo-bar-1.2.3.tar.gz"
878 )]
879 #[case(
880 "https://example.com/artifacts",
881 "foo_bar",
882 "1.2.3",
883 "https://example.com/artifacts/foo_bar-1.2.3.tar.gz"
884 )]
885 #[case(
887 "https://example.com/artifacts",
888 "n",
889 "1.2.3-alpha.1",
890 "https://example.com/artifacts/n-1.2.3-alpha.1.tar.gz"
891 )]
892 #[case(
893 "https://example.com/artifacts",
894 "n",
895 "1.2.3+build.7",
896 "https://example.com/artifacts/n-1.2.3+build.7.tar.gz"
897 )]
898 #[case(
900 "https://example.com/artifacts?channel=stable",
901 "n",
902 "1.2.3",
903 "https://example.com/artifacts/n-1.2.3.tar.gz?channel=stable"
904 )]
905 #[case(
906 "https://example.com/artifacts#deploy",
907 "n",
908 "1.2.3",
909 "https://example.com/artifacts/n-1.2.3.tar.gz#deploy"
910 )]
911 #[case(
912 "https://example.com/artifacts?t=1#x",
913 "n",
914 "1.2.3",
915 "https://example.com/artifacts/n-1.2.3.tar.gz?t=1#x"
916 )]
917 #[case(
919 "http://localhost:8080/artifacts",
920 "n",
921 "1.2.3",
922 "http://localhost:8080/artifacts/n-1.2.3.tar.gz"
923 )]
924 #[case(
926 "https://u:p@host/path",
927 "n",
928 "1.2.3",
929 "https://u:p@host/path/n-1.2.3.tar.gz"
930 )]
931 #[case(
933 "file://server/share/plugins",
934 "n",
935 "1.2.3",
936 "file://server/share/plugins/n-1.2.3.tar.gz"
937 )]
938 #[case(
940 "https://xn--n3h.example/a",
941 "n",
942 "1.2.3",
943 "https://xn--n3h.example/a/n-1.2.3.tar.gz"
944 )]
945 #[case("https://host", "n", "1.2.3", "https://host/n-1.2.3.tar.gz")]
947 fn artifact_url_composition(
948 #[case] base: &str,
949 #[case] name_input: &str,
950 #[case] version_input: &str,
951 #[case] expected: &str,
952 ) {
953 let base = ArtifactsUrl::try_new(base).expect("valid base URL in test");
954 let url = base.artifact_url(&name(name_input), &version(version_input));
955 assert_eq!(url.as_str(), expected);
956 }
957}
958
959#[cfg(test)]
960mod artifact_hash_tests {
961 use super::*;
962 use assert_matches::assert_matches;
963
964 #[test]
965 fn valid_hash_accepted() {
966 let h = ArtifactHash::try_new(
967 "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
968 )
969 .unwrap();
970 assert_eq!(h.as_str().len(), "sha256:".len() + 64);
971 }
972
973 #[test]
974 fn wrong_prefix_rejected() {
975 assert_matches!(
976 ArtifactHash::try_new(
977 "sha512:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
978 ),
979 Err(SchemaError::InvalidHash { .. })
980 );
981 }
982
983 #[test]
984 fn wrong_length_rejected() {
985 assert_matches!(
986 ArtifactHash::try_new("sha256:abc"),
987 Err(SchemaError::InvalidHash { .. })
988 );
989 }
990
991 #[test]
992 fn uppercase_hex_rejected() {
993 assert_matches!(
994 ArtifactHash::try_new(
995 "sha256:9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08"
996 ),
997 Err(SchemaError::InvalidHash { .. })
998 );
999 }
1000
1001 #[rstest::rstest]
1004 #[case("sha256:g000000000000000000000000000000000000000000000000000000000000000")]
1005 #[case("sha256:z000000000000000000000000000000000000000000000000000000000000000")]
1006 #[case("sha256:!000000000000000000000000000000000000000000000000000000000000000")]
1007 #[case("sha256: 000000000000000000000000000000000000000000000000000000000000000")]
1008 #[case("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00z08")]
1009 fn non_hex_chars_rejected(#[case] input: &str) {
1010 assert_matches!(
1011 ArtifactHash::try_new(input),
1012 Err(SchemaError::InvalidHash { .. })
1013 );
1014 }
1015}
1016
1017#[cfg(test)]
1018mod published_at_tests {
1019 use super::*;
1020 use assert_matches::assert_matches;
1021 use rstest::rstest;
1022
1023 #[test]
1024 fn valid_cargo_pubtime_format_is_accepted() {
1025 let published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1026 assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1027 assert_eq!(published_at.to_string(), "2026-04-29T18:45:12Z");
1028 }
1029
1030 #[test]
1031 fn from_utc_datetime_formats_as_cargo_pubtime_in_utc() {
1032 let source_offset = UtcOffset::from_hms(-5, 0, 0).unwrap();
1033 let source_datetime = PrimitiveDateTime::new(
1034 Date::from_calendar_date(2026, Month::April, 29).unwrap(),
1035 Time::from_hms_nano(13, 45, 12, 987_654_321).unwrap(),
1036 )
1037 .assume_offset(source_offset);
1038
1039 let published_at = PublishedAt::from_utc_datetime(source_datetime).unwrap();
1040
1041 assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1042 }
1043
1044 #[rstest]
1045 #[case("2026-04-29T18:45:12.123Z")]
1046 #[case("2026-04-29T13:45:12-05:00")]
1047 #[case("2026-04-29T18:45:12+00:00")]
1048 #[case("2026-4-29T18:45:12Z")]
1049 #[case("2026-04-29t18:45:12Z")]
1050 #[case("2026-04-29T18:45:12z")]
1051 #[case("2026-04-29 18:45:12Z")]
1052 #[case("2026-02-30T18:45:12Z")]
1053 #[case("2026-04-29T24:00:00Z")]
1054 fn invalid_cargo_pubtime_format_is_rejected(#[case] input: &str) {
1055 assert_matches!(
1056 PublishedAt::try_new(input),
1057 Err(SchemaError::InvalidPublishedAt { .. })
1058 );
1059 }
1060}
1061
1062#[cfg(test)]
1063mod index_tests {
1064 use super::*;
1065 use assert_matches::assert_matches;
1066 use pretty_assertions::assert_eq;
1067
1068 const MINIMAL: &str = r#"{
1069 "index_schema_version": "2.0",
1070 "artifacts_url": "https://plugins.example.com/artifacts",
1071 "plugins": [
1072 {
1073 "name": "downsampler",
1074 "version": "1.2.0",
1075 "published_at": "2026-04-29T18:45:12Z",
1076 "description": "Test plugin",
1077 "triggers": ["process_writes"],
1078 "dependencies": {
1079 "database_version": ">=3.2.0,<4.0.0",
1080 "python": []
1081 },
1082 "hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
1083 }
1084 ]
1085}"#;
1086
1087 fn minimal_with_published_at() -> String {
1088 MINIMAL.to_owned()
1089 }
1090
1091 fn minimal_without_published_at() -> String {
1092 MINIMAL.replace(
1093 r#" "published_at": "2026-04-29T18:45:12Z",
1094"#,
1095 "",
1096 )
1097 }
1098
1099 #[test]
1100 fn parses_published_at_per_entry() {
1101 let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1102 assert_eq!(idx.plugins[0].published_at.as_str(), "2026-04-29T18:45:12Z");
1103 }
1104
1105 #[test]
1106 fn missing_published_at_rejected_per_entry() {
1107 let errors = Index::parse_json(&minimal_without_published_at()).unwrap_err();
1108 assert_eq!(errors.errors().len(), 1);
1109 assert_matches!(
1110 errors.errors()[0].error,
1111 SchemaError::InvalidPublishedAt { .. }
1112 );
1113 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1114 }
1115
1116 #[test]
1117 fn non_string_published_at_rejected_per_entry() {
1118 let src = minimal_with_published_at().replace(
1119 r#""published_at": "2026-04-29T18:45:12Z""#,
1120 r#""published_at": 123"#,
1121 );
1122 let errors = Index::parse_json(&src).unwrap_err();
1123 assert_eq!(errors.errors().len(), 1);
1124 assert_matches!(
1125 errors.errors()[0].error,
1126 SchemaError::InvalidPublishedAt { .. }
1127 );
1128 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1129 }
1130
1131 #[test]
1132 fn malformed_published_at_rejected_per_entry() {
1133 let src =
1134 minimal_with_published_at().replace("2026-04-29T18:45:12Z", "2026-04-29T18:45:12.123Z");
1135 let errors = Index::parse_json(&src).unwrap_err();
1136 assert_eq!(errors.errors().len(), 1);
1137 assert_matches!(
1138 errors.errors()[0].error,
1139 SchemaError::InvalidPublishedAt { .. }
1140 );
1141 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1142 }
1143
1144 #[test]
1145 fn parses_minimal_index() {
1146 let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1147 assert_eq!(idx.plugins.len(), 1);
1148 assert_eq!(idx.plugins[0].name.as_str(), "downsampler");
1149 }
1150
1151 #[test]
1152 fn yanked_absent_means_not_yanked() {
1153 let idx = Index::parse_json(MINIMAL).unwrap();
1154 assert!(!idx.plugins[0].yanked);
1155 }
1156
1157 #[test]
1158 fn yanked_true_parsed() {
1159 let src = MINIMAL.replace(r#""hash": "#, r#""yanked": true, "hash": "#);
1160 let idx = Index::parse_json(&src).unwrap();
1161 assert!(idx.plugins[0].yanked);
1162 }
1163
1164 #[test]
1165 fn yanked_false_parsed() {
1166 let src = MINIMAL.replace(r#""hash": "#, r#""yanked": false, "hash": "#);
1167 let idx = Index::parse_json(&src).unwrap();
1168 assert!(!idx.plugins[0].yanked);
1169 }
1170
1171 #[rstest::rstest]
1176 #[case::not_json("not json")]
1177 #[case::trailing_garbage(r#"{"index_schema_version": "2.0"} extra"#)]
1178 #[case::root_is_array("[]")]
1179 #[case::root_is_number("42")]
1180 #[case::root_is_string(r#""hello""#)]
1181 #[case::missing_artifacts_url(r#"{"index_schema_version": "2.0", "plugins": []}"#)]
1182 #[case::missing_plugins(
1183 r#"{"index_schema_version": "2.0", "artifacts_url": "https://example.com/a"}"#
1184 )]
1185 fn malformed_json_short_circuits(#[case] input: &str) {
1186 let errors = Index::parse_json(input).unwrap_err();
1187 assert_eq!(
1188 errors.errors().len(),
1189 1,
1190 "expected single root-level error, got {:?}",
1191 errors.errors()
1192 );
1193 assert_matches!(errors.errors()[0].error, SchemaError::JsonParse { .. });
1194 assert_eq!(
1195 errors.errors()[0].path.as_str(),
1196 "",
1197 "expected root path, got {:?}",
1198 errors.errors()[0].path.as_str()
1199 );
1200 }
1201
1202 #[test]
1203 fn duplicate_name_version_rejected() {
1204 let dup = r#"{
1205 "index_schema_version": "2.0",
1206 "artifacts_url": "https://plugins.example.com/artifacts",
1207 "plugins": [
1208 { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1209 "dependencies": {"database_version":">=3.0.0","python":[]},
1210 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1211 { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1212 "dependencies": {"database_version":">=3.0.0","python":[]},
1213 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1214 ]
1215}"#;
1216 let errors = Index::parse_json(dup).unwrap_err();
1217 assert_eq!(errors.errors().len(), 1);
1218 assert_matches!(
1219 errors.errors()[0].error,
1220 SchemaError::DuplicateIndexEntry { .. }
1221 );
1222 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1223 }
1224
1225 #[test]
1226 fn index_rejects_hyphen_underscore_collision() {
1227 let json = r#"{
1230 "index_schema_version": "2.0",
1231 "artifacts_url": "https://plugins.example.com/artifacts",
1232 "plugins": [
1233 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1234 "dependencies": {"database_version":">=3.0.0","python":[]},
1235 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1236 { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1237 "dependencies": {"database_version":">=3.0.0","python":[]},
1238 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1239 ]
1240}"#;
1241 let errors = Index::parse_json(json).expect_err("should reject canonical collision");
1242 assert_eq!(errors.errors().len(), 1);
1243 assert_matches!(
1244 errors.errors()[0].error,
1245 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1246 if name == "foo_bar"
1247 && canonical == "foo_bar"
1248 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1249 );
1250 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1251 }
1252
1253 #[test]
1254 fn index_rejects_case_collision() {
1255 let json = r#"{
1257 "index_schema_version": "2.0",
1258 "artifacts_url": "https://plugins.example.com/artifacts",
1259 "plugins": [
1260 { "name": "MyPlugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1261 "dependencies": {"database_version":">=3.0.0","python":[]},
1262 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1263 { "name": "myplugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1264 "dependencies": {"database_version":">=3.0.0","python":[]},
1265 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1266 ]
1267}"#;
1268 let errors = Index::parse_json(json).expect_err("should reject case collision");
1269 assert_eq!(errors.errors().len(), 1);
1270 assert_matches!(
1271 errors.errors()[0].error,
1272 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1273 if name == "myplugin"
1274 && canonical == "myplugin"
1275 && existing == &vec![("MyPlugin".to_owned(), "1.0.0".to_owned())]
1276 );
1277 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1278 }
1279
1280 #[test]
1281 fn index_rejects_three_way_canonical_collision() {
1282 let json = r#"{
1285 "index_schema_version": "2.0",
1286 "artifacts_url": "https://plugins.example.com/artifacts",
1287 "plugins": [
1288 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1289 "dependencies": {"database_version":">=3.0.0","python":[]},
1290 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1291 { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1292 "dependencies": {"database_version":">=3.0.0","python":[]},
1293 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" },
1294 { "name": "FOO-BAR", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x3", "triggers": ["process_writes"],
1295 "dependencies": {"database_version":">=3.0.0","python":[]},
1296 "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1297 ]
1298}"#;
1299 let errors = Index::parse_json(json).expect_err("should reject two collisions");
1300 let e = errors.errors();
1301 assert_eq!(
1302 e.len(),
1303 2,
1304 "expected 2 errors, got {}: {:?}",
1305 e.len(),
1306 e.iter()
1307 .map(|r| (r.path.as_str(), &r.error))
1308 .collect::<Vec<_>>()
1309 );
1310 assert_matches!(
1311 e[0].error,
1312 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1313 if name == "foo_bar"
1314 && canonical == "foo_bar"
1315 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1316 );
1317 assert_eq!(e[0].path.as_str(), "plugins[1]");
1318 assert_matches!(
1319 e[1].error,
1320 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1321 if name == "FOO-BAR"
1322 && canonical == "foo_bar"
1323 && existing == &vec![
1324 ("foo-bar".to_owned(), "1.0.0".to_owned()),
1325 ("foo_bar".to_owned(), "1.0.0".to_owned()),
1326 ]
1327 );
1328 assert_eq!(e[1].path.as_str(), "plugins[2]");
1329 }
1330
1331 #[test]
1332 fn index_rejects_canonical_collision_across_versions() {
1333 let json = r#"{
1336 "index_schema_version": "2.0",
1337 "artifacts_url": "https://plugins.example.com/artifacts",
1338 "plugins": [
1339 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1340 "dependencies": {"database_version":">=3.0.0","python":[]},
1341 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1342 { "name": "foo_bar", "version": "1.0.1", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1343 "dependencies": {"database_version":">=3.0.0","python":[]},
1344 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1345 ]
1346}"#;
1347 let errors =
1348 Index::parse_json(json).expect_err("should reject canonical collision across versions");
1349 assert_eq!(errors.errors().len(), 1);
1350 assert_matches!(
1351 errors.errors()[0].error,
1352 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1353 if name == "foo_bar"
1354 && canonical == "foo_bar"
1355 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1356 );
1357 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1358 }
1359
1360 #[test]
1361 fn ignores_unknown_per_entry_field() {
1362 let with_unknown =
1363 MINIMAL.replace(r#""hash": "#, r#""experimental_tag": "beta", "hash": "#);
1364 assert!(Index::parse_json(&with_unknown).is_ok());
1365 }
1366
1367 #[test]
1370 fn empty_triggers_rejected_per_entry() {
1371 let src = MINIMAL.replace(r#""triggers": ["process_writes"]"#, r#""triggers": []"#);
1372 let errors = Index::parse_json(&src).unwrap_err();
1373 assert_eq!(errors.errors().len(), 1);
1374 assert_matches!(errors.errors()[0].error, SchemaError::EmptyTriggers);
1375 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].triggers");
1376 }
1377
1378 #[test]
1379 fn invalid_homepage_scheme_rejected_per_entry() {
1380 let src = MINIMAL.replace(r#""hash": "#, r#""homepage": "ftp://bad/", "hash": "#);
1381 let errors = Index::parse_json(&src).unwrap_err();
1382 assert_eq!(errors.errors().len(), 1);
1383 assert_matches!(
1384 errors.errors()[0].error,
1385 SchemaError::InvalidUrlScheme { .. }
1386 );
1387 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].homepage");
1388 }
1389
1390 #[test]
1391 fn invalid_repository_scheme_rejected_per_entry() {
1392 let src = MINIMAL.replace(r#""hash": "#, r#""repository": "git://bad/", "hash": "#);
1393 let errors = Index::parse_json(&src).unwrap_err();
1394 assert_eq!(errors.errors().len(), 1);
1395 assert_matches!(
1396 errors.errors()[0].error,
1397 SchemaError::InvalidUrlScheme { .. }
1398 );
1399 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].repository");
1400 }
1401
1402 #[test]
1403 fn invalid_documentation_scheme_rejected_per_entry() {
1404 let src = MINIMAL.replace(
1405 r#""hash": "#,
1406 r#""documentation": "s3://bucket/docs", "hash": "#,
1407 );
1408 let errors = Index::parse_json(&src).unwrap_err();
1409 assert_eq!(errors.errors().len(), 1);
1410 assert_matches!(
1411 errors.errors()[0].error,
1412 SchemaError::InvalidUrlScheme { .. }
1413 );
1414 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].documentation");
1415 }
1416
1417 #[test]
1418 fn http_and_https_optional_urls_accepted() {
1419 let src = MINIMAL.replace(
1420 r#""hash": "#,
1421 r#""homepage": "http://example.com", "repository": "https://github.com/x/y", "hash": "#,
1422 );
1423 assert!(Index::parse_json(&src).is_ok());
1424 }
1425
1426 #[test]
1427 fn ignores_unknown_top_level_field() {
1428 let src = MINIMAL.replace(
1429 r#""artifacts_url":"#,
1430 r#""experimental_top_level": true, "artifacts_url":"#,
1431 );
1432 assert!(Index::parse_json(&src).is_ok());
1433 }
1434
1435 #[test]
1438 fn collects_multiple_entry_defects_and_duplicate() {
1439 let long_desc = "a".repeat(201);
1442 let json = format!(
1443 r#"{{
1444 "index_schema_version": "2.0",
1445 "artifacts_url": "https://plugins.example.com/artifacts",
1446 "plugins": [
1447 {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1448 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1449 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }},
1450 {{ "name": "beta", "version": "2.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1451 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1452 "hash": "not-a-hash" }},
1453 {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1454 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1455 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }},
1456 {{ "name": "gamma", "version": "3.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "{long_desc}", "triggers": ["process_writes"],
1457 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1458 "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }}
1459 ]
1460}}"#
1461 );
1462
1463 let errors = Index::parse_json(&json).expect_err("should fail");
1464 let e = errors.errors();
1465 assert_eq!(
1466 e.len(),
1467 3,
1468 "expected 3 errors, got {}: {:?}",
1469 e.len(),
1470 e.iter()
1471 .map(|r| (r.path.as_str(), &r.error))
1472 .collect::<Vec<_>>()
1473 );
1474
1475 let paths: Vec<&str> = e.iter().map(|r| r.path.as_str()).collect();
1476 assert!(
1477 paths.contains(&"plugins[1].hash"),
1478 "missing hash path: {paths:?}"
1479 );
1480 assert!(
1481 paths.iter().any(|p| p.starts_with("plugins[2]")),
1482 "missing duplicate path: {paths:?}"
1483 );
1484 assert!(
1485 paths.contains(&"plugins[3].description"),
1486 "missing description path: {paths:?}"
1487 );
1488 }
1489
1490 #[test]
1491 fn collects_published_at_defect_with_other_entry_defects() {
1492 let json = r#"{
1493 "index_schema_version": "2.0",
1494 "artifacts_url": "https://plugins.example.com/artifacts",
1495 "plugins": [
1496 { "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12.123Z", "description": "ok", "triggers": [],
1497 "dependencies": {"database_version":">=3.0.0","python":[]},
1498 "hash": "not-a-hash" }
1499 ]
1500}"#;
1501
1502 let errors = Index::parse_json(json).expect_err("should fail");
1503 let e = errors.errors();
1504 assert_eq!(e.len(), 3);
1505 assert!(
1506 e.iter().any(|reported| {
1507 reported.path.as_str() == "plugins[0].published_at"
1508 && matches!(reported.error, SchemaError::InvalidPublishedAt { .. })
1509 }),
1510 "missing InvalidPublishedAt at plugins[0].published_at: {e:?}"
1511 );
1512 assert!(
1513 e.iter().any(|reported| {
1514 reported.path.as_str() == "plugins[0].triggers"
1515 && matches!(reported.error, SchemaError::EmptyTriggers)
1516 }),
1517 "missing EmptyTriggers at plugins[0].triggers: {e:?}"
1518 );
1519 assert!(
1520 e.iter().any(|reported| {
1521 reported.path.as_str() == "plugins[0].hash"
1522 && matches!(reported.error, SchemaError::InvalidHash { .. })
1523 }),
1524 "missing InvalidHash at plugins[0].hash: {e:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn index_schema_version_mismatch_short_circuits() {
1530 let json = r#"{
1531 "index_schema_version": "99.0",
1532 "artifacts_url": "ftp://bad",
1533 "plugins": []
1534}"#;
1535 let errors = Index::parse_json(json).expect_err("should fail");
1536 assert_eq!(errors.errors().len(), 1);
1537 assert_matches::assert_matches!(
1538 errors.errors()[0].error,
1539 SchemaError::UnsupportedIndexMajor { .. }
1540 );
1541 }
1542
1543 #[test]
1544 fn old_non_empty_index_without_published_at_is_rejected_by_schema_version() {
1545 let json = r#"{
1546 "index_schema_version": "1.0",
1547 "artifacts_url": "https://plugins.example.com/artifacts",
1548 "plugins": [
1549 { "name": "alpha", "version": "1.0.0", "description": "ok", "triggers": ["process_writes"],
1550 "dependencies": {"database_version":">=3.0.0","python":[]},
1551 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
1552 ]
1553}"#;
1554
1555 let errors = Index::parse_json(json).expect_err("old schema should fail");
1556 assert_eq!(errors.errors().len(), 1);
1557 assert_eq!(errors.errors()[0].path.as_str(), "index_schema_version");
1558 assert_matches!(
1559 errors.errors()[0].error,
1560 SchemaError::UnsupportedIndexMajor { ref found, supported }
1561 if found == "1.0" && supported == 2
1562 );
1563 }
1564}
1565
1566#[cfg(test)]
1567mod canonical_serialization_tests {
1568 use super::*;
1569 use pretty_assertions::assert_eq;
1570
1571 fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1572 IndexEntry {
1573 name: name.parse().unwrap(),
1574 version,
1575 published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1576 description: Description::try_new("desc").unwrap(),
1577 triggers: vec![TriggerType::ProcessWrites],
1578 homepage: None,
1579 repository: None,
1580 documentation: None,
1581 dependencies: Dependencies {
1582 database_version: ">=3.0.0".parse().unwrap(),
1583 python: vec![],
1584 },
1585 hash: ArtifactHash::try_new(
1586 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1587 )
1588 .unwrap(),
1589 yanked: false,
1590 }
1591 }
1592
1593 #[test]
1594 fn plugins_sorted_by_name_then_version() {
1595 let idx = Index {
1596 index_schema_version: IndexSchemaVersion::CURRENT,
1597 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1598 plugins: vec![
1599 make_entry("zebra", semver::Version::new(1, 0, 0)),
1600 make_entry("alpha", semver::Version::new(2, 0, 0)),
1601 make_entry("alpha", semver::Version::new(1, 0, 0)),
1602 ],
1603 };
1604 let out = idx.to_canonical_json().unwrap();
1605 let alpha_1_pos = out.find("\"alpha\"").unwrap();
1606 let alpha_2_pos = out[alpha_1_pos + 1..].find("\"alpha\"").unwrap() + alpha_1_pos + 1;
1607 let zebra_pos = out.find("\"zebra\"").unwrap();
1608 assert!(alpha_1_pos < alpha_2_pos, "alpha 1.0 before alpha 2.0");
1609 assert!(alpha_2_pos < zebra_pos, "alpha before zebra");
1610 }
1611
1612 #[test]
1613 fn two_serialize_calls_produce_byte_identical() {
1614 let idx = Index {
1615 index_schema_version: IndexSchemaVersion::CURRENT,
1616 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1617 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1618 };
1619 let a = idx.to_canonical_json().unwrap();
1620 let b = idx.to_canonical_json().unwrap();
1621 assert_eq!(a, b);
1622 }
1623
1624 #[test]
1625 fn ends_with_newline() {
1626 let idx = Index {
1627 index_schema_version: IndexSchemaVersion::CURRENT,
1628 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1629 plugins: vec![],
1630 };
1631 let out = idx.to_canonical_json().unwrap();
1632 assert!(out.ends_with('\n'));
1633 }
1634
1635 #[test]
1636 fn two_space_indent() {
1637 let idx = Index {
1638 index_schema_version: IndexSchemaVersion::CURRENT,
1639 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1640 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1641 };
1642 let out = idx.to_canonical_json().unwrap();
1643 assert!(
1644 out.contains("\n \"index_schema_version\""),
1645 "expected 2-space indent at top level"
1646 );
1647 }
1648
1649 #[test]
1650 fn snapshot_locks_full_shape() {
1651 let idx = Index {
1652 index_schema_version: IndexSchemaVersion::CURRENT,
1653 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1654 plugins: vec![make_entry("alpha", semver::Version::new(1, 0, 0)), {
1655 let mut e = make_entry("beta", semver::Version::new(2, 1, 0));
1656 e.yanked = true;
1657 e
1658 }],
1659 };
1660 insta::assert_snapshot!("canonical_full_shape", idx.to_canonical_json().unwrap());
1661 }
1662
1663 #[test]
1664 fn entry_field_order_is_canonical() {
1665 let idx = Index {
1666 index_schema_version: IndexSchemaVersion::CURRENT,
1667 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1668 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1669 };
1670 let out = idx.to_canonical_json().unwrap();
1671 let name_pos = out.find("\"name\"").unwrap();
1672 let version_pos = out.find("\"version\"").unwrap();
1673 let published_at_pos = out.find("\"published_at\"").unwrap();
1674 let description_pos = out.find("\"description\"").unwrap();
1675 let triggers_pos = out.find("\"triggers\"").unwrap();
1676 let dependencies_pos = out.find("\"dependencies\"").unwrap();
1677 let hash_pos = out.find("\"hash\"").unwrap();
1678 assert!(name_pos < version_pos);
1679 assert!(version_pos < published_at_pos);
1680 assert!(published_at_pos < description_pos);
1681 assert!(description_pos < triggers_pos);
1682 assert!(triggers_pos < dependencies_pos);
1683 assert!(dependencies_pos < hash_pos);
1684 }
1685
1686 #[test]
1687 fn published_at_is_preserved_exactly_in_canonical_json() {
1688 let mut entry = make_entry("x", semver::Version::new(1, 0, 0));
1689 entry.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1690 let idx = Index {
1691 index_schema_version: IndexSchemaVersion::CURRENT,
1692 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1693 plugins: vec![entry],
1694 };
1695 let out = idx.to_canonical_json().unwrap();
1696 assert!(out.contains(r#""published_at": "2027-01-02T03:04:05Z""#));
1697 }
1698
1699 #[test]
1700 fn published_at_does_not_affect_sort() {
1701 let mut alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1702 alpha.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1703 let mut beta = make_entry("beta", semver::Version::new(1, 0, 0));
1704 beta.published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1705 let idx = Index {
1706 index_schema_version: IndexSchemaVersion::CURRENT,
1707 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1708 plugins: vec![beta, alpha],
1709 };
1710 let out = idx.to_canonical_json().unwrap();
1711 let alpha_pos = out.find("\"alpha\"").unwrap();
1712 let beta_pos = out.find("\"beta\"").unwrap();
1713 assert!(alpha_pos < beta_pos, "name sort must ignore published_at");
1714 }
1715
1716 #[test]
1717 fn parse_canonical_round_trip_is_idempotent() {
1718 let idx = Index {
1719 index_schema_version: IndexSchemaVersion::CURRENT,
1720 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1721 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1722 };
1723 let first = idx.to_canonical_json().unwrap();
1724 let reparsed = Index::parse_json(&first).unwrap();
1725 let second = reparsed.to_canonical_json().unwrap();
1726 assert_eq!(first, second);
1727 }
1728
1729 #[test]
1730 fn nfc_normalization_makes_precomposed_equal_decomposed() {
1731 let precomposed = Description::try_new("caf\u{00E9}").unwrap();
1735 let decomposed = Description::try_new("cafe\u{0301}").unwrap();
1736 let entry_a = IndexEntry {
1737 description: precomposed,
1738 ..make_entry("x", semver::Version::new(1, 0, 0))
1739 };
1740 let entry_b = IndexEntry {
1741 description: decomposed,
1742 ..make_entry("x", semver::Version::new(1, 0, 0))
1743 };
1744
1745 let idx_a = Index {
1746 index_schema_version: IndexSchemaVersion::CURRENT,
1747 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1748 plugins: vec![entry_a],
1749 };
1750 let idx_b = Index {
1751 index_schema_version: IndexSchemaVersion::CURRENT,
1752 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1753 plugins: vec![entry_b],
1754 };
1755 assert_eq!(
1756 idx_a.to_canonical_json().unwrap(),
1757 idx_b.to_canonical_json().unwrap()
1758 );
1759 }
1760
1761 #[test]
1762 fn yank_status_does_not_affect_sort() {
1763 let mut yanked_alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1764 yanked_alpha.yanked = true;
1765 let idx = Index {
1766 index_schema_version: IndexSchemaVersion::CURRENT,
1767 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1768 plugins: vec![
1769 make_entry("beta", semver::Version::new(1, 0, 0)),
1770 yanked_alpha,
1771 ],
1772 };
1773 let out = idx.to_canonical_json().unwrap();
1774 let alpha_pos = out.find("\"alpha\"").unwrap();
1775 let beta_pos = out.find("\"beta\"").unwrap();
1776 assert!(
1777 alpha_pos < beta_pos,
1778 "yanked alpha should still sort before beta"
1779 );
1780 }
1781
1782 #[test]
1786 fn prerelease_sorts_before_release_at_same_major_minor_patch() {
1787 let prerelease = make_entry("p", "1.0.0-alpha".parse().unwrap());
1788 let release = make_entry("p", semver::Version::new(1, 0, 0));
1789 let idx = Index {
1790 index_schema_version: IndexSchemaVersion::CURRENT,
1791 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1792 plugins: vec![release, prerelease],
1794 };
1795 let out = idx.to_canonical_json().unwrap();
1796 let alpha_pos = out.find(r#""1.0.0-alpha""#).unwrap();
1797 let release_pos = out.find(r#""1.0.0""#).unwrap();
1798 assert!(
1799 alpha_pos < release_pos,
1800 "prerelease 1.0.0-alpha must sort before release 1.0.0"
1801 );
1802 }
1803
1804 #[test]
1809 fn build_metadata_does_not_affect_precedence() {
1810 let v_a: semver::Version = "1.0.0+build.1".parse().unwrap();
1811 let v_b: semver::Version = "1.0.0+build.2".parse().unwrap();
1812
1813 assert_eq!(v_a.cmp_precedence(&v_b), std::cmp::Ordering::Equal);
1814 assert_ne!(v_a.cmp(&v_b), std::cmp::Ordering::Equal);
1817
1818 let idx = Index {
1819 index_schema_version: IndexSchemaVersion::CURRENT,
1820 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1821 plugins: vec![make_entry("p", v_a.clone()), make_entry("p", v_b.clone())],
1823 };
1824 let out = idx.to_canonical_json().unwrap();
1825 let pos_a = out.find("1.0.0+build.1").unwrap();
1826 let pos_b = out.find("1.0.0+build.2").unwrap();
1827 assert!(
1828 pos_a < pos_b,
1829 "stable sort preserves input order for equal-precedence entries"
1830 );
1831 }
1832}
1833
1834#[cfg(test)]
1835mod insert_tests {
1836 use super::*;
1837 use crate::{
1838 ArtifactHash, ArtifactsUrl, Dependencies, Description, IndexInsertError, TriggerType,
1839 };
1840 use assert_matches::assert_matches;
1841
1842 fn empty_index() -> Index {
1843 Index {
1844 index_schema_version: IndexSchemaVersion::CURRENT,
1845 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1846 plugins: vec![],
1847 }
1848 }
1849
1850 fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1851 IndexEntry {
1852 name: name.parse().unwrap(),
1853 version,
1854 published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1855 description: Description::try_new("desc").unwrap(),
1856 triggers: vec![TriggerType::ProcessWrites],
1857 homepage: None,
1858 repository: None,
1859 documentation: None,
1860 dependencies: Dependencies {
1861 database_version: ">=3.0.0".parse().unwrap(),
1862 python: vec![],
1863 },
1864 hash: ArtifactHash::try_new(
1865 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1866 )
1867 .unwrap(),
1868 yanked: false,
1869 }
1870 }
1871
1872 #[test]
1873 fn empty_index_accepts_append() {
1874 let mut idx = empty_index();
1875 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1876 .unwrap();
1877 assert_eq!(idx.plugins.len(), 1);
1878 }
1879
1880 #[test]
1881 fn same_spelling_different_version_accepted() {
1882 let mut idx = empty_index();
1883 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1884 .unwrap();
1885 idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
1886 .unwrap();
1887 assert_eq!(idx.plugins.len(), 2);
1888 }
1889
1890 #[test]
1891 fn same_spelling_same_version_returns_duplicate() {
1892 let mut idx = empty_index();
1893 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1894 .unwrap();
1895 let err = idx
1896 .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1897 .unwrap_err();
1898 assert_matches!(err, IndexInsertError::Duplicate { .. });
1899 }
1900
1901 #[test]
1902 fn duplicate_error_lists_existing_versions_in_index_order() {
1903 let mut idx = empty_index();
1904 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1905 .unwrap();
1906 idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
1907 .unwrap();
1908 let err = idx
1909 .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1910 .unwrap_err();
1911 match err {
1912 IndexInsertError::Duplicate {
1913 existing_versions, ..
1914 } => {
1915 assert_eq!(
1916 existing_versions,
1917 vec![semver::Version::new(1, 0, 0), semver::Version::new(1, 1, 0)]
1918 );
1919 }
1920 other => panic!("expected Duplicate, got {other:?}"),
1921 }
1922 }
1923
1924 #[test]
1925 fn hyphen_underscore_canonical_collision_rejected() {
1926 let mut idx = empty_index();
1927 idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
1928 .unwrap();
1929 let err = idx
1930 .push_entry(make_entry("foo_bar", semver::Version::new(1, 0, 0)))
1931 .unwrap_err();
1932 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
1933 }
1934
1935 #[test]
1936 fn case_only_canonical_collision_rejected() {
1937 let mut idx = empty_index();
1938 idx.push_entry(make_entry("MyPlugin", semver::Version::new(1, 0, 0)))
1939 .unwrap();
1940 let err = idx
1941 .push_entry(make_entry("myplugin", semver::Version::new(1, 0, 0)))
1942 .unwrap_err();
1943 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
1944 }
1945
1946 #[test]
1947 fn canonical_collision_rejected_even_when_version_differs() {
1948 let mut idx = empty_index();
1949 idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
1950 .unwrap();
1951 let err = idx
1952 .push_entry(make_entry("foo_bar", semver::Version::new(2, 0, 0)))
1953 .unwrap_err();
1954 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
1955 }
1956
1957 #[test]
1958 fn index_unchanged_after_failed_push_entry() {
1959 let mut idx = empty_index();
1960 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1961 .unwrap();
1962 let snapshot = idx.clone();
1963 let _ = idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)));
1964 assert_eq!(idx, snapshot);
1965 }
1966
1967 #[test]
1968 fn check_entry_insert_does_not_mutate() {
1969 let mut idx = empty_index();
1970 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1971 .unwrap();
1972 let snapshot = idx.clone();
1973 let entry = make_entry("alpha", semver::Version::new(2, 0, 0));
1974 idx.check_entry_insert(&entry).unwrap();
1975 assert_eq!(idx, snapshot);
1976 }
1977}
1978
1979#[cfg(test)]
1980mod from_manifest_tests {
1981 use super::*;
1982 use crate::{
1983 ArtifactHash, Dependencies, Description, Manifest, ManifestSchemaVersion, PluginMetadata,
1984 PythonRequirement, TriggerType,
1985 };
1986
1987 fn sample_manifest() -> Manifest {
1988 Manifest {
1989 manifest_schema_version: ManifestSchemaVersion::new(1, 1),
1990 plugin: PluginMetadata {
1991 name: "downsampler".parse().unwrap(),
1992 version: semver::Version::new(1, 2, 0),
1993 description: Description::try_new("A downsampling plugin").unwrap(),
1994 triggers: vec![
1995 TriggerType::ProcessWrites,
1996 TriggerType::ProcessScheduledCall,
1997 ],
1998 homepage: Some(url::Url::parse("https://example.com").unwrap()),
1999 repository: Some(url::Url::parse("https://github.com/example/repo").unwrap()),
2000 documentation: Some(url::Url::parse("https://docs.example.com").unwrap()),
2001 exclude: vec![],
2002 },
2003 dependencies: Dependencies {
2004 database_version: ">=3.2.0,<4.0.0".parse().unwrap(),
2005 python: vec![PythonRequirement::try_new("requests>=2.31,<3").unwrap()],
2006 },
2007 }
2008 }
2009
2010 fn sample_hash() -> ArtifactHash {
2011 ArtifactHash::try_new(
2012 "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
2013 )
2014 .unwrap()
2015 }
2016
2017 #[test]
2018 fn copies_name() {
2019 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2020 assert_eq!(entry.name.as_str(), "downsampler");
2021 }
2022
2023 #[test]
2024 fn copies_version() {
2025 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2026 assert_eq!(entry.version, semver::Version::new(1, 2, 0));
2027 }
2028
2029 #[test]
2030 fn copies_description() {
2031 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2032 assert_eq!(entry.description.as_str(), "A downsampling plugin");
2033 }
2034
2035 #[test]
2036 fn copies_triggers() {
2037 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2038 assert_eq!(
2039 entry.triggers,
2040 vec![
2041 TriggerType::ProcessWrites,
2042 TriggerType::ProcessScheduledCall
2043 ]
2044 );
2045 }
2046
2047 #[test]
2048 fn copies_homepage() {
2049 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2050 assert_eq!(entry.homepage.unwrap().as_str(), "https://example.com/");
2051 }
2052
2053 #[test]
2054 fn copies_repository() {
2055 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2056 assert_eq!(
2057 entry.repository.unwrap().as_str(),
2058 "https://github.com/example/repo"
2059 );
2060 }
2061
2062 #[test]
2063 fn copies_documentation() {
2064 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2065 assert_eq!(
2066 entry.documentation.unwrap().as_str(),
2067 "https://docs.example.com/"
2068 );
2069 }
2070
2071 #[test]
2072 fn copies_dependencies() {
2073 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2074 assert_eq!(entry.dependencies.python.len(), 1);
2075 }
2076
2077 #[test]
2078 fn copies_hash() {
2079 let h = sample_hash();
2080 let entry = IndexEntry::from_manifest(sample_manifest(), h.clone());
2081 assert_eq!(entry.hash, h);
2082 }
2083
2084 #[test]
2085 fn copies_injected_published_at() {
2086 let published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
2087 let entry = IndexEntry::from_manifest_with_published_at(
2088 sample_manifest(),
2089 sample_hash(),
2090 published_at.clone(),
2091 );
2092 assert_eq!(entry.published_at, published_at);
2093 }
2094
2095 #[test]
2096 fn from_manifest_assigns_valid_cargo_pubtime_published_at() {
2097 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2098 assert_eq!(
2099 PublishedAt::try_new(entry.published_at.as_str()).unwrap(),
2100 entry.published_at
2101 );
2102 assert_eq!(entry.published_at.as_str().len(), PublishedAt::LEN);
2103 assert!(entry.published_at.as_str().ends_with('Z'));
2104 assert!(!entry.published_at.as_str().contains('.'));
2105 assert!(!entry.published_at.as_str().contains('+'));
2106 }
2107
2108 #[test]
2109 fn yanked_is_false() {
2110 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2111 assert!(!entry.yanked);
2112 }
2113}