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