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 = 1;
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 = crate::identity::parse_registry_scheme_url(raw, |url, scheme| {
93            SchemaError::UnsupportedArtifactScheme { url, scheme }
94        })?;
95        Ok(Self(url))
96    }
97
98    pub fn as_url(&self) -> &url::Url {
99        &self.0
100    }
101
102    /// Computes the fully-resolved artifact URL for `(name, version)` under
103    /// this `ArtifactsUrl`. Normalizes the base's trailing slash and preserves
104    /// query string and fragment verbatim. The artifact filename is always
105    /// `{name}-{version}.tar.gz` per the flat-layout registry index rule.
106    ///
107    /// Infallible by construction: name/version characters are restricted by
108    /// their respective validators to RFC 3986 path-safe ASCII, and the scheme
109    /// set accepted by `try_new` (`http`, `https`, `file`) never satisfies
110    /// `Url::cannot_be_a_base`.
111    pub fn artifact_url(&self, name: &PluginName, version: &semver::Version) -> url::Url {
112        let filename = format!("{}-{}.tar.gz", name.as_str(), version);
113        let mut url = self.0.clone();
114        url.path_segments_mut()
115            .expect("ArtifactsUrl schemes (http/https/file) are always base-able")
116            .pop_if_empty()
117            .push(&filename);
118        url
119    }
120}
121
122impl fmt::Display for ArtifactsUrl {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        self.0.fmt(f)
125    }
126}
127
128impl<'de> serde::Deserialize<'de> for ArtifactsUrl {
129    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130    where
131        D: serde::Deserializer<'de>,
132    {
133        let raw = String::deserialize(deserializer)?;
134        Self::try_new(&raw).map_err(serde::de::Error::custom)
135    }
136}
137
138impl serde::Serialize for ArtifactsUrl {
139    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140    where
141        S: serde::Serializer,
142    {
143        serializer.collect_str(&self.0)
144    }
145}
146
147/// SHA-256 artifact hash, stored in the canonical form
148/// `sha256:<64 lowercase hex chars>`.
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct ArtifactHash(String);
151
152impl ArtifactHash {
153    pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
154        const PREFIX: &str = "sha256:";
155        let invalid = || SchemaError::InvalidHash {
156            value: raw.to_owned(),
157        };
158        let Some(hex) = raw.strip_prefix(PREFIX) else {
159            return Err(invalid());
160        };
161        if hex.len() != 64
162            || !hex
163                .chars()
164                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
165        {
166            return Err(invalid());
167        }
168        Ok(Self(raw.to_owned()))
169    }
170
171    pub fn as_str(&self) -> &str {
172        &self.0
173    }
174}
175
176impl fmt::Display for ArtifactHash {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(&self.0)
179    }
180}
181
182impl<'de> serde::Deserialize<'de> for ArtifactHash {
183    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
184    where
185        D: serde::Deserializer<'de>,
186    {
187        let raw = String::deserialize(deserializer)?;
188        Self::try_new(&raw).map_err(serde::de::Error::custom)
189    }
190}
191
192impl serde::Serialize for ArtifactHash {
193    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
194    where
195        S: serde::Serializer,
196    {
197        serializer.serialize_str(&self.0)
198    }
199}
200
201/// Publication timestamp for a plugin version.
202///
203/// The wire format mirrors Cargo registry-index `pubtime` exactly:
204/// `YYYY-MM-DDTHH:MM:SSZ`.
205#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
206pub struct PublishedAt(String);
207
208impl PublishedAt {
209    const LEN: usize = "YYYY-MM-DDTHH:MM:SSZ".len();
210
211    pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
212        if !has_cargo_pubtime_shape(raw) {
213            return Err(SchemaError::InvalidPublishedAt {
214                value: raw.to_owned(),
215            });
216        }
217
218        let year = raw[0..4].parse::<i32>().expect("shape checked digits");
219        let month = raw[5..7].parse::<u8>().expect("shape checked digits");
220        let day = raw[8..10].parse::<u8>().expect("shape checked digits");
221        let hour = raw[11..13].parse::<u8>().expect("shape checked digits");
222        let minute = raw[14..16].parse::<u8>().expect("shape checked digits");
223        let second = raw[17..19].parse::<u8>().expect("shape checked digits");
224
225        let month = Month::try_from(month).map_err(|_| SchemaError::InvalidPublishedAt {
226            value: raw.to_owned(),
227        })?;
228        let date = Date::from_calendar_date(year, month, day).map_err(|_| {
229            SchemaError::InvalidPublishedAt {
230                value: raw.to_owned(),
231            }
232        })?;
233        let time =
234            Time::from_hms(hour, minute, second).map_err(|_| SchemaError::InvalidPublishedAt {
235                value: raw.to_owned(),
236            })?;
237        let _ = PrimitiveDateTime::new(date, time).assume_utc();
238
239        Ok(Self(raw.to_owned()))
240    }
241
242    pub fn now_utc() -> Self {
243        Self::from_utc_datetime(OffsetDateTime::now_utc())
244            .expect("current UTC timestamp must fit Cargo pubtime format")
245    }
246
247    pub fn from_utc_datetime(timestamp: OffsetDateTime) -> Result<Self, SchemaError> {
248        let timestamp = timestamp.to_offset(UtcOffset::UTC);
249        let year = timestamp.year();
250        if !(0..=9999).contains(&year) {
251            return Err(SchemaError::InvalidPublishedAt {
252                value: year.to_string(),
253            });
254        }
255
256        let value = format!(
257            "{year:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
258            u8::from(timestamp.month()),
259            timestamp.day(),
260            timestamp.hour(),
261            timestamp.minute(),
262            timestamp.second()
263        );
264        Ok(Self(value))
265    }
266
267    pub fn as_str(&self) -> &str {
268        &self.0
269    }
270}
271
272fn has_cargo_pubtime_shape(value: &str) -> bool {
273    let bytes = value.as_bytes();
274    bytes.len() == PublishedAt::LEN
275        && bytes[4] == b'-'
276        && bytes[7] == b'-'
277        && bytes[10] == b'T'
278        && bytes[13] == b':'
279        && bytes[16] == b':'
280        && bytes[19] == b'Z'
281        && bytes
282            .iter()
283            .enumerate()
284            .all(|(idx, byte)| matches!(idx, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit())
285}
286
287impl fmt::Display for PublishedAt {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(&self.0)
290    }
291}
292
293impl FromStr for PublishedAt {
294    type Err = SchemaError;
295
296    fn from_str(s: &str) -> Result<Self, Self::Err> {
297        Self::try_new(s)
298    }
299}
300
301impl<'de> serde::Deserialize<'de> for PublishedAt {
302    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303    where
304        D: serde::Deserializer<'de>,
305    {
306        let raw = String::deserialize(deserializer)?;
307        Self::try_new(&raw).map_err(serde::de::Error::custom)
308    }
309}
310
311impl serde::Serialize for PublishedAt {
312    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
313    where
314        S: serde::Serializer,
315    {
316        serializer.serialize_str(&self.0)
317    }
318}
319
320/// A parsed plugin registry index.
321#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
322pub struct Index {
323    pub index_schema_version: IndexSchemaVersion,
324    pub artifacts_url: ArtifactsUrl,
325    pub plugins: Vec<IndexEntry>,
326}
327
328/// One per-version entry inside an index's `plugins[]` array.
329#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
330pub struct IndexEntry {
331    pub name: PluginName,
332    pub version: semver::Version,
333    pub published_at: PublishedAt,
334    pub description: Description,
335    pub triggers: Vec<TriggerType>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub homepage: Option<url::Url>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub repository: Option<url::Url>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub documentation: Option<url::Url>,
342    pub dependencies: Dependencies,
343    pub hash: ArtifactHash,
344    #[serde(default, skip_serializing_if = "is_false")]
345    pub yanked: bool,
346}
347
348impl IndexEntry {
349    pub fn from_manifest(manifest: crate::Manifest, hash: ArtifactHash) -> Self {
350        Self::from_manifest_with_published_at(manifest, hash, PublishedAt::now_utc())
351    }
352
353    pub fn from_manifest_with_published_at(
354        manifest: crate::Manifest,
355        hash: ArtifactHash,
356        published_at: PublishedAt,
357    ) -> Self {
358        let plugin = manifest.plugin;
359        Self {
360            name: plugin.name,
361            version: plugin.version,
362            published_at,
363            description: plugin.description,
364            triggers: plugin.triggers,
365            homepage: plugin.homepage,
366            repository: plugin.repository,
367            documentation: plugin.documentation,
368            dependencies: manifest.dependencies,
369            hash,
370            yanked: false,
371        }
372    }
373}
374
375fn is_false(b: &bool) -> bool {
376    !*b
377}
378
379impl Index {
380    /// Parses an index from JSON, collecting every field-level defect from
381    /// every entry in one pass (including duplicate `(name, version)` pairs).
382    ///
383    /// Syntax errors and an unsupported/malformed `index_schema_version`
384    /// short-circuit with a single error.
385    ///
386    /// # Examples
387    ///
388    /// ```
389    /// use influxdb3_plugin_schemas::Index;
390    ///
391    /// let source = r#"{
392    ///   "index_schema_version": "2.0",
393    ///   "artifacts_url": "https://plugins.example.com/artifacts",
394    ///   "plugins": []
395    /// }"#;
396    ///
397    /// let index = Index::parse_json(source).unwrap();
398    /// assert!(index.plugins.is_empty());
399    /// ```
400    pub fn parse_json(input: &str) -> Result<Self, crate::SchemaErrors> {
401        use crate::raw::RawIndex;
402        use crate::{FieldPath, ReportedError, SchemaErrors};
403        use std::collections::HashMap;
404        use std::str::FromStr;
405
406        let raw: RawIndex = serde_json::from_str(input)
407            .map_err(|source| SchemaErrors::single_at_root(SchemaError::JsonParse { source }))?;
408
409        let schema_version =
410            IndexSchemaVersion::from_str(&raw.index_schema_version).map_err(|e| {
411                SchemaErrors::new(vec![ReportedError::new(
412                    FieldPath::root().field("index_schema_version"),
413                    e,
414                )])
415            })?;
416
417        let mut errors = Vec::new();
418        let root = FieldPath::root();
419
420        let artifacts_url = match ArtifactsUrl::try_new(&raw.artifacts_url) {
421            Ok(u) => Some(u),
422            Err(e) => {
423                errors.push(ReportedError::new(root.field("artifacts_url"), e));
424                None
425            }
426        };
427
428        let mut entries_ok: Vec<IndexEntry> = Vec::with_capacity(raw.plugins.len());
429
430        // Canonical-keyed index of prior entries: lets us distinguish exact
431        // (spelling, version) duplicates (DuplicateIndexEntry) from
432        // different-spelling canonical collisions (CanonicalCollision) while
433        // reporting accurate payloads. Canonical form folds case and `-`/`_`
434        // per Cargo's canon_crate_name.
435        let mut seen_by_canonical: HashMap<String, Vec<(String, String)>> = HashMap::new();
436
437        for (i, raw_entry) in raw.plugins.iter().enumerate() {
438            let entry_path = root.field("plugins").index(i);
439            let validated = validate_raw_entry(raw_entry, &entry_path, &mut errors);
440
441            let canonical = crate::identity::canonical_name(&raw_entry.name);
442            let existing = seen_by_canonical.entry(canonical.clone()).or_default();
443
444            let exact_dup = existing
445                .iter()
446                .any(|(n, v)| n == &raw_entry.name && v == &raw_entry.version);
447            if exact_dup {
448                errors.push(ReportedError::new(
449                    entry_path.clone(),
450                    SchemaError::DuplicateIndexEntry {
451                        name: raw_entry.name.clone(),
452                        version: raw_entry.version.clone(),
453                    },
454                ));
455            } else if existing.iter().any(|(n, _)| n != &raw_entry.name) {
456                // Different spelling, canonical-equal → CanonicalCollision.
457                // Same spelling + new version is allowed (a new release of
458                // the same plugin) and falls through to the append below.
459                errors.push(ReportedError::new(
460                    entry_path.clone(),
461                    SchemaError::CanonicalCollision {
462                        name: raw_entry.name.clone(),
463                        canonical: canonical.clone(),
464                        existing: existing.clone(),
465                    },
466                ));
467            }
468
469            existing.push((raw_entry.name.clone(), raw_entry.version.clone()));
470
471            if let Some(entry) = validated {
472                entries_ok.push(entry);
473            }
474        }
475
476        if !errors.is_empty() {
477            return Err(SchemaErrors::new(errors));
478        }
479
480        Ok(Index {
481            index_schema_version: schema_version,
482            artifacts_url: artifacts_url.unwrap(),
483            plugins: entries_ok,
484        })
485    }
486
487    /// Serializes this index to its canonical JSON form:
488    ///
489    /// - Field ordering matches struct declaration order.
490    /// - `plugins[]` sorted by `name` ascending, then `version` ascending by
491    ///   SemVer precedence. Metadata fields such as `published_at` and yank
492    ///   status do not affect ordering.
493    /// - 2-space indent, pretty-printed, trailing newline.
494    /// - NFC Unicode normalization on free-text `description` fields. Other
495    ///   string fields are already ASCII-constrained by their validators; URL
496    ///   fields normalize via `url::Url` parse.
497    pub fn to_canonical_json(&self) -> Result<String, SchemaError> {
498        let mut normalized = self.clone();
499        // Canonical output always declares the writer's schema version: legacy
500        // indexes upgrade implicitly on write, with no migration step (design
501        // doc D7). Parsing stays minor-tolerant within the supported major.
502        normalized.index_schema_version = IndexSchemaVersion::CURRENT;
503        // `cmp_precedence` ignores build metadata (SemVer 2.0.0 rule); plain
504        // `Version::cmp` would lexically order build strings, violating that.
505        normalized.plugins.sort_by(|a, b| {
506            a.name
507                .as_str()
508                .cmp(b.name.as_str())
509                .then_with(|| a.version.cmp_precedence(&b.version))
510        });
511        // NFC may grow the string past the 200-char bound via combining-mark
512        // sequences; surface that as a structured error rather than panic.
513        for entry in &mut normalized.plugins {
514            let nfc = normalize_nfc(entry.description.as_str());
515            entry.description = Description::try_new(&nfc)?;
516        }
517
518        let mut buf = Vec::with_capacity(256);
519        let formatter = serde_json::ser::PrettyFormatter::with_indent(b"  ");
520        let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
521        normalized
522            .serialize(&mut ser)
523            .map_err(|source| SchemaError::JsonSerialize { source })?;
524        buf.push(b'\n');
525        String::from_utf8(buf).map_err(|e| SchemaError::JsonSerialize {
526            source: serde_json::Error::custom(e.to_string()),
527        })
528    }
529
530    /// Checks whether `entry` can be inserted into this index without
531    /// violating uniqueness or canonical-collision invariants.
532    ///
533    /// Returns `Ok(())` when the insert would be safe, or an
534    /// [`crate::IndexInsertError`] describing the conflict.
535    ///
536    /// This method does **not** modify the index.
537    pub fn check_entry_insert(&self, entry: &IndexEntry) -> Result<(), crate::IndexInsertError> {
538        use crate::IndexInsertError;
539
540        let new_canonical = entry.name.canonical();
541
542        let existing_canonical: Vec<(String, semver::Version)> = self
543            .plugins
544            .iter()
545            .filter(|e| e.name.canonical() == new_canonical)
546            .map(|e| (e.name.as_str().to_owned(), e.version.clone()))
547            .collect();
548
549        let any_spelling_differs = existing_canonical
550            .iter()
551            .any(|(n, _)| n != entry.name.as_str());
552        if any_spelling_differs {
553            return Err(IndexInsertError::CanonicalCollision {
554                name: entry.name.as_str().to_owned(),
555                canonical: new_canonical,
556                existing: existing_canonical,
557            });
558        }
559
560        let same_version_dup = existing_canonical.iter().any(|(_, v)| v == &entry.version);
561        if same_version_dup {
562            let existing_versions: Vec<semver::Version> =
563                existing_canonical.iter().map(|(_, v)| v.clone()).collect();
564            return Err(IndexInsertError::Duplicate {
565                name: entry.name.as_str().to_owned(),
566                version: entry.version.clone(),
567                existing_versions,
568            });
569        }
570
571        Ok(())
572    }
573
574    /// Validates `entry` against the current index and, if valid, appends it
575    /// to `self.plugins`.
576    ///
577    /// Returns `Err` without modifying the index when the entry would create a
578    /// duplicate `(name, version)` pair or a canonical-name collision.
579    pub fn push_entry(&mut self, entry: IndexEntry) -> Result<(), crate::IndexInsertError> {
580        self.check_entry_insert(&entry)?;
581        self.plugins.push(entry);
582        Ok(())
583    }
584}
585
586fn normalize_nfc(s: &str) -> String {
587    s.nfc().collect()
588}
589
590/// Validates a `RawIndexEntry`, pushing errors into `errors` with paths
591/// relative to `path`. Returns `None` if any error was pushed for this entry.
592fn validate_raw_entry(
593    raw: &crate::raw::RawIndexEntry,
594    path: &crate::FieldPath,
595    errors: &mut Vec<crate::ReportedError>,
596) -> Option<IndexEntry> {
597    use crate::manifest::parse_optional_http_url_from_path;
598    use crate::{Description, PluginName, PythonRequirement, ReportedError, TriggerType};
599    use std::str::FromStr;
600
601    let local_err_count = errors.len();
602
603    let name = match PluginName::from_str(&raw.name) {
604        Ok(n) => Some(n),
605        Err(e) => {
606            errors.push(ReportedError::new(path.field("name"), e));
607            None
608        }
609    };
610
611    let version = match semver::Version::parse(&raw.version) {
612        Ok(v) => Some(v),
613        Err(source) => {
614            errors.push(ReportedError::new(
615                path.field("version"),
616                SchemaError::InvalidVersion {
617                    version: raw.version.clone(),
618                    source,
619                },
620            ));
621            None
622        }
623    };
624
625    let published_at = match &raw.published_at {
626        Some(serde_json::Value::String(value)) => match PublishedAt::try_new(value) {
627            Ok(published_at) => Some(published_at),
628            Err(e) => {
629                errors.push(ReportedError::new(path.field("published_at"), e));
630                None
631            }
632        },
633        Some(value) => {
634            errors.push(ReportedError::new(
635                path.field("published_at"),
636                SchemaError::InvalidPublishedAt {
637                    value: value.to_string(),
638                },
639            ));
640            None
641        }
642        None => {
643            errors.push(ReportedError::new(
644                path.field("published_at"),
645                SchemaError::InvalidPublishedAt {
646                    value: "<missing>".to_owned(),
647                },
648            ));
649            None
650        }
651    };
652
653    let description = match Description::try_new(&raw.description) {
654        Ok(d) => Some(d),
655        Err(e) => {
656            errors.push(ReportedError::new(path.field("description"), e));
657            None
658        }
659    };
660
661    let mut triggers_ok: Vec<TriggerType> = Vec::with_capacity(raw.triggers.len());
662    if raw.triggers.is_empty() {
663        errors.push(ReportedError::new(
664            path.field("triggers"),
665            SchemaError::EmptyTriggers,
666        ));
667    } else {
668        for (i, t) in raw.triggers.iter().enumerate() {
669            match TriggerType::from_str(t) {
670                Ok(tt) => triggers_ok.push(tt),
671                Err(e) => errors.push(ReportedError::new(path.field("triggers").index(i), e)),
672            }
673        }
674    }
675
676    let homepage = parse_optional_http_url_from_path(&raw.homepage, errors, path, "homepage");
677    let repository = parse_optional_http_url_from_path(&raw.repository, errors, path, "repository");
678    let documentation =
679        parse_optional_http_url_from_path(&raw.documentation, errors, path, "documentation");
680
681    let database_version = match semver::VersionReq::parse(&raw.dependencies.database_version) {
682        Ok(r) => Some(r),
683        Err(source) => {
684            errors.push(ReportedError::new(
685                path.field("dependencies").field("database_version"),
686                SchemaError::InvalidDatabaseVersion {
687                    range: raw.dependencies.database_version.clone(),
688                    source,
689                },
690            ));
691            None
692        }
693    };
694
695    let mut python_ok: Vec<PythonRequirement> = Vec::with_capacity(raw.dependencies.python.len());
696    for (i, p) in raw.dependencies.python.iter().enumerate() {
697        match PythonRequirement::try_new(p) {
698            Ok(pr) => python_ok.push(pr),
699            Err(e) => errors.push(ReportedError::new(
700                path.field("dependencies").field("python").index(i),
701                e,
702            )),
703        }
704    }
705
706    let plugins_ok = crate::manifest::validate_raw_plugin_dependencies(
707        &raw.dependencies.plugins,
708        &path.field("dependencies"),
709        errors,
710    );
711
712    let hash = match ArtifactHash::try_new(&raw.hash) {
713        Ok(h) => Some(h),
714        Err(e) => {
715            errors.push(ReportedError::new(path.field("hash"), e));
716            None
717        }
718    };
719
720    if errors.len() > local_err_count {
721        return None;
722    }
723
724    Some(IndexEntry {
725        name: name.unwrap(),
726        version: version.unwrap(),
727        published_at: published_at.unwrap(),
728        description: description.unwrap(),
729        triggers: triggers_ok,
730        homepage,
731        repository,
732        documentation,
733        dependencies: crate::Dependencies {
734            database_version: database_version.unwrap(),
735            python: python_ok,
736            plugins: plugins_ok,
737        },
738        hash: hash.unwrap(),
739        yanked: raw.yanked,
740    })
741}
742
743#[cfg(test)]
744mod schema_version_tests {
745    use super::*;
746    use assert_matches::assert_matches;
747
748    #[test]
749    fn parses_supported_major() {
750        let v: IndexSchemaVersion = "2.0".parse().unwrap();
751        assert_eq!(v.major(), 2);
752        assert_eq!(v.minor(), 0);
753    }
754
755    #[test]
756    fn rejects_unsupported_major() {
757        let err = "3.0".parse::<IndexSchemaVersion>().unwrap_err();
758        assert_matches!(err, SchemaError::UnsupportedIndexMajor { .. });
759    }
760
761    #[test]
762    fn rejects_malformed() {
763        assert_matches!(
764            "abc".parse::<IndexSchemaVersion>(),
765            Err(SchemaError::MalformedSchemaVersion { .. })
766        );
767    }
768
769    #[test]
770    fn current_uses_declared_major_and_minor_constants() {
771        assert_eq!(
772            IndexSchemaVersion::CURRENT.major(),
773            IndexSchemaVersion::CURRENT_MAJOR
774        );
775        assert_eq!(
776            IndexSchemaVersion::CURRENT.minor(),
777            IndexSchemaVersion::CURRENT_MINOR
778        );
779    }
780
781    #[test]
782    fn current_to_string_round_trips() {
783        let s = IndexSchemaVersion::CURRENT.to_string();
784        let parsed: IndexSchemaVersion = s.parse().unwrap();
785        assert_eq!(parsed, IndexSchemaVersion::CURRENT);
786    }
787
788    #[test]
789    fn current_serializes_as_schema_two_one() {
790        assert_eq!(IndexSchemaVersion::CURRENT.to_string(), "2.1");
791    }
792}
793
794#[cfg(test)]
795mod artifacts_url_tests {
796    use super::*;
797    use assert_matches::assert_matches;
798    use rstest::rstest;
799
800    #[rstest]
801    #[case("https://plugins.example/artifacts")]
802    #[case("http://localhost:8080/artifacts")]
803    #[case("file:///srv/plugins")]
804    fn valid_schemes_accepted(#[case] input: &str) {
805        assert!(ArtifactsUrl::try_new(input).is_ok());
806    }
807
808    #[rstest]
809    #[case("oci://registry.example")]
810    #[case("s3://bucket/plugins")]
811    #[case("git://example/plugins")]
812    #[case("git+https://example/plugins")]
813    #[case("git+ssh://example/plugins")]
814    #[case("ftp://example/plugins")]
815    #[case("sftp://example/plugins")]
816    fn rejected_schemes(#[case] input: &str) {
817        let err = ArtifactsUrl::try_new(input).unwrap_err();
818        assert_matches!(err, SchemaError::UnsupportedArtifactScheme { .. });
819    }
820
821    #[test]
822    fn malformed_url_rejected() {
823        let err = ArtifactsUrl::try_new("not a url").unwrap_err();
824        assert_matches!(err, SchemaError::InvalidUrl { .. });
825    }
826
827    fn name(input: &str) -> PluginName {
828        use std::str::FromStr;
829        PluginName::from_str(input).expect("valid plugin name in test")
830    }
831
832    fn version(input: &str) -> semver::Version {
833        semver::Version::parse(input).expect("valid semver in test")
834    }
835
836    #[rstest]
837    // A1 / A2: trailing-slash variants on https base
838    #[case(
839        "https://example.com/artifacts",
840        "n",
841        "1.2.3",
842        "https://example.com/artifacts/n-1.2.3.tar.gz"
843    )]
844    #[case(
845        "https://example.com/artifacts/",
846        "n",
847        "1.2.3",
848        "https://example.com/artifacts/n-1.2.3.tar.gz"
849    )]
850    // A3 / A4: file:// no host
851    #[case(
852        "file:///path/to/registry",
853        "n",
854        "1.2.3",
855        "file:///path/to/registry/n-1.2.3.tar.gz"
856    )]
857    #[case(
858        "file:///path/to/registry/",
859        "n",
860        "1.2.3",
861        "file:///path/to/registry/n-1.2.3.tar.gz"
862    )]
863    // A5 / A6: nested base path
864    #[case(
865        "https://host/a/b/c",
866        "n",
867        "1.2.3",
868        "https://host/a/b/c/n-1.2.3.tar.gz"
869    )]
870    #[case(
871        "https://host/a/b/c/",
872        "n",
873        "1.2.3",
874        "https://host/a/b/c/n-1.2.3.tar.gz"
875    )]
876    // A7 / A8: plugin name with hyphen / underscore
877    #[case(
878        "https://example.com/artifacts",
879        "foo-bar",
880        "1.2.3",
881        "https://example.com/artifacts/foo-bar-1.2.3.tar.gz"
882    )]
883    #[case(
884        "https://example.com/artifacts",
885        "foo_bar",
886        "1.2.3",
887        "https://example.com/artifacts/foo_bar-1.2.3.tar.gz"
888    )]
889    // A9 / A10: semver prerelease and build metadata
890    #[case(
891        "https://example.com/artifacts",
892        "n",
893        "1.2.3-alpha.1",
894        "https://example.com/artifacts/n-1.2.3-alpha.1.tar.gz"
895    )]
896    #[case(
897        "https://example.com/artifacts",
898        "n",
899        "1.2.3+build.7",
900        "https://example.com/artifacts/n-1.2.3+build.7.tar.gz"
901    )]
902    // A11 / A12 / A13: query and fragment preservation
903    #[case(
904        "https://example.com/artifacts?channel=stable",
905        "n",
906        "1.2.3",
907        "https://example.com/artifacts/n-1.2.3.tar.gz?channel=stable"
908    )]
909    #[case(
910        "https://example.com/artifacts#deploy",
911        "n",
912        "1.2.3",
913        "https://example.com/artifacts/n-1.2.3.tar.gz#deploy"
914    )]
915    #[case(
916        "https://example.com/artifacts?t=1#x",
917        "n",
918        "1.2.3",
919        "https://example.com/artifacts/n-1.2.3.tar.gz?t=1#x"
920    )]
921    // A14: port
922    #[case(
923        "http://localhost:8080/artifacts",
924        "n",
925        "1.2.3",
926        "http://localhost:8080/artifacts/n-1.2.3.tar.gz"
927    )]
928    // A15: userinfo
929    #[case(
930        "https://u:p@host/path",
931        "n",
932        "1.2.3",
933        "https://u:p@host/path/n-1.2.3.tar.gz"
934    )]
935    // A16: file:// with host (UNC-style)
936    #[case(
937        "file://server/share/plugins",
938        "n",
939        "1.2.3",
940        "file://server/share/plugins/n-1.2.3.tar.gz"
941    )]
942    // A17: IDNA host
943    #[case(
944        "https://xn--n3h.example/a",
945        "n",
946        "1.2.3",
947        "https://xn--n3h.example/a/n-1.2.3.tar.gz"
948    )]
949    // A18: host-only, no path (parser normalizes to "/")
950    #[case("https://host", "n", "1.2.3", "https://host/n-1.2.3.tar.gz")]
951    fn artifact_url_composition(
952        #[case] base: &str,
953        #[case] name_input: &str,
954        #[case] version_input: &str,
955        #[case] expected: &str,
956    ) {
957        let base = ArtifactsUrl::try_new(base).expect("valid base URL in test");
958        let url = base.artifact_url(&name(name_input), &version(version_input));
959        assert_eq!(url.as_str(), expected);
960    }
961}
962
963#[cfg(test)]
964mod artifact_hash_tests {
965    use super::*;
966    use assert_matches::assert_matches;
967
968    #[test]
969    fn valid_hash_accepted() {
970        let h = ArtifactHash::try_new(
971            "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
972        )
973        .unwrap();
974        assert_eq!(h.as_str().len(), "sha256:".len() + 64);
975    }
976
977    #[test]
978    fn wrong_prefix_rejected() {
979        assert_matches!(
980            ArtifactHash::try_new(
981                "sha512:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
982            ),
983            Err(SchemaError::InvalidHash { .. })
984        );
985    }
986
987    #[test]
988    fn wrong_length_rejected() {
989        assert_matches!(
990            ArtifactHash::try_new("sha256:abc"),
991            Err(SchemaError::InvalidHash { .. })
992        );
993    }
994
995    #[test]
996    fn uppercase_hex_rejected() {
997        assert_matches!(
998            ArtifactHash::try_new(
999                "sha256:9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08"
1000            ),
1001            Err(SchemaError::InvalidHash { .. })
1002        );
1003    }
1004
1005    /// Doc rule: hash hex zone is exactly 64 lowercase hex chars (`0-9a-f`).
1006    /// Any char outside that set — letters `g-z`, ASCII symbols, etc. — is rejected.
1007    #[rstest::rstest]
1008    #[case("sha256:g000000000000000000000000000000000000000000000000000000000000000")]
1009    #[case("sha256:z000000000000000000000000000000000000000000000000000000000000000")]
1010    #[case("sha256:!000000000000000000000000000000000000000000000000000000000000000")]
1011    #[case("sha256: 000000000000000000000000000000000000000000000000000000000000000")]
1012    #[case("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00z08")]
1013    fn non_hex_chars_rejected(#[case] input: &str) {
1014        assert_matches!(
1015            ArtifactHash::try_new(input),
1016            Err(SchemaError::InvalidHash { .. })
1017        );
1018    }
1019}
1020
1021#[cfg(test)]
1022mod published_at_tests {
1023    use super::*;
1024    use assert_matches::assert_matches;
1025    use rstest::rstest;
1026
1027    #[test]
1028    fn valid_cargo_pubtime_format_is_accepted() {
1029        let published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1030        assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1031        assert_eq!(published_at.to_string(), "2026-04-29T18:45:12Z");
1032    }
1033
1034    #[test]
1035    fn from_utc_datetime_formats_as_cargo_pubtime_in_utc() {
1036        let source_offset = UtcOffset::from_hms(-5, 0, 0).unwrap();
1037        let source_datetime = PrimitiveDateTime::new(
1038            Date::from_calendar_date(2026, Month::April, 29).unwrap(),
1039            Time::from_hms_nano(13, 45, 12, 987_654_321).unwrap(),
1040        )
1041        .assume_offset(source_offset);
1042
1043        let published_at = PublishedAt::from_utc_datetime(source_datetime).unwrap();
1044
1045        assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1046    }
1047
1048    #[rstest]
1049    #[case("2026-04-29T18:45:12.123Z")]
1050    #[case("2026-04-29T13:45:12-05:00")]
1051    #[case("2026-04-29T18:45:12+00:00")]
1052    #[case("2026-4-29T18:45:12Z")]
1053    #[case("2026-04-29t18:45:12Z")]
1054    #[case("2026-04-29T18:45:12z")]
1055    #[case("2026-04-29 18:45:12Z")]
1056    #[case("2026-02-30T18:45:12Z")]
1057    #[case("2026-04-29T24:00:00Z")]
1058    fn invalid_cargo_pubtime_format_is_rejected(#[case] input: &str) {
1059        assert_matches!(
1060            PublishedAt::try_new(input),
1061            Err(SchemaError::InvalidPublishedAt { .. })
1062        );
1063    }
1064}
1065
1066#[cfg(test)]
1067mod index_tests {
1068    use super::*;
1069    use assert_matches::assert_matches;
1070    use pretty_assertions::assert_eq;
1071
1072    const MINIMAL: &str = r#"{
1073  "index_schema_version": "2.0",
1074  "artifacts_url": "https://plugins.example.com/artifacts",
1075  "plugins": [
1076    {
1077      "name": "downsampler",
1078      "version": "1.2.0",
1079      "published_at": "2026-04-29T18:45:12Z",
1080      "description": "Test plugin",
1081      "triggers": ["process_writes"],
1082      "dependencies": {
1083        "database_version": ">=3.2.0,<4.0.0",
1084        "python": []
1085      },
1086      "hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
1087    }
1088  ]
1089}"#;
1090
1091    fn minimal_with_published_at() -> String {
1092        MINIMAL.to_owned()
1093    }
1094
1095    fn minimal_without_published_at() -> String {
1096        MINIMAL.replace(
1097            r#"      "published_at": "2026-04-29T18:45:12Z",
1098"#,
1099            "",
1100        )
1101    }
1102
1103    #[test]
1104    fn parses_published_at_per_entry() {
1105        let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1106        assert_eq!(idx.plugins[0].published_at.as_str(), "2026-04-29T18:45:12Z");
1107    }
1108
1109    #[test]
1110    fn missing_published_at_rejected_per_entry() {
1111        let errors = Index::parse_json(&minimal_without_published_at()).unwrap_err();
1112        assert_eq!(errors.errors().len(), 1);
1113        assert_matches!(
1114            errors.errors()[0].error,
1115            SchemaError::InvalidPublishedAt { .. }
1116        );
1117        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1118    }
1119
1120    #[test]
1121    fn non_string_published_at_rejected_per_entry() {
1122        let src = minimal_with_published_at().replace(
1123            r#""published_at": "2026-04-29T18:45:12Z""#,
1124            r#""published_at": 123"#,
1125        );
1126        let errors = Index::parse_json(&src).unwrap_err();
1127        assert_eq!(errors.errors().len(), 1);
1128        assert_matches!(
1129            errors.errors()[0].error,
1130            SchemaError::InvalidPublishedAt { .. }
1131        );
1132        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1133    }
1134
1135    #[test]
1136    fn malformed_published_at_rejected_per_entry() {
1137        let src =
1138            minimal_with_published_at().replace("2026-04-29T18:45:12Z", "2026-04-29T18:45:12.123Z");
1139        let errors = Index::parse_json(&src).unwrap_err();
1140        assert_eq!(errors.errors().len(), 1);
1141        assert_matches!(
1142            errors.errors()[0].error,
1143            SchemaError::InvalidPublishedAt { .. }
1144        );
1145        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1146    }
1147
1148    #[test]
1149    fn parses_minimal_index() {
1150        let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1151        assert_eq!(idx.plugins.len(), 1);
1152        assert_eq!(idx.plugins[0].name.as_str(), "downsampler");
1153    }
1154
1155    #[test]
1156    fn yanked_absent_means_not_yanked() {
1157        let idx = Index::parse_json(MINIMAL).unwrap();
1158        assert!(!idx.plugins[0].yanked);
1159    }
1160
1161    #[test]
1162    fn yanked_true_parsed() {
1163        let src = MINIMAL.replace(r#""hash": "#, r#""yanked": true, "hash": "#);
1164        let idx = Index::parse_json(&src).unwrap();
1165        assert!(idx.plugins[0].yanked);
1166    }
1167
1168    #[test]
1169    fn yanked_false_parsed() {
1170        let src = MINIMAL.replace(r#""hash": "#, r#""yanked": false, "hash": "#);
1171        let idx = Index::parse_json(&src).unwrap();
1172        assert!(!idx.plugins[0].yanked);
1173    }
1174
1175    /// Doc rule: "Syntax errors, missing required fields, or wrong JSON
1176    /// container shape are reported as root-level JSON parse errors."
1177    /// Each case must surface as a single `JsonParse` error at root, without
1178    /// any field-level processing.
1179    #[rstest::rstest]
1180    #[case::not_json("not json")]
1181    #[case::trailing_garbage(r#"{"index_schema_version": "2.0"} extra"#)]
1182    #[case::root_is_array("[]")]
1183    #[case::root_is_number("42")]
1184    #[case::root_is_string(r#""hello""#)]
1185    #[case::missing_artifacts_url(r#"{"index_schema_version": "2.0", "plugins": []}"#)]
1186    #[case::missing_plugins(
1187        r#"{"index_schema_version": "2.0", "artifacts_url": "https://example.com/a"}"#
1188    )]
1189    fn malformed_json_short_circuits(#[case] input: &str) {
1190        let errors = Index::parse_json(input).unwrap_err();
1191        assert_eq!(
1192            errors.errors().len(),
1193            1,
1194            "expected single root-level error, got {:?}",
1195            errors.errors()
1196        );
1197        assert_matches!(errors.errors()[0].error, SchemaError::JsonParse { .. });
1198        assert_eq!(
1199            errors.errors()[0].path.as_str(),
1200            "",
1201            "expected root path, got {:?}",
1202            errors.errors()[0].path.as_str()
1203        );
1204    }
1205
1206    #[test]
1207    fn duplicate_name_version_rejected() {
1208        let dup = r#"{
1209  "index_schema_version": "2.0",
1210  "artifacts_url": "https://plugins.example.com/artifacts",
1211  "plugins": [
1212    { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1213      "dependencies": {"database_version":">=3.0.0","python":[]},
1214      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1215    { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1216      "dependencies": {"database_version":">=3.0.0","python":[]},
1217      "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1218  ]
1219}"#;
1220        let errors = Index::parse_json(dup).unwrap_err();
1221        assert_eq!(errors.errors().len(), 1);
1222        assert_matches!(
1223            errors.errors()[0].error,
1224            SchemaError::DuplicateIndexEntry { .. }
1225        );
1226        assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1227    }
1228
1229    #[test]
1230    fn index_rejects_hyphen_underscore_collision() {
1231        // `foo-bar` and `foo_bar` share the same canonical form (`foo_bar`);
1232        // the second entry is rejected even though the raw strings differ.
1233        let json = r#"{
1234  "index_schema_version": "2.0",
1235  "artifacts_url": "https://plugins.example.com/artifacts",
1236  "plugins": [
1237    { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1238      "dependencies": {"database_version":">=3.0.0","python":[]},
1239      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1240    { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1241      "dependencies": {"database_version":">=3.0.0","python":[]},
1242      "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1243  ]
1244}"#;
1245        let errors = Index::parse_json(json).expect_err("should reject canonical collision");
1246        assert_eq!(errors.errors().len(), 1);
1247        assert_matches!(
1248            errors.errors()[0].error,
1249            SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1250                if name == "foo_bar"
1251                    && canonical == "foo_bar"
1252                    && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1253        );
1254        assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1255    }
1256
1257    #[test]
1258    fn index_rejects_case_collision() {
1259        // `MyPlugin` and `myplugin` share canonical form `myplugin`.
1260        let json = r#"{
1261  "index_schema_version": "2.0",
1262  "artifacts_url": "https://plugins.example.com/artifacts",
1263  "plugins": [
1264    { "name": "MyPlugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1265      "dependencies": {"database_version":">=3.0.0","python":[]},
1266      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1267    { "name": "myplugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1268      "dependencies": {"database_version":">=3.0.0","python":[]},
1269      "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1270  ]
1271}"#;
1272        let errors = Index::parse_json(json).expect_err("should reject case collision");
1273        assert_eq!(errors.errors().len(), 1);
1274        assert_matches!(
1275            errors.errors()[0].error,
1276            SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1277                if name == "myplugin"
1278                    && canonical == "myplugin"
1279                    && existing == &vec![("MyPlugin".to_owned(), "1.0.0".to_owned())]
1280        );
1281        assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1282    }
1283
1284    #[test]
1285    fn index_rejects_three_way_canonical_collision() {
1286        // Three entries collapse to canonical `foo_bar`; first is accepted,
1287        // second and third each report with their original spelling.
1288        let json = r#"{
1289  "index_schema_version": "2.0",
1290  "artifacts_url": "https://plugins.example.com/artifacts",
1291  "plugins": [
1292    { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1293      "dependencies": {"database_version":">=3.0.0","python":[]},
1294      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1295    { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1296      "dependencies": {"database_version":">=3.0.0","python":[]},
1297      "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" },
1298    { "name": "FOO-BAR", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x3", "triggers": ["process_writes"],
1299      "dependencies": {"database_version":">=3.0.0","python":[]},
1300      "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1301  ]
1302}"#;
1303        let errors = Index::parse_json(json).expect_err("should reject two collisions");
1304        let e = errors.errors();
1305        assert_eq!(
1306            e.len(),
1307            2,
1308            "expected 2 errors, got {}: {:?}",
1309            e.len(),
1310            e.iter()
1311                .map(|r| (r.path.as_str(), &r.error))
1312                .collect::<Vec<_>>()
1313        );
1314        assert_matches!(
1315            e[0].error,
1316            SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1317                if name == "foo_bar"
1318                    && canonical == "foo_bar"
1319                    && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1320        );
1321        assert_eq!(e[0].path.as_str(), "plugins[1]");
1322        assert_matches!(
1323            e[1].error,
1324            SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1325                if name == "FOO-BAR"
1326                    && canonical == "foo_bar"
1327                    && existing == &vec![
1328                        ("foo-bar".to_owned(), "1.0.0".to_owned()),
1329                        ("foo_bar".to_owned(), "1.0.0".to_owned()),
1330                    ]
1331        );
1332        assert_eq!(e[1].path.as_str(), "plugins[2]");
1333    }
1334
1335    #[test]
1336    fn index_rejects_canonical_collision_across_versions() {
1337        // Previously allowed; now rejected. Different spellings that canonicalize
1338        // equal must collide regardless of version.
1339        let json = r#"{
1340  "index_schema_version": "2.0",
1341  "artifacts_url": "https://plugins.example.com/artifacts",
1342  "plugins": [
1343    { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1344      "dependencies": {"database_version":">=3.0.0","python":[]},
1345      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1346    { "name": "foo_bar", "version": "1.0.1", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1347      "dependencies": {"database_version":">=3.0.0","python":[]},
1348      "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1349  ]
1350}"#;
1351        let errors =
1352            Index::parse_json(json).expect_err("should reject canonical collision across versions");
1353        assert_eq!(errors.errors().len(), 1);
1354        assert_matches!(
1355            errors.errors()[0].error,
1356            SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1357                if name == "foo_bar"
1358                    && canonical == "foo_bar"
1359                    && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1360        );
1361        assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1362    }
1363
1364    #[test]
1365    fn ignores_unknown_per_entry_field() {
1366        let with_unknown =
1367            MINIMAL.replace(r#""hash": "#, r#""experimental_tag": "beta", "hash": "#);
1368        assert!(Index::parse_json(&with_unknown).is_ok());
1369    }
1370
1371    /// Index-entry validation mirrors manifest validation: same trigger and
1372    /// URL-scheme rules apply per entry.
1373    #[test]
1374    fn empty_triggers_rejected_per_entry() {
1375        let src = MINIMAL.replace(r#""triggers": ["process_writes"]"#, r#""triggers": []"#);
1376        let errors = Index::parse_json(&src).unwrap_err();
1377        assert_eq!(errors.errors().len(), 1);
1378        assert_matches!(errors.errors()[0].error, SchemaError::EmptyTriggers);
1379        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].triggers");
1380    }
1381
1382    #[test]
1383    fn invalid_homepage_scheme_rejected_per_entry() {
1384        let src = MINIMAL.replace(r#""hash": "#, r#""homepage": "ftp://bad/", "hash": "#);
1385        let errors = Index::parse_json(&src).unwrap_err();
1386        assert_eq!(errors.errors().len(), 1);
1387        assert_matches!(
1388            errors.errors()[0].error,
1389            SchemaError::InvalidUrlScheme { .. }
1390        );
1391        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].homepage");
1392    }
1393
1394    #[test]
1395    fn invalid_repository_scheme_rejected_per_entry() {
1396        let src = MINIMAL.replace(r#""hash": "#, r#""repository": "git://bad/", "hash": "#);
1397        let errors = Index::parse_json(&src).unwrap_err();
1398        assert_eq!(errors.errors().len(), 1);
1399        assert_matches!(
1400            errors.errors()[0].error,
1401            SchemaError::InvalidUrlScheme { .. }
1402        );
1403        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].repository");
1404    }
1405
1406    #[test]
1407    fn invalid_documentation_scheme_rejected_per_entry() {
1408        let src = MINIMAL.replace(
1409            r#""hash": "#,
1410            r#""documentation": "s3://bucket/docs", "hash": "#,
1411        );
1412        let errors = Index::parse_json(&src).unwrap_err();
1413        assert_eq!(errors.errors().len(), 1);
1414        assert_matches!(
1415            errors.errors()[0].error,
1416            SchemaError::InvalidUrlScheme { .. }
1417        );
1418        assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].documentation");
1419    }
1420
1421    #[test]
1422    fn http_and_https_optional_urls_accepted() {
1423        let src = MINIMAL.replace(
1424            r#""hash": "#,
1425            r#""homepage": "http://example.com", "repository": "https://github.com/x/y", "hash": "#,
1426        );
1427        assert!(Index::parse_json(&src).is_ok());
1428    }
1429
1430    #[test]
1431    fn ignores_unknown_top_level_field() {
1432        let src = MINIMAL.replace(
1433            r#""artifacts_url":"#,
1434            r#""experimental_top_level": true, "artifacts_url":"#,
1435        );
1436        assert!(Index::parse_json(&src).is_ok());
1437    }
1438
1439    /// Per-entry defects and duplicate-(name, version) detection collect in
1440    /// a single pass.
1441    #[test]
1442    fn collects_multiple_entry_defects_and_duplicate() {
1443        // plugins[0] valid; plugins[1] bad hash; plugins[2] duplicates
1444        // plugins[0]; plugins[3] description too long. Expect 3 errors.
1445        let long_desc = "a".repeat(201);
1446        let json = format!(
1447            r#"{{
1448  "index_schema_version": "2.0",
1449  "artifacts_url": "https://plugins.example.com/artifacts",
1450  "plugins": [
1451    {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1452       "dependencies": {{"database_version":">=3.0.0","python":[]}},
1453       "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }},
1454    {{ "name": "beta",  "version": "2.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1455       "dependencies": {{"database_version":">=3.0.0","python":[]}},
1456       "hash": "not-a-hash" }},
1457    {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1458       "dependencies": {{"database_version":">=3.0.0","python":[]}},
1459       "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }},
1460    {{ "name": "gamma", "version": "3.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "{long_desc}", "triggers": ["process_writes"],
1461       "dependencies": {{"database_version":">=3.0.0","python":[]}},
1462       "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }}
1463  ]
1464}}"#
1465        );
1466
1467        let errors = Index::parse_json(&json).expect_err("should fail");
1468        let e = errors.errors();
1469        assert_eq!(
1470            e.len(),
1471            3,
1472            "expected 3 errors, got {}: {:?}",
1473            e.len(),
1474            e.iter()
1475                .map(|r| (r.path.as_str(), &r.error))
1476                .collect::<Vec<_>>()
1477        );
1478
1479        let paths: Vec<&str> = e.iter().map(|r| r.path.as_str()).collect();
1480        assert!(
1481            paths.contains(&"plugins[1].hash"),
1482            "missing hash path: {paths:?}"
1483        );
1484        assert!(
1485            paths.iter().any(|p| p.starts_with("plugins[2]")),
1486            "missing duplicate path: {paths:?}"
1487        );
1488        assert!(
1489            paths.contains(&"plugins[3].description"),
1490            "missing description path: {paths:?}"
1491        );
1492    }
1493
1494    #[test]
1495    fn collects_published_at_defect_with_other_entry_defects() {
1496        let json = r#"{
1497  "index_schema_version": "2.0",
1498  "artifacts_url": "https://plugins.example.com/artifacts",
1499  "plugins": [
1500    { "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12.123Z", "description": "ok", "triggers": [],
1501      "dependencies": {"database_version":">=3.0.0","python":[]},
1502      "hash": "not-a-hash" }
1503  ]
1504}"#;
1505
1506        let errors = Index::parse_json(json).expect_err("should fail");
1507        let e = errors.errors();
1508        assert_eq!(e.len(), 3);
1509        assert!(
1510            e.iter().any(|reported| {
1511                reported.path.as_str() == "plugins[0].published_at"
1512                    && matches!(reported.error, SchemaError::InvalidPublishedAt { .. })
1513            }),
1514            "missing InvalidPublishedAt at plugins[0].published_at: {e:?}"
1515        );
1516        assert!(
1517            e.iter().any(|reported| {
1518                reported.path.as_str() == "plugins[0].triggers"
1519                    && matches!(reported.error, SchemaError::EmptyTriggers)
1520            }),
1521            "missing EmptyTriggers at plugins[0].triggers: {e:?}"
1522        );
1523        assert!(
1524            e.iter().any(|reported| {
1525                reported.path.as_str() == "plugins[0].hash"
1526                    && matches!(reported.error, SchemaError::InvalidHash { .. })
1527            }),
1528            "missing InvalidHash at plugins[0].hash: {e:?}"
1529        );
1530    }
1531
1532    #[test]
1533    fn index_schema_version_mismatch_short_circuits() {
1534        let json = r#"{
1535  "index_schema_version": "99.0",
1536  "artifacts_url": "ftp://bad",
1537  "plugins": []
1538}"#;
1539        let errors = Index::parse_json(json).expect_err("should fail");
1540        assert_eq!(errors.errors().len(), 1);
1541        assert_matches::assert_matches!(
1542            errors.errors()[0].error,
1543            SchemaError::UnsupportedIndexMajor { .. }
1544        );
1545    }
1546
1547    #[test]
1548    fn old_non_empty_index_without_published_at_is_rejected_by_schema_version() {
1549        let json = r#"{
1550  "index_schema_version": "1.0",
1551  "artifacts_url": "https://plugins.example.com/artifacts",
1552  "plugins": [
1553    { "name": "alpha", "version": "1.0.0", "description": "ok", "triggers": ["process_writes"],
1554      "dependencies": {"database_version":">=3.0.0","python":[]},
1555      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
1556  ]
1557}"#;
1558
1559        let errors = Index::parse_json(json).expect_err("old schema should fail");
1560        assert_eq!(errors.errors().len(), 1);
1561        assert_eq!(errors.errors()[0].path.as_str(), "index_schema_version");
1562        assert_matches!(
1563            errors.errors()[0].error,
1564            SchemaError::UnsupportedIndexMajor { ref found, supported }
1565                if found == "1.0" && supported == 2
1566        );
1567    }
1568}
1569
1570#[cfg(test)]
1571mod canonical_serialization_tests {
1572    use super::*;
1573    use pretty_assertions::assert_eq;
1574
1575    fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1576        IndexEntry {
1577            name: name.parse().unwrap(),
1578            version,
1579            published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1580            description: Description::try_new("desc").unwrap(),
1581            triggers: vec![TriggerType::ProcessWrites],
1582            homepage: None,
1583            repository: None,
1584            documentation: None,
1585            dependencies: Dependencies {
1586                database_version: ">=3.0.0".parse().unwrap(),
1587                python: vec![],
1588                plugins: vec![],
1589            },
1590            hash: ArtifactHash::try_new(
1591                "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1592            )
1593            .unwrap(),
1594            yanked: false,
1595        }
1596    }
1597
1598    #[test]
1599    fn plugins_sorted_by_name_then_version() {
1600        let idx = Index {
1601            index_schema_version: IndexSchemaVersion::CURRENT,
1602            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1603            plugins: vec![
1604                make_entry("zebra", semver::Version::new(1, 0, 0)),
1605                make_entry("alpha", semver::Version::new(2, 0, 0)),
1606                make_entry("alpha", semver::Version::new(1, 0, 0)),
1607            ],
1608        };
1609        let out = idx.to_canonical_json().unwrap();
1610        let alpha_1_pos = out.find("\"alpha\"").unwrap();
1611        let alpha_2_pos = out[alpha_1_pos + 1..].find("\"alpha\"").unwrap() + alpha_1_pos + 1;
1612        let zebra_pos = out.find("\"zebra\"").unwrap();
1613        assert!(alpha_1_pos < alpha_2_pos, "alpha 1.0 before alpha 2.0");
1614        assert!(alpha_2_pos < zebra_pos, "alpha before zebra");
1615    }
1616
1617    #[test]
1618    fn two_serialize_calls_produce_byte_identical() {
1619        let idx = Index {
1620            index_schema_version: IndexSchemaVersion::CURRENT,
1621            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1622            plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1623        };
1624        let a = idx.to_canonical_json().unwrap();
1625        let b = idx.to_canonical_json().unwrap();
1626        assert_eq!(a, b);
1627    }
1628
1629    #[test]
1630    fn ends_with_newline() {
1631        let idx = Index {
1632            index_schema_version: IndexSchemaVersion::CURRENT,
1633            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1634            plugins: vec![],
1635        };
1636        let out = idx.to_canonical_json().unwrap();
1637        assert!(out.ends_with('\n'));
1638    }
1639
1640    #[test]
1641    fn two_space_indent() {
1642        let idx = Index {
1643            index_schema_version: IndexSchemaVersion::CURRENT,
1644            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1645            plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1646        };
1647        let out = idx.to_canonical_json().unwrap();
1648        assert!(
1649            out.contains("\n  \"index_schema_version\""),
1650            "expected 2-space indent at top level"
1651        );
1652    }
1653
1654    #[test]
1655    fn snapshot_locks_full_shape() {
1656        let idx = Index {
1657            index_schema_version: IndexSchemaVersion::CURRENT,
1658            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1659            plugins: vec![make_entry("alpha", semver::Version::new(1, 0, 0)), {
1660                let mut e = make_entry("beta", semver::Version::new(2, 1, 0));
1661                e.yanked = true;
1662                e.dependencies.plugins = vec![crate::PluginDependency {
1663                    index_url: crate::IndexUrl::try_new("https://plugins.example.com/index.json")
1664                        .unwrap(),
1665                    name: "geo-lookup".parse().unwrap(),
1666                    version: ">=1.0.0,<2.0.0".parse().unwrap(),
1667                }];
1668                e
1669            }],
1670        };
1671        insta::assert_snapshot!("canonical_full_shape", idx.to_canonical_json().unwrap());
1672    }
1673
1674    /// D4: an entry with no plugin dependencies omits the `plugins` key from
1675    /// its `dependencies` object entirely — legacy entries stay byte-identical
1676    /// when a legacy index is rewritten by newer tooling.
1677    #[test]
1678    fn empty_plugin_dependencies_key_is_omitted() {
1679        let idx = Index {
1680            index_schema_version: IndexSchemaVersion::CURRENT,
1681            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1682            plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1683        };
1684        let out = idx.to_canonical_json().unwrap();
1685        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1686        assert!(
1687            parsed["plugins"][0]["dependencies"]
1688                .get("plugins")
1689                .is_none(),
1690            "empty dependencies.plugins must be omitted, got: {}",
1691            parsed["plugins"][0]["dependencies"]
1692        );
1693        // `python` keeps its always-emitted pattern; the asymmetry is D4.
1694        assert!(parsed["plugins"][0]["dependencies"].get("python").is_some());
1695    }
1696
1697    /// A plugin dependency's `index_url` serializes in its normalized
1698    /// `url::Url` form — scheme and host case-folded, default port dropped —
1699    /// the same rule as every other URL field.
1700    #[test]
1701    fn plugin_dependency_index_url_serializes_normalized() {
1702        let idx = Index {
1703            index_schema_version: IndexSchemaVersion::CURRENT,
1704            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1705            plugins: vec![{
1706                let mut e = make_entry("x", semver::Version::new(1, 0, 0));
1707                e.dependencies.plugins = vec![crate::PluginDependency {
1708                    index_url: crate::IndexUrl::try_new(
1709                        "HTTPS://Plugins.EXAMPLE.com:443/index.json",
1710                    )
1711                    .unwrap(),
1712                    name: "geo-lookup".parse().unwrap(),
1713                    version: ">=1.0.0".parse().unwrap(),
1714                }];
1715                e
1716            }],
1717        };
1718        let out = idx.to_canonical_json().unwrap();
1719        assert!(
1720            out.contains("\"index_url\": \"https://plugins.example.com/index.json\""),
1721            "index_url must serialize in normalized form, got: {out}"
1722        );
1723    }
1724
1725    /// D7: canonical output always declares the writer's schema version — a
1726    /// legacy `2.0` index upgrades implicitly on write, and (with D4) its
1727    /// entries serialize byte-identically to the same content declared `2.1`.
1728    #[test]
1729    fn canonical_write_stamps_current_version_on_legacy_index() {
1730        let legacy = r#"{
1731          "index_schema_version": "2.0",
1732          "artifacts_url": "https://example.com/artifacts",
1733          "plugins": [
1734            {
1735              "name": "downsampler",
1736              "version": "1.2.0",
1737              "published_at": "2026-04-29T18:45:12Z",
1738              "description": "desc",
1739              "triggers": ["process_writes"],
1740              "dependencies": { "database_version": ">=3.0.0", "python": [] },
1741              "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1742            }
1743          ]
1744        }"#;
1745        let current = legacy.replace("\"2.0\"", "\"2.1\"");
1746
1747        let from_legacy = Index::parse_json(legacy)
1748            .unwrap()
1749            .to_canonical_json()
1750            .unwrap();
1751        let from_current = Index::parse_json(&current)
1752            .unwrap()
1753            .to_canonical_json()
1754            .unwrap();
1755
1756        assert!(from_legacy.contains("\"index_schema_version\": \"2.1\""));
1757        assert_eq!(
1758            from_legacy, from_current,
1759            "legacy rewrite must differ only in the stamped version"
1760        );
1761    }
1762
1763    #[test]
1764    fn entry_field_order_is_canonical() {
1765        let idx = Index {
1766            index_schema_version: IndexSchemaVersion::CURRENT,
1767            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1768            plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1769        };
1770        let out = idx.to_canonical_json().unwrap();
1771        let name_pos = out.find("\"name\"").unwrap();
1772        let version_pos = out.find("\"version\"").unwrap();
1773        let published_at_pos = out.find("\"published_at\"").unwrap();
1774        let description_pos = out.find("\"description\"").unwrap();
1775        let triggers_pos = out.find("\"triggers\"").unwrap();
1776        let dependencies_pos = out.find("\"dependencies\"").unwrap();
1777        let hash_pos = out.find("\"hash\"").unwrap();
1778        assert!(name_pos < version_pos);
1779        assert!(version_pos < published_at_pos);
1780        assert!(published_at_pos < description_pos);
1781        assert!(description_pos < triggers_pos);
1782        assert!(triggers_pos < dependencies_pos);
1783        assert!(dependencies_pos < hash_pos);
1784    }
1785
1786    #[test]
1787    fn published_at_is_preserved_exactly_in_canonical_json() {
1788        let mut entry = make_entry("x", semver::Version::new(1, 0, 0));
1789        entry.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1790        let idx = Index {
1791            index_schema_version: IndexSchemaVersion::CURRENT,
1792            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1793            plugins: vec![entry],
1794        };
1795        let out = idx.to_canonical_json().unwrap();
1796        assert!(out.contains(r#""published_at": "2027-01-02T03:04:05Z""#));
1797    }
1798
1799    #[test]
1800    fn published_at_does_not_affect_sort() {
1801        let mut alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1802        alpha.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1803        let mut beta = make_entry("beta", semver::Version::new(1, 0, 0));
1804        beta.published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1805        let idx = Index {
1806            index_schema_version: IndexSchemaVersion::CURRENT,
1807            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1808            plugins: vec![beta, alpha],
1809        };
1810        let out = idx.to_canonical_json().unwrap();
1811        let alpha_pos = out.find("\"alpha\"").unwrap();
1812        let beta_pos = out.find("\"beta\"").unwrap();
1813        assert!(alpha_pos < beta_pos, "name sort must ignore published_at");
1814    }
1815
1816    #[test]
1817    fn parse_canonical_round_trip_is_idempotent() {
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("x", semver::Version::new(1, 0, 0))],
1822        };
1823        let first = idx.to_canonical_json().unwrap();
1824        let reparsed = Index::parse_json(&first).unwrap();
1825        let second = reparsed.to_canonical_json().unwrap();
1826        assert_eq!(first, second);
1827    }
1828
1829    #[test]
1830    fn nfc_normalization_makes_precomposed_equal_decomposed() {
1831        // A uses precomposed "é" (U+00E9); B uses "e" + combining acute
1832        // (U+0065 U+0301). NFC collapses both to U+00E9, so canonical output
1833        // is byte-identical.
1834        let precomposed = Description::try_new("caf\u{00E9}").unwrap();
1835        let decomposed = Description::try_new("cafe\u{0301}").unwrap();
1836        let entry_a = IndexEntry {
1837            description: precomposed,
1838            ..make_entry("x", semver::Version::new(1, 0, 0))
1839        };
1840        let entry_b = IndexEntry {
1841            description: decomposed,
1842            ..make_entry("x", semver::Version::new(1, 0, 0))
1843        };
1844
1845        let idx_a = Index {
1846            index_schema_version: IndexSchemaVersion::CURRENT,
1847            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1848            plugins: vec![entry_a],
1849        };
1850        let idx_b = Index {
1851            index_schema_version: IndexSchemaVersion::CURRENT,
1852            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1853            plugins: vec![entry_b],
1854        };
1855        assert_eq!(
1856            idx_a.to_canonical_json().unwrap(),
1857            idx_b.to_canonical_json().unwrap()
1858        );
1859    }
1860
1861    #[test]
1862    fn yank_status_does_not_affect_sort() {
1863        let mut yanked_alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1864        yanked_alpha.yanked = true;
1865        let idx = Index {
1866            index_schema_version: IndexSchemaVersion::CURRENT,
1867            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1868            plugins: vec![
1869                make_entry("beta", semver::Version::new(1, 0, 0)),
1870                yanked_alpha,
1871            ],
1872        };
1873        let out = idx.to_canonical_json().unwrap();
1874        let alpha_pos = out.find("\"alpha\"").unwrap();
1875        let beta_pos = out.find("\"beta\"").unwrap();
1876        assert!(
1877            alpha_pos < beta_pos,
1878            "yanked alpha should still sort before beta"
1879        );
1880    }
1881
1882    /// SemVer 2.0.0: a prerelease has lower precedence than the corresponding
1883    /// release (`1.0.0-alpha < 1.0.0`), so canonical ordering must put it
1884    /// first — otherwise "latest version" queries would be wrong.
1885    #[test]
1886    fn prerelease_sorts_before_release_at_same_major_minor_patch() {
1887        let prerelease = make_entry("p", "1.0.0-alpha".parse().unwrap());
1888        let release = make_entry("p", semver::Version::new(1, 0, 0));
1889        let idx = Index {
1890            index_schema_version: IndexSchemaVersion::CURRENT,
1891            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1892            // Reverse-of-expected order forces the sort.
1893            plugins: vec![release, prerelease],
1894        };
1895        let out = idx.to_canonical_json().unwrap();
1896        let alpha_pos = out.find(r#""1.0.0-alpha""#).unwrap();
1897        let release_pos = out.find(r#""1.0.0""#).unwrap();
1898        assert!(
1899            alpha_pos < release_pos,
1900            "prerelease 1.0.0-alpha must sort before release 1.0.0"
1901        );
1902    }
1903
1904    /// SemVer 2.0.0: build metadata is ignored for precedence, so entries
1905    /// differing only in build metadata must compare equal via
1906    /// `cmp_precedence`. Plain `Version::cmp` would order them lexically on
1907    /// the build string, so `to_canonical_json` must use `cmp_precedence`.
1908    #[test]
1909    fn build_metadata_does_not_affect_precedence() {
1910        let v_a: semver::Version = "1.0.0+build.1".parse().unwrap();
1911        let v_b: semver::Version = "1.0.0+build.2".parse().unwrap();
1912
1913        assert_eq!(v_a.cmp_precedence(&v_b), std::cmp::Ordering::Equal);
1914        // Sanity: plain `cmp` distinguishes them, which is why the sort
1915        // cannot use `cmp` here.
1916        assert_ne!(v_a.cmp(&v_b), std::cmp::Ordering::Equal);
1917
1918        let idx = Index {
1919            index_schema_version: IndexSchemaVersion::CURRENT,
1920            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1921            // Equal-precedence entries preserve input order via stable sort.
1922            plugins: vec![make_entry("p", v_a.clone()), make_entry("p", v_b.clone())],
1923        };
1924        let out = idx.to_canonical_json().unwrap();
1925        let pos_a = out.find("1.0.0+build.1").unwrap();
1926        let pos_b = out.find("1.0.0+build.2").unwrap();
1927        assert!(
1928            pos_a < pos_b,
1929            "stable sort preserves input order for equal-precedence entries"
1930        );
1931    }
1932}
1933
1934#[cfg(test)]
1935mod insert_tests {
1936    use super::*;
1937    use crate::{
1938        ArtifactHash, ArtifactsUrl, Dependencies, Description, IndexInsertError, TriggerType,
1939    };
1940    use assert_matches::assert_matches;
1941
1942    fn empty_index() -> Index {
1943        Index {
1944            index_schema_version: IndexSchemaVersion::CURRENT,
1945            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1946            plugins: vec![],
1947        }
1948    }
1949
1950    fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1951        IndexEntry {
1952            name: name.parse().unwrap(),
1953            version,
1954            published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1955            description: Description::try_new("desc").unwrap(),
1956            triggers: vec![TriggerType::ProcessWrites],
1957            homepage: None,
1958            repository: None,
1959            documentation: None,
1960            dependencies: Dependencies {
1961                database_version: ">=3.0.0".parse().unwrap(),
1962                python: vec![],
1963                plugins: vec![],
1964            },
1965            hash: ArtifactHash::try_new(
1966                "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1967            )
1968            .unwrap(),
1969            yanked: false,
1970        }
1971    }
1972
1973    #[test]
1974    fn empty_index_accepts_append() {
1975        let mut idx = empty_index();
1976        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1977            .unwrap();
1978        assert_eq!(idx.plugins.len(), 1);
1979    }
1980
1981    #[test]
1982    fn same_spelling_different_version_accepted() {
1983        let mut idx = empty_index();
1984        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1985            .unwrap();
1986        idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
1987            .unwrap();
1988        assert_eq!(idx.plugins.len(), 2);
1989    }
1990
1991    #[test]
1992    fn same_spelling_same_version_returns_duplicate() {
1993        let mut idx = empty_index();
1994        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1995            .unwrap();
1996        let err = idx
1997            .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1998            .unwrap_err();
1999        assert_matches!(err, IndexInsertError::Duplicate { .. });
2000    }
2001
2002    #[test]
2003    fn duplicate_error_lists_existing_versions_in_index_order() {
2004        let mut idx = empty_index();
2005        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2006            .unwrap();
2007        idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
2008            .unwrap();
2009        let err = idx
2010            .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2011            .unwrap_err();
2012        match err {
2013            IndexInsertError::Duplicate {
2014                existing_versions, ..
2015            } => {
2016                assert_eq!(
2017                    existing_versions,
2018                    vec![semver::Version::new(1, 0, 0), semver::Version::new(1, 1, 0)]
2019                );
2020            }
2021            other => panic!("expected Duplicate, got {other:?}"),
2022        }
2023    }
2024
2025    #[test]
2026    fn hyphen_underscore_canonical_collision_rejected() {
2027        let mut idx = empty_index();
2028        idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
2029            .unwrap();
2030        let err = idx
2031            .push_entry(make_entry("foo_bar", semver::Version::new(1, 0, 0)))
2032            .unwrap_err();
2033        assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2034    }
2035
2036    #[test]
2037    fn case_only_canonical_collision_rejected() {
2038        let mut idx = empty_index();
2039        idx.push_entry(make_entry("MyPlugin", semver::Version::new(1, 0, 0)))
2040            .unwrap();
2041        let err = idx
2042            .push_entry(make_entry("myplugin", semver::Version::new(1, 0, 0)))
2043            .unwrap_err();
2044        assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2045    }
2046
2047    #[test]
2048    fn canonical_collision_rejected_even_when_version_differs() {
2049        let mut idx = empty_index();
2050        idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
2051            .unwrap();
2052        let err = idx
2053            .push_entry(make_entry("foo_bar", semver::Version::new(2, 0, 0)))
2054            .unwrap_err();
2055        assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2056    }
2057
2058    #[test]
2059    fn index_unchanged_after_failed_push_entry() {
2060        let mut idx = empty_index();
2061        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2062            .unwrap();
2063        let snapshot = idx.clone();
2064        let _ = idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)));
2065        assert_eq!(idx, snapshot);
2066    }
2067
2068    #[test]
2069    fn check_entry_insert_does_not_mutate() {
2070        let mut idx = empty_index();
2071        idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2072            .unwrap();
2073        let snapshot = idx.clone();
2074        let entry = make_entry("alpha", semver::Version::new(2, 0, 0));
2075        idx.check_entry_insert(&entry).unwrap();
2076        assert_eq!(idx, snapshot);
2077    }
2078}
2079
2080#[cfg(test)]
2081mod from_manifest_tests {
2082    use super::*;
2083    use crate::{
2084        ArtifactHash, Dependencies, Description, Manifest, ManifestSchemaVersion, PluginMetadata,
2085        PythonRequirement, TriggerType,
2086    };
2087
2088    fn sample_manifest() -> Manifest {
2089        Manifest {
2090            manifest_schema_version: ManifestSchemaVersion::new(1, 1),
2091            plugin: PluginMetadata {
2092                name: "downsampler".parse().unwrap(),
2093                version: semver::Version::new(1, 2, 0),
2094                description: Description::try_new("A downsampling plugin").unwrap(),
2095                triggers: vec![
2096                    TriggerType::ProcessWrites,
2097                    TriggerType::ProcessScheduledCall,
2098                ],
2099                homepage: Some(url::Url::parse("https://example.com").unwrap()),
2100                repository: Some(url::Url::parse("https://github.com/example/repo").unwrap()),
2101                documentation: Some(url::Url::parse("https://docs.example.com").unwrap()),
2102                exclude: vec![],
2103            },
2104            dependencies: Dependencies {
2105                database_version: ">=3.2.0,<4.0.0".parse().unwrap(),
2106                python: vec![PythonRequirement::try_new("requests>=2.31,<3").unwrap()],
2107                plugins: vec![crate::PluginDependency {
2108                    index_url: crate::IndexUrl::try_new("https://plugins.example.com/index.json")
2109                        .unwrap(),
2110                    name: "geo-lookup".parse().unwrap(),
2111                    version: ">=1.0.0,<2.0.0".parse().unwrap(),
2112                }],
2113            },
2114        }
2115    }
2116
2117    fn sample_hash() -> ArtifactHash {
2118        ArtifactHash::try_new(
2119            "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
2120        )
2121        .unwrap()
2122    }
2123
2124    #[test]
2125    fn copies_name() {
2126        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2127        assert_eq!(entry.name.as_str(), "downsampler");
2128    }
2129
2130    #[test]
2131    fn copies_version() {
2132        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2133        assert_eq!(entry.version, semver::Version::new(1, 2, 0));
2134    }
2135
2136    #[test]
2137    fn copies_description() {
2138        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2139        assert_eq!(entry.description.as_str(), "A downsampling plugin");
2140    }
2141
2142    #[test]
2143    fn copies_triggers() {
2144        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2145        assert_eq!(
2146            entry.triggers,
2147            vec![
2148                TriggerType::ProcessWrites,
2149                TriggerType::ProcessScheduledCall
2150            ]
2151        );
2152    }
2153
2154    #[test]
2155    fn copies_homepage() {
2156        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2157        assert_eq!(entry.homepage.unwrap().as_str(), "https://example.com/");
2158    }
2159
2160    #[test]
2161    fn copies_repository() {
2162        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2163        assert_eq!(
2164            entry.repository.unwrap().as_str(),
2165            "https://github.com/example/repo"
2166        );
2167    }
2168
2169    #[test]
2170    fn copies_documentation() {
2171        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2172        assert_eq!(
2173            entry.documentation.unwrap().as_str(),
2174            "https://docs.example.com/"
2175        );
2176    }
2177
2178    #[test]
2179    fn copies_dependencies() {
2180        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2181        assert_eq!(entry.dependencies.python.len(), 1);
2182        assert_eq!(entry.dependencies.plugins.len(), 1);
2183        let dep = &entry.dependencies.plugins[0];
2184        assert_eq!(
2185            dep.index_url.as_url().as_str(),
2186            "https://plugins.example.com/index.json"
2187        );
2188        assert_eq!(dep.name.as_str(), "geo-lookup");
2189    }
2190
2191    #[test]
2192    fn copies_hash() {
2193        let h = sample_hash();
2194        let entry = IndexEntry::from_manifest(sample_manifest(), h.clone());
2195        assert_eq!(entry.hash, h);
2196    }
2197
2198    #[test]
2199    fn copies_injected_published_at() {
2200        let published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
2201        let entry = IndexEntry::from_manifest_with_published_at(
2202            sample_manifest(),
2203            sample_hash(),
2204            published_at.clone(),
2205        );
2206        assert_eq!(entry.published_at, published_at);
2207    }
2208
2209    #[test]
2210    fn from_manifest_assigns_valid_cargo_pubtime_published_at() {
2211        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2212        assert_eq!(
2213            PublishedAt::try_new(entry.published_at.as_str()).unwrap(),
2214            entry.published_at
2215        );
2216        assert_eq!(entry.published_at.as_str().len(), PublishedAt::LEN);
2217        assert!(entry.published_at.as_str().ends_with('Z'));
2218        assert!(!entry.published_at.as_str().contains('.'));
2219        assert!(!entry.published_at.as_str().contains('+'));
2220    }
2221
2222    #[test]
2223    fn yanked_is_false() {
2224        let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2225        assert!(!entry.yanked);
2226    }
2227}
2228
2229#[cfg(test)]
2230mod plugin_dependency_index_tests {
2231    use super::*;
2232    use assert_matches::assert_matches;
2233
2234    fn index_with_entry_deps(deps_json: &str) -> String {
2235        format!(
2236            r#"{{
2237  "index_schema_version": "2.1",
2238  "artifacts_url": "https://example.com/artifacts",
2239  "plugins": [
2240    {{
2241      "name": "downsampler",
2242      "version": "1.2.0",
2243      "published_at": "2026-04-29T18:45:12Z",
2244      "description": "desc",
2245      "triggers": ["process_writes"],
2246      "dependencies": {{ "database_version": ">=3.0.0", "python": []{deps_json} }},
2247      "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2248    }}
2249  ]
2250}}"#
2251        )
2252    }
2253
2254    #[test]
2255    fn parses_entry_plugin_dependencies() {
2256        let src = index_with_entry_deps(
2257            r#", "plugins": [
2258                {"index_url": "https://plugins.example.com/index.json",
2259                 "name": "geo-lookup",
2260                 "version": ">=1.0.0,<2.0.0"}
2261            ]"#,
2262        );
2263        let idx = Index::parse_json(&src).expect("entry plugin deps should parse");
2264        let deps = &idx.plugins[0].dependencies.plugins;
2265        assert_eq!(deps.len(), 1);
2266        assert_eq!(
2267            deps[0].index_url.as_url().as_str(),
2268            "https://plugins.example.com/index.json"
2269        );
2270        assert_eq!(deps[0].name.as_str(), "geo-lookup");
2271    }
2272
2273    #[test]
2274    fn missing_entry_plugins_defaults_empty() {
2275        let src = index_with_entry_deps("");
2276        let idx = Index::parse_json(&src).unwrap();
2277        assert!(idx.plugins[0].dependencies.plugins.is_empty());
2278    }
2279
2280    /// Same shared validator as the manifest parser, with index-side paths.
2281    #[test]
2282    fn rejects_invalid_entry_fields_with_index_paths() {
2283        let src = index_with_entry_deps(
2284            r#", "plugins": [
2285                {"index_url": "https://plugins.example.com/index.json",
2286                 "name": "geo-lookup",
2287                 "version": ">=1.0.0"},
2288                {"index_url": "s3://bucket/index.json",
2289                 "name": "Bad Name",
2290                 "version": ">=bad"}
2291            ]"#,
2292        );
2293        let errors = Index::parse_json(&src).expect_err("should reject");
2294        let paths: Vec<&str> = errors.errors().iter().map(|r| r.path.as_str()).collect();
2295        assert_eq!(
2296            paths,
2297            vec![
2298                "plugins[0].dependencies.plugins[1].index_url",
2299                "plugins[0].dependencies.plugins[1].name",
2300                "plugins[0].dependencies.plugins[1].version",
2301            ]
2302        );
2303    }
2304
2305    #[test]
2306    fn rejects_duplicate_entry_dependency() {
2307        let src = index_with_entry_deps(
2308            r#", "plugins": [
2309                {"index_url": "https://plugins.example.com/index.json",
2310                 "name": "geo-lookup",
2311                 "version": ">=1.0.0"},
2312                {"index_url": "https://plugins.EXAMPLE.com/index.json",
2313                 "name": "geo_lookup",
2314                 "version": ">=2.0.0"}
2315            ]"#,
2316        );
2317        let errors = Index::parse_json(&src).expect_err("duplicate should reject");
2318        assert_eq!(errors.errors().len(), 1, "errors: {errors}");
2319        assert_eq!(
2320            errors.errors()[0].path.as_str(),
2321            "plugins[0].dependencies.plugins[1]"
2322        );
2323        assert_matches!(
2324            errors.errors()[0].error,
2325            SchemaError::DuplicatePluginDependency { .. }
2326        );
2327    }
2328}