Skip to main content

git_policy_cli/
doctor_config.rs

1//! What:
2//!  What `git cli-git doctor` reports from the configuration: the external hooks Git configuration defines, and the
3//!  retired `hooks.concurrentCommits` key.
4//! Why:
5//!  The wrapper runs no external hook, so a configured hook is a finding the author must act on, and a `hooks` key in
6//!  `cli-git.config.jsonc` is accepted for older files but means nothing; doctor says both plainly.
7//!  The commit path prints the first as a warning from the snapshot it already holds; doctor lists it on request.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const findings = await configFindings({ context, cwd }); // { hooks: [...], retiredHooksKey: boolean }
12//! ```
13
14use super::config_file::load_repository_config;
15use super::config_snapshot::read_config_snapshot;
16use super::external_hooks::{ConfiguredHook, configured_hooks, describe_hook, explain_external_hooks};
17use super::transaction_git::{GitContext, GitRequest, run_git};
18use std::path::{Path, PathBuf};
19
20/// What:
21///  What the configuration contributes to the doctor report.
22/// Why:
23///   Standard output lists the hooks one per line, and standard error carries the explanation and the notes.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// type ConfigFindings = { hooks: ConfiguredHook[]; retiredHooksKey: boolean };
28/// ```
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct ConfigFindings {
31    /// The configured external hooks, none of which runs.
32    pub hooks: Vec<ConfiguredHook>,
33    /// Whether `cli-git.config.jsonc` has the retired `hooks` key.
34    pub retired_hooks_key: bool,
35}
36
37/// What:
38///  The worktree top level, when Git reports one.
39/// Why:
40///   The JSONC file sits at the top level; a bare repository has none and so no file.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// await git(['rev-parse', '--show-toplevel']).catch(() => undefined)
45/// ```
46fn top_level(context: &GitContext, cwd: &Path) -> Option<PathBuf> {
47    let request: GitRequest = GitRequest::new(cwd, &["rev-parse", "--show-toplevel"]);
48    let output = run_git(context, &request).ok()?;
49    if !output.succeeded() {
50        return None;
51    }
52    let text: String = String::from_utf8(output.stdout).ok()?;
53    let trimmed: &str = text.trim_end_matches('\n');
54    if trimmed.is_empty() {
55        return None;
56    }
57    return Some(PathBuf::from(trimmed));
58}
59
60/// What:
61///  Read the configuration findings for `cwd`.
62/// Why:
63///   One snapshot read gives the hooks; one file read gives the retired key.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// configFindings({ context, cwd });
68/// ```
69pub fn config_findings(context: &GitContext, cwd: &Path) -> ConfigFindings {
70    let hooks: Vec<ConfiguredHook> = read_config_snapshot(context, cwd)
71        .map(|snapshot| { return configured_hooks(&snapshot); })
72        .unwrap_or_default();
73    // A configuration that cannot be loaded is not doctor's to report: the command works beside a broken file, and
74    // every other command names the problem.
75    let retired_hooks_key: bool = top_level(context, cwd)
76        .and_then(|root| { return load_repository_config(root.as_path()).ok(); })
77        .is_some_and(|loaded| { return loaded.config.concurrency.hooks.present; });
78    return ConfigFindings { hooks, retired_hooks_key };
79}
80
81/// What:
82///  The lines for standard output: one per configured hook.
83/// Why:
84///   The hooks sit beside the traditional hooks in the report a script can read.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// findings.hooks.map(describeHook).join('\n')
89/// ```
90pub fn render_hook_lines(findings: &ConfigFindings) -> String {
91    let mut text: String = String::new();
92    for hook in &findings.hooks {
93        text.push_str(describe_hook(hook).as_str());
94        text.push('\n');
95    }
96    return text;
97}
98
99/// What:
100///  The explanation and notes for standard error.
101/// Why:
102///   The ways forward for hooks, and the note that `hooks.concurrentCommits` is accepted and ignored.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// renderNotes(findings);
107/// ```
108pub fn render_notes(findings: &ConfigFindings) -> String {
109    let mut text: String = String::new();
110    if !findings.hooks.is_empty() {
111        text.push_str("git cli-git doctor: ");
112        text.push_str(explain_external_hooks(findings.hooks.as_slice()).as_str());
113        text.push('\n');
114    }
115    if findings.retired_hooks_key {
116        text.push_str(
117            "git cli-git doctor: cli-git.config.jsonc has a `hooks` key (`hooks.concurrentCommits`). It is accepted so \
118             older files still load, and ignored: the wrapper runs no external hook, so there is no hook lock to \
119             tune. Delete the key when convenient.\n",
120        );
121    }
122    return text;
123}
124
125/// Reading the findings is controlled in the test module.
126#[cfg(test)]
127#[path = "doctor_config_tests.rs"]
128mod tests;