Skip to main content

git_policy_cli/
config_parse.rs

1//! What:
2//!  Turn `cli-git.config.jsonc` text into validated typed settings.
3//! Why:
4//!  Every unknown key,
5//!  wrong type or ambiguous duplicate is rejected before any
6//!      policy or transaction reads a setting.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // parseConfig(source) returns CliGitConfig or throws ConfigError naming the key.
11//! ```
12
13/// What:
14///  `use super::...` imports sibling modules of this crate.
15/// Why:
16///   Parsing combines the error type,
17///  the policy section reader,
18///  the typed
19///       settings and the shared value readers.
20///
21/// In TS you'd write (pseudocode):
22/// ```ts
23/// import { ConfigError } from './config-error.ts';
24/// ```
25use super::config_error::ConfigError;
26use super::config_policies::parse_policies;
27use super::config_schema::{CliGitConfig, ConcurrencyConfig};
28use super::config_values::{boolean, member_keys, member_path, safe_integer, wrong_kind};
29/// What:
30///  Import the repository's JSONC parser and its value type.
31/// Why:
32///   cli-git reads its configuration through the shared JSONC package only.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// import { parseJsonc, type JsoncValue } from 'jsonc-edit';
37/// ```
38use monochromatic_jsonc_edit::{JsoncValue, parse_jsonc};
39
40/// What:
41///  Every accepted top-level key,
42///  for the unknown-key diagnostic.
43///       `&str` is borrowed text compiled into the executable (sibling:
44///  owned `String`).
45/// Why:
46///   One list keeps the message and the dispatch from drifting apart.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// const ACCEPTED_KEYS = 'policies, hooks, indexLock, landing';
51/// ```
52const ACCEPTED_KEYS: &str = "policies, hooks, indexLock, landing";
53
54/// What:
55///  Explain a top-level key the schema does not accept.
56/// Why:
57///   `plugins` and `trust` existed in the executable TypeScript configuration;
58///  their
59///       authors need the reason and the remedy,
60///  not a bare "unknown key".
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// function unknownTopLevelKey(key: string): ConfigError;
65/// ```
66fn unknown_top_level_key(key: &str) -> ConfigError {
67    if key == "plugins" {
68        return ConfigError::new(
69            "Configuration key plugins is retired: policies are compiled into cli-git and \
70             JSONC configuration cannot load plugin code. Remove the key and configure the \
71             shipped policies under policies.",
72        );
73    }
74    if key == "postCommit" {
75        return ConfigError::new(
76            "Configuration key postCommit is not supported: cli-git runs no program named by any configuration, so \
77             there is nothing to declare under it. Remove the key. A check that must run on every commit belongs in \
78             a cli-git policy under policies.",
79        );
80    }
81    if key == "trust" {
82        return ConfigError::new(
83            "Configuration key trust is retired: JSONC configuration is data and needs no \
84             code-execution approval. Remove the key.",
85        );
86    }
87    // What: `format!` builds owned text; `.as_str()` lends it to the constructor.
88    // Why:  The message names the offending key and every accepted one.
89    //
90    // In TS you'd write (pseudocode):
91    // ```ts
92    // return new ConfigError(`Unknown configuration key: ${key}. Accepted keys: ...`);
93    // ```
94    return ConfigError::new(
95        format!("Unknown configuration key: {key}. Accepted keys: {ACCEPTED_KEYS}.").as_str(),
96    );
97}
98
99/// What:
100///  Validate one concurrency section and apply its single optional key.
101///       `Result<T, E>` is "value or error":
102///  `Ok(...)` or `Err(...)`.
103/// Why:
104///   `hooks`,
105///  `indexLock` and `landing` share one shape:
106///  an object whose only
107///       accepted key may be omitted to keep the default.
108///  All three are retired:
109///       the value is checked and recorded as present, and nothing reads it afterwards.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// function applyConcurrency(base: ConcurrencyConfig, key: string, value: JsoncValue): ConcurrencyConfig;
114/// ```
115fn apply_concurrency(
116    base: ConcurrencyConfig,
117    key: &str,
118    value: &JsoncValue,
119) -> Result<ConcurrencyConfig, ConfigError> {
120    // The single key each section accepts.
121    let allowed: &str = if key == "hooks" {
122        "concurrentCommits"
123    } else if key == "indexLock" {
124        "unprovenOwnerTimeoutMs"
125    } else {
126        "reserveAfterLostRaces"
127    };
128    // What: `let Some(x) = ... else { return ... };` unwraps the present case or exits.
129    // Why:  A section that is not an object has no keys to read.
130    //
131    // In TS you'd write (pseudocode):
132    // ```ts
133    // if (!isRecord(value)) throw wrongKind(value, key, "an object");
134    // ```
135    let Some(entries) = value.entries() else {
136        // `Err(...)` is the failure variant returned to the caller.
137        return Err(wrong_kind(value, key, "an object"));
138    };
139    // A trailing `?` returns the reader error to our caller, or unwraps its value.
140    let keys: Vec<String> = member_keys(entries, key)?;
141    // `ConcurrencyConfig` is `Copy`, so this is an independent editable copy.
142    let mut result: ConcurrencyConfig = base;
143    // What: `.iter().enumerate()` yields `(index, item)` pairs; `usize` is the index type.
144    // Why:  The decoded keys are parallel to `entries`, so the index reaches the value.
145    //
146    // In TS you'd write (pseudocode):
147    // ```ts
148    // for (const [index, member] of keys.entries()) { ... }
149    // ```
150    for (index, member) in keys.iter().enumerate() {
151        let path: String = member_path(key, member.as_str());
152        if member != allowed {
153            return Err(ConfigError::new(
154                format!(
155                    "Unknown configuration key: {path}. The only accepted key under {key} is {allowed}."
156                )
157                .as_str(),
158            ));
159        }
160        // `&entries[index].value` borrows the member value parallel to this key.
161        let member_value: &JsoncValue = &entries[index].value;
162        if key == "hooks" {
163            result.hooks.concurrent_commits = boolean(member_value, path.as_str())?;
164            result.hooks.present = true;
165        } else if key == "indexLock" {
166            result.index_lock.unproven_owner_timeout_ms =
167                safe_integer(member_value, path.as_str(), 0)?;
168            result.index_lock.present = true;
169        } else {
170            result.landing.reserve_after_lost_races = safe_integer(member_value, path.as_str(), 1)?;
171            result.landing.present = true;
172        }
173    }
174    // `Ok(result)` is the success variant carrying the tuned copy.
175    return Ok(result);
176}
177
178/// What:
179///  Parse and validate one complete configuration document.
180/// Why:
181///   This is the only way settings enter the native wrapper:
182///  data in,
183///  typed
184///       settings or one actionable error out.
185///  Nothing in the document is executed,
186///       imported or resolved as a path.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// function parseConfig(source: string): CliGitConfig;
191/// ```
192pub fn parse_config(source: &str) -> Result<CliGitConfig, ConfigError> {
193    // What: `match` on the parser's `Result` keeps the value or converts its error.
194    //       `Ok(parsed) => parsed` unwraps success; `Err(error) => { ... }` handles failure.
195    // Why:  A syntax failure must keep its byte offset so the author can find it.
196    //
197    // In TS you'd write (pseudocode):
198    // ```ts
199    // let document; try { document = parseJsonc(source); } catch (e) { throw new ConfigError(...); }
200    // ```
201    let document: JsoncValue = match parse_jsonc(source) {
202        Ok(parsed) => parsed,
203        Err(error) => {
204            return Err(ConfigError::new(
205                format!(
206                    "JSONC syntax error at byte {}: {}",
207                    error.offset, error.message
208                )
209                .as_str(),
210            ));
211        }
212    };
213    let Some(entries) = document.entries() else {
214        // `&document` lends the value to the diagnostic builder.
215        return Err(wrong_kind(&document, "", "an object"));
216    };
217    let keys: Vec<String> = member_keys(entries, "")?;
218    let mut config: CliGitConfig = CliGitConfig::defaults();
219    for (index, key) in keys.iter().enumerate() {
220        // `&entries[index].value` borrows the member value parallel to this key.
221        let value: &JsoncValue = &entries[index].value;
222        if key == "policies" {
223            config.policies = parse_policies(value)?;
224        } else if key == "hooks" || key == "indexLock" || key == "landing" {
225            config.concurrency = apply_concurrency(config.concurrency, key.as_str(), value)?;
226        } else {
227            return Err(unknown_top_level_key(key.as_str()));
228        }
229    }
230    return Ok(config);
231}
232
233/// Acceptance controls stay out of the release executable.
234#[cfg(test)]
235#[path = "config_parse_acceptance_tests.rs"]
236mod acceptance_tests;
237
238/// Structural rejection controls and the shared rejection helpers.
239#[cfg(test)]
240#[path = "config_parse_tests.rs"]
241mod tests;
242
243/// Invalid-value rejection controls.
244#[cfg(test)]
245#[path = "config_parse_value_tests.rs"]
246mod value_tests;