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