Skip to main content

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