Skip to main content

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