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