Skip to main content

rusty_pee/
strict.rs

1//! Strict moreutils-compat mode entry point.
2//!
3//! Mirrors the rusty-sponge/rusty-vipe `strict.rs` pattern: bypasses clap
4//! entirely (clap can't produce byte-equal moreutils errors), runs a
5//! hand-rolled argv scan, emits moreutils-style stderr per FR-013 + FR-018.
6//!
7//! ## STF-003 option A
8//!
9//! For any unknown flag, we emit ONLY the first unknown-flag error and exit
10//! non-zero. moreutils' Perl `Getopt::Long` iterates per-character; we
11//! accept that as documented divergence (Strict-mode "moreutils-style"
12//! rather than "moreutils-byte-equal" — see FR-013 note).
13//!
14//! ## Recognized inputs (Strict mode)
15//!
16//! | input                       | behavior                                        |
17//! |-----------------------------|-------------------------------------------------|
18//! | `--`                        | end-of-options; rest are positional commands    |
19//! | `--strict` / `--no-strict`  | consumed by mode resolution upstream; ignored   |
20//! | `--capture`                 | rejected per FR-013/FR-018 (Rusty extension)    |
21//! | `--help` / `--version`      | rejected per FR-013                             |
22//! | `completions`               | rejected per FR-013                             |
23//! | other `-x` / `--foo`        | first-error formatter (STF-003 option A)        |
24//! | positionals                 | command strings, spawned via platform shell     |
25
26use std::ffi::OsString;
27use std::io::Write;
28use std::process::ExitCode;
29
30use crate::{aggregate, fanout, spawner};
31
32/// The first unknown flag encountered by the Strict-mode parser.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum UnknownFlag {
35    /// Single-character short flag, e.g. `-x` → `Short('x')`.
36    Short(char),
37    /// Long flag with `--` prefix stripped, e.g. `--foo` → `Long("foo")`.
38    Long(String),
39}
40
41/// First-error-only formatter for unknown flags per FR-013.
42pub fn format_unknown_flag(flag: &UnknownFlag) -> String {
43    match flag {
44        UnknownFlag::Short(c) => format!("rusty-pee: invalid option -- '{c}'"),
45        UnknownFlag::Long(name) => format!("rusty-pee: unknown option -- '{name}'"),
46    }
47}
48
49/// Moreutils byte-equal spawn-failure formatter per FR-009 + HINT-004.
50///
51/// Note the "Can not" two-word spelling — that's the literal moreutils byte
52/// sequence; do NOT change to "Cannot".
53pub fn format_spawn_failure(cmd: &str) -> String {
54    format!("pee: Can not open pipe to '{cmd}'")
55}
56
57/// Strict-mode entry point. Bypasses clap entirely.
58pub fn run(argv: &[OsString]) -> ExitCode {
59    let parsed = match parse_argv(argv) {
60        Ok(p) => p,
61        Err(unk) => {
62            let msg = format_unknown_flag(&unk);
63            let _ = writeln!(std::io::stderr().lock(), "{msg}");
64            return ExitCode::from(2);
65        }
66    };
67
68    // FR-006: zero commands → drain stdin + exit 0 (matches Default mode).
69    if parsed.commands.is_empty() {
70        use std::io::Read;
71        let stdin = std::io::stdin();
72        let mut handle = stdin.lock();
73        let mut buf = vec![0u8; crate::BUFSIZ];
74        loop {
75            match handle.read(&mut buf) {
76                Ok(0) => break,
77                Ok(_) => {}
78                Err(_) => return ExitCode::from(1),
79            }
80        }
81        return ExitCode::SUCCESS;
82    }
83
84    // Spawn every command via the platform shell. On failure, emit the
85    // moreutils byte-equal error and exit 1 (FR-009 Strict branch).
86    let mut children = Vec::with_capacity(parsed.commands.len());
87    for cmd in &parsed.commands {
88        match spawner::spawn_one(cmd) {
89            Ok(c) => children.push(c),
90            Err(_) => {
91                let _ = writeln!(std::io::stderr().lock(), "{}", format_spawn_failure(cmd));
92                // Best-effort: kill any already-spawned children.
93                for mut c in children.into_iter() {
94                    let _ = c.kill();
95                    let _ = c.wait();
96                }
97                return ExitCode::from(1);
98            }
99        }
100    }
101
102    // Fan-out + wait.
103    let stdin = std::io::stdin();
104    let statuses = match fanout::run(stdin.lock(), children) {
105        Ok(s) => s,
106        Err(_) => return ExitCode::from(1),
107    };
108
109    // FR-008: bitwise OR aggregation.
110    let codes: Vec<i32> = statuses.iter().map(|s| s.code().unwrap_or(1)).collect();
111    let aggregated = aggregate::strict_or(&codes);
112    let byte = if (0..=255).contains(&aggregated) {
113        aggregated as u8
114    } else {
115        1u8
116    };
117    ExitCode::from(byte)
118}
119
120/// Result of scanning the Strict-mode argv.
121#[derive(Debug, Default)]
122struct StrictArgs {
123    commands: Vec<String>,
124}
125
126/// Parse argv per Strict-mode rules. On the FIRST unknown flag, return
127/// `Err(UnknownFlag)` — the caller emits one error line and exits non-zero.
128///
129/// Per FR-013, the following are rejected as unknown flags/subcommands:
130/// - `--help`, `--version`, `--capture`
131/// - `completions` (subcommand-style positional in first position)
132fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, UnknownFlag> {
133    let mut out = StrictArgs::default();
134    let mut iter = argv.iter().skip(1);
135    let mut first_positional = true;
136
137    while let Some(arg) = iter.next() {
138        let s = arg.to_string_lossy();
139
140        // Upstream mode-resolution flags — already consumed.
141        if s == "--strict" || s == "--no-strict" {
142            continue;
143        }
144
145        // End-of-options sentinel: rest are commands.
146        if s == "--" {
147            for rest in iter.by_ref() {
148                out.commands.push(rest.to_string_lossy().into_owned());
149            }
150            break;
151        }
152
153        // `completions` as first positional → rejected per FR-013.
154        if first_positional && s == "completions" {
155            return Err(UnknownFlag::Long(String::from("completions")));
156        }
157
158        // Long flags.
159        if let Some(rest) = s.strip_prefix("--") {
160            // `--capture`, `--help`, `--version` — all rejected.
161            let flag_name = rest.split('=').next().unwrap_or(rest).to_string();
162            return Err(UnknownFlag::Long(flag_name));
163        }
164
165        // Short flags (`-x`, `-xyz`).
166        if let Some(rest) = s.strip_prefix('-') {
167            if !rest.is_empty() {
168                let first = rest.chars().next().expect("non-empty after strip_prefix");
169                return Err(UnknownFlag::Short(first));
170            }
171        }
172
173        // Positional: command string.
174        first_positional = false;
175        out.commands.push(s.into_owned());
176    }
177
178    Ok(out)
179}
180
181/// Pre-clap scan for `--strict` / `--no-strict` flags. Last occurrence wins.
182pub fn pre_scan_strict_flag(argv: &[OsString]) -> Option<bool> {
183    let mut chosen: Option<bool> = None;
184    for arg in argv.iter().skip(1) {
185        let s = arg.to_string_lossy();
186        if s == "--strict" {
187            chosen = Some(true);
188        } else if s == "--no-strict" {
189            chosen = Some(false);
190        } else if s == "--" {
191            break;
192        }
193    }
194    chosen
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn argv(parts: &[&str]) -> Vec<OsString> {
202        parts.iter().map(|s| OsString::from(*s)).collect()
203    }
204
205    #[test]
206    fn parse_no_flags_yields_no_commands() {
207        let r = parse_argv(&argv(&["pee"])).unwrap();
208        assert!(r.commands.is_empty());
209    }
210
211    #[test]
212    fn parse_two_commands() {
213        let r = parse_argv(&argv(&["pee", "wc -l", "grep foo"])).unwrap();
214        assert_eq!(r.commands, vec!["wc -l", "grep foo"]);
215    }
216
217    #[test]
218    fn parse_rejects_help() {
219        let err = parse_argv(&argv(&["pee", "--help"])).unwrap_err();
220        assert_eq!(err, UnknownFlag::Long(String::from("help")));
221    }
222
223    #[test]
224    fn parse_rejects_version() {
225        let err = parse_argv(&argv(&["pee", "--version"])).unwrap_err();
226        assert_eq!(err, UnknownFlag::Long(String::from("version")));
227    }
228
229    #[test]
230    fn parse_rejects_capture() {
231        let err = parse_argv(&argv(&["pee", "--capture"])).unwrap_err();
232        assert_eq!(err, UnknownFlag::Long(String::from("capture")));
233    }
234
235    #[test]
236    fn parse_rejects_completions_subcommand() {
237        let err = parse_argv(&argv(&["pee", "completions", "bash"])).unwrap_err();
238        assert_eq!(err, UnknownFlag::Long(String::from("completions")));
239    }
240
241    #[test]
242    fn parse_completions_after_other_positional_treated_as_command() {
243        // Once a non-completions positional has been seen, "completions"
244        // becomes just another command string.
245        let r = parse_argv(&argv(&["pee", "wc -l", "completions"])).unwrap();
246        assert_eq!(r.commands, vec!["wc -l", "completions"]);
247    }
248
249    #[test]
250    fn parse_rejects_unknown_long_flag() {
251        let err = parse_argv(&argv(&["pee", "--foo"])).unwrap_err();
252        assert_eq!(err, UnknownFlag::Long(String::from("foo")));
253    }
254
255    #[test]
256    fn parse_rejects_unknown_short_flag() {
257        let err = parse_argv(&argv(&["pee", "-x"])).unwrap_err();
258        assert_eq!(err, UnknownFlag::Short('x'));
259    }
260
261    #[test]
262    fn parse_first_unknown_wins() {
263        let err = parse_argv(&argv(&["pee", "-x", "--foo"])).unwrap_err();
264        assert_eq!(err, UnknownFlag::Short('x'));
265        let err = parse_argv(&argv(&["pee", "--foo", "-x"])).unwrap_err();
266        assert_eq!(err, UnknownFlag::Long(String::from("foo")));
267    }
268
269    #[test]
270    fn parse_double_dash_treats_rest_as_commands() {
271        let r = parse_argv(&argv(&["pee", "--", "--help", "-x"])).unwrap();
272        assert_eq!(
273            r.commands,
274            vec!["--help", "-x"],
275            "after `--` everything is a command"
276        );
277    }
278
279    #[test]
280    fn pre_scan_detects_strict() {
281        assert_eq!(
282            pre_scan_strict_flag(&argv(&["rusty-pee", "--strict"])),
283            Some(true)
284        );
285    }
286
287    #[test]
288    fn pre_scan_stops_at_double_dash() {
289        assert_eq!(
290            pre_scan_strict_flag(&argv(&["rusty-pee", "--", "--strict"])),
291            None
292        );
293    }
294
295    #[test]
296    fn format_unknown_short_matches_spec() {
297        assert_eq!(
298            format_unknown_flag(&UnknownFlag::Short('x')),
299            "rusty-pee: invalid option -- 'x'"
300        );
301    }
302
303    #[test]
304    fn format_unknown_long_matches_spec() {
305        assert_eq!(
306            format_unknown_flag(&UnknownFlag::Long(String::from("foo"))),
307            "rusty-pee: unknown option -- 'foo'"
308        );
309    }
310
311    #[test]
312    fn format_spawn_failure_uses_two_word_can_not() {
313        // HINT-004: moreutils uses "Can not" (two words), not "Cannot".
314        assert_eq!(
315            format_spawn_failure("nonexistent"),
316            "pee: Can not open pipe to 'nonexistent'"
317        );
318    }
319}