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 /// `security/forbidden-strings`:
71 /// candidate bytes are scanned by the bundled scanner.
72 ForbiddenStrings,
73}
74
75/// What:
76/// `struct PolicyDescriptor` is one registry row.
77/// `&'static str` is a borrowed string baked into the executable for its whole run
78/// (`'static` is that "lives forever" lifetime).
79/// Sibling:
80/// `String`,
81/// an owned copy.
82/// `bool` is `true`/`false`,
83/// exactly TS `boolean`.
84/// Why:
85/// Registry text is compiled in and never changes,
86/// so borrowing forever is free,
87/// while `String` would allocate at startup for no gain.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type PolicyDescriptor = { id: PolicyId; name: string; defaultSeverity: Severity;
92/// warnSafe: boolean; acceptsOptions: boolean; offUnlessListed: boolean };
93/// ```
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub struct PolicyDescriptor {
96 /// Typed identity used by Rust callers.
97 pub id: PolicyId,
98 /// Stable configuration key and JSONL `policyId`.
99 pub name: &'static str,
100 /// The incumbent policy definition's `defaultSeverity`.
101 /// A built-in policy the
102 /// repository configuration does not name runs at this severity.
103 pub default_severity: Severity,
104 /// Whether `warn` keeps the policy's protection (an unsafe `warn` earns a warning event).
105 pub warn_safe: bool,
106 /// Whether the `["severity", { ... }]` form is accepted for this policy.
107 pub accepts_options: bool,
108 /// Whether the policy runs only where `cli-git.config.jsonc` names it.
109 /// The four
110 /// policies that plugins used to provide are off everywhere else,
111 /// with or without
112 /// a configuration file (decided 2026-10-05).
113 pub off_unless_listed: bool,
114}
115
116/// What:
117/// `pub const POLICY_REGISTRY: &[PolicyDescriptor]` is a compiled-in,
118/// read-only list.
119/// `&[T]` is a borrowed view of an array;
120/// siblings are `Vec<T>` (owned,
121/// growable)
122/// and `[T; N]` (fixed length in the type).
123/// Why:
124/// Execution and configuration order must be identical on every run;
125/// a borrowed
126/// constant needs no allocation and cannot be reordered at runtime.
127/// Every default is the incumbent policy definition's own `defaultSeverity`.
128/// The four formerly plugin-provided policies are additionally off unless the
129/// repository configuration names them,
130/// because the incumbent only ran them in
131/// repositories whose configuration registered their plugin.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// export const POLICY_REGISTRY: readonly PolicyDescriptor[] = [ ... ];
136/// ```
137pub const POLICY_REGISTRY: &[PolicyDescriptor] = &[
138 PolicyDescriptor {
139 id: PolicyId::RequireRoot,
140 name: "require-root",
141 default_severity: Severity::Error,
142 warn_safe: false,
143 accepts_options: false,
144 off_unless_listed: false,
145 },
146 PolicyDescriptor {
147 id: PolicyId::LinkedWorktreeOnly,
148 name: "linked-worktree-only",
149 default_severity: Severity::Error,
150 warn_safe: false,
151 accepts_options: false,
152 off_unless_listed: false,
153 },
154 PolicyDescriptor {
155 id: PolicyId::BranchWorktreeOnly,
156 name: "branch-worktree-only",
157 default_severity: Severity::Error,
158 warn_safe: true,
159 accepts_options: false,
160 off_unless_listed: false,
161 },
162 PolicyDescriptor {
163 id: PolicyId::AddExplicit,
164 name: "add-explicit",
165 default_severity: Severity::Error,
166 warn_safe: false,
167 accepts_options: false,
168 off_unless_listed: false,
169 },
170 PolicyDescriptor {
171 id: PolicyId::FinalNewline,
172 name: "final-newline",
173 default_severity: Severity::Warn,
174 warn_safe: true,
175 accepts_options: false,
176 off_unless_listed: false,
177 },
178 PolicyDescriptor {
179 id: PolicyId::MarkdownAutofix,
180 name: "markdown/autofix",
181 default_severity: Severity::Warn,
182 warn_safe: true,
183 accepts_options: true,
184 off_unless_listed: true,
185 },
186 PolicyDescriptor {
187 id: PolicyId::ForbiddenRootContext,
188 name: "mono/forbidden-root-context",
189 default_severity: Severity::Error,
190 warn_safe: true,
191 accepts_options: false,
192 off_unless_listed: true,
193 },
194 PolicyDescriptor {
195 id: PolicyId::DependentVersionBump,
196 name: "mono/dependent-version-bump",
197 default_severity: Severity::Error,
198 warn_safe: false,
199 accepts_options: false,
200 off_unless_listed: true,
201 },
202 PolicyDescriptor {
203 id: PolicyId::ForbiddenStrings,
204 name: "security/forbidden-strings",
205 default_severity: Severity::Error,
206 warn_safe: false,
207 accepts_options: true,
208 off_unless_listed: true,
209 },
210];
211
212/// What:
213/// Find a registry row by its configuration key.
214/// `Option<T>` is Rust's "value or nothing":
215/// `Some(row)` or `None`.
216/// `&'static PolicyDescriptor` borrows a row that lives as long as the program.
217/// Why:
218/// An unknown policy ID must be reported,
219/// never treated as a new policy.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// function policyByName(name: string): PolicyDescriptor | undefined;
224/// ```
225pub fn policy_by_name(name: &str) -> Option<&'static PolicyDescriptor> {
226 // What: `usize` is the index type of every Rust list (siblings `u32`, `i64`);
227 // `&POLICY_REGISTRY[index]` borrows one row without copying it.
228 // Why: Nine rows make a linear scan cheaper than building a map, and an indexed
229 // `while` loop needs no callback to stop at the first match.
230 //
231 // In TS you'd write (pseudocode):
232 // ```ts
233 // for (let index = 0; index < POLICY_REGISTRY.length; index += 1) { ... }
234 // ```
235 let mut index: usize = 0;
236 while index < POLICY_REGISTRY.len() {
237 let descriptor: &PolicyDescriptor = &POLICY_REGISTRY[index];
238 index += 1;
239 if descriptor.name == name {
240 // What: `Some(descriptor)` wraps the found row in the "present" variant.
241 // Why: The caller must handle absence explicitly; there is no `undefined`.
242 //
243 // In TS you'd write (pseudocode):
244 // ```ts
245 // return descriptor;
246 // ```
247 return Some(descriptor);
248 }
249 }
250 // `None` is the "absent" variant: no shipped policy has this name.
251 return None;
252}
253
254/// What:
255/// Find the registry row for a typed identity.
256/// Why:
257/// Callers holding a `PolicyId` need its name,
258/// default and warn-safety.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// function policyDescriptor(id: PolicyId): PolicyDescriptor;
263/// ```
264pub fn policy_descriptor(id: PolicyId) -> &'static PolicyDescriptor {
265 // `for descriptor in POLICY_REGISTRY` borrows each row in order; no index is needed
266 // because the row itself is returned, not wrapped in `Some(...)`.
267 for descriptor in POLICY_REGISTRY {
268 if descriptor.id == id {
269 return descriptor;
270 }
271 }
272 // What: `unreachable!` stops the program with this message if ever executed.
273 // The `!` marks a macro (compiler-expanded code), not negation.
274 // Why: Every `PolicyId` variant has a row; the registry test proves it, so
275 // reaching here means the registry itself was edited incorrectly.
276 //
277 // In TS you'd write (pseudocode):
278 // ```ts
279 // throw new Error('unreachable: every PolicyId has a registry row');
280 // ```
281 unreachable!("every PolicyId variant has a POLICY_REGISTRY row");
282}
283
284/// What:
285/// Translate a configuration word into a typed severity.
286/// Why:
287/// Only the three documented spellings are settings;
288/// anything else is an error
289/// the caller reports with the offending key.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// function severityFromName(name: string): Severity | undefined;
294/// ```
295pub fn severity_from_name(name: &str) -> Option<Severity> {
296 if name == "off" {
297 return Some(Severity::Off);
298 }
299 if name == "warn" {
300 return Some(Severity::Warn);
301 }
302 if name == "error" {
303 return Some(Severity::Error);
304 }
305 return None;
306}
307
308/// What:
309/// Translate a typed severity back to its configuration and JSONL spelling.
310/// Why:
311/// Events and diagnostics print the same words the configuration accepts.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// function severityName(severity: Severity): string;
316/// ```
317pub fn severity_name(severity: Severity) -> &'static str {
318 if severity == Severity::Off {
319 return "off";
320 }
321 if severity == Severity::Warn {
322 return "warn";
323 }
324 return "error";
325}
326
327/// What:
328/// `#[cfg(test)]` compiles the next item only for `cargo test`;
329/// `#[path = "..."]` names the sibling file holding the module body.
330/// Why:
331/// Registry controls stay out of the release executable.
332///
333/// In TS you'd write (pseudocode):
334/// ```ts
335/// // policy_registry.unit.test.ts lives beside this file.
336/// ```
337#[cfg(test)]
338#[path = "policy_registry_tests.rs"]
339mod tests;