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;