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(®ion[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(®ion[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;