git_policy_cli/config_schema.rs
1//! What:
2//! The typed settings a validated `cli-git.config.jsonc` produces,
3//! with defaults.
4//! Why:
5//! The policy engine and transactions read plain typed values,
6//! never JSON.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // type CliGitConfig = { policies: PolicyConfig; concurrency: ConcurrencyConfig };
11//! ```
12
13/// What:
14/// Import the compiled-in registry and its identity and severity types.
15/// Why:
16/// Defaults are derived from the registry so the two can never disagree.
17///
18/// In TS you'd write (pseudocode):
19/// ```ts
20/// import { POLICY_REGISTRY, type PolicyId, type Severity } from './policy-registry.ts';
21/// ```
22use super::policy_registry::{
23 POLICY_REGISTRY, PolicyDescriptor, PolicyId, Severity, policy_descriptor,
24};
25
26/// What:
27/// `u64` constant,
28/// an unsigned 64-bit integer (siblings `u32`,
29/// `usize`,
30/// `i64`).
31/// Why:
32/// Milliseconds up to 2^53 - 1 are accepted,
33/// which `u32` cannot hold.
34/// This is the incumbent's default patience for an unproven `index.lock` owner.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// const DEFAULT_UNPROVEN_OWNER_TIMEOUT_MS = 1_000;
39/// ```
40pub const DEFAULT_UNPROVEN_OWNER_TIMEOUT_MS: u64 = 1_000;
41
42/// Incumbent default:
43/// reserve the next landing slot after one lost landing race.
44pub const DEFAULT_RESERVE_AFTER_LOST_RACES: u64 = 1;
45
46/// What:
47/// The effective severity of one shipped policy.
48/// `#[derive(...)]` generates copy,
49/// debug printing and `==` for the struct.
50/// Why:
51/// `explicit` separates "the repository chose this" from "the default applied";
52/// only an explicit unsafe `warn` earns a configuration warning.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type PolicySetting = { id: PolicyId; severity: Severity; explicit: boolean };
57/// ```
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct PolicySetting {
60 /// Which shipped policy this row configures.
61 pub id: PolicyId,
62 /// Severity in effect for this invocation.
63 pub severity: Severity,
64 /// Whether the repository configuration named this policy.
65 pub explicit: bool,
66}
67
68/// What:
69/// The closed set of Markdown rules the commit-time policy may run.
70/// Why:
71/// The shipped policy runs the owned linter with a fixed rule list;
72/// an unknown
73/// rule name is a configuration error,
74/// never a pass-through argument.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// type MarkdownRule = 'lfs-image-url';
79/// ```
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum MarkdownRule {
82 /// `lfs-image-url`:
83 /// rewrite image links to LFS-tracked files to immutable object URLs.
84 LfsImageUrl,
85}
86
87/// What:
88/// Validated options of `markdown/autofix`.
89/// `Vec<T>` is an owned growable list (siblings:
90/// `&[T]` borrowed,
91/// `[T; N]` fixed).
92/// `String` is owned text (sibling:
93/// `&str` borrowed).
94/// Why:
95/// The settings outlive the parsed document,
96/// so they own their lists and text.
97/// There is deliberately no command or executable field.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// type MarkdownAutofixOptions = { rules: MarkdownRule[]; exclude: string[] };
102/// ```
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct MarkdownAutofixOptions {
105 /// Rules to run,
106 /// without repeats;
107 /// defaults to the LFS image rewrite alone.
108 pub rules: Vec<MarkdownRule>,
109 /// gitignore-syntax patterns,
110 /// relative to the repository root,
111 /// left untouched.
112 pub exclude: Vec<String>,
113}
114
115/// What:
116/// One rules file named by the `rulesFile` option:
117/// its path inside the repository and whether it may be absent.
118/// Why:
119/// The configuration names several files as an array,
120/// and a gitignored local file must be allowed to be missing
121/// on a fresh clone while a committed shared file must not.
122/// The path is the configuration's own text, with no comma or `?` syntax,
123/// because JSON can already say which file is meant.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// type RulesFileEntry = { path: string; optional: boolean };
128/// ```
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct RulesFileEntry {
131 /// The file,
132 /// relative to the repository's top level and already checked to stay inside it.
133 pub path: String,
134 /// Whether the file may be absent;
135 /// an absent optional file is skipped and an absent required file stops the policy.
136 pub optional: bool,
137}
138
139/// What:
140/// Validated options of `security/forbidden-strings`.
141/// `Option<Vec<RulesFileEntry>>` is "a
142/// configured list of files or nothing".
143/// Why:
144/// The scanner is linked into cli-git,
145/// so the remaining choices are whether its
146/// embedded baseline rules run and which repository files hold the private rules.
147/// There is deliberately no executable path.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// type ForbiddenStringsOptions = { builtinRules: boolean; rulesFile?: string | (string | { path: string; optional?: boolean })[] };
152/// ```
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct ForbiddenStringsOptions {
155 /// Whether the scanner's embedded baseline rules are loaded;
156 /// default `true`.
157 pub builtin_rules: bool,
158 /// The private rules files in the order they are read,
159 /// each relative to the repository's top level and already checked to
160 /// stay inside it;
161 /// never an empty list;
162 /// when absent,
163 /// `FORBIDDEN_STRINGS_RULES` and then the default file apply.
164 pub rules_files: Option<Vec<RulesFileEntry>>,
165}
166
167/// What:
168/// Every policy's severity plus the option records of option-bearing policies.
169/// Why:
170/// One value answers "does this policy run,
171/// how strictly,
172/// with what options".
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// type PolicyConfig = { settings: PolicySetting[];
177/// forbiddenStrings: ForbiddenStringsOptions; markdownAutofix: MarkdownAutofixOptions };
178/// ```
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct PolicyConfig {
181 /// One row per registry policy,
182 /// in registry order.
183 pub settings: Vec<PolicySetting>,
184 /// Options of `security/forbidden-strings`,
185 /// defaults applied.
186 pub forbidden_strings: ForbiddenStringsOptions,
187 /// Options of `markdown/autofix`,
188 /// defaults applied.
189 pub markdown_autofix: MarkdownAutofixOptions,
190}
191
192/// What:
193/// Hook serialization tuning (`hooks` key).
194/// Why:
195/// `false` serializes preparation and post-landing hooks through the hook lock.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// type HooksConfig = { concurrentCommits: boolean };
200/// ```
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
202pub struct HooksConfig {
203 /// Whether hooks of concurrent commits may overlap;
204 /// default `false`.
205 pub concurrent_commits: bool,
206}
207
208/// What:
209/// Foreign `index.lock` patience (`indexLock` key).
210/// Why:
211/// A lock whose owner cannot be proven alive is waited on only this long.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// type IndexLockConfig = { unprovenOwnerTimeoutMs: number };
216/// ```
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub struct IndexLockConfig {
219 /// Backoff budget in milliseconds;
220 /// whole number of at least 0.
221 pub unproven_owner_timeout_ms: u64,
222}
223
224/// What:
225/// Landing starvation tuning (`landing` key).
226/// Why:
227/// A commit that keeps losing landing races eventually reserves the next slot.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// type LandingConfig = { reserveAfterLostRaces: number };
232/// ```
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub struct LandingConfig {
235 /// Lost races before reserving;
236 /// whole number of at least 1.
237 pub reserve_after_lost_races: u64,
238}
239
240/// What:
241/// The three concurrent-commit tuning groups together.
242/// Why:
243/// None of them disables the commit transaction;
244/// they only tune it.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// type ConcurrencyConfig = { hooks: HooksConfig; indexLock: IndexLockConfig; landing: LandingConfig };
249/// ```
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub struct ConcurrencyConfig {
252 /// Hook serialization.
253 pub hooks: HooksConfig,
254 /// Foreign index-lock patience.
255 pub index_lock: IndexLockConfig,
256 /// Landing starvation protection.
257 pub landing: LandingConfig,
258}
259
260/// What:
261/// The complete validated configuration of one repository.
262/// Why:
263/// A repository without a file and a repository with an empty `{}` file both yield
264/// `CliGitConfig::defaults()`:
265/// a policy that is off unless listed stays off either way.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// type CliGitConfig = { policies: PolicyConfig; concurrency: ConcurrencyConfig };
270/// ```
271#[derive(Clone, Debug, Eq, PartialEq)]
272pub struct CliGitConfig {
273 /// Policy severities and options.
274 pub policies: PolicyConfig,
275 /// Concurrent-commit tuning.
276 pub concurrency: ConcurrencyConfig,
277}
278
279/// What:
280/// `impl ConcurrencyConfig { ... }` attaches functions to the struct.
281/// Why:
282/// Startup recovery runs before configuration loads and always uses these values.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// const DEFAULT_CONCURRENCY_CONFIG: ConcurrencyConfig = { ... };
287/// ```
288impl ConcurrencyConfig {
289 /// What:
290 /// Build the incumbent's default tuning.
291 /// Why:
292 /// One function is the single source of the defaults for every caller.
293 ///
294 /// In TS you'd write (pseudocode):
295 /// ```ts
296 /// static defaults(): ConcurrencyConfig;
297 /// ```
298 pub fn defaults() -> ConcurrencyConfig {
299 return ConcurrencyConfig {
300 hooks: HooksConfig {
301 concurrent_commits: false,
302 },
303 index_lock: IndexLockConfig {
304 unproven_owner_timeout_ms: DEFAULT_UNPROVEN_OWNER_TIMEOUT_MS,
305 },
306 landing: LandingConfig {
307 reserve_after_lost_races: DEFAULT_RESERVE_AFTER_LOST_RACES,
308 },
309 };
310 }
311}
312
313/// What:
314/// The severity of a policy the repository configuration does not name.
315/// `&PolicyDescriptor`
316/// borrows the policy's registry row.
317/// Why:
318/// The five built-in policies run everywhere at their default severity.
319/// The four
320/// policies that plugins used to provide run only where `cli-git.config.jsonc` names
321/// them,
322/// whether or not a file exists,
323/// so an empty file and no file behave alike.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// const unlistedSeverity = descriptor.offUnlessListed ? 'off' : descriptor.defaultSeverity;
328/// ```
329pub fn unlisted_severity(descriptor: &PolicyDescriptor) -> Severity {
330 if descriptor.off_unless_listed {
331 return Severity::Off;
332 }
333 return descriptor.default_severity;
334}
335
336/// Defaults and lookup for policy settings.
337impl PolicyConfig {
338 /// What:
339 /// Build the settings of a repository whose configuration names no policy,
340 /// or
341 /// that has no configuration file at all.
342 /// Why:
343 /// Parsing starts from these values and replaces only the policies the file names.
344 ///
345 /// In TS you'd write (pseudocode):
346 /// ```ts
347 /// static defaults(): PolicyConfig;
348 /// ```
349 pub fn defaults() -> PolicyConfig {
350 // What: `Vec::<PolicySetting>::with_capacity(n)` is an empty list with room for
351 // `n` rows; `mut` permits `push`.
352 // Why: One row per registry policy, known up front.
353 //
354 // In TS you'd write (pseudocode):
355 // ```ts
356 // const settings: PolicySetting[] = [];
357 // ```
358 let mut settings: Vec<PolicySetting> =
359 Vec::<PolicySetting>::with_capacity(POLICY_REGISTRY.len());
360 // `for ... in` borrows each registry row in order.
361 for descriptor in POLICY_REGISTRY {
362 settings.push(PolicySetting {
363 id: descriptor.id,
364 severity: unlisted_severity(descriptor),
365 explicit: false,
366 });
367 }
368 return PolicyConfig {
369 settings,
370 forbidden_strings: ForbiddenStringsOptions {
371 builtin_rules: true,
372 // `None` is the "absent" variant: no file is configured.
373 rules_files: None,
374 },
375 markdown_autofix: MarkdownAutofixOptions {
376 // `vec![...]` is a macro building an owned list from literal items.
377 rules: vec![MarkdownRule::LfsImageUrl],
378 // `Vec::new()` is the empty owned list.
379 exclude: Vec::<String>::new(),
380 },
381 };
382 }
383
384 /// What:
385 /// Read the effective setting of one policy.
386 /// `&self` borrows this record.
387 /// Why:
388 /// Callers ask by typed identity instead of scanning the list themselves.
389 ///
390 /// In TS you'd write (pseudocode):
391 /// ```ts
392 /// setting(id: PolicyId): PolicySetting;
393 /// ```
394 pub fn setting(&self, id: PolicyId) -> PolicySetting {
395 // `&self.settings` lends the list; `*setting` copies one small row out of it.
396 for setting in &self.settings {
397 if setting.id == id {
398 return *setting;
399 }
400 }
401 // Unreachable while `settings` holds one row per registry policy; fall back to
402 // the unlisted severity rather than aborting a Git command.
403 return PolicySetting {
404 id,
405 severity: unlisted_severity(policy_descriptor(id)),
406 explicit: false,
407 };
408 }
409}
410
411/// Whole-configuration defaults.
412impl CliGitConfig {
413 /// What:
414 /// Build the configuration of a repository without `cli-git.config.jsonc`,
415 /// which
416 /// is also what an empty `{}` file produces.
417 /// Why:
418 /// Parsing starts from these values and replaces only what the file states.
419 ///
420 /// In TS you'd write (pseudocode):
421 /// ```ts
422 /// static defaults(): CliGitConfig;
423 /// ```
424 pub fn defaults() -> CliGitConfig {
425 return CliGitConfig {
426 policies: PolicyConfig::defaults(),
427 concurrency: ConcurrencyConfig::defaults(),
428 };
429 }
430}
431
432/// What:
433/// Translate a configured rule name into its typed rule.
434/// `Option<T>` is "value or nothing":
435/// `Some(rule)` or `None`.
436/// Why:
437/// Only shipped rules exist;
438/// the caller reports any other name with its key.
439///
440/// In TS you'd write (pseudocode):
441/// ```ts
442/// function markdownRuleFromName(name: string): MarkdownRule | undefined;
443/// ```
444pub fn markdown_rule_from_name(name: &str) -> Option<MarkdownRule> {
445 if name == "lfs-image-url" {
446 // `Some(...)` is the "present" variant.
447 return Some(MarkdownRule::LfsImageUrl);
448 }
449 // `None` is the "absent" variant.
450 return None;
451}
452
453/// What:
454/// Translate a typed rule back to its configuration spelling.
455/// `&'static str` borrows text compiled into the executable for its whole run.
456/// Why:
457/// Diagnostics and the later linter invocation print the accepted spelling.
458///
459/// In TS you'd write (pseudocode):
460/// ```ts
461/// function markdownRuleName(rule: MarkdownRule): string;
462/// ```
463pub fn markdown_rule_name(rule: MarkdownRule) -> &'static str {
464 // `match` must name every variant, so a new rule cannot be forgotten here.
465 match rule {
466 MarkdownRule::LfsImageUrl => return "lfs-image-url",
467 }
468}
469
470/// Value controls stay out of the release executable.
471#[cfg(test)]
472#[path = "config_schema_tests.rs"]
473mod tests;