Skip to main content

rusty_vipe/
strict.rs

1//! Strict moreutils-compat mode entry point.
2//!
3//! Mirrors the `rusty-sponge` `strict.rs` pattern: bypasses clap entirely
4//! (clap can't produce byte-equal moreutils errors), runs a hand-rolled argv
5//! scan, emits moreutils-style stderr per 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 accept
11//! that as documented divergence (Strict-mode "moreutils-style" rather than
12//! "moreutils-byte-equal" — see FR-018 note).
13//!
14//! ## Recognized inputs (Strict mode)
15//!
16//! | input                       | behavior                                        |
17//! |-----------------------------|-------------------------------------------------|
18//! | `--suffix=<ext>`            | set tempfile suffix                             |
19//! | `--`                        | end of options; rest are editor extras          |
20//! | `--strict` / `--no-strict`  | consumed by mode resolution upstream; ignored   |
21//! | `--help` / `--version`      | rejected per FR-013 via first-error formatter   |
22//! | `--editor` / `--editor=*`   | rejected per FR-013 via first-error formatter   |
23//! | `completions`               | rejected per FR-013 via first-error formatter   |
24//! | other `-x` / `--foo`        | first-error formatter (STF-003 option A)        |
25
26use std::ffi::OsString;
27use std::io::Write;
28use std::process::ExitCode;
29
30use crate::editor;
31use crate::pipeline;
32use crate::tty;
33use crate::{CompatibilityMode, DEFAULT_SUFFIX, Error, validate_suffix};
34
35/// First-error-only formatter for unknown short flags per FR-018.
36pub fn format_unknown_flag(flag: &UnknownFlag) -> String {
37    match flag {
38        UnknownFlag::Short(c) => format!("rusty-vipe: invalid option -- '{c}'"),
39        UnknownFlag::Long(name) => format!("rusty-vipe: unknown option -- '{name}'"),
40    }
41}
42
43/// Moreutils-style "editor died" formatter per FR-018. Output matches the
44/// Perl `die` template `<argv joined> exited nonzero, aborting`.
45pub fn format_editor_died(argv: &[OsString]) -> String {
46    let joined = argv
47        .iter()
48        .map(|a| a.to_string_lossy().into_owned())
49        .collect::<Vec<_>>()
50        .join(" ");
51    format!("{joined} exited nonzero, aborting")
52}
53
54/// Strict-mode entry point. Bypasses clap entirely.
55///
56/// Returns the process exit code:
57/// - 2 — first unknown flag detected (STF-003 option A)
58/// - clamped editor exit code (1–255 Unix, 1–254 Windows) — on non-zero editor exit
59/// - 127 — editor not found / invalid env editor value
60/// - 0 — success
61pub fn run(argv: &[OsString]) -> ExitCode {
62    let parsed = match parse_argv(argv) {
63        Ok(p) => p,
64        Err(ParseError::Unknown(unk)) => {
65            let msg = format_unknown_flag(&unk);
66            let _ = writeln!(std::io::stderr().lock(), "{msg}");
67            return ExitCode::from(2);
68        }
69        Err(ParseError::InvalidSuffix(reason)) => {
70            let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {reason}");
71            return ExitCode::from(2);
72        }
73    };
74
75    // Resolve editor via the same ladder as Default mode, except no
76    // `--editor` override is permitted in Strict mode (FR-013).
77    let env_visual = std::env::var("VISUAL").ok();
78    let env_editor = std::env::var("EDITOR").ok();
79    let resolved = match editor::resolve(
80        None,
81        env_visual.as_deref(),
82        env_editor.as_deref(),
83        CompatibilityMode::Strict,
84    ) {
85        Ok(r) => r,
86        Err(Error::InvalidEditorCommand(raw)) => {
87            let _ = writeln!(
88                std::io::stderr().lock(),
89                "rusty-vipe: invalid EDITOR/VISUAL value: {raw}"
90            );
91            return ExitCode::from(127);
92        }
93        Err(e) => {
94            let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
95            return ExitCode::from(127);
96        }
97    };
98
99    // Drain stdin to a tempfile with the configured (or default) suffix.
100    let suffix = parsed.suffix.as_deref().unwrap_or(DEFAULT_SUFFIX);
101    let stdin = std::io::stdin();
102    let tempfile = match pipeline::drain_to_tempfile(stdin.lock(), suffix) {
103        Ok(tf) => tf,
104        Err(e) => {
105            let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
106            return ExitCode::from(1);
107        }
108    };
109
110    let preserved_stdout = match tty::preserve_stdout() {
111        Ok(p) => p,
112        Err(e) => {
113            let _ = writeln!(
114                std::io::stderr().lock(),
115                "rusty-vipe: failed to preserve stdout: {e}"
116            );
117            return ExitCode::from(1);
118        }
119    };
120
121    let tty_handles = if pipeline::test_bypass_tty_enabled() {
122        None
123    } else {
124        match tty::open_controlling_tty() {
125            Ok(handles) => Some(handles),
126            Err(Error::NoControllingTty) => {
127                let _ = writeln!(
128                    std::io::stderr().lock(),
129                    "rusty-vipe: no controlling terminal; cannot launch editor"
130                );
131                return ExitCode::from(1);
132            }
133            Err(e) => {
134                let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
135                return ExitCode::from(1);
136            }
137        }
138    };
139
140    let extras: Vec<OsString> = parsed.editor_extras;
141    let status = match pipeline::spawn_editor(&resolved.argv, &extras, tempfile.path(), tty_handles)
142    {
143        Ok(s) => s,
144        Err(Error::EditorNotFound(name)) => {
145            let _ = writeln!(
146                std::io::stderr().lock(),
147                "rusty-vipe: editor not found: {name}"
148            );
149            return ExitCode::from(127);
150        }
151        Err(e) => {
152            let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
153            return ExitCode::from(1);
154        }
155    };
156
157    if !status.success() {
158        // FR-018: emit moreutils-style "exited nonzero, aborting" with the
159        // FULL editor argv (resolved + extras + tempfile path).
160        let mut full_argv: Vec<OsString> = resolved.argv.clone();
161        full_argv.extend(extras.iter().cloned());
162        full_argv.push(tempfile.path().to_path_buf().into_os_string());
163        let _ = writeln!(
164            std::io::stderr().lock(),
165            "{}",
166            format_editor_died(&full_argv)
167        );
168
169        let code = pipeline::clamp_exit_code(status);
170        let byte = if (1..=255).contains(&code) {
171            code as u8
172        } else {
173            1u8
174        };
175        return ExitCode::from(byte);
176    }
177
178    match pipeline::write_back_to_saved_stdout(tempfile.path(), preserved_stdout) {
179        Ok(()) => ExitCode::SUCCESS,
180        Err(Error::TempFileDeleted(_)) => {
181            let _ = writeln!(
182                std::io::stderr().lock(),
183                "rusty-vipe: tempfile no longer exists after editor exited"
184            );
185            ExitCode::from(1)
186        }
187        Err(e) => {
188            let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
189            ExitCode::from(1)
190        }
191    }
192}
193
194/// Pre-clap scan for `--strict` / `--no-strict` flags. Returns the resolved
195/// strict_flag value for [`crate::mode::resolve`] (last occurrence wins).
196pub fn pre_scan_strict_flag(argv: &[OsString]) -> Option<bool> {
197    let mut chosen: Option<bool> = None;
198    for arg in argv.iter().skip(1) {
199        let s = arg.to_string_lossy();
200        if s == "--strict" {
201            chosen = Some(true);
202        } else if s == "--no-strict" {
203            chosen = Some(false);
204        } else if s == "--" {
205            break;
206        }
207    }
208    chosen
209}
210
211/// The first unknown flag encountered by the Strict-mode parser.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum UnknownFlag {
214    /// Single-character short flag, e.g. `-x` → `Short('x')`.
215    Short(char),
216    /// Long flag with `--` prefix stripped, e.g. `--foo` → `Long("foo")`.
217    Long(String),
218}
219
220/// Errors returned by [`parse_argv`] when the Strict-mode argv is malformed.
221#[derive(Debug, Clone, PartialEq, Eq)]
222enum ParseError {
223    /// First unknown flag encountered (STF-003 option A).
224    Unknown(UnknownFlag),
225    /// `--suffix=<bad>` failed validation.
226    InvalidSuffix(&'static str),
227}
228
229/// Result of scanning the strict-mode argv.
230#[derive(Debug, Default)]
231struct StrictArgs {
232    suffix: Option<String>,
233    editor_extras: Vec<OsString>,
234}
235
236/// Parse argv per Strict-mode rules. On the FIRST unknown flag, return
237/// `Err(UnknownFlag)` — the caller emits one error line and exits non-zero.
238///
239/// Per FR-013, the following are rejected as unknown flags:
240/// - `--help`, `--version`
241/// - `--editor` (with or without `=value`)
242/// - `completions` (subcommand-style positional)
243fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, ParseError> {
244    let mut out = StrictArgs::default();
245    let mut iter = argv.iter().skip(1);
246
247    while let Some(arg) = iter.next() {
248        let s = arg.to_string_lossy();
249
250        // Upstream mode-resolution flags — already consumed.
251        if s == "--strict" || s == "--no-strict" {
252            continue;
253        }
254
255        // End-of-options sentinel: rest are editor extras.
256        if s == "--" {
257            for rest in iter.by_ref() {
258                out.editor_extras.push(rest.clone());
259            }
260            break;
261        }
262
263        // `completions` subcommand — rejected per FR-013.
264        if s == "completions" {
265            return Err(ParseError::Unknown(UnknownFlag::Long(String::from(
266                "completions",
267            ))));
268        }
269
270        // Long flags.
271        if let Some(rest) = s.strip_prefix("--") {
272            // `--suffix=<ext>` is the only accepted long flag.
273            if let Some(value) = rest.strip_prefix("suffix=") {
274                validate_suffix(value).map_err(ParseError::InvalidSuffix)?;
275                out.suffix = Some(value.to_string());
276                continue;
277            }
278            // `--suffix` (no value, value follows in next arg) — also accept.
279            if rest == "suffix" {
280                let value = iter
281                    .next()
282                    .map(|v| v.to_string_lossy().into_owned())
283                    .unwrap_or_default();
284                validate_suffix(&value).map_err(ParseError::InvalidSuffix)?;
285                out.suffix = Some(value);
286                continue;
287            }
288            // Anything else (--help, --version, --editor, --editor=foo, --foo) is unknown.
289            // For `--editor=foo` form, the flag name is `editor` (strip the value part).
290            let flag_name = rest.split('=').next().unwrap_or(rest).to_string();
291            return Err(ParseError::Unknown(UnknownFlag::Long(flag_name)));
292        }
293
294        // Short flags (`-x`, `-xyz`).
295        if let Some(rest) = s.strip_prefix('-') {
296            if !rest.is_empty() {
297                // First unknown short letter wins.
298                let first = rest.chars().next().expect("non-empty after strip_prefix");
299                return Err(ParseError::Unknown(UnknownFlag::Short(first)));
300            }
301        }
302
303        // Positional: editor extra (forwarded before the tempfile path).
304        out.editor_extras.push(arg.clone());
305    }
306
307    Ok(out)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn argv(parts: &[&str]) -> Vec<OsString> {
315        parts.iter().map(|s| OsString::from(*s)).collect()
316    }
317
318    #[test]
319    fn pre_scan_detects_strict() {
320        assert_eq!(
321            pre_scan_strict_flag(&argv(&["rusty-vipe", "--strict"])),
322            Some(true)
323        );
324    }
325
326    #[test]
327    fn pre_scan_detects_no_strict() {
328        assert_eq!(
329            pre_scan_strict_flag(&argv(&["rusty-vipe", "--no-strict"])),
330            Some(false)
331        );
332    }
333
334    #[test]
335    fn pre_scan_returns_none_when_neither() {
336        assert_eq!(pre_scan_strict_flag(&argv(&["rusty-vipe"])), None);
337    }
338
339    #[test]
340    fn pre_scan_last_occurrence_wins() {
341        assert_eq!(
342            pre_scan_strict_flag(&argv(&["rusty-vipe", "--strict", "--no-strict"])),
343            Some(false)
344        );
345    }
346
347    #[test]
348    fn pre_scan_stops_at_double_dash() {
349        assert_eq!(
350            pre_scan_strict_flag(&argv(&["rusty-vipe", "--", "--strict"])),
351            None,
352            "--strict after -- is a positional, not the strict flag"
353        );
354    }
355
356    #[test]
357    fn parse_no_flags_yields_defaults() {
358        let r = parse_argv(&argv(&["vipe"])).unwrap();
359        assert_eq!(r.suffix, None);
360        assert!(r.editor_extras.is_empty());
361    }
362
363    #[test]
364    fn parse_suffix_equals_value() {
365        let r = parse_argv(&argv(&["vipe", "--suffix=.md"])).unwrap();
366        assert_eq!(r.suffix.as_deref(), Some(".md"));
367    }
368
369    #[test]
370    fn parse_suffix_separate_value() {
371        let r = parse_argv(&argv(&["vipe", "--suffix", ".json"])).unwrap();
372        assert_eq!(r.suffix.as_deref(), Some(".json"));
373    }
374
375    fn unwrap_unknown(err: ParseError) -> UnknownFlag {
376        match err {
377            ParseError::Unknown(u) => u,
378            other => panic!("expected ParseError::Unknown, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn parse_rejects_help() {
384        let err = parse_argv(&argv(&["vipe", "--help"])).unwrap_err();
385        assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("help")));
386    }
387
388    #[test]
389    fn parse_rejects_version() {
390        let err = parse_argv(&argv(&["vipe", "--version"])).unwrap_err();
391        assert_eq!(
392            unwrap_unknown(err),
393            UnknownFlag::Long(String::from("version"))
394        );
395    }
396
397    #[test]
398    fn parse_rejects_editor_flag() {
399        let err = parse_argv(&argv(&["vipe", "--editor=foo"])).unwrap_err();
400        assert_eq!(
401            unwrap_unknown(err),
402            UnknownFlag::Long(String::from("editor"))
403        );
404    }
405
406    #[test]
407    fn parse_rejects_editor_flag_empty() {
408        let err = parse_argv(&argv(&["vipe", "--editor="])).unwrap_err();
409        assert_eq!(
410            unwrap_unknown(err),
411            UnknownFlag::Long(String::from("editor"))
412        );
413    }
414
415    #[test]
416    fn parse_rejects_completions_subcommand() {
417        let err = parse_argv(&argv(&["vipe", "completions", "bash"])).unwrap_err();
418        assert_eq!(
419            unwrap_unknown(err),
420            UnknownFlag::Long(String::from("completions"))
421        );
422    }
423
424    #[test]
425    fn parse_rejects_unknown_long_flag() {
426        let err = parse_argv(&argv(&["vipe", "--foo"])).unwrap_err();
427        assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("foo")));
428    }
429
430    #[test]
431    fn parse_rejects_unknown_short_flag() {
432        let err = parse_argv(&argv(&["vipe", "-x"])).unwrap_err();
433        assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
434    }
435
436    #[test]
437    fn parse_first_unknown_wins_when_short_and_long_both_present() {
438        // STF-003 option A: only first unknown is reported. Order matters.
439        let err = parse_argv(&argv(&["vipe", "-x", "--foo"])).unwrap_err();
440        assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
441
442        let err = parse_argv(&argv(&["vipe", "--foo", "-x"])).unwrap_err();
443        assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("foo")));
444    }
445
446    #[test]
447    fn parse_grouped_short_unknown_reports_first_char() {
448        // `-xyz` → first char `x` is the reported unknown.
449        let err = parse_argv(&argv(&["vipe", "-xyz"])).unwrap_err();
450        assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
451    }
452
453    #[test]
454    fn parse_positional_becomes_editor_extra() {
455        let r = parse_argv(&argv(&["vipe", "--wait", "extra"])).unwrap_err();
456        // `--wait` is unknown long → error wins before positional is collected.
457        assert_eq!(unwrap_unknown(r), UnknownFlag::Long(String::from("wait")));
458
459        // Pure positionals are collected as editor extras.
460        let r = parse_argv(&argv(&["vipe", "extra-arg"])).unwrap();
461        assert_eq!(r.editor_extras, vec![OsString::from("extra-arg")]);
462    }
463
464    #[test]
465    fn parse_rejects_invalid_suffix_path_separator() {
466        let err = parse_argv(&argv(&["vipe", "--suffix=/foo"])).unwrap_err();
467        assert!(
468            matches!(err, ParseError::InvalidSuffix(_)),
469            "/foo should fail suffix validation, got {err:?}"
470        );
471    }
472
473    #[test]
474    fn parse_rejects_invalid_suffix_too_long() {
475        let long = "a".repeat(300);
476        let arg = format!("--suffix={long}");
477        let argv_vec: Vec<OsString> = vec![OsString::from("vipe"), OsString::from(arg)];
478        let err = parse_argv(&argv_vec).unwrap_err();
479        assert!(matches!(err, ParseError::InvalidSuffix(_)));
480    }
481
482    #[test]
483    fn parse_double_dash_treats_rest_as_extras() {
484        let r = parse_argv(&argv(&["vipe", "--", "--help", "-x"])).unwrap();
485        assert_eq!(
486            r.editor_extras,
487            vec![OsString::from("--help"), OsString::from("-x")],
488            "after `--` everything is an editor extra"
489        );
490    }
491
492    #[test]
493    fn parse_strict_and_no_strict_are_silently_consumed() {
494        let r = parse_argv(&argv(&["vipe", "--strict", "--suffix=.md", "--no-strict"])).unwrap();
495        assert_eq!(r.suffix.as_deref(), Some(".md"));
496    }
497
498    #[test]
499    fn format_unknown_short_flag_matches_spec_text() {
500        let msg = format_unknown_flag(&UnknownFlag::Short('x'));
501        assert_eq!(msg, "rusty-vipe: invalid option -- 'x'");
502    }
503
504    #[test]
505    fn format_unknown_long_flag_matches_spec_text() {
506        let msg = format_unknown_flag(&UnknownFlag::Long(String::from("foo")));
507        assert_eq!(msg, "rusty-vipe: unknown option -- 'foo'");
508    }
509
510    #[test]
511    fn format_editor_died_joins_argv_with_spaces() {
512        let argv = vec![
513            OsString::from("vi"),
514            OsString::from("--wait"),
515            OsString::from("/tmp/foo.txt"),
516        ];
517        assert_eq!(
518            format_editor_died(&argv),
519            "vi --wait /tmp/foo.txt exited nonzero, aborting"
520        );
521    }
522
523    #[test]
524    fn format_editor_died_single_arg() {
525        let argv = vec![OsString::from("vi")];
526        assert_eq!(format_editor_died(&argv), "vi exited nonzero, aborting");
527    }
528}