Skip to main content

git_policy_cli/
policy_registry.rs

1//! What:
2//!  The fixed registry of policies compiled into the native wrapper.
3//! Why:
4//!  Repository JSONC can only tune shipped policies;
5//!  it can never name code to run.
6//!
7//! In TS you'd write (pseudocode):
8//! ```ts
9//! // const POLICY_REGISTRY = [{ name: 'require-root', defaultSeverity: 'error', ... }] as const;
10//! ```
11
12/// What:
13///  `enum Severity` is a closed set of three named values.
14///       `#[derive(...)]` asks the compiler to generate copying,
15///  printing and `==`.
16/// Why:
17///   A closed set makes an unhandled severity a compile error,
18///  unlike a free string.
19///
20/// In TS you'd write (pseudocode):
21/// ```ts
22/// type Severity = 'off' | 'warn' | 'error';
23/// ```
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum Severity {
26    /// The policy does not run.
27    Off,
28    /// Findings are reported without blocking the Git command.
29    Warn,
30    /// Findings block the Git command.
31    Error,
32}
33
34/// What:
35///  One variant per shipped policy;
36///  the variant is the policy's identity in Rust.
37/// Why:
38///   Typed identities let options and settings be looked up without string comparison.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type PolicyId = 'require-root' | 'linked-worktree-only' | ... | 'security/forbidden-strings';
43/// ```
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum PolicyId {
46    /// `require-root`:
47    ///  repository-root working directory requirement.
48    RequireRoot,
49    /// `linked-worktree-only`:
50    ///  destructive commands stay in linked worktrees.
51    LinkedWorktreeOnly,
52    /// `branch-worktree-only`:
53    ///  branch creation goes through `git worktree add`.
54    BranchWorktreeOnly,
55    /// `add-explicit`:
56    ///  bulk staging patterns are rejected.
57    AddExplicit,
58    /// `final-newline`:
59    ///  text candidates end with one line feed.
60    FinalNewline,
61    /// `markdown/autofix`:
62    ///  commit-time Markdown normalization.
63    MarkdownAutofix,
64    /// `mono/forbidden-root-context`:
65    ///  no root `CONTEXT.md` enters a commit.
66    ForbiddenRootContext,
67    /// `mono/dependent-version-bump`:
68    ///  dependent package versions follow a bumped package.
69    DependentVersionBump,
70    /// `mono/catalog-specifiers`:
71    ///  every dependency in a package manifest is a catalog or workspace reference.
72    CatalogSpecifiers,
73    /// `security/forbidden-strings`:
74    ///  candidate bytes are scanned by the bundled scanner.
75    ForbiddenStrings,
76}
77
78/// What:
79///  `struct PolicyDescriptor` is one registry row.
80///       `&'static str` is a borrowed string baked into the executable for its whole run
81///       (`'static` is that "lives forever" lifetime).
82///  Sibling:
83///  `String`,
84///  an owned copy.
85///       `bool` is `true`/`false`,
86///  exactly TS `boolean`.
87/// Why:
88///   Registry text is compiled in and never changes,
89///  so borrowing forever is free,
90///       while `String` would allocate at startup for no gain.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type PolicyDescriptor = { id: PolicyId; name: string; defaultSeverity: Severity;
95///   warnSafe: boolean; acceptsOptions: boolean; offUnlessListed: boolean };
96/// ```
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub struct PolicyDescriptor {
99    /// Typed identity used by Rust callers.
100    pub id: PolicyId,
101    /// Stable configuration key and JSONL `policyId`.
102    pub name: &'static str,
103    /// The incumbent policy definition's `defaultSeverity`.
104    ///  A built-in policy the
105    /// repository configuration does not name runs at this severity.
106    pub default_severity: Severity,
107    /// Whether `warn` keeps the policy's protection (an unsafe `warn` earns a warning event).
108    pub warn_safe: bool,
109    /// Whether the `["severity", { ... }]` form is accepted for this policy.
110    pub accepts_options: bool,
111    /// Whether the policy runs only where `cli-git.config.jsonc` names it.
112    ///  The four
113    /// policies that plugins used to provide are off everywhere else,
114    ///  with or without
115    /// a configuration file (decided 2026-10-05).
116    ///  `mono/catalog-specifiers`, added afterwards, is optional the same way, so a configuration that does not
117    /// name it behaves as it did before the policy shipped.
118    pub off_unless_listed: bool,
119}
120
121/// What:
122///  `pub const POLICY_REGISTRY: &[PolicyDescriptor]` is a compiled-in,
123///  read-only list.
124///       `&[T]` is a borrowed view of an array;
125///  siblings are `Vec<T>` (owned,
126///  growable)
127///       and `[T; N]` (fixed length in the type).
128/// Why:
129///   Execution and configuration order must be identical on every run;
130///  a borrowed
131///       constant needs no allocation and cannot be reordered at runtime.
132///       Every default is the incumbent policy definition's own `defaultSeverity`.
133///       The four formerly plugin-provided policies, and `mono/catalog-specifiers`, are additionally off unless the
134///       repository configuration names them,
135///  because the incumbent only ran them in
136///       repositories whose configuration registered their plugin.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// export const POLICY_REGISTRY: readonly PolicyDescriptor[] = [ ... ];
141/// ```
142pub const POLICY_REGISTRY: &[PolicyDescriptor] = &[
143    PolicyDescriptor {
144        id: PolicyId::RequireRoot,
145        name: "require-root",
146        default_severity: Severity::Error,
147        warn_safe: false,
148        accepts_options: false,
149        off_unless_listed: false,
150    },
151    PolicyDescriptor {
152        id: PolicyId::LinkedWorktreeOnly,
153        name: "linked-worktree-only",
154        default_severity: Severity::Error,
155        warn_safe: false,
156        accepts_options: false,
157        off_unless_listed: false,
158    },
159    PolicyDescriptor {
160        id: PolicyId::BranchWorktreeOnly,
161        name: "branch-worktree-only",
162        default_severity: Severity::Error,
163        warn_safe: true,
164        accepts_options: false,
165        off_unless_listed: false,
166    },
167    PolicyDescriptor {
168        id: PolicyId::AddExplicit,
169        name: "add-explicit",
170        default_severity: Severity::Error,
171        warn_safe: false,
172        accepts_options: false,
173        off_unless_listed: false,
174    },
175    PolicyDescriptor {
176        id: PolicyId::FinalNewline,
177        name: "final-newline",
178        default_severity: Severity::Warn,
179        warn_safe: true,
180        accepts_options: false,
181        off_unless_listed: false,
182    },
183    PolicyDescriptor {
184        id: PolicyId::MarkdownAutofix,
185        name: "markdown/autofix",
186        default_severity: Severity::Warn,
187        warn_safe: true,
188        accepts_options: true,
189        off_unless_listed: true,
190    },
191    PolicyDescriptor {
192        id: PolicyId::ForbiddenRootContext,
193        name: "mono/forbidden-root-context",
194        default_severity: Severity::Error,
195        warn_safe: true,
196        accepts_options: false,
197        off_unless_listed: true,
198    },
199    PolicyDescriptor {
200        id: PolicyId::DependentVersionBump,
201        name: "mono/dependent-version-bump",
202        default_severity: Severity::Error,
203        warn_safe: false,
204        accepts_options: false,
205        off_unless_listed: true,
206    },
207    // `warn_safe` is `false`: a `warn` lets a literal version into the commit, so it earns the unsafe-`warn` notice
208    // (as `mono/dependent-version-bump` and `security/forbidden-strings` do), unlike a policy whose `warn` still fixes.
209    PolicyDescriptor {
210        id: PolicyId::CatalogSpecifiers,
211        name: "mono/catalog-specifiers",
212        default_severity: Severity::Error,
213        warn_safe: false,
214        accepts_options: false,
215        off_unless_listed: true,
216    },
217    PolicyDescriptor {
218        id: PolicyId::ForbiddenStrings,
219        name: "security/forbidden-strings",
220        default_severity: Severity::Error,
221        warn_safe: false,
222        accepts_options: true,
223        off_unless_listed: true,
224    },
225];
226
227/// What:
228///  Find a registry row by its configuration key.
229///       `Option<T>` is Rust's "value or nothing":
230///  `Some(row)` or `None`.
231///       `&'static PolicyDescriptor` borrows a row that lives as long as the program.
232/// Why:
233///   An unknown policy ID must be reported,
234///  never treated as a new policy.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// function policyByName(name: string): PolicyDescriptor | undefined;
239/// ```
240pub fn policy_by_name(name: &str) -> Option<&'static PolicyDescriptor> {
241    // What: `usize` is the index type of every Rust list (siblings `u32`, `i64`);
242    //       `&POLICY_REGISTRY[index]` borrows one row without copying it.
243    // Why:  Ten rows make a linear scan cheaper than building a map, and an indexed
244    //       `while` loop needs no callback to stop at the first match.
245    //
246    // In TS you'd write (pseudocode):
247    // ```ts
248    // for (let index = 0; index < POLICY_REGISTRY.length; index += 1) { ... }
249    // ```
250    let mut index: usize = 0;
251    while index < POLICY_REGISTRY.len() {
252        let descriptor: &PolicyDescriptor = &POLICY_REGISTRY[index];
253        index += 1;
254        if descriptor.name == name {
255            // What: `Some(descriptor)` wraps the found row in the "present" variant.
256            // Why:  The caller must handle absence explicitly; there is no `undefined`.
257            //
258            // In TS you'd write (pseudocode):
259            // ```ts
260            // return descriptor;
261            // ```
262            return Some(descriptor);
263        }
264    }
265    // `None` is the "absent" variant: no shipped policy has this name.
266    return None;
267}
268
269/// What:
270///  Find the registry row for a typed identity.
271/// Why:
272///   Callers holding a `PolicyId` need its name,
273///  default and warn-safety.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// function policyDescriptor(id: PolicyId): PolicyDescriptor;
278/// ```
279pub fn policy_descriptor(id: PolicyId) -> &'static PolicyDescriptor {
280    // `for descriptor in POLICY_REGISTRY` borrows each row in order; no index is needed
281    // because the row itself is returned, not wrapped in `Some(...)`.
282    for descriptor in POLICY_REGISTRY {
283        if descriptor.id == id {
284            return descriptor;
285        }
286    }
287    // What: `unreachable!` stops the program with this message if ever executed.
288    //       The `!` marks a macro (compiler-expanded code), not negation.
289    // Why:  Every `PolicyId` variant has a row; the registry test proves it, so
290    //       reaching here means the registry itself was edited incorrectly.
291    //
292    // In TS you'd write (pseudocode):
293    // ```ts
294    // throw new Error('unreachable: every PolicyId has a registry row');
295    // ```
296    unreachable!("every PolicyId variant has a POLICY_REGISTRY row");
297}
298
299/// What:
300///  Translate a configuration word into a typed severity.
301/// Why:
302///   Only the three documented spellings are settings;
303///  anything else is an error
304///       the caller reports with the offending key.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// function severityFromName(name: string): Severity | undefined;
309/// ```
310pub fn severity_from_name(name: &str) -> Option<Severity> {
311    if name == "off" {
312        return Some(Severity::Off);
313    }
314    if name == "warn" {
315        return Some(Severity::Warn);
316    }
317    if name == "error" {
318        return Some(Severity::Error);
319    }
320    return None;
321}
322
323/// What:
324///  Translate a typed severity back to its configuration and JSONL spelling.
325/// Why:
326///   Events and diagnostics print the same words the configuration accepts.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// function severityName(severity: Severity): string;
331/// ```
332pub fn severity_name(severity: Severity) -> &'static str {
333    if severity == Severity::Off {
334        return "off";
335    }
336    if severity == Severity::Warn {
337        return "warn";
338    }
339    return "error";
340}
341
342/// What:
343///  `#[cfg(test)]` compiles the next item only for `cargo test`;
344///       `#[path = "..."]` names the sibling file holding the module body.
345/// Why:
346///   Registry controls stay out of the release executable.
347///
348/// In TS you'd write (pseudocode):
349/// ```ts
350/// // policy_registry.unit.test.ts lives beside this file.
351/// ```
352#[cfg(test)]
353#[path = "policy_registry_tests.rs"]
354mod tests;