onevcs/rules.rs
1//! The rules file: how each repository publishes a change.
2//!
3//! YAML, first match wins. A rule matches on the host/owner/name of an identity
4//! or on a checkout path, and contributes whichever of the two policy fields it
5//! sets; anything it leaves unset comes from [`RulesFile::default`].
6//!
7//! What *verifies* a change is not here and never comes back. The repository's own
8//! merge path is the verifier — the host's required checks for an identity that
9//! publishes through a change request, the `pre-push` hook for one that publishes
10//! `local-direct` — and a second tier beside it front-ran the real one and threw the
11//! answer away.
12//!
13//! **These two fields are the whole of how an identity publishes.** The registry
14//! records nothing about it: it used to infer a workflow and a repository type from
15//! whether the origin had a host, and every verb that routed on them — the merge
16//! train's gate, the merge-path coverage verdict, the handoff naming the verb for a
17//! complete branch — routes on `publication` now, with `approvals` carrying the
18//! review requirement the repository type never actually carried. A rule matching an
19//! organisation (`host` and `owner`) is that organisation's default, and one adding a
20//! `name` is the per-repository override; nothing else is needed to say that one
21//! hosted repository lands locally while its siblings open change requests.
22//!
23//! Nothing here declares `deny_unknown_fields`, so a file a *newer* build wrote
24//! loads on this one: the keys it understands decide the policy and the rest are
25//! ignored. What that trades away is a typo being caught, and it is traded
26//! deliberately — an older build refusing an operator's whole rules file stops every
27//! verb on the host. A key this build refuses *by name* is unaffected: the `gate:`
28//! version 3 removed is refused where the file is loaded, which is also the only
29//! place that knows which version declared it.
30
31use clap::ValueEnum;
32use serde::{Deserialize, Serialize};
33
34/// A rules file, as it is stored on disk.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RulesFile {
37 /// The schema version: `1` is this shape without `trailer_prefix`, `2` with it,
38 /// `3` without `gate`.
39 // llmlint: ignore[boundary_inputs_validated] which versions this build can read is the
40 // loader's question rather than this type's, and it answers it: it refuses one below
41 // the oldest it reads, refuses a trailer_prefix in a version that predates the key,
42 // drops a `gate:` from a version that still had one before this type ever sees it, and
43 // refuses one by name at the version that removed it. The shape is enforced here — an
44 // undeclared publication or approvals value, or a missing default, is rejected at this
45 // boundary and asserted in tests/contract.rs.
46 pub version: u32,
47 /// The prefix every provenance trailer key carries, written and read.
48 ///
49 /// Unset is [`TrailerPrefix::default`], which is what this crate has always
50 /// written. A host whose branches were preserved by something spelling those
51 /// keys differently sets the prefix it already wrote, and this build then
52 /// recognizes, lists, and recovers that work — the prefix is the whole hook,
53 /// and no particular value of it means anything here.
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub trailer_prefix: Option<TrailerPrefix>,
56 /// The rules, in priority order: the first one that matches wins.
57 pub rules: Vec<Rule>,
58 /// The policy for a repository no rule matches.
59 pub default: Policy,
60}
61
62/// The prefix every provenance trailer key is spelled under.
63///
64/// The check is in the conversion, so a rules file naming a prefix that could not
65/// spell a git trailer key does not deserialize at all — the marker is written and
66/// read from one value, and a value neither side can find again is unrepresentable
67/// rather than representable-and-noticed-later.
68#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(try_from = "String", into = "String")]
70pub struct TrailerPrefix(String);
71
72/// The prefix itself, quoted — never the wrapper. Every refusal that names one
73/// writes it with `{:?}`, and a derived `Debug` would spell each of them
74/// `TrailerPrefix("Onevcs-")` at an operator rather than the value they configured.
75impl std::fmt::Debug for TrailerPrefix {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 std::fmt::Debug::fmt(&self.0, f)
78 }
79}
80
81impl Default for TrailerPrefix {
82 /// The prefix this crate has always written, for a host that configures none.
83 fn default() -> Self {
84 TrailerPrefix(crate::provenance::DEFAULT_PREFIX.to_owned())
85 }
86}
87
88impl TryFrom<String> for TrailerPrefix {
89 type Error = String;
90
91 fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
92 match crate::provenance::validate_prefix(&value) {
93 Ok(()) => Ok(TrailerPrefix(value)),
94 Err(reason) => Err(format!(
95 "{value:?} cannot spell a git trailer key: {reason}"
96 )),
97 }
98 }
99}
100
101impl From<TrailerPrefix> for String {
102 fn from(prefix: TrailerPrefix) -> Self {
103 prefix.0
104 }
105}
106
107impl std::ops::Deref for TrailerPrefix {
108 type Target = str;
109
110 fn deref(&self) -> &str {
111 &self.0
112 }
113}
114
115impl std::fmt::Display for TrailerPrefix {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str(&self.0)
118 }
119}
120
121/// One rule: what it matches, and which parts of the policy it sets.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Rule {
124 /// What this rule applies to.
125 #[serde(rename = "match")]
126 pub r#match: RuleMatch,
127 /// How a change is published. Unset falls back to the default policy.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub publication: Option<MergePolicy>,
130 /// Whether approvals are required. Unset falls back to the default policy.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub approvals: Option<Approvals>,
133}
134
135/// What a rule applies to. Every field is optional; the ones that are set must
136/// all match.
137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
138pub struct RuleMatch {
139 /// The identity's host, e.g. `github.com`.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub host: Option<String>,
142 /// The identity's owner, e.g. `acme-corp`.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub owner: Option<String>,
145 /// The repository name; `*` matches any.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub name: Option<String>,
148 /// A checkout path glob, e.g. `~/projects/*`.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub path: Option<String>,
151}
152
153/// A complete policy: every field a rule may set, all of them decided.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct Policy {
156 /// How a change is published.
157 pub publication: MergePolicy,
158 /// Whether approvals are required.
159 pub approvals: Approvals,
160}
161
162/// How a change reaches the base branch.
163///
164/// A per-run explicit policy may narrow this (`change-auto` to `change-open`) but
165/// never widen it past [`Approvals::Required`].
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
167#[serde(rename_all = "kebab-case")]
168#[value(rename_all = "kebab-case")]
169pub enum MergePolicy {
170 /// Merged in a local checkout and pushed; no change request is opened.
171 LocalDirect,
172 /// An ordinary, ready-for-review change request is opened and left open.
173 ChangeOpen,
174 /// A change request is opened and set to merge itself once its checks pass.
175 ChangeAuto,
176 /// A change request is opened and merged immediately.
177 ChangeDirect,
178}
179
180impl MergePolicy {
181 /// This policy narrowed to `requested`, or the reason that would widen it.
182 ///
183 /// The rule belongs to the rules system rather than to any one implementation
184 /// of [`Vcs`](crate::Vcs): a supplied implementation publishing under a policy
185 /// of its own applies this, rather than a restatement of it that could drift.
186 /// The direction is not symmetric and cannot be made so — widening is how work
187 /// reaches a base branch without the review its repository requires, and no
188 /// later step notices.
189 pub fn narrow(self, requested: MergePolicy) -> crate::Result<MergePolicy> {
190 crate::policy::narrow_publication(self, requested)
191 }
192}
193
194/// Whether a change needs someone else's approval before it may merge.
195///
196/// The review requirement, and the only place it is stated: a repository whose
197/// changes are reviewed before they land says so here, under a rule that matches it,
198/// and nothing infers it from where the repository is hosted.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "lowercase")]
201pub enum Approvals {
202 /// At least one approval is required.
203 Required,
204 /// No approval is required.
205 None,
206}