Skip to main content

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`, `4` with a rule's `visibility`.
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    /// How this rule's change requests take the draft lifecycle. Each key it leaves
134    /// unset falls back to the default policy's, and then to the shipped default.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub drafts: Option<Drafts>,
137    /// Whether the repositories this rule matches are public, said by the operator
138    /// rather than asked of the host. Set, it wins over every probe and the host is
139    /// not asked; unset, the host's answer is recorded. Version 4 and later.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub visibility: Option<DeclaredVisibility>,
142    /// Whether a moved base is merged into a branch that already exists always, or
143    /// only when it is needed. Unset falls back to the default policy's, and then to
144    /// the value the resolved publication implies.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub base_merge: Option<BaseMerge>,
147    /// How long a publication watches its change request's checks. Each key it leaves
148    /// unset falls back to the default policy's, and then to the shipped default.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub checks: Option<Checks>,
151}
152
153/// A repository's visibility as a rule declares it: one of the two answers, never
154/// "unknown", since a rule that knows nothing sets nothing.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "lowercase")]
157pub enum DeclaredVisibility {
158    /// Anybody can read it.
159    Public,
160    /// It is private.
161    Private,
162}
163
164impl From<DeclaredVisibility> for crate::boundary::Visibility {
165    fn from(declared: DeclaredVisibility) -> Self {
166        match declared {
167            DeclaredVisibility::Public => crate::boundary::Visibility::Public,
168            DeclaredVisibility::Private => crate::boundary::Visibility::Private,
169        }
170    }
171}
172
173/// What a rule applies to. Every field is optional; the ones that are set must
174/// all match.
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
176pub struct RuleMatch {
177    /// The identity's host, e.g. `github.com`.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub host: Option<String>,
180    /// The identity's owner, e.g. `acme-corp`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub owner: Option<String>,
183    /// The repository name; `*` matches any.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub name: Option<String>,
186    /// A checkout path glob, e.g. `~/projects/*`.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub path: Option<String>,
189}
190
191/// A complete policy: every field a rule may set, all of them decided.
192///
193/// `drafts` is the one exception to "decided": each of its keys has a shipped
194/// default, so a `default:` that names none of them is complete, and the resolution
195/// reports which layer decided each key.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct Policy {
198    /// How a change is published.
199    pub publication: MergePolicy,
200    /// Whether approvals are required.
201    pub approvals: Approvals,
202    /// How a change request takes the draft lifecycle, where the file says.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub drafts: Option<Drafts>,
205    /// Whether a moved base is merged into a branch that already exists always, or
206    /// only when it is needed, where the file says.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub base_merge: Option<BaseMerge>,
209    /// How long a publication watches its change request's checks, where the file
210    /// says.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub checks: Option<Checks>,
213}
214
215/// When a moved base is merged into a branch that already exists: a continued branch
216/// at session open, a resumed session, and the sync a publication runs before it
217/// pushes.
218///
219/// Unset on the matched rule and on `default:`, it is derived from the resolved
220/// publication: `local-direct` merges [`Always`](BaseMerge::Always), and every
221/// change-request policy merges only [`OnConflict`](BaseMerge::OnConflict) — a merge
222/// nothing asked for restarts every check the host runs on the branch, and the host's
223/// own merge of the change request brings the base in anyway.
224///
225/// Admitted with **no version bump**, as `drafts:` was: an older build ignores the key
226/// and merges the base as it always did, which is [`Always`](BaseMerge::Always).
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum BaseMerge {
230    /// Merge the base in whenever it moved, which is what every build before this key
231    /// did.
232    Always,
233    /// Merge the base in only when it conflicts with the branch — so the conflict is
234    /// resolved before anything is published — or when the host requires a branch to
235    /// be up to date with its base before it merges. Otherwise the branch stays at its
236    /// tip and no merge commit is made.
237    OnConflict,
238}
239
240/// The `checks:` mapping a rule and `default:` may carry: how a publication watches
241/// its change request's checks.
242///
243/// Like `drafts:`, it **refuses a key it does not know**, by name, where the file is
244/// loaded — a misspelt `wait_second:` read as absent would leave an operator believing
245/// they had set a bound — and an older build ignores the whole block, so no version
246/// bump is owed.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct Checks {
250    /// How long, in seconds, a publication watches the required checks of its change
251    /// request before the wait elapses. `ONEVCS_CHECKS_TIMEOUT_SECONDS` overrides it
252    /// for the whole process; unset here and on `default:`, it is 3600.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub wait_seconds: Option<WaitSeconds>,
255}
256
257/// A number of seconds to wait: finite, above zero, and at most a billion (about 31
258/// years).
259///
260/// The check is in the conversion, as [`TrailerPrefix`]'s is, so a rules file naming a
261/// wait no watch could run to does not deserialize at all. Being finite is also what
262/// makes the value [`Eq`]: the one floating-point value that is not equal to itself is
263/// refused here. The ceiling is a chosen one rather than a duration's own limit: it is
264/// what keeps a watch's deadline — now plus the wait — representable on every
265/// platform, so a wait such as `1e300` is refused here rather than reaching that
266/// arithmetic and crashing.
267#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
268#[serde(try_from = "f64", into = "f64")]
269pub struct WaitSeconds(f64);
270
271impl Eq for WaitSeconds {}
272
273impl WaitSeconds {
274    /// The shipped `checks.wait_seconds`: an hour, which is what the watch was bounded
275    /// by before the key existed.
276    pub(crate) const SHIPPED: WaitSeconds = WaitSeconds(crate::gh::DEFAULT_CHECKS_TIMEOUT_SECONDS);
277
278    /// The longest wait accepted: a billion seconds, about 31 years — far past any
279    /// watch anyone means, and chosen so a watch's deadline is always representable.
280    pub(crate) const MAX_SECONDS: f64 = 1_000_000_000.0;
281
282    /// The accepted range, as every refusal of a wait spells it.
283    pub(crate) const RANGE: &'static str =
284        "a finite number of seconds above zero and at most 1000000000, the ceiling chosen \
285         so a watch's deadline is always representable";
286
287    /// The wait, in seconds.
288    #[must_use]
289    pub fn get(self) -> f64 {
290        self.0
291    }
292}
293
294impl TryFrom<f64> for WaitSeconds {
295    type Error = String;
296
297    fn try_from(value: f64) -> std::result::Result<Self, Self::Error> {
298        if value.is_finite() && value > 0.0 && value <= Self::MAX_SECONDS {
299            Ok(WaitSeconds(value))
300        } else {
301            Err(format!("{value} is not a wait: write {}", Self::RANGE))
302        }
303    }
304}
305
306impl From<WaitSeconds> for f64 {
307    fn from(wait: WaitSeconds) -> Self {
308        wait.0
309    }
310}
311
312/// The `drafts:` mapping a rule and `default:` may carry: how a publication's change
313/// request takes the draft lifecycle.
314///
315/// Every change request a publication carrying no draft reason opens starts as a
316/// draft while its required checks run, and is lifted to ready or kept a draft once
317/// they settle — by the identity's `publication` and `approvals`, which stay the
318/// whole of the routing. These two keys only switch the lifecycle and its one
319/// warning; neither says what a green change does.
320///
321/// Unlike the rest of the file, this mapping **refuses a key it does not know**, by
322/// name, where the file is loaded: it is new, nothing wrote a later key into it, and
323/// a misspelt `disable:` read as absent would leave an operator believing they had
324/// opted out. A file that carries no `drafts:` at all is read exactly as it was, and
325/// an older build ignores the whole block — which is that build's pre-draft
326/// behaviour, so no version bump is owed.
327#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct Drafts {
330    /// `true` opens change requests ready, as before the lifecycle, while an explicit
331    /// held or release-awaiting draft keeps working. Shipped default `false`.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub disabled: Option<bool>,
334    /// Whether lifting a draft early — its required checks never ran on it, which is
335    /// what a workflow that skips drafts looks like — prints one warning line on
336    /// stderr. Shipped default `true`.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub warn_on_early_lift: Option<bool>,
339}
340
341/// How a change reaches the base branch.
342///
343/// A per-run explicit policy may narrow this (`change-auto` to `change-open`) but
344/// never widen it past [`Approvals::Required`].
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
346#[serde(rename_all = "kebab-case")]
347#[value(rename_all = "kebab-case")]
348pub enum MergePolicy {
349    /// Merged in a local checkout and pushed; no change request is opened.
350    LocalDirect,
351    /// A change request is opened and left open — as a draft while its checks run,
352    /// then ready, or kept a draft for its user's review where approvals are
353    /// required.
354    ChangeOpen,
355    /// A change request is opened and set to merge itself once its checks pass.
356    ChangeAuto,
357    /// A change request is opened and merged immediately.
358    ChangeDirect,
359}
360
361impl MergePolicy {
362    /// This policy narrowed to `requested`, or the reason that would widen it.
363    ///
364    /// The rule belongs to the rules system rather than to any one implementation
365    /// of [`Vcs`](crate::Vcs): a supplied implementation publishing under a policy
366    /// of its own applies this, rather than a restatement of it that could drift.
367    /// The direction is not symmetric and cannot be made so — widening is how work
368    /// reaches a base branch without the review its repository requires, and no
369    /// later step notices.
370    pub fn narrow(self, requested: MergePolicy) -> crate::Result<MergePolicy> {
371        crate::policy::narrow_publication(self, requested)
372    }
373}
374
375/// Whether a change needs someone else's approval before it may merge.
376///
377/// The review requirement, and the only place it is stated: a repository whose
378/// changes are reviewed before they land says so here, under a rule that matches it,
379/// and nothing infers it from where the repository is hosted.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum Approvals {
383    /// At least one approval is required.
384    Required,
385    /// No approval is required.
386    None,
387}