Skip to main content

rusty_sponge/
strict.rs

1//! Strict moreutils-compat mode entry point.
2//!
3//! Bypasses clap entirely (clap can't produce byte-equal moreutils errors)
4//! and runs a hand-rolled argv scan that mirrors the documented inputs from
5//! the captured moreutils 0.69-1 fixture set:
6//!
7//! | invocation                | stdout                                                                  | stderr                                  | exit |
8//! |---------------------------|-------------------------------------------------------------------------|-----------------------------------------|------|
9//! | `sponge -h`               | `sponge [-a] <file>: soak up all input from stdin and write it to <file>` + LF | —                                       | 0    |
10//! | `sponge -x` (unknown letter) | —                                                                       | `sponge: invalid option -- 'x'\n`       | 0    |
11//! | `sponge file` (success)   | —                                                                       | —                                       | 0    |
12//! | `sponge -a file` (append) | —                                                                       | —                                       | 0    |
13//! | `sponge dir/` (target=dir)| —                                                                       | `error opening output file: Is a directory\n` | 1    |
14//!
15//! Per STF-003 (autopilot option A): for any unknown long-form input like
16//! `--some-flag`, we emit ONLY the first unknown-option error and continue
17//! (one line). moreutils' POSIX `getopt` iterates per-character producing
18//! up to N errors; we accept the documented divergence rather than carry a
19//! custom getopt-style scanner.
20
21use std::ffi::OsString;
22use std::io::Write;
23use std::path::Path;
24use std::process::ExitCode;
25
26use crate::{Sponge, SpongeBuilder, Target};
27
28/// The exact usage banner moreutils sponge writes to stdout for `-h` and
29/// after some error paths. 72 bytes including trailing newline; captured
30/// from moreutils 0.69-1.
31const STRICT_USAGE_BANNER: &str =
32    "sponge [-a] <file>: soak up all input from stdin and write it to <file>\n";
33
34/// Strict-mode entry. Returns the process exit code.
35pub fn run(argv: &[OsString]) -> ExitCode {
36    let parsed = parse_argv(argv);
37
38    if parsed.show_usage {
39        // moreutils sponge writes its usage banner to STDOUT, not stderr,
40        // and exits 0 (STF-005).
41        let stdout = std::io::stdout();
42        let mut out = stdout.lock();
43        let _ = out.write_all(STRICT_USAGE_BANNER.as_bytes());
44        return ExitCode::SUCCESS;
45    }
46
47    // Emit unknown-option errors first. moreutils exits 0 for these — it
48    // does NOT bail out. Sponge then falls through to its main path
49    // (stdin → stdout or stdin → target).
50    if let Some(unk) = parsed.unknown_letters.first() {
51        // Per STF-003 option A: emit only the FIRST unknown-option error
52        // and then continue (moreutils emits one per char of an unknown
53        // long flag; we emit one total).
54        let stderr = std::io::stderr();
55        let mut err = stderr.lock();
56        let _ = writeln!(err, "sponge: invalid option -- '{unk}'");
57    }
58
59    // Dispatch.
60    let target = match parsed.target {
61        Some(path) => Target::File(path.into()),
62        None => Target::Stdout,
63    };
64
65    // In Strict mode, --help/--version are not recognized (and we already
66    // emitted the unknown-option error above). Pre-flight target-is-dir
67    // check uses the moreutils-byte-equal error string.
68    if let Target::File(ref path) = target {
69        if let Ok(meta) = std::fs::symlink_metadata(path) {
70            if meta.is_dir() {
71                let stderr = std::io::stderr();
72                let mut err = stderr.lock();
73                let _ = writeln!(err, "error opening output file: Is a directory");
74                return ExitCode::from(1);
75            }
76        }
77    }
78
79    // Build the Sponge runtime via the same builder library consumers use,
80    // ensuring behavior parity between binary and library paths.
81    let builder = SpongeBuilder::new()
82        .target(target)
83        .append(parsed.append)
84        .compat(crate::CompatibilityMode::Strict);
85    let mut sponge: Sponge = match builder.build() {
86        Ok(s) => s,
87        Err(_e) => {
88            // Strict mode swallows builder errors as best-effort; this path
89            // is exercised only if our own validation rejects a combination
90            // moreutils would accept (e.g., -a without target). Emit nothing
91            // and exit 0 to match moreutils' "exit 0 for everything but IO"
92            // behavior.
93            return ExitCode::SUCCESS;
94        }
95    };
96
97    let stdin = std::io::stdin();
98    let locked = stdin.lock();
99    match sponge.run(locked) {
100        Ok(()) => ExitCode::SUCCESS,
101        Err(crate::Error::TargetIsDirectory(_)) => {
102            // Edge case if directory check above somehow missed (race).
103            let stderr = std::io::stderr();
104            let mut err = stderr.lock();
105            let _ = writeln!(err, "error opening output file: Is a directory");
106            ExitCode::from(1)
107        }
108        Err(crate::Error::Io(io_err)) => {
109            // Moreutils-shaped IO error: `error opening output file: <strerror>`.
110            let stderr = std::io::stderr();
111            let mut err = stderr.lock();
112            let _ = writeln!(err, "error opening output file: {io_err}");
113            ExitCode::from(1)
114        }
115        Err(_) => ExitCode::from(1),
116    }
117}
118
119/// Result of scanning the strict-mode argv: which flags were seen, which
120/// unknown single letters were rejected, the (optional) first positional
121/// target. Per moreutils first-wins semantics (STF-003-adjacent), additional
122/// positionals are silently dropped.
123struct StrictArgs {
124    append: bool,
125    show_usage: bool,
126    unknown_letters: Vec<char>,
127    target: Option<OsString>,
128}
129
130fn parse_argv(argv: &[OsString]) -> StrictArgs {
131    let mut out = StrictArgs {
132        append: false,
133        show_usage: false,
134        unknown_letters: Vec::new(),
135        target: None,
136    };
137
138    // Skip argv[0] (program name).
139    let mut iter = argv.iter().skip(1);
140    while let Some(arg) = iter.next() {
141        let s = arg.to_string_lossy();
142
143        // Special-case our own --strict / --no-strict — these are consumed
144        // by mode resolution upstream and must not reach the strict parser.
145        if s == "--strict" || s == "--no-strict" {
146            continue;
147        }
148
149        // `--` end-of-options sentinel: subsequent args are positionals.
150        if s == "--" {
151            for rest in iter.by_ref() {
152                if out.target.is_none() {
153                    out.target = Some(rest.clone());
154                }
155            }
156            break;
157        }
158
159        // Short flags (one or more chars after a single `-`).
160        if s.starts_with('-') && s.len() >= 2 && !s.starts_with("--") {
161            for c in s.chars().skip(1) {
162                match c {
163                    'a' => out.append = true,
164                    'h' => out.show_usage = true,
165                    other => {
166                        // Per STF-003 option A: record only the FIRST unknown
167                        // letter; we emit a single error line in `run()`.
168                        if out.unknown_letters.is_empty() {
169                            out.unknown_letters.push(other);
170                        }
171                    }
172                }
173            }
174            continue;
175        }
176
177        // Long flags (`--...`). moreutils has no long-form options; treat
178        // the leading `--` as an unknown option per STF-003 option A.
179        if s.starts_with("--") {
180            if out.unknown_letters.is_empty() {
181                out.unknown_letters.push('-');
182            }
183            continue;
184        }
185
186        // Positional. First wins per moreutils observed behavior.
187        if out.target.is_none() {
188            out.target = Some(arg.clone());
189        }
190    }
191
192    out
193}
194
195/// Pre-clap scan for `--strict` / `--no-strict` so the binary can decide
196/// whether to enter [`run`] *before* clap gets a chance to print its own
197/// help/version messages. Returns `Some(true)` for `--strict`, `Some(false)`
198/// for `--no-strict`, `None` otherwise. The last occurrence wins.
199pub fn pre_scan_strict_flag(argv: &[OsString]) -> Option<bool> {
200    let mut chosen: Option<bool> = None;
201    for arg in argv.iter().skip(1) {
202        let s = arg.to_string_lossy();
203        if s == "--strict" {
204            chosen = Some(true);
205        } else if s == "--no-strict" {
206            chosen = Some(false);
207        } else if s == "--" {
208            break;
209        }
210    }
211    chosen
212}
213
214/// Helper: trim the program name to its basename for path-style argv[0].
215#[allow(dead_code)]
216fn argv0_basename(argv0: &Path) -> Option<&std::ffi::OsStr> {
217    argv0.file_stem()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn argv(parts: &[&str]) -> Vec<OsString> {
225        parts.iter().map(|s| OsString::from(*s)).collect()
226    }
227
228    #[test]
229    fn parse_no_args_means_stdout_target() {
230        let p = parse_argv(&argv(&["sponge"]));
231        assert!(!p.append);
232        assert!(!p.show_usage);
233        assert!(p.unknown_letters.is_empty());
234        assert!(p.target.is_none());
235    }
236
237    #[test]
238    fn parse_dash_h_sets_show_usage() {
239        let p = parse_argv(&argv(&["sponge", "-h"]));
240        assert!(p.show_usage);
241    }
242
243    #[test]
244    fn parse_dash_a_sets_append() {
245        let p = parse_argv(&argv(&["sponge", "-a", "file.txt"]));
246        assert!(p.append);
247        assert_eq!(p.target.as_deref(), Some(std::ffi::OsStr::new("file.txt")));
248    }
249
250    #[test]
251    fn parse_dash_x_records_one_unknown_letter() {
252        let p = parse_argv(&argv(&["sponge", "-x"]));
253        assert_eq!(p.unknown_letters, vec!['x']);
254    }
255
256    #[test]
257    fn parse_grouped_unknown_records_only_first() {
258        // -xyz → option A: only the first unknown letter is captured.
259        let p = parse_argv(&argv(&["sponge", "-xyz"]));
260        assert_eq!(p.unknown_letters, vec!['x']);
261    }
262
263    #[test]
264    fn parse_long_unknown_flag_records_dash() {
265        // --some-flag → first error is the leading -- per STF-003 option A.
266        let p = parse_argv(&argv(&["sponge", "--some-flag"]));
267        assert_eq!(p.unknown_letters, vec!['-']);
268    }
269
270    #[test]
271    fn parse_first_positional_wins() {
272        let p = parse_argv(&argv(&["sponge", "first.txt", "second.txt", "third.txt"]));
273        assert_eq!(p.target.as_deref(), Some(std::ffi::OsStr::new("first.txt")));
274    }
275
276    #[test]
277    fn parse_double_dash_consumes_first_positional_only() {
278        let p = parse_argv(&argv(&["sponge", "--", "-a", "file.txt"]));
279        assert!(!p.append, "-a after -- is a positional, not a flag");
280        assert_eq!(p.target.as_deref(), Some(std::ffi::OsStr::new("-a")));
281    }
282
283    #[test]
284    fn pre_scan_detects_strict() {
285        assert_eq!(
286            pre_scan_strict_flag(&argv(&["rusty-sponge", "--strict", "out.txt"])),
287            Some(true)
288        );
289    }
290
291    #[test]
292    fn pre_scan_detects_no_strict() {
293        assert_eq!(
294            pre_scan_strict_flag(&argv(&["rusty-sponge", "--no-strict", "out.txt"])),
295            Some(false)
296        );
297    }
298
299    #[test]
300    fn pre_scan_returns_none_when_neither() {
301        assert_eq!(
302            pre_scan_strict_flag(&argv(&["rusty-sponge", "out.txt"])),
303            None
304        );
305    }
306
307    #[test]
308    fn pre_scan_last_occurrence_wins() {
309        assert_eq!(
310            pre_scan_strict_flag(&argv(&["rusty-sponge", "--strict", "--no-strict"])),
311            Some(false)
312        );
313    }
314}