Skip to main content

git_policy_cli/
management_arguments.rs

1//! What:
2//!  The grammar of `git cli-git <command>`,
3//!  the wrapper's own management namespace.
4//! Why:
5//!  `check` and `fix` run policies over an explicit scope;
6//!  `doctor` lists the traditional hooks a repository holds;
7//!  `trust`,
8//!  `untrust` and
9//!      `status` are retired with executable configuration and only explain that.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! // parseManagementArgs(['check', '--all']) => { command: 'check', all: true, policies: [], pathspecs: [] }
14//! ```
15
16/// What:
17///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
18///       must be UTF-8).
19/// Why:
20///   Pathspecs are file names and are kept as the exact bytes the caller gave.
21///
22/// In TS you'd write (pseudocode):
23/// ```ts
24/// // string[] argv, but byte-preserving.
25/// ```
26use std::ffi::OsString;
27
28/// What:
29///  The complete grammar shown when an invocation is refused.
30///       `&str` is borrowed text compiled into the executable.
31/// Why:
32///   The text states the enforced rule:
33///  exactly one scope per direct command.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// const MANAGEMENT_USAGE = 'Usage: git cli-git check ...';
38/// ```
39pub const MANAGEMENT_USAGE: &str = "\
40Usage: git cli-git check (--all | -- <pathspec>...) [--policy <id>]...
41       git cli-git fix (--all | -- <pathspec>...) [--policy <id>]...
42       git cli-git doctor
43       git cli-git --help
44";
45
46/// Successful namespace help,
47///  shown without touching any repository.
48pub const MANAGEMENT_HELP: &str = "\
49Usage: git cli-git <command> [options]
50
51Run repository policies directly.
52
53Commands:
54  check    Check policies over an explicit scope.
55  fix      Apply policy fixes over an explicit scope.
56  doctor   List traditional hooks (unsupported). Exit 0 when none or only git-lfs shims are found,
57           1 when any other hook is found, 2 outside a repository.
58
59Scope (exactly one):
60  --all             The whole repository.
61  -- <pathspec>...  The named pathspecs.
62
63Options:
64  --policy <id>  Run only this policy; may be repeated.
65  --help         Show this help.
66
67Policies are configured as data in cli-git.config.jsonc at the repository top level.
68The trust, untrust and status commands are retired: configuration is no longer code,
69so there is nothing to approve.
70";
71
72/// What:
73///  The three commands that existed only to approve executable configuration.
74///       `#[derive(...)]` generates copying,
75///  debug printing and `==`.
76/// Why:
77///   Each still parses,
78///  so old instructions get an explanation instead of a usage error.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type RetiredCommand = 'trust' | 'untrust' | 'status';
83/// ```
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum RetiredCommand {
86    /// `git cli-git trust [--yes]`.
87    Trust,
88    /// `git cli-git untrust`.
89    Untrust,
90    /// `git cli-git status`.
91    Status,
92}
93
94/// What:
95///  One understood management invocation.
96///       `Vec<String>` is an owned list of owned UTF-8 text;
97///  `Vec<OsString>` keeps raw bytes.
98/// Why:
99///   The runner acts on a typed value;
100///  nothing downstream re-reads raw arguments.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// type ManagementAction = { command: 'help' } | { command: 'retired'; ... } | { command: 'check' | 'fix'; ... };
105/// ```
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub enum ManagementAction {
108    /// `--help` or `-h`:
109    ///  print the namespace help.
110    Help,
111    /// A retired trust command;
112    ///  `help` is true for `trust --help`.
113    Retired {
114        /// Which retired command was named.
115        command: RetiredCommand,
116        /// Whether help was requested,
117        ///  which selects standard output.
118        help: bool,
119    },
120    /// `doctor`:
121    ///  list the traditional hooks the repository holds.
122    Doctor,
123    /// `check` or `fix` over exactly one scope.
124    Direct {
125        /// `true` for `fix`,
126        ///  `false` for `check`.
127        fix: bool,
128        /// Whether the whole repository was selected with `--all`.
129        all: bool,
130        /// Selected policy IDs,
131        ///  first occurrence order,
132        ///  without repeats;
133        ///  not yet validated.
134        policies: Vec<String>,
135        /// Pathspecs after `--`,
136        ///  in order,
137        ///  as raw bytes.
138        pathspecs: Vec<OsString>,
139    },
140}
141
142/// What:
143///  Why a management invocation was refused.
144/// Why:
145///   Each refusal has its own remedy,
146///  so each has its own message.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// type ManagementRefusal = 'usage' | 'pathspecs-before-separator' | 'scope-required';
151/// ```
152#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub enum ManagementRefusal {
154    /// Unknown command,
155    ///  unknown option,
156    ///  missing option value or stray argument.
157    Usage,
158    /// A pathspec appeared before `--`;
159    ///  `fix` tells which direct command.
160    PathspecsBeforeSeparator {
161        /// `true` for `fix`,
162        ///  `false` for `check`.
163        fix: bool,
164    },
165    /// Neither or both of `--all` and pathspecs were given.
166    ScopeRequired {
167        /// `true` for `fix`,
168        ///  `false` for `check`.
169        fix: bool,
170    },
171}
172
173/// What:
174///  Report whether a token starts an option rather than naming a path.
175/// Why:
176///   As in Git,
177///  a lone `-` is a positional,
178///  and anything else dash-led is an option.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// function isOptionToken(token: string): boolean { return token.startsWith('-') && token !== '-'; }
183/// ```
184fn is_option_token(token: &OsString) -> bool {
185    // `&[u8]` borrows the token's raw bytes (`u8` is one byte).
186    let bytes: &[u8] = token.as_encoded_bytes();
187    return bytes.starts_with(b"-") && bytes != b"-";
188}
189
190/// What:
191///  Parse a retired command's remaining arguments.
192///       `&[OsString]` borrows the arguments after the command name.
193///       `Result<T, E>` is "value or error":
194///  `Ok(...)` or `Err(...)`.
195/// Why:
196///   The accepted forms are the ones the commands had:
197///  `trust` takes `--yes` and
198///       `--help`/`-h`;
199///  `untrust` and `status` take nothing.
200///  A lone `--` ends options.
201///
202/// In TS you'd write (pseudocode):
203/// ```ts
204/// function parseRetired(command: RetiredCommand, rest: string[]): ManagementAction;
205/// ```
206fn parse_retired(
207    command: RetiredCommand,
208    rest: &[OsString],
209) -> Result<ManagementAction, ManagementRefusal> {
210    // `mut` allows the flags to be set while scanning.
211    let mut help: bool = false;
212    let mut terminated: bool = false;
213    // `for token in rest` borrows each argument in order.
214    for token in rest {
215        if terminated {
216            // What: `Err(...)` is the failure variant; `return` leaves early.
217            // Why:  These commands never took positional arguments.
218            //
219            // In TS you'd write (pseudocode):
220            // ```ts
221            // return MANAGEMENT_REFUSED;
222            // ```
223            return Err(ManagementRefusal::Usage);
224        }
225        if token == "--" {
226            terminated = true;
227        } else if command == RetiredCommand::Trust && (token == "--help" || token == "-h") {
228            help = true;
229        } else if command == RetiredCommand::Trust && token == "--yes" {
230            // Noninteractive consent has nothing left to consent to; it is accepted and ignored.
231            continue;
232        } else {
233            return Err(ManagementRefusal::Usage);
234        }
235    }
236    // `Ok(...)` is the success variant carrying the understood action.
237    return Ok(ManagementAction::Retired { command, help });
238}
239
240/// What:
241///  Add one selected policy ID to the list,
242///  once.
243///  `&mut Vec<String>` lends the list
244///       for writing;
245///  `&[u8]` borrows the value's raw bytes;
246///  `Result<(), ManagementRefusal>`
247///       is "nothing,
248///  or a refusal".
249/// Why:
250///   A separated value and an attached value are recorded the same way:
251///  policy IDs are
252///       ASCII names,
253///  so other bytes can never name a policy,
254///  and a repeated ID counts once.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// function recordPolicy(policies: string[], value: Uint8Array): void { const id = decodeUtf8OrRefuse(value); if (!policies.includes(id)) policies.push(id); }
259/// ```
260fn record_policy(policies: &mut Vec<String>, value: &[u8]) -> Result<(), ManagementRefusal> {
261    // What: `let Ok(text) = ... else { ... };` keeps valid UTF-8 or exits.
262    // Why:  Bytes that are not text cannot be a policy ID.
263    //
264    // In TS you'd write (pseudocode):
265    // ```ts
266    // const id = decodeUtf8OrRefuse(value);
267    // ```
268    let Ok(id) = std::str::from_utf8(value) else {
269        return Err(ManagementRefusal::Usage);
270    };
271    let owned: String = String::from(id);
272    // `.contains(&owned)` borrows the new ID to keep only its first occurrence.
273    if !policies.contains(&owned) {
274        policies.push(owned);
275    }
276    // `Ok(())` is success carrying nothing.
277    return Ok(());
278}
279
280/// What:
281///  Parse `check` or `fix` arguments in one forward pass.
282///       `&[OsString]` borrows the arguments after the command word.
283/// Why:
284///   `--policy` takes its next argument (or an attached `=value`) even when that
285///       value starts with a dash;
286///  everything after `--` is a pathspec.
287///  A pathspec
288///       before `--` is refused so a mistyped option can never become a path,
289///  and the
290///       two scopes are mutually exclusive.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// function parseDirect(fix: boolean, rest: string[]): ManagementAction;
295/// ```
296fn parse_direct(fix: bool, rest: &[OsString]) -> Result<ManagementAction, ManagementRefusal> {
297    let mut all: bool = false;
298    // `Vec::<String>::new()` is an empty owned list; `mut` allows pushing.
299    let mut policies: Vec<String> = Vec::<String>::new();
300    let mut pathspecs: Vec<OsString> = Vec::<OsString>::new();
301    let mut terminated: bool = false;
302    let mut pathspec_before_separator: bool = false;
303    // True while the token being visited is the value of the `--policy` before it.
304    let mut is_policy_value: bool = false;
305    // `for token in rest` borrows each argument once, in order, so the scan always ends.
306    for token in rest {
307        if is_policy_value {
308            is_policy_value = false;
309            // A trailing `?` returns the refusal to our caller, or continues.
310            record_policy(&mut policies, token.as_encoded_bytes())?;
311            continue;
312        }
313        if terminated {
314            // `.clone()` copies the pathspec bytes into the owned result.
315            pathspecs.push(token.clone());
316            continue;
317        }
318        if token == "--" {
319            terminated = true;
320            continue;
321        }
322        if !is_option_token(token) {
323            pathspec_before_separator = true;
324            continue;
325        }
326        if token == "--all" {
327            all = true;
328            continue;
329        }
330        // The policy value is the next argument, or attached after `--policy=`.
331        if token == "--policy" {
332            is_policy_value = true;
333            continue;
334        }
335        // `let Some(x) = ... else { ... };` unwraps the attached value or refuses the option.
336        let Some(attached) = token.as_encoded_bytes().strip_prefix(b"--policy=") else {
337            return Err(ManagementRefusal::Usage);
338        };
339        record_policy(&mut policies, attached)?;
340    }
341    // `--policy` was the last argument: its value is missing.
342    if is_policy_value {
343        return Err(ManagementRefusal::Usage);
344    }
345    if pathspec_before_separator {
346        return Err(ManagementRefusal::PathspecsBeforeSeparator { fix });
347    }
348    if all == !pathspecs.is_empty() {
349        return Err(ManagementRefusal::ScopeRequired { fix });
350    }
351    return Ok(ManagementAction::Direct {
352        fix,
353        all,
354        policies,
355        pathspecs,
356    });
357}
358
359/// What:
360///  Parse the arguments after `git cli-git` into one action or refusal.
361/// Why:
362///   The leading command name selects one grammar,
363///  so an invocation naming two
364///       commands is refused for its stray argument rather than guessed at.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// function parseManagementArguments(args: string[]): ManagementAction;
369/// ```
370pub fn parse_management_arguments(
371    arguments: &[OsString],
372) -> Result<ManagementAction, ManagementRefusal> {
373    // What: `let Some(name) = arguments.first() else { ... };` unwraps the command name.
374    // Why:  A bare `git cli-git` names no command.
375    //
376    // In TS you'd write (pseudocode):
377    // ```ts
378    // const [name, ...rest] = args; if (name === undefined) return MANAGEMENT_REFUSED;
379    // ```
380    let Some(name) = arguments.first() else {
381        return Err(ManagementRefusal::Usage);
382    };
383    // `&arguments[1..]` borrows every argument after the first.
384    let rest: &[OsString] = &arguments[1..];
385    if name == "--help" || name == "-h" {
386        if !rest.is_empty() {
387            return Err(ManagementRefusal::Usage);
388        }
389        return Ok(ManagementAction::Help);
390    }
391    if name == "trust" {
392        return parse_retired(RetiredCommand::Trust, rest);
393    }
394    if name == "untrust" {
395        return parse_retired(RetiredCommand::Untrust, rest);
396    }
397    if name == "status" {
398        return parse_retired(RetiredCommand::Status, rest);
399    }
400    // `doctor` takes no options or scope, so any stray argument is a usage error.
401    if name == "doctor" {
402        if !rest.is_empty() {
403            return Err(ManagementRefusal::Usage);
404        }
405        return Ok(ManagementAction::Doctor);
406    }
407    if name == "check" {
408        return parse_direct(false, rest);
409    }
410    if name == "fix" {
411        return parse_direct(true, rest);
412    }
413    return Err(ManagementRefusal::Usage);
414}
415
416/// Grammar controls stay out of the release executable.
417#[cfg(test)]
418#[path = "management_arguments_tests.rs"]
419mod tests;