git_policy_cli/management_selection.rs
1//! What:
2//! The `--policy` selection of a direct `git cli-git check` or `fix`:
3//! the names turned into shipped policies,
4//! and the refusal of a selection that names a policy which is off.
5//! Why:
6//! A mistyped policy ID must stop the command instead of selecting nothing.
7//! A policy that is off runs nothing,
8//! so a run limited to it would exit 0 with no output and read as "checked and clean".
9//! A CI step or a person who selected the policy by name must learn that it did not run,
10//! and which `cli-git.config.jsonc` key turns it on.
11//! The commit,
12//! add and push lifecycles are not direct runs and keep skipping policies that are off.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! // const ids = names.map(shippedPolicy); const off = ids.filter(id => config.severity(id) === 'off'); if (off.length) throw new ConfigError(...);
17//! ```
18
19/// Import the refusal's error type.
20use super::config_error::ConfigError;
21/// Import the severities in effect.
22use super::config_schema::{PolicyConfig, PolicySetting};
23/// Import the registry rows and the severity spellings.
24use super::policy_registry::{
25 POLICY_REGISTRY, PolicyDescriptor, PolicyId, Severity, policy_by_name, policy_descriptor, severity_name,
26};
27/// Import the lifecycle points and which policy runs at which.
28use super::policy_trigger::{Trigger, policy_runs_on};
29
30/// What:
31/// Turn the selected policy names into policy identities.
32/// `Result<Vec<PolicyId>, ConfigError>` is the identities,
33/// or the error naming the
34/// first name no shipped policy has.
35/// Why:
36/// `--policy` selects among shipped policies only;
37/// a mistyped ID must stop the
38/// command instead of selecting nothing.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// function selectedPolicies(names: string[]): PolicyId[]; // throws ConfigError on an unknown name
43/// ```
44pub fn selected_policies(names: &[String]) -> Result<Vec<PolicyId>, ConfigError> {
45 // `Vec::<PolicyId>::new()` is an empty owned list; `mut` allows pushing.
46 let mut selected: Vec<PolicyId> = Vec::<PolicyId>::new();
47 // `for name in names` borrows each selected ID in order.
48 for name in names {
49 // `match` unpacks "a registry row or nothing".
50 match policy_by_name(name.as_str()) {
51 Some(descriptor) => selected.push(descriptor.id),
52 None => {
53 let mut known: String = String::new();
54 for descriptor in POLICY_REGISTRY {
55 if !known.is_empty() {
56 known.push_str(", ");
57 }
58 known.push_str(descriptor.name);
59 }
60 // `Err(...)` is the failure variant carrying the error.
61 return Err(ConfigError::new(
62 format!("Unknown policy ID: {name}. Shipped policies: {known}.").as_str(),
63 ));
64 }
65 }
66 }
67 // `Ok(...)` is the success variant: every selected ID is shipped.
68 return Ok(selected);
69}
70
71/// What:
72/// The refusal text for one selected policy that is off.
73/// `PolicySetting::explicit` tells a listed `"off"` from a policy the file does not name.
74/// Why:
75/// The two remedies differ (change a value,
76/// or add a key),
77/// and both are spelled with the exact key and a severity that turns the policy on:
78/// the policy's own default.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// const offMessage = (name: string, explicit: boolean, on: string): string => `...`;
83/// ```
84fn off_message(name: &str, explicit: bool, on: &str) -> String {
85 let cause: String = if explicit {
86 format!("cli-git.config.jsonc sets policies.\"{name}\" to \"off\"")
87 } else {
88 format!("cli-git.config.jsonc does not list \"{name}\", and an unlisted policy of this kind is off")
89 };
90 return format!(
91 "Policy {name} is off, so --policy {name} would check nothing and exit clean: {cause}. \
92 To turn it on, set \"policies\": {{ \"{name}\": \"{on}\" }} in cli-git.config.jsonc and run the command again."
93 );
94}
95
96/// What:
97/// The refusal text for one selected policy that is on but does not run for this direct command.
98/// `runs_for` is the direct commands that do run it,
99/// in `check`,
100/// `fix` order.
101/// Why:
102/// A run limited to it would check nothing and exit clean,
103/// exactly as for a policy that is off.
104/// The remedy is the command that does run it,
105/// or,
106/// for a policy that runs on neither,
107/// the Git command it guards.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const unrunMessage = (name: string, command: string, runsFor: string[]): string => `...`;
112/// ```
113fn unrun_message(name: &str, command: &str, runs_for: &[&str]) -> String {
114 if runs_for.is_empty() {
115 return format!(
116 "Policy {name} does not run for the cli-git check or fix commands, only when the Git command it guards is \
117 forwarded, so --policy {name} would check nothing and exit clean. Run that Git command to see it."
118 );
119 }
120 let mut commands: Vec<String> = Vec::new();
121 for word in runs_for {
122 commands.push(format!("cli-git {word}"));
123 }
124 return format!(
125 "Policy {name} does not run for cli-git {command}, so --policy {name} would check nothing and exit clean. \
126 It runs for {}.",
127 commands.join(" or ")
128 );
129}
130
131/// What:
132/// The refusal for a direct command,
133/// when any selected policy has severity `off`,
134/// or is on but does not run for this command;
135/// `None` when nothing is selected or every selected policy runs.
136/// `&[PolicyId]` is the `--policy` selection,
137/// `&PolicyConfig` the severities in effect,
138/// and `trigger` is `DirectCheck` or `DirectFix`.
139/// Why:
140/// One message names every such policy,
141/// in selection order,
142/// so a repeated `--policy` is answered once and a run never silently drops a selected policy.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// function selectionError(selected: PolicyId[], policies: PolicyConfig, trigger: PolicyTrigger): ConfigError | undefined;
147/// ```
148pub fn selection_error(selected: &[PolicyId], policies: &PolicyConfig, trigger: Trigger) -> Option<ConfigError> {
149 let mut messages: Vec<String> = Vec::new();
150 // A policy named twice gets one sentence.
151 let mut seen: Vec<PolicyId> = Vec::new();
152 for id in selected {
153 if seen.contains(id) {
154 continue;
155 }
156 seen.push(*id);
157 let setting: PolicySetting = policies.setting(*id);
158 let descriptor: &PolicyDescriptor = policy_descriptor(*id);
159 if setting.severity == Severity::Off {
160 let on: &str = severity_name(descriptor.default_severity);
161 messages.push(off_message(descriptor.name, setting.explicit, on));
162 } else if !policy_runs_on(*id, trigger) {
163 let mut runs_for: Vec<&str> = Vec::new();
164 if policy_runs_on(*id, Trigger::DirectCheck) {
165 runs_for.push("check");
166 }
167 if policy_runs_on(*id, Trigger::DirectFix) {
168 runs_for.push("fix");
169 }
170 let command: &str = if trigger == Trigger::DirectFix { "fix" } else { "check" };
171 messages.push(unrun_message(descriptor.name, command, runs_for.as_slice()));
172 }
173 }
174 if messages.is_empty() {
175 return None;
176 }
177 return Some(ConfigError::new(messages.join(" ").as_str()));
178}
179
180/// Refusal controls stay out of the release executable.
181#[cfg(test)]
182#[path = "management_selection_tests.rs"]
183mod tests;