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