Skip to main content

git_policy_cli/
wrapper_invocation.rs

1//! What:
2//!  Remove every wrapper control from one invocation,
3//!  before and after the subcommand,
4//!       and say how the command's own arguments were read.
5//! Why:
6//!  Detecting a control and removing it must be one act.
7//!  This module is the only place
8//!      that removes wrapper tokens:
9//!  positions come from Git's own option table of the
10//!      command where one is ported,
11//!  and are deleted by position,
12//!  so a control never
13//!      reaches Git and a message,
14//!  value or path that spells a control is never removed.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! // const { args, controls, reading } = stripWrapperControls(rawArgs);
19//! ```
20
21/// What:
22///  `use` brings names from sibling files into this file;
23///  `super::` means "the parent
24///       module",
25///  where every sibling file of this crate is declared.
26/// Why:
27///   Each command module reads its region with Git's table and reports where wrapper
28///       tokens sat;
29///  this module only collects those positions and deletes them.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// import { parseAddRegion } from './command_add.ts';
34/// import { withoutTokens } from './command_options_query.ts';
35/// ```
36use super::command_add::parse_add_region;
37use super::command_branch_create::{branch_creation_command, parse_branch_creation_region};
38use super::command_clean::parse_clean_region;
39use super::command_commit::parse_commit_region;
40use super::command_config::parse_config_region;
41use super::command_options::{OptionError, WrapperOccurrence};
42use super::command_options_query::{WrapperFlags, without_tokens};
43use super::command_push::parse_push_region;
44use super::command_reset::parse_reset_region;
45use super::command_stash::parse_stash_region;
46use super::command_status::parse_status_region;
47use super::command_worktree::changes_worktree_registrations;
48use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
49use super::policy_registry::PolicyId;
50use super::wrapper_controls::{
51    CONTROL_SPELLINGS, ControlMeaning, Controls, control_flags, control_meaning, no_controls,
52    record_control, strip_global_controls,
53};
54/// What:
55///  `OsString` is owned operating-system text of raw bytes.
56///  Sibling the reader might
57///       expect:
58///  `String`,
59///  which must be valid UTF-8.
60/// Why:
61///   Arguments may hold bytes that are not UTF-8 and are forwarded unchanged.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// // string, but byte-preserving.
66/// ```
67use std::ffi::OsString;
68
69/// What:
70///  How the tokens after the subcommand were read.
71///  An `enum` is a closed set of named
72///       alternatives;
73///  `Refused` carries Git's refusal.
74///  `#[derive(...)]` asks the compiler
75///       to generate copying,
76///  debug printing and `==`.
77/// Why:
78///   The caller must know whether controls were found with Git's own grammar,
79///  only at
80///       the start of the region,
81///  or not at all because Git refuses the command.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type RegionReading = { kind: 'no-command' | 'table' | 'leading-only' } | { kind: 'refused'; error: OptionError };
86/// ```
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum RegionReading {
89    /// Git runs no subcommand,
90    ///  so there is no region.
91    NoCommand,
92    /// The command's Git 2.56.0 option table read the whole region.
93    Table,
94    /// No table is ported for this command:
95    ///  only the tokens directly after the subcommand
96    /// word,
97    ///  which no option can claim as its value,
98    ///  were examined.
99    LeadingOnly,
100    /// Git 2.56.0 itself refuses the region;
101    ///  only leading tokens were examined.
102    Refused(OptionError),
103}
104
105/// What:
106///  One invocation with its wrapper controls taken out.
107///  A `struct` is a record with
108///       named fields;
109///  `Vec<OsString>` is an owned argument list.
110/// Why:
111///   Every later stage reads these arguments,
112///  which hold nothing Git does not know,
113///       and reads the controls' effect from `controls`.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// type StrippedInvocation = { args: string[]; layout: GlobalLayout; controls: Controls; reading: RegionReading };
118/// ```
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct StrippedInvocation {
121    /// The arguments Git may receive:
122    ///  no wrapper control in option position remains.
123    pub arguments: Vec<OsString>,
124    /// Where the subcommand of `arguments` sits and what Git does with the prefix.
125    pub layout: GlobalLayout,
126    /// What the removed controls asked for.
127    pub controls: Controls,
128    /// How the command region was read.
129    pub reading: RegionReading,
130}
131
132/// What:
133///  What a command's own escape hatch means,
134///  the hatch being the first entry of the
135///       flag list its module builds.
136/// Why:
137///   `git commit` has a hatch for a fixed transform,
138///  not for a policy;
139///  the other
140///       commands' hatches escape a policy;
141///  some commands have none.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// type OwnHatch = { kind: 'none' | 'commit-only' } | { kind: 'policy'; policy: PolicyId };
146/// ```
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148enum OwnHatch {
149    /// The command module declares no hatch of its own.
150    None,
151    /// `--no-enforce-only` of `git commit`.
152    CommitOnly,
153    /// A hatch that escapes this policy.
154    Policy(PolicyId),
155}
156
157/// What:
158///  Wrapper tokens one table reading found,
159///  and what the command's own hatch means.
160/// Why:
161///   The positions are deleted and the meanings recorded by one shared function.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// type TableControls = { flags: WrapperFlags; own: OwnHatch };
166/// ```
167#[derive(Clone, Debug, Eq, PartialEq)]
168struct TableControls {
169    /// Own-hatch positions and every other control,
170    ///  by region token index.
171    flags: WrapperFlags,
172    /// The meaning of the own-hatch positions.
173    own: OwnHatch,
174}
175
176/// What:
177///  Wrap the occurrences of a module that declares no hatch of its own.
178///       `Vec::<usize>::new()` is an empty owned list (`usize` is the list index type).
179/// Why:
180///   `push`,
181///  `status` and `config` report every wrapper token with the caller's own
182///       list index,
183///  so nothing is an own hatch.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// const withoutOwnHatch = (other: WrapperOccurrence[]): TableControls => ({ flags: { escape: [], other }, own: 'none' });
188/// ```
189fn without_own_hatch(other: Vec<WrapperOccurrence>) -> TableControls {
190    return TableControls {
191        flags: WrapperFlags {
192            escape: Vec::<usize>::new(),
193            other,
194        },
195        own: OwnHatch::None,
196    };
197}
198
199/// What:
200///  Read the region with the command's Git table,
201///  when one is ported.
202///  `&[u8]` borrows
203///       the subcommand word's bytes;
204///  `&[&[u8]]` borrows the control spellings.
205///       `Result<Option<T>, E>` is "Git's refusal,
206///  or a reading,
207///  or no table for this word".
208/// Why:
209///   Only Git's grammar knows which token is an option and which is an option's value.
210/// Gotcha:
211///  A trailing `?` returns Git's refusal to our caller,
212///  or unwraps the reading.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// function readTable(word: string, region: string[], flags: string[]): TableControls | undefined; // throws OptionError
217/// ```
218fn read_table(
219    word: &[u8],
220    region: &[OsString],
221    flags: &[&[u8]],
222) -> Result<Option<TableControls>, OptionError> {
223    if word == b"commit" {
224        // `Ok(Some(x))` is success carrying a present value.
225        return Ok(Some(TableControls {
226            flags: parse_commit_region(region, flags)?.wrapper,
227            own: OwnHatch::CommitOnly,
228        }));
229    }
230    if word == b"add" {
231        return Ok(Some(TableControls {
232            flags: parse_add_region(region, flags)?.wrapper,
233            own: OwnHatch::Policy(PolicyId::AddExplicit),
234        }));
235    }
236    if word == b"reset" {
237        return Ok(Some(TableControls {
238            flags: parse_reset_region(region, flags)?.wrapper,
239            own: OwnHatch::Policy(PolicyId::LinkedWorktreeOnly),
240        }));
241    }
242    if word == b"clean" {
243        return Ok(Some(TableControls {
244            flags: parse_clean_region(region, flags)?.wrapper,
245            own: OwnHatch::Policy(PolicyId::LinkedWorktreeOnly),
246        }));
247    }
248    if word == b"stash" {
249        return Ok(Some(TableControls {
250            flags: parse_stash_region(region, flags)?.wrapper,
251            own: OwnHatch::Policy(PolicyId::LinkedWorktreeOnly),
252        }));
253    }
254    // `if let Some(command) = ...` runs only for `branch`, `checkout` and `switch`.
255    if let Some(command) = branch_creation_command(word) {
256        return Ok(Some(TableControls {
257            flags: parse_branch_creation_region(command, region, flags)?.wrapper,
258            own: OwnHatch::Policy(PolicyId::BranchWorktreeOnly),
259        }));
260    }
261    if word == b"push" {
262        return Ok(Some(without_own_hatch(
263            parse_push_region(region, flags)?.wrapper,
264        )));
265    }
266    if word == b"status" {
267        return Ok(Some(without_own_hatch(
268            parse_status_region(region, flags)?.wrapper,
269        )));
270    }
271    if word == b"config" {
272        return Ok(Some(without_own_hatch(
273            parse_config_region(region, flags)?.wrapper,
274        )));
275    }
276    // `Ok(None)` is success carrying "no table for this command".
277    return Ok(None);
278}
279
280/// What:
281///  Record what a table reading found and return the region positions to delete.
282///       `&mut Controls` lends the record for writing.
283/// Why:
284///   An own hatch and a general control are deleted the same way;
285///  only their recorded
286///       effect differs.
287///
288/// In TS you'd write (pseudocode):
289/// ```ts
290/// function recordTableControls(found: TableControls, controls: Controls): number[];
291/// ```
292fn record_table_controls(found: &TableControls, controls: &mut Controls) -> Vec<usize> {
293    // `.clone()` copies the own-hatch positions into the list this function returns.
294    let mut positions: Vec<usize> = found.flags.escape.clone();
295    if !found.flags.escape.is_empty() {
296        // `match` picks one arm per variant; the compiler refuses a forgotten variant.
297        match found.own {
298            OwnHatch::None => {}
299            OwnHatch::CommitOnly => controls.commit_only_escaped = true,
300            OwnHatch::Policy(policy) => record_control(controls, ControlMeaning::Escape(policy)),
301        }
302    }
303    // `for occurrence in &found.flags.other` borrows each general control in argument order.
304    for occurrence in &found.flags.other {
305        record_control(controls, CONTROL_SPELLINGS[occurrence.flag].meaning);
306        positions.push(occurrence.token);
307    }
308    return positions;
309}
310
311/// What:
312///  Record and return the positions of the controls directly after the subcommand.
313/// Why:
314///   The first token after the subcommand word,
315///  and each token after a control,
316///  cannot
317///       be the value of an option,
318///  whatever the command's grammar is.
319///  Further on,
320///  only a
321///       ported table can tell an option from a value,
322///  so the scan stops at the first
323///       token that is not a control.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// function leadingControls(region: string[], controls: Controls): number[];
328/// ```
329fn leading_controls(region: &[OsString], controls: &mut Controls) -> Vec<usize> {
330    let mut positions: Vec<usize> = Vec::<usize>::new();
331    // `.iter().enumerate()` yields `(index, token)` pairs in order.
332    for (index, token) in region.iter().enumerate() {
333        // `.as_encoded_bytes()` lends the raw bytes; `let Some(x) = ... else` leaves the loop.
334        let Some(meaning) = control_meaning(token.as_encoded_bytes()) else {
335            break;
336        };
337        record_control(controls, meaning);
338        positions.push(index);
339    }
340    return positions;
341}
342
343/// What:
344///  Record and return the control positions of a command that has no ported table.
345/// Why:
346///   `git worktree add` and `git worktree move` take the worktree-copy opt-out,
347///  which
348///       callers write after the second word.
349///  That word is a subcommand,
350///  so the token
351///       after it cannot be an option's value either,
352///  and the same leading scan applies.
353///
354/// In TS you'd write (pseudocode):
355/// ```ts
356/// function untabledControls(word: string, region: string[], controls: Controls): number[];
357/// ```
358fn untabled_controls(word: &[u8], region: &[OsString], controls: &mut Controls) -> Vec<usize> {
359    let mut positions: Vec<usize> = leading_controls(region, controls);
360    // The first token that is not a control sits right after the leading controls.
361    let second_word: usize = positions.len();
362    if word == b"worktree" && changes_worktree_registrations(&region[second_word..]) {
363        let after: usize = second_word + 1;
364        // Positions of the inner scan count from `after`, so each is shifted back.
365        for position in leading_controls(&region[after..], controls) {
366            positions.push(position + after);
367        }
368    }
369    return positions;
370}
371
372/// What:
373///  Remove every wrapper control from one invocation.
374///  `&[OsString]` borrows the
375///       arguments after the program name.
376/// Why:
377///   Controls before the subcommand go first,
378///  because Git's global-option reader
379///       would otherwise report them as unknown options and every policy decision built
380///       on it would leave the command alone.
381///  Then the command's own table,
382///  when ported
383///       and when Git accepts the region,
384///  says where the remaining controls sit.
385///
386/// In TS you'd write (pseudocode):
387/// ```ts
388/// function stripWrapperControls(args: string[]): StrippedInvocation;
389/// ```
390pub fn strip_wrapper_controls(arguments: &[OsString]) -> StrippedInvocation {
391    // `mut` allows the scans below to add to the record.
392    let mut controls: Controls = no_controls();
393    // `&mut controls` lends the record for writing.
394    let global_clean: Vec<OsString> = strip_global_controls(arguments, &mut controls);
395    // `.as_slice()` lends the owned list as a borrowed view.
396    let layout: GlobalLayout = global_layout(global_clean.as_slice());
397    if layout.outcome != GlobalOutcome::Command {
398        return StrippedInvocation {
399            arguments: global_clean,
400            layout,
401            controls,
402            reading: RegionReading::NoCommand,
403        };
404    }
405    let region_start: usize = layout.prefix_len + 1;
406    // `&global_clean[n..]` borrows the tokens from index `n` on.
407    let region: &[OsString] = &global_clean[region_start..];
408    let flags: Vec<&'static [u8]> = control_flags();
409    // `.as_encoded_bytes()` lends the raw bytes of the subcommand word.
410    let word: &[u8] = global_clean[layout.prefix_len].as_encoded_bytes();
411    let table: Result<Option<TableControls>, OptionError> =
412        read_table(word, region, flags.as_slice());
413    // `match` unpacks the three outcomes into the positions to delete and the reading.
414    let (positions, reading): (Vec<usize>, RegionReading) = match table {
415        Ok(Some(found)) => (
416            record_table_controls(&found, &mut controls),
417            RegionReading::Table,
418        ),
419        Ok(None) => (
420            untabled_controls(word, region, &mut controls),
421            RegionReading::LeadingOnly,
422        ),
423        Err(error) => (
424            leading_controls(region, &mut controls),
425            RegionReading::Refused(error),
426        ),
427    };
428    return StrippedInvocation {
429        arguments: without_tokens(global_clean.as_slice(), region_start, positions.as_slice()),
430        layout,
431        controls,
432        reading,
433    };
434}
435
436/// What:
437///  The raw bytes of the command word of an invocation that names a command.
438///       `&StrippedInvocation` borrows the invocation;
439///  `&[u8]` borrows the word's bytes.
440/// Why:
441///   Every later decision starts from the command word,
442///  read in one place.
443/// Gotcha:
444///  The caller must have seen that the invocation names a command;
445///  without one
446///         there is no word and this function panics.
447///
448/// In TS you'd write (pseudocode):
449/// ```ts
450/// function commandWord(stripped: StrippedInvocation): string { return stripped.args[stripped.layout.prefixLen]; }
451/// ```
452pub fn command_word(stripped: &StrippedInvocation) -> &[u8] {
453    // `.as_encoded_bytes()` lends the raw bytes of the token.
454    return stripped.arguments[stripped.layout.prefix_len].as_encoded_bytes();
455}
456
457/// What:
458///  The tokens after the command word of an invocation that names a command.
459///       `&[OsString]` borrows those tokens.
460/// Why:
461///   The refusal frontier and the push gate both read the command's own options;
462///       taking the region from one function keeps the two from reading different tokens.
463///
464/// In TS you'd write (pseudocode):
465/// ```ts
466/// function commandRegion(stripped: StrippedInvocation): string[] { return stripped.args.slice(stripped.layout.prefixLen + 1); }
467/// ```
468pub fn command_region(stripped: &StrippedInvocation) -> &[OsString] {
469    // `&list[n..]` borrows the tokens from index `n` on.
470    return &stripped.arguments[stripped.layout.prefix_len + 1..];
471}
472
473/// Position,
474///  value and separator controls stay out of the release executable.
475#[cfg(test)]
476#[path = "wrapper_invocation_tests.rs"]
477mod tests;