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/// The retired `hooks` key.
194/// Why:
195/// `hooks.concurrentCommits` tuned the hook lock, and the wrapper runs no external hook, so there is no lock to
196/// tune.
197/// Older files that set it still load: the value is checked and ignored, and `git cli-git doctor` says so.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// type HooksConfig = { concurrentCommits: boolean; present: boolean };
202/// ```
203#[derive(Clone, Copy, Debug, Eq, PartialEq)]
204pub struct HooksConfig {
205 /// The value the file gave; never used.
206 pub concurrent_commits: bool,
207 /// Whether the file states `hooks.concurrentCommits`.
208 pub present: bool,
209}
210
211/// What:
212/// Foreign `index.lock` patience (`indexLock` key).
213/// Why:
214/// A lock whose owner cannot be proven alive is waited on only this long.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// type IndexLockConfig = { unprovenOwnerTimeoutMs: number };
219/// ```
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub struct IndexLockConfig {
222 /// Backoff budget in milliseconds;
223 /// whole number of at least 0.
224 pub unproven_owner_timeout_ms: u64,
225}
226
227/// What:
228/// Landing starvation tuning (`landing` key).
229/// Why:
230/// A commit that keeps losing landing races eventually reserves the next slot.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// type LandingConfig = { reserveAfterLostRaces: number };
235/// ```
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub struct LandingConfig {
238 /// Lost races before reserving;
239 /// whole number of at least 1.
240 pub reserve_after_lost_races: u64,
241}
242
243/// What:
244/// The three concurrent-commit tuning groups together.
245/// Why:
246/// None of them disables the commit transaction;
247/// they only tune it.
248///
249/// In TS you'd write (pseudocode):
250/// ```ts
251/// type ConcurrencyConfig = { hooks: HooksConfig; indexLock: IndexLockConfig; landing: LandingConfig };
252/// ```
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub struct ConcurrencyConfig {
255 /// Hook serialization.
256 pub hooks: HooksConfig,
257 /// Foreign index-lock patience.
258 pub index_lock: IndexLockConfig,
259 /// Landing starvation protection.
260 pub landing: LandingConfig,
261}
262
263/// What:
264/// The complete validated configuration of one repository.
265/// Why:
266/// A repository without a file and a repository with an empty `{}` file both yield
267/// `CliGitConfig::defaults()`:
268/// a policy that is off unless listed stays off either way.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// type CliGitConfig = { policies: PolicyConfig; concurrency: ConcurrencyConfig };
273/// ```
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct CliGitConfig {
276 /// Policy severities and options.
277 pub policies: PolicyConfig,
278 /// Concurrent-commit tuning.
279 pub concurrency: ConcurrencyConfig,
280}
281
282/// What:
283/// `impl ConcurrencyConfig { ... }` attaches functions to the struct.
284/// Why:
285/// Startup recovery runs before configuration loads and always uses these values.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// const DEFAULT_CONCURRENCY_CONFIG: ConcurrencyConfig = { ... };
290/// ```
291impl ConcurrencyConfig {
292 /// What:
293 /// Build the incumbent's default tuning.
294 /// Why:
295 /// One function is the single source of the defaults for every caller.
296 ///
297 /// In TS you'd write (pseudocode):
298 /// ```ts
299 /// static defaults(): ConcurrencyConfig;
300 /// ```
301 pub fn defaults() -> ConcurrencyConfig {
302 return ConcurrencyConfig {
303 hooks: HooksConfig {
304 concurrent_commits: false,
305 present: false,
306 },
307 index_lock: IndexLockConfig {
308 unproven_owner_timeout_ms: DEFAULT_UNPROVEN_OWNER_TIMEOUT_MS,
309 },
310 landing: LandingConfig {
311 reserve_after_lost_races: DEFAULT_RESERVE_AFTER_LOST_RACES,
312 },
313 };
314 }
315}
316
317/// What:
318/// The severity of a policy the repository configuration does not name.
319/// `&PolicyDescriptor`
320/// borrows the policy's registry row.
321/// Why:
322/// The five built-in policies run everywhere at their default severity.
323/// The four
324/// policies that plugins used to provide, and `mono/catalog-specifiers`, run only where
325/// `cli-git.config.jsonc` names them,
326/// whether or not a file exists,
327/// so an empty file and no file behave alike.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// const unlistedSeverity = descriptor.offUnlessListed ? 'off' : descriptor.defaultSeverity;
332/// ```
333pub fn unlisted_severity(descriptor: &PolicyDescriptor) -> Severity {
334 if descriptor.off_unless_listed {
335 return Severity::Off;
336 }
337 return descriptor.default_severity;
338}
339
340/// Defaults and lookup for policy settings.
341impl PolicyConfig {
342 /// What:
343 /// Build the settings of a repository whose configuration names no policy,
344 /// or
345 /// that has no configuration file at all.
346 /// Why:
347 /// Parsing starts from these values and replaces only the policies the file names.
348 ///
349 /// In TS you'd write (pseudocode):
350 /// ```ts
351 /// static defaults(): PolicyConfig;
352 /// ```
353 pub fn defaults() -> PolicyConfig {
354 // What: `Vec::<PolicySetting>::with_capacity(n)` is an empty list with room for
355 // `n` rows; `mut` permits `push`.
356 // Why: One row per registry policy, known up front.
357 //
358 // In TS you'd write (pseudocode):
359 // ```ts
360 // const settings: PolicySetting[] = [];
361 // ```
362 let mut settings: Vec<PolicySetting> =
363 Vec::<PolicySetting>::with_capacity(POLICY_REGISTRY.len());
364 // `for ... in` borrows each registry row in order.
365 for descriptor in POLICY_REGISTRY {
366 settings.push(PolicySetting {
367 id: descriptor.id,
368 severity: unlisted_severity(descriptor),
369 explicit: false,
370 });
371 }
372 return PolicyConfig {
373 settings,
374 forbidden_strings: ForbiddenStringsOptions {
375 builtin_rules: true,
376 // `None` is the "absent" variant: no file is configured.
377 rules_files: None,
378 },
379 markdown_autofix: MarkdownAutofixOptions {
380 // `vec![...]` is a macro building an owned list from literal items.
381 rules: vec![MarkdownRule::LfsImageUrl],
382 // `Vec::new()` is the empty owned list.
383 exclude: Vec::<String>::new(),
384 },
385 };
386 }
387
388 /// What:
389 /// Read the effective setting of one policy.
390 /// `&self` borrows this record.
391 /// Why:
392 /// Callers ask by typed identity instead of scanning the list themselves.
393 ///
394 /// In TS you'd write (pseudocode):
395 /// ```ts
396 /// setting(id: PolicyId): PolicySetting;
397 /// ```
398 pub fn setting(&self, id: PolicyId) -> PolicySetting {
399 // `&self.settings` lends the list; `*setting` copies one small row out of it.
400 for setting in &self.settings {
401 if setting.id == id {
402 return *setting;
403 }
404 }
405 // Unreachable while `settings` holds one row per registry policy; fall back to
406 // the unlisted severity rather than aborting a Git command.
407 return PolicySetting {
408 id,
409 severity: unlisted_severity(policy_descriptor(id)),
410 explicit: false,
411 };
412 }
413}
414
415/// Whole-configuration defaults.
416impl CliGitConfig {
417 /// What:
418 /// Build the configuration of a repository without `cli-git.config.jsonc`,
419 /// which
420 /// is also what an empty `{}` file produces.
421 /// Why:
422 /// Parsing starts from these values and replaces only what the file states.
423 ///
424 /// In TS you'd write (pseudocode):
425 /// ```ts
426 /// static defaults(): CliGitConfig;
427 /// ```
428 pub fn defaults() -> CliGitConfig {
429 return CliGitConfig {
430 policies: PolicyConfig::defaults(),
431 concurrency: ConcurrencyConfig::defaults(),
432 };
433 }
434}
435
436/// What:
437/// Translate a configured rule name into its typed rule.
438/// `Option<T>` is "value or nothing":
439/// `Some(rule)` or `None`.
440/// Why:
441/// Only shipped rules exist;
442/// the caller reports any other name with its key.
443///
444/// In TS you'd write (pseudocode):
445/// ```ts
446/// function markdownRuleFromName(name: string): MarkdownRule | undefined;
447/// ```
448pub fn markdown_rule_from_name(name: &str) -> Option<MarkdownRule> {
449 if name == "lfs-image-url" {
450 // `Some(...)` is the "present" variant.
451 return Some(MarkdownRule::LfsImageUrl);
452 }
453 // `None` is the "absent" variant.
454 return None;
455}
456
457/// What:
458/// Translate a typed rule back to its configuration spelling.
459/// `&'static str` borrows text compiled into the executable for its whole run.
460/// Why:
461/// Diagnostics and the later linter invocation print the accepted spelling.
462///
463/// In TS you'd write (pseudocode):
464/// ```ts
465/// function markdownRuleName(rule: MarkdownRule): string;
466/// ```
467pub fn markdown_rule_name(rule: MarkdownRule) -> &'static str {
468 // `match` must name every variant, so a new rule cannot be forgotten here.
469 match rule {
470 MarkdownRule::LfsImageUrl => return "lfs-image-url",
471 }
472}
473
474/// Value controls stay out of the release executable.
475#[cfg(test)]
476#[path = "config_schema_tests.rs"]
477mod tests;