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