git_policy_cli/config_file.rs
1//! What:
2//! Find and read `cli-git.config.jsonc` at a repository root.
3//! Why:
4//! Configuration is one data file at one fixed place;
5//! legacy executable
6//! configuration is reported for migration and never read,
7//! run or silently ignored.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // loadRepositoryConfig(root) returns { config, source, ignoredLegacy } or throws ConfigError.
12//! ```
13
14/// What:
15/// `use super::...` imports sibling modules of this crate.
16/// Why:
17/// Loading combines the error type,
18/// the document parser and the typed settings.
19///
20/// In TS you'd write (pseudocode):
21/// ```ts
22/// import { ConfigError } from './config-error.ts';
23/// ```
24use super::config_error::ConfigError;
25use super::config_parse::parse_config;
26use super::config_schema::CliGitConfig;
27/// What:
28/// Import the trait that gives files `.take(..)` and `.read_to_end(..)`.
29/// A trait is an interface;
30/// its methods exist only while it is in scope.
31/// Why:
32/// The file is read through a byte cap instead of trusting its reported size.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// import { open } from 'node:fs/promises';
37/// ```
38use std::io::Read;
39/// What:
40/// `Path` is a borrowed filesystem path;
41/// `PathBuf` is its owned form.
42/// The same pair as `&str` and `String`,
43/// but holding raw OS bytes.
44/// Why:
45/// Repository paths need not be UTF-8,
46/// so they are never converted to text
47/// except inside a diagnostic.
48///
49/// In TS you'd write (pseudocode):
50/// ```ts
51/// import { join } from 'node:path';
52/// ```
53use std::path::{Path, PathBuf};
54
55/// The only configuration file the native wrapper reads.
56pub const CONFIG_FILE_NAME: &str = "cli-git.config.jsonc";
57
58/// What:
59/// Executable configuration names of the TypeScript wrapper,
60/// in its lookup order.
61/// `&[&str]` is a borrowed list of borrowed strings compiled into the executable.
62/// Why:
63/// Their presence must produce a migration diagnostic.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// const LEGACY_CONFIG_FILE_NAMES = ['cli-git.config.mjs', 'cli-git.config.ts'] as const;
68/// ```
69pub const LEGACY_CONFIG_FILE_NAMES: &[&str] = &["cli-git.config.mjs", "cli-git.config.ts"];
70
71/// What:
72/// Largest accepted configuration file,
73/// one mebibyte.
74/// `u64` is an unsigned 64-bit integer,
75/// the type of file sizes (sibling `usize`).
76/// Why:
77/// A repository file must not control how much memory every Git command reads.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// const MAX_CONFIG_BYTES = 1024 * 1024;
82/// ```
83pub const MAX_CONFIG_BYTES: u64 = 1_048_576;
84
85/// What:
86/// The outcome of loading one repository's configuration.
87/// `Option<PathBuf>` is "an owned path or nothing";
88/// `Vec<PathBuf>` is an owned list.
89/// Why:
90/// Callers need the settings,
91/// the file they came from for diagnostics,
92/// and any
93/// legacy file that was left beside an authoritative JSONC file so they can
94/// report it instead of ignoring it silently.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// type LoadedConfig = { config: CliGitConfig; source?: string; ignoredLegacy: string[] };
99/// ```
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct LoadedConfig {
102 /// Validated settings;
103 /// `CliGitConfig::defaults()` when the repository has no file.
104 pub config: CliGitConfig,
105 /// The JSONC file that was read,
106 /// absent when the repository has none.
107 pub source: Option<PathBuf>,
108 /// Legacy files present beside the JSONC file;
109 /// never read,
110 /// to be reported.
111 pub ignored_legacy: Vec<PathBuf>,
112}
113
114/// What:
115/// Report whether a directory entry exists,
116/// without following a final symlink.
117/// `Result<bool, ConfigError>` is "true/false or an error".
118/// Why:
119/// Only "not found" means absent;
120/// any other failure (for example a permission
121/// error) must stop the command instead of pretending no file exists.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// async function entryExists(path: string): Promise<boolean>;
126/// ```
127fn entry_exists(path: &Path) -> Result<bool, ConfigError> {
128 // What: `match` on the metadata `Result`: `Ok(_)` ignores the metadata itself.
129 // `error.kind()` classifies the OS error; `::` walks module paths.
130 // Why: Existence is all that matters for a legacy file.
131 //
132 // In TS you'd write (pseudocode):
133 // ```ts
134 // try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
135 // ```
136 match std::fs::symlink_metadata(path) {
137 // `Ok(true)` is the success variant carrying the answer.
138 Ok(_) => return Ok(true),
139 Err(error) => {
140 if error.kind() == std::io::ErrorKind::NotFound {
141 return Ok(false);
142 }
143 // `Err(...)` is the failure variant; `path.display()` renders lossy text.
144 return Err(ConfigError::new(
145 format!("Cannot inspect {}: {error}.", path.display()).as_str(),
146 ));
147 }
148 }
149}
150
151/// What:
152/// Explain a legacy configuration file that has no JSONC replacement yet.
153/// Why:
154/// The author needs to know it was not run,
155/// where the replacement goes and
156/// which parts have no equivalent.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// function migrationRequired(legacy: string, target: string): ConfigError;
161/// ```
162fn migration_required(legacy: &Path, target: &Path) -> ConfigError {
163 return ConfigError::new(
164 format!(
165 "Legacy configuration {} is not executed or read by the native cli-git. \
166 Translate its settings into {} (data only: policies, hooks, indexLock, landing), \
167 then remove the legacy file. Plugins, executable paths, command arrays and trust \
168 settings have no JSONC equivalent because every policy is built in.",
169 legacy.display(),
170 target.display()
171 )
172 .as_str(),
173 );
174}
175
176/// What:
177/// Describe a legacy file that sits beside an authoritative JSONC file.
178/// `String` is owned text returned to the caller.
179/// Why:
180/// The executable prints this so the stale file is never ignored silently.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// function ignoredLegacyNotice(legacy: string, source: string): string;
185/// ```
186pub fn ignored_legacy_notice(legacy: &Path, source: &Path) -> String {
187 return format!(
188 "Legacy configuration {} is ignored: {} is authoritative for the native cli-git. \
189 Remove the legacy file once no TypeScript cli-git reads it.",
190 legacy.display(),
191 source.display()
192 );
193}
194
195/// What:
196/// Read at most `MAX_CONFIG_BYTES` of a regular,
197/// non-symlink file as UTF-8 text.
198/// Why:
199/// A symbolic link could point outside the repository,
200/// a directory or device is
201/// not configuration,
202/// and an oversized or non-UTF-8 file is rejected by name.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// async function readConfigText(path: string): Promise<string>;
207/// ```
208fn read_config_text(path: &Path) -> Result<String, ConfigError> {
209 // What: `let x = match ... { Ok(v) => v, Err(e) => return Err(...) };` unwraps or exits.
210 // Why: Metadata without following the link reveals a symlink before it is opened.
211 //
212 // In TS you'd write (pseudocode):
213 // ```ts
214 // const entry = await lstat(path);
215 // ```
216 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(path) {
217 Ok(found) => found,
218 Err(error) => {
219 return Err(ConfigError::new(
220 format!("Cannot inspect {}: {error}.", path.display()).as_str(),
221 ));
222 }
223 };
224 if metadata.file_type().is_symlink() {
225 return Err(ConfigError::new(
226 format!(
227 "Configuration path must not be a symbolic link: {}. Replace it with a regular file.",
228 path.display()
229 )
230 .as_str(),
231 ));
232 }
233 if !metadata.is_file() {
234 return Err(ConfigError::new(
235 format!(
236 "Configuration path must be a regular file: {}.",
237 path.display()
238 )
239 .as_str(),
240 ));
241 }
242 let file: std::fs::File = match std::fs::File::open(path) {
243 Ok(opened) => opened,
244 Err(error) => {
245 return Err(ConfigError::new(
246 format!("Cannot open {}: {error}.", path.display()).as_str(),
247 ));
248 }
249 };
250 // What: `Vec::<u8>::new()` is an empty owned byte list (`u8` is one byte, 0 to 255).
251 // Why: Bytes are validated as UTF-8 only after the size cap has been enforced.
252 //
253 // In TS you'd write (pseudocode):
254 // ```ts
255 // const bytes = Buffer.alloc(0);
256 // ```
257 let mut bytes: Vec<u8> = Vec::<u8>::new();
258 // What: `.take(n)` stops reading after `n` bytes; `&mut bytes` lends the buffer for
259 // appending. One byte past the cap proves the file is too large.
260 // Why: The cap holds even if the file grows after its metadata was read.
261 //
262 // In TS you'd write (pseudocode):
263 // ```ts
264 // const { bytesRead } = await handle.read(buffer, 0, MAX_CONFIG_BYTES + 1, 0);
265 // ```
266 if let Err(error) = file.take(MAX_CONFIG_BYTES + 1).read_to_end(&mut bytes) {
267 return Err(ConfigError::new(
268 format!("Cannot read {}: {error}.", path.display()).as_str(),
269 ));
270 }
271 // `as u64` widens the list length for comparison with the byte cap.
272 if bytes.len() as u64 > MAX_CONFIG_BYTES {
273 return Err(ConfigError::new(
274 format!(
275 "Configuration file {} is larger than {MAX_CONFIG_BYTES} bytes.",
276 path.display()
277 )
278 .as_str(),
279 ));
280 }
281 // `String::from_utf8` takes ownership of the bytes and checks them without copying.
282 match String::from_utf8(bytes) {
283 Ok(text) => return Ok(text),
284 Err(error) => {
285 return Err(ConfigError::new(
286 format!(
287 "Configuration file {} is not UTF-8 text: {error}.",
288 path.display()
289 )
290 .as_str(),
291 ));
292 }
293 }
294}
295
296/// What:
297/// Load the configuration of the repository whose top level is `repository_root`.
298/// Why:
299/// This is the single entry point for policy settings.
300/// Nothing in a repository
301/// is executed:
302/// the JSONC file is parsed as data,
303/// and legacy `.mjs`/`.ts`
304/// configuration is only detected by name.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// async function loadRepositoryConfig(repositoryRoot: string): Promise<LoadedConfig>;
309/// ```
310pub fn load_repository_config(repository_root: &Path) -> Result<LoadedConfig, ConfigError> {
311 // `.join(name)` appends one path segment and returns an owned path.
312 let source: PathBuf = repository_root.join(CONFIG_FILE_NAME);
313 // `Vec::<PathBuf>::new()` is an empty owned list; `mut` allows pushing.
314 let mut legacy: Vec<PathBuf> = Vec::<PathBuf>::new();
315 // `for name in LEGACY_CONFIG_FILE_NAMES` visits each compiled-in name.
316 for name in LEGACY_CONFIG_FILE_NAMES {
317 let candidate: PathBuf = repository_root.join(name);
318 // A trailing `?` returns the helper's error to our caller, or unwraps its value.
319 if entry_exists(candidate.as_path())? {
320 legacy.push(candidate);
321 }
322 }
323 if !entry_exists(source.as_path())? {
324 // `.first()` is `Some(&item)` for a non-empty list, `None` otherwise.
325 if let Some(first) = legacy.first() {
326 return Err(migration_required(first.as_path(), source.as_path()));
327 }
328 return Ok(LoadedConfig {
329 config: CliGitConfig::defaults(),
330 // `None` records that no file was read.
331 source: None,
332 ignored_legacy: legacy,
333 });
334 }
335 let text: String = read_config_text(source.as_path())?;
336 // What: `.strip_prefix(..)` is `Some(rest)` when the text starts with the marker.
337 // `.unwrap_or(default)` picks `rest`, or the whole text when there is no marker.
338 // Why: A UTF-8 byte order mark is an editor artefact, not configuration content.
339 //
340 // In TS you'd write (pseudocode):
341 // ```ts
342 // const body = text.startsWith('') ? text.slice(1) : text;
343 // ```
344 let body: &str = text.strip_prefix('\u{feff}').unwrap_or(text.as_str());
345 match parse_config(body) {
346 Ok(config) => {
347 return Ok(LoadedConfig {
348 config,
349 // `Some(source)` records the file the settings came from.
350 source: Some(source),
351 ignored_legacy: legacy,
352 });
353 }
354 Err(error) => {
355 return Err(ConfigError::new(
356 format!("{}: {}", source.display(), error.message).as_str(),
357 ));
358 }
359 }
360}
361
362/// Disposable-directory controls stay out of the release executable.
363#[cfg(test)]
364#[path = "config_file_tests.rs"]
365mod tests;
366
367/// Controls on the repository's own configuration file stay out of the release executable.
368#[cfg(test)]
369#[path = "config_repository_tests.rs"]
370mod repository_tests;