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}