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 == "trust" {
75 return ConfigError::new(
76 "Configuration key trust is retired: JSONC configuration is data and needs no \
77 code-execution approval. Remove the key.",
78 );
79 }
80 // What: `format!` builds owned text; `.as_str()` lends it to the constructor.
81 // Why: The message names the offending key and every accepted one.
82 //
83 // In TS you'd write (pseudocode):
84 // ```ts
85 // return new ConfigError(`Unknown configuration key: ${key}. Accepted keys: ...`);
86 // ```
87 return ConfigError::new(
88 format!("Unknown configuration key: {key}. Accepted keys: {ACCEPTED_KEYS}.").as_str(),
89 );
90}
91
92/// What:
93/// Validate one concurrency section and apply its single optional key.
94/// `Result<T, E>` is "value or error":
95/// `Ok(...)` or `Err(...)`.
96/// Why:
97/// `hooks`,
98/// `indexLock` and `landing` share one shape:
99/// an object whose only
100/// accepted key may be omitted to keep the default.
101/// They tune hook serialization,
102/// index-lock patience and landing starvation;
103/// none can disable the transaction.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// function applyConcurrency(base: ConcurrencyConfig, key: string, value: JsoncValue): ConcurrencyConfig;
108/// ```
109fn apply_concurrency(
110 base: ConcurrencyConfig,
111 key: &str,
112 value: &JsoncValue,
113) -> Result<ConcurrencyConfig, ConfigError> {
114 // The single key each section accepts.
115 let allowed: &str = if key == "hooks" {
116 "concurrentCommits"
117 } else if key == "indexLock" {
118 "unprovenOwnerTimeoutMs"
119 } else {
120 "reserveAfterLostRaces"
121 };
122 // What: `let Some(x) = ... else { return ... };` unwraps the present case or exits.
123 // Why: A section that is not an object has no keys to read.
124 //
125 // In TS you'd write (pseudocode):
126 // ```ts
127 // if (!isRecord(value)) throw wrongKind(value, key, "an object");
128 // ```
129 let Some(entries) = value.entries() else {
130 // `Err(...)` is the failure variant returned to the caller.
131 return Err(wrong_kind(value, key, "an object"));
132 };
133 // A trailing `?` returns the reader error to our caller, or unwraps its value.
134 let keys: Vec<String> = member_keys(entries, key)?;
135 // `ConcurrencyConfig` is `Copy`, so this is an independent editable copy.
136 let mut result: ConcurrencyConfig = base;
137 // What: `.iter().enumerate()` yields `(index, item)` pairs; `usize` is the index type.
138 // Why: The decoded keys are parallel to `entries`, so the index reaches the value.
139 //
140 // In TS you'd write (pseudocode):
141 // ```ts
142 // for (const [index, member] of keys.entries()) { ... }
143 // ```
144 for (index, member) in keys.iter().enumerate() {
145 let path: String = member_path(key, member.as_str());
146 if member != allowed {
147 return Err(ConfigError::new(
148 format!(
149 "Unknown configuration key: {path}. The only accepted key under {key} is {allowed}."
150 )
151 .as_str(),
152 ));
153 }
154 // `&entries[index].value` borrows the member value parallel to this key.
155 let member_value: &JsoncValue = &entries[index].value;
156 if key == "hooks" {
157 result.hooks.concurrent_commits = boolean(member_value, path.as_str())?;
158 } else if key == "indexLock" {
159 result.index_lock.unproven_owner_timeout_ms =
160 safe_integer(member_value, path.as_str(), 0)?;
161 } else {
162 result.landing.reserve_after_lost_races = safe_integer(member_value, path.as_str(), 1)?;
163 }
164 }
165 // `Ok(result)` is the success variant carrying the tuned copy.
166 return Ok(result);
167}
168
169/// What:
170/// Parse and validate one complete configuration document.
171/// Why:
172/// This is the only way settings enter the native wrapper:
173/// data in,
174/// typed
175/// settings or one actionable error out.
176/// Nothing in the document is executed,
177/// imported or resolved as a path.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// function parseConfig(source: string): CliGitConfig;
182/// ```
183pub fn parse_config(source: &str) -> Result<CliGitConfig, ConfigError> {
184 // What: `match` on the parser's `Result` keeps the value or converts its error.
185 // `Ok(parsed) => parsed` unwraps success; `Err(error) => { ... }` handles failure.
186 // Why: A syntax failure must keep its byte offset so the author can find it.
187 //
188 // In TS you'd write (pseudocode):
189 // ```ts
190 // let document; try { document = parseJsonc(source); } catch (e) { throw new ConfigError(...); }
191 // ```
192 let document: JsoncValue = match parse_jsonc(source) {
193 Ok(parsed) => parsed,
194 Err(error) => {
195 return Err(ConfigError::new(
196 format!(
197 "JSONC syntax error at byte {}: {}",
198 error.offset, error.message
199 )
200 .as_str(),
201 ));
202 }
203 };
204 let Some(entries) = document.entries() else {
205 // `&document` lends the value to the diagnostic builder.
206 return Err(wrong_kind(&document, "", "an object"));
207 };
208 let keys: Vec<String> = member_keys(entries, "")?;
209 let mut config: CliGitConfig = CliGitConfig::defaults();
210 for (index, key) in keys.iter().enumerate() {
211 // `&entries[index].value` borrows the member value parallel to this key.
212 let value: &JsoncValue = &entries[index].value;
213 if key == "policies" {
214 config.policies = parse_policies(value)?;
215 } else if key == "hooks" || key == "indexLock" || key == "landing" {
216 config.concurrency = apply_concurrency(config.concurrency, key.as_str(), value)?;
217 } else {
218 return Err(unknown_top_level_key(key.as_str()));
219 }
220 }
221 return Ok(config);
222}
223
224/// Acceptance controls stay out of the release executable.
225#[cfg(test)]
226#[path = "config_parse_acceptance_tests.rs"]
227mod acceptance_tests;
228
229/// Structural rejection controls and the shared rejection helpers.
230#[cfg(test)]
231#[path = "config_parse_tests.rs"]
232mod tests;
233
234/// Invalid-value rejection controls.
235#[cfg(test)]
236#[path = "config_parse_value_tests.rs"]
237mod value_tests;