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