Skip to main content

influxdb3_plugin_schemas/
index.rs

1//! Plugin registry index (`index.json`) types and canonical serialization.
2
3use 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/// The `index_schema_version` top-level field. Mirrors `ManifestSchemaVersion`:
12/// format `<major>.<minor>`, unsupported majors rejected.
13#[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/// Registry artifact-base URL. Scheme is restricted to `https`, `http`, or
86/// `file`.
87#[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    /// Computes the fully-resolved artifact URL for `(name, version)` under
110    /// this `ArtifactsUrl`. Normalizes the base's trailing slash and preserves
111    /// query string and fragment verbatim. The artifact filename is always
112    /// `{name}-{version}.tar.gz` per the flat-layout registry index rule.
113    ///
114    /// Infallible by construction: name/version characters are restricted by
115    /// their respective validators to RFC 3986 path-safe ASCII, and the scheme
116    /// set accepted by `try_new` (`http`, `https`, `file`) never satisfies
117    /// `Url::cannot_be_a_base`.
118    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/// SHA-256 artifact hash, stored in the canonical form
155/// `sha256:<64 lowercase hex chars>`.
156#[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/// Publication timestamp for a plugin version.
209///
210/// The wire format mirrors Cargo registry-index `pubtime` exactly:
211/// `YYYY-MM-DDTHH:MM:SSZ`.
212#[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/// A parsed plugin registry index.
328#[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/// One per-version entry inside an index's `plugins[]` array.
336#[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    /// Parses an index from JSON, collecting every field-level defect from
388    /// every entry in one pass (including duplicate `(name, version)` pairs).
389    ///
390    /// Syntax errors and an unsupported/malformed `index_schema_version`
391    /// short-circuit with a single error.
392    ///
393    /// # Examples
394    ///
395    /// ```
396    /// use influxdb3_plugin_schemas::Index;
397    ///
398    /// let source = r#"{
399    ///   "index_schema_version": "2.0",
400    ///   "artifacts_url": "https://plugins.example.com/artifacts",
401    ///   "plugins": []
402    /// }"#;
403    ///
404    /// let index = Index::parse_json(source).unwrap();
405    /// assert!(index.plugins.is_empty());
406    /// ```
407    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        // Canonical-keyed index of prior entries: lets us distinguish exact
438        // (spelling, version) duplicates (DuplicateIndexEntry) from
439        // different-spelling canonical collisions (CanonicalCollision) while
440        // reporting accurate payloads. Canonical form folds case and `-`/`_`
441        // per Cargo's canon_crate_name.
442        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                // Different spelling, canonical-equal → CanonicalCollision.
464                // Same spelling + new version is allowed (a new release of
465                // the same plugin) and falls through to the append below.
466                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    /// Serializes this index to its canonical JSON form:
495    ///
496    /// - Field ordering matches struct declaration order.
497    /// - `plugins[]` sorted by `name` ascending, then `version` ascending by
498    ///   SemVer precedence. Metadata fields such as `published_at` and yank
499    ///   status do not affect ordering.
500    /// - 2-space indent, pretty-printed, trailing newline.
501    /// - NFC Unicode normalization on free-text `description` fields. Other
502    ///   string fields are already ASCII-constrained by their validators; URL
503    ///   fields normalize via `url::Url` parse.
504    pub fn to_canonical_json(&self) -> Result<String, SchemaError> {
505        let mut normalized = self.clone();
506        // `cmp_precedence` ignores build metadata (SemVer 2.0.0 rule); plain
507        // `Version::cmp` would lexically order build strings, violating that.
508        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        // NFC may grow the string past the 200-char bound via combining-mark
515        // sequences; surface that as a structured error rather than panic.
516        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    /// Checks whether `entry` can be inserted into this index without
534    /// violating uniqueness or canonical-collision invariants.
535    ///
536    /// Returns `Ok(())` when the insert would be safe, or an
537    /// [`crate::IndexInsertError`] describing the conflict.
538    ///
539    /// This method does **not** modify the index.
540    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    /// Validates `entry` against the current index and, if valid, appends it
578    /// to `self.plugins`.
579    ///
580    /// Returns `Err` without modifying the index when the entry would create a
581    /// duplicate `(name, version)` pair or a canonical-name collision.
582    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
593/// Validates a `RawIndexEntry`, pushing errors into `errors` with paths
594/// relative to `path`. Returns `None` if any error was pushed for this entry.
595fn 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    // A1 / A2: trailing-slash variants on https base
834    #[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    // A3 / A4: file:// no host
847    #[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    // A5 / A6: nested base path
860    #[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    // A7 / A8: plugin name with hyphen / underscore
873    #[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    // A9 / A10: semver prerelease and build metadata
886    #[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    // A11 / A12 / A13: query and fragment preservation
899    #[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    // A14: port
918    #[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    // A15: userinfo
925    #[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    // A16: file:// with host (UNC-style)
932    #[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    // A17: IDNA host
939    #[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    // A18: host-only, no path (parser normalizes to "/")
946    #[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    /// Doc rule: hash hex zone is exactly 64 lowercase hex chars (`0-9a-f`).
1002    /// Any char outside that set — letters `g-z`, ASCII symbols, etc. — is rejected.
1003    #[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    /// Doc rule: "Syntax errors, missing required fields, or wrong JSON
1172    /// container shape are reported as root-level JSON parse errors."
1173    /// Each case must surface as a single `JsonParse` error at root, without
1174    /// any field-level processing.
1175    #[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        // `foo-bar` and `foo_bar` share the same canonical form (`foo_bar`);
1228        // the second entry is rejected even though the raw strings differ.
1229        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        // `MyPlugin` and `myplugin` share canonical form `myplugin`.
1256        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        // Three entries collapse to canonical `foo_bar`; first is accepted,
1283        // second and third each report with their original spelling.
1284        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        // Previously allowed; now rejected. Different spellings that canonicalize
1334        // equal must collide regardless of version.
1335        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    /// Index-entry validation mirrors manifest validation: same trigger and
1368    /// URL-scheme rules apply per entry.
1369    #[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    /// Per-entry defects and duplicate-(name, version) detection collect in
1436    /// a single pass.
1437    #[test]
1438    fn collects_multiple_entry_defects_and_duplicate() {
1439        // plugins[0] valid; plugins[1] bad hash; plugins[2] duplicates
1440        // plugins[0]; plugins[3] description too long. Expect 3 errors.
1441        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        // A uses precomposed "é" (U+00E9); B uses "e" + combining acute
1732        // (U+0065 U+0301). NFC collapses both to U+00E9, so canonical output
1733        // is byte-identical.
1734        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    /// SemVer 2.0.0: a prerelease has lower precedence than the corresponding
1783    /// release (`1.0.0-alpha < 1.0.0`), so canonical ordering must put it
1784    /// first — otherwise "latest version" queries would be wrong.
1785    #[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            // Reverse-of-expected order forces the sort.
1793            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    /// SemVer 2.0.0: build metadata is ignored for precedence, so entries
1805    /// differing only in build metadata must compare equal via
1806    /// `cmp_precedence`. Plain `Version::cmp` would order them lexically on
1807    /// the build string, so `to_canonical_json` must use `cmp_precedence`.
1808    #[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        // Sanity: plain `cmp` distinguishes them, which is why the sort
1815        // cannot use `cmp` here.
1816        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            // Equal-precedence entries preserve input order via stable sort.
1822            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}