diff --git a/epiphany-pipeline/src/lib.rs b/epiphany-pipeline/src/lib.rs index 39e79e32..e8697753 100644 --- a/epiphany-pipeline/src/lib.rs +++ b/epiphany-pipeline/src/lib.rs @@ -60,6 +60,7 @@ pub enum PipelineRefusal { FieldBound { field: String, limit: u32, actual: u32 }, InvalidFormat { field: String, value: String }, InvalidIdentity { kind: PipelineKind, key: String, expected: String }, + DuplicateRepo { field: String, repo: String }, ForeignStore { r#type: String }, } @@ -275,19 +276,24 @@ impl Slug { /// 100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or /// trailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never /// `.` or `..`, and does not end in `.git`, ASCII-case-insensitively. -/// -/// The raw string is display-only. `OrgRepo` is not generated by -/// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd` -/// and `Ord` on the raw string, with no per-member override, so a type built -/// from it compares and keys on spelling. `OrgRepo` instead carries -/// hand-written impls of those five traits, every one of them over -/// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a -/// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany` -/// and `gamecult/epiphany` compare equal, hash equal, order together and -/// collapse to one map entry, and a caller cannot reach the case-sensitive -/// behaviour by accident. `Hash` and `Eq` agree because both are defined over -/// the same `identity()` string. Every other bounded type keeps the macro's -/// derives untouched. +/// Identity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany` +/// are the same repository. +// +// The raw string is display-only. `OrgRepo` is not generated by +// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd` +// and `Ord` on the raw string, with no per-member override, so a type built +// from it compares and keys on spelling. `OrgRepo` instead carries +// hand-written impls of those five traits, every one of them over +// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a +// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany` +// and `gamecult/epiphany` compare equal, hash equal, order together and +// collapse to one map entry, and a caller cannot reach the case-sensitive +// behaviour by accident. `Hash` and `Eq` agree because both are defined over +// the same `identity()` string. Every other bounded type keeps the macro's +// derives untouched. This reasoning is a source comment, not a doc comment, +// so it does not enter the published schema description (RS-L closing fix 2): +// a consumer needs the grammar, the bound and the case-insensitivity, not the +// Rust-macro justification for how that got built. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] #[schemars(extend("maxLength" = 140))] @@ -392,7 +398,7 @@ macro_rules! bounded_field { /// The single field list: one invocation emits the value struct and its /// `Bounded` impl, so a field can never be left out of validation. macro_rules! value_types { - ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? })*) => {$( + ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$( $(#[$attr])* #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct $name { @@ -405,6 +411,7 @@ macro_rules! value_types { impl Bounded for $name { fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* + $($extra(self, at)?;)? Ok(()) } } @@ -423,6 +430,26 @@ macro_rules! unit_enums { )*}; } +/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same +/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and +/// `gamecult/epiphany` in one list refuse exactly as one entry twice would. +/// This is a cross-element invariant on `PipelineCampaign` alone, not a +/// per-type format rule any other `value_types!` struct shares, so it is +/// wired in as that struct's one `=> extra` hook rather than folded into +/// `Bounded for OrgRepo` or the list bound in `bounded_field!`. +fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { + let mut seen = std::collections::HashSet::new(); + for repo in &campaign.repos { + if !seen.insert(repo.identity()) { + return Err(PipelineRefusal::DuplicateRepo { + field: format!("{at}.repos"), + repo: repo.0.clone(), + }); + } + } + Ok(()) +} + unit_enums! { EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } ClaimOutcome { Holds, Falsified, Unproven } @@ -480,7 +507,7 @@ value_types! { promise: Option