Skip to main content

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;