Skip to main content

rusty_vipe/
lib.rs

1//! # rusty-vipe
2//!
3//! A Rust port of the moreutils `vipe` utility: pop `$EDITOR` mid-pipe so the
4//! user can edit the buffered bytes interactively, then resume the pipeline
5//! with the edited output.
6//!
7//! ## Quick start
8//!
9//! ```no_run
10//! use rusty_vipe::{VipeBuilder, EditorSource, CompatibilityMode};
11//! use std::io::Cursor;
12//!
13//! let mut input = Cursor::new(b"line1\nline2\nline3\n".to_vec());
14//! let mut output: Vec<u8> = Vec::new();
15//!
16//! let mut vipe = VipeBuilder::new()
17//!     .editor(EditorSource::Override("fake-editor --transform=passthrough".into()))
18//!     .suffix(".txt")
19//!     .compat(CompatibilityMode::Default)
20//!     .build()?;
21//!
22//! vipe.run(&mut input, &mut output)?;
23//! # Ok::<(), rusty_vipe::Error>(())
24//! ```
25//!
26//! ## Stability (lockstep SemVer)
27//!
28//! Library and binary share a single crate version. Within `0.x`, minor
29//! version bumps may introduce breaking changes per standard Cargo
30//! semantics. Every public enum and struct is `#[non_exhaustive]` so
31//! variant additions are not breaking changes once `1.0` lands.
32//!
33//! ## Pipeline-safety contract
34//!
35//! When the editor exits non-zero, [`Vipe::run`] does NOT touch the
36//! caller-supplied writer and returns `Err(Error::EditorNonZeroExit(code))`.
37//! This matches the CLI invariant — no bytes downstream on abort.
38
39pub mod error;
40
41pub use error::Error;
42
43/// Where the editor command comes from.
44///
45/// # Examples
46///
47/// ```
48/// use rusty_vipe::EditorSource;
49///
50/// // Use an explicit editor command (whitespace-aware splitting).
51/// let _ = EditorSource::Override(String::from("code --wait"));
52///
53/// // Or follow the standard env precedence ladder.
54/// let _ = EditorSource::EnvLookup;
55/// ```
56#[non_exhaustive]
57#[derive(Debug, Clone)]
58pub enum EditorSource {
59    /// Explicit override (`--editor=<cmd>` flag, Default mode only). Carries
60    /// the raw command string; whitespace-aware splitting happens at run time.
61    Override(String),
62    /// Follow the precedence-laddered env lookup: `$VISUAL` > `$EDITOR` >
63    /// `/usr/bin/editor` (Unix) > `vi` (Unix) / `notepad.exe` (Windows).
64    EnvLookup,
65}
66
67/// Whether to apply Default-mode ergonomic extensions or Strict moreutils parity.
68///
69/// # Examples
70///
71/// ```
72/// use rusty_vipe::CompatibilityMode;
73///
74/// assert_eq!(CompatibilityMode::default(), CompatibilityMode::Default);
75/// // Strict mode rejects `--editor`, `--help`, `--version`, and completions.
76/// let _ = CompatibilityMode::Strict;
77/// ```
78#[non_exhaustive]
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum CompatibilityMode {
81    /// Default mode: `--help`, `--version`, `--editor=<cmd>`, `completions`
82    /// subcommand all honored.
83    #[default]
84    Default,
85    /// Strict mode: byte-equal moreutils stderr for documented inputs;
86    /// rejects every Default-mode addition.
87    Strict,
88}
89
90/// Default tempfile suffix (matches moreutils 0.69 `--suffix` default).
91pub const DEFAULT_SUFFIX: &str = ".txt";
92
93/// Maximum permitted length (in bytes) for a `--suffix` value. Most POSIX and
94/// Windows filesystems cap a single filename component at 255 bytes; we reject
95/// suffixes that would push the tempfile name past that limit.
96pub const MAX_SUFFIX_LEN: usize = 255;
97
98/// Validate a `--suffix=<ext>` value at parse time. Rejects path separators
99/// (`/`, `\`), NUL bytes (which terminate C strings on every supported OS),
100/// and lengths past `MAX_SUFFIX_LEN`. Empty suffix is allowed (means literally
101/// no extension, per FR-012 Clarification Q2).
102pub fn validate_suffix(value: &str) -> Result<(), &'static str> {
103    if value.len() > MAX_SUFFIX_LEN {
104        return Err("--suffix value too long (max 255 bytes)");
105    }
106    if value.contains('\0') {
107        return Err("--suffix must not contain a NUL byte");
108    }
109    if value.contains('/') || value.contains('\\') {
110        return Err("--suffix must not contain path separators ('/' or '\\\\')");
111    }
112    Ok(())
113}
114
115/// Runtime engine for one vipe invocation. Constructed via [`VipeBuilder`].
116#[non_exhaustive]
117#[derive(Debug)]
118pub struct Vipe {
119    editor: EditorSource,
120    suffix: String,
121    compat: CompatibilityMode,
122}
123
124/// Builder for [`Vipe`]. All chain methods are `#[must_use]`.
125#[non_exhaustive]
126#[derive(Debug, Clone)]
127pub struct VipeBuilder {
128    editor: EditorSource,
129    suffix: String,
130    compat: CompatibilityMode,
131}
132
133impl Default for VipeBuilder {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl VipeBuilder {
140    /// Construct a new builder defaulting to `EditorSource::EnvLookup`,
141    /// `.txt` suffix, Default mode.
142    #[must_use]
143    pub fn new() -> Self {
144        Self {
145            editor: EditorSource::EnvLookup,
146            suffix: DEFAULT_SUFFIX.to_string(),
147            compat: CompatibilityMode::Default,
148        }
149    }
150
151    /// Set the editor source.
152    #[must_use]
153    pub fn editor(mut self, editor: EditorSource) -> Self {
154        self.editor = editor;
155        self
156    }
157
158    /// Set the tempfile suffix. Empty string means literally no extension.
159    #[must_use]
160    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
161        self.suffix = suffix.into();
162        self
163    }
164
165    /// Set the compatibility mode.
166    #[must_use]
167    pub fn compat(mut self, compat: CompatibilityMode) -> Self {
168        self.compat = compat;
169        self
170    }
171
172    /// Validate and build a [`Vipe`].
173    pub fn build(self) -> Result<Vipe, Error> {
174        // Strict mode rejects explicit editor overrides per FR-013.
175        if self.compat == CompatibilityMode::Strict
176            && matches!(self.editor, EditorSource::Override(_))
177        {
178            return Err(Error::CompatibilityViolation(
179                "--editor not honored in Strict mode",
180            ));
181        }
182        // Empty Override is rejected — empty strings on the CLI fall through
183        // via the binary's argv-parsing path, but a programmatic empty Override
184        // signals user error.
185        if let EditorSource::Override(ref s) = self.editor {
186            if s.is_empty() {
187                return Err(Error::InvalidBuilderConfiguration("empty editor override"));
188            }
189        }
190        // Suffix validation mirrors the CLI parser (FR-012 Edge Cases).
191        validate_suffix(&self.suffix).map_err(Error::InvalidBuilderConfiguration)?;
192        Ok(Vipe {
193            editor: self.editor,
194            suffix: self.suffix,
195            compat: self.compat,
196        })
197    }
198}
199
200impl Vipe {
201    /// Drain `reader` to a tempfile, spawn the editor against it, then write
202    /// the post-edit tempfile bytes to `writer`.
203    ///
204    /// On non-zero editor exit, `writer` is NOT touched and the call returns
205    /// `Err(Error::EditorNonZeroExit(code))` with the already-clamped code
206    /// (Unix 1–255 verbatim; Windows 1–254 verbatim, else clamped to 1).
207    ///
208    /// **Writer-untouched invariant**: `writer` receives zero bytes (and zero
209    /// `flush()` calls) on every error path — `EditorNonZeroExit`,
210    /// `TempFileDeleted`, `NoControllingTty`, `InvalidEditorCommand`,
211    /// `EditorNotFound`, and any underlying `Io` error during the
212    /// drain/spawn/read phases. Only the final successful read-and-write step
213    /// touches `writer`. See FR-029 for the formal contract.
214    pub fn run<R: std::io::Read, W: std::io::Write>(
215        &mut self,
216        reader: R,
217        mut writer: W,
218    ) -> Result<(), Error> {
219        // 1. Resolve editor argv. EnvLookup uses process VISUAL/EDITOR; Override
220        //    uses the embedded command string. Strict mode is enforced at
221        //    build() time, so we don't re-check here.
222        let argv = self.resolve_editor_argv()?;
223
224        // 2. Drain `reader` into a tempfile with the configured suffix.
225        let tempfile = pipeline::drain_to_tempfile(reader, &self.suffix)?;
226
227        // 3. Open the controlling terminal for the editor's stdio.
228        //    Library consumers running headless (no PTY) get NoControllingTty.
229        //    The test-bypass env var is honored so embedders' own test suites
230        //    can drive Vipe::run in CI.
231        let tty_handles = if pipeline::test_bypass_tty_enabled() {
232            None
233        } else {
234            Some(tty::open_controlling_tty()?)
235        };
236
237        // 4. Spawn editor + wait. Extras are empty for the library path
238        //    (the binary path forwards positional args, but the library API
239        //    intentionally doesn't expose that — embedders set the full argv
240        //    via EditorSource::Override).
241        let extras: Vec<std::ffi::OsString> = Vec::new();
242        let status = pipeline::spawn_editor(&argv, &extras, tempfile.path(), tty_handles)?;
243
244        // 5. FR-006: non-zero exit aborts; writer NOT touched.
245        if !status.success() {
246            let code = pipeline::clamp_exit_code(status);
247            return Err(Error::EditorNonZeroExit(code));
248        }
249
250        // 6. Read tempfile bytes and write to the user's writer. Distinguish
251        //    NotFound (user deleted the tempfile from within the editor) per
252        //    FR-007.
253        let bytes = match std::fs::read(tempfile.path()) {
254            Ok(b) => b,
255            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
256                return Err(Error::TempFileDeleted(tempfile.path().to_path_buf()));
257            }
258            Err(e) => return Err(Error::Io(e)),
259        };
260        writer.write_all(&bytes)?;
261        writer.flush()?;
262        Ok(())
263    }
264
265    /// Resolve `self.editor` into a spawnable argv. Pure helper extracted so
266    /// `run` reads top-to-bottom in pipeline order.
267    fn resolve_editor_argv(&self) -> Result<Vec<std::ffi::OsString>, Error> {
268        match &self.editor {
269            EditorSource::Override(cmd) => {
270                let argv = editor::parse_editor_value(cmd)?;
271                if argv.is_empty() {
272                    return Err(Error::InvalidBuilderConfiguration(
273                        "editor override resolved to empty argv",
274                    ));
275                }
276                Ok(argv)
277            }
278            EditorSource::EnvLookup => {
279                let env_visual = std::env::var("VISUAL").ok();
280                let env_editor = std::env::var("EDITOR").ok();
281                let resolved = editor::resolve(
282                    None,
283                    env_visual.as_deref(),
284                    env_editor.as_deref(),
285                    self.compat,
286                )?;
287                Ok(resolved.argv)
288            }
289        }
290    }
291}
292
293// Library-essential modules (always available — needed by `Vipe::run`).
294// These intentionally avoid clap/anyhow/signal-hook so library consumers can
295// depend on rusty-vipe with `default-features = false`.
296pub mod editor;
297pub mod pipeline;
298pub mod tty;
299
300// CLI-only modules: clap parsing, signal handlers, Strict-mode argv scan,
301// CompatibilityMode resolver — gated behind `cli` because they pull clap,
302// signal-hook, and other binary-only deps.
303#[cfg(feature = "cli")]
304pub mod cli;
305#[cfg(feature = "cli")]
306pub mod mode;
307#[cfg(feature = "cli")]
308pub mod signal;
309#[cfg(feature = "cli")]
310pub mod strict;
311
312/// Binary entry-point helper used by both `src/main.rs` and `src/bin/vipe.rs`.
313///
314/// Per FR-006 / AD-012: editor non-zero exit is propagated as the process
315/// exit code (with Windows clamping); writer (the preserved stdout sink) is
316/// NOT touched on non-zero exit.
317#[cfg(feature = "cli")]
318pub fn run() -> std::process::ExitCode {
319    use clap::Parser;
320    use std::ffi::OsString;
321    use std::process::ExitCode;
322
323    // Install signal handlers as early as possible (FR-014).
324    if let Err(e) = signal::install_handlers() {
325        eprintln!("warning: could not install signal handlers: {e}");
326    }
327
328    // Pre-clap detection of `--strict` / `--no-strict` + env + argv[0] for
329    // Strict-mode dispatch. Strict mode bypasses clap entirely (clap can't
330    // produce byte-equal moreutils errors).
331    let raw_argv: Vec<OsString> = std::env::args_os().collect();
332    let pre_strict = strict::pre_scan_strict_flag(&raw_argv);
333    let env_strict = std::env::var_os("RUSTY_VIPE_STRICT");
334    let argv0 = raw_argv.first().cloned();
335    let resolved_mode = mode::resolve(pre_strict, env_strict.as_deref(), argv0.as_deref());
336    if resolved_mode == CompatibilityMode::Strict {
337        return strict::run(&raw_argv);
338    }
339
340    let cli_args = match cli::Cli::try_parse() {
341        Ok(args) => args,
342        Err(e) => {
343            e.print().ok();
344            return match e.kind() {
345                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
346                    ExitCode::SUCCESS
347                }
348                _ => ExitCode::from(2),
349            };
350        }
351    };
352
353    // Subcommands (completions). Same pattern as rusty-sponge.
354    if let Some(cli::Subcommand::Completions { shell }) = cli_args.command {
355        use clap::CommandFactory;
356        let mut cmd = cli::Cli::command();
357        let name = cmd.get_name().to_string();
358        clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
359        return ExitCode::SUCCESS;
360    }
361
362    // Resolve editor argv.
363    let env_visual = std::env::var("VISUAL").ok();
364    let env_editor = std::env::var("EDITOR").ok();
365    let editor_resolved = match editor::resolve(
366        cli_args.editor.as_deref(),
367        env_visual.as_deref(),
368        env_editor.as_deref(),
369        CompatibilityMode::Default,
370    ) {
371        Ok(r) => r,
372        Err(Error::InvalidEditorCommand(raw)) => {
373            eprintln!("rusty-vipe: invalid EDITOR/VISUAL value: {raw}");
374            return ExitCode::from(127);
375        }
376        Err(e) => {
377            eprintln!("rusty-vipe: {e}");
378            return ExitCode::from(127);
379        }
380    };
381
382    // Drain stdin to a tempfile with the configured suffix.
383    let suffix = cli_args.suffix.as_deref().unwrap_or(DEFAULT_SUFFIX);
384    let stdin = std::io::stdin();
385    let tempfile = match pipeline::drain_to_tempfile(stdin.lock(), suffix) {
386        Ok(tf) => tf,
387        Err(e) => {
388            eprintln!("rusty-vipe: {e}");
389            return ExitCode::from(1);
390        }
391    };
392
393    // Preserve the original stdout sink BEFORE TTY reattachment (HINT-002).
394    let preserved_stdout = match tty::preserve_stdout() {
395        Ok(p) => p,
396        Err(e) => {
397            eprintln!("rusty-vipe: failed to preserve stdout: {e}");
398            return ExitCode::from(1);
399        }
400    };
401
402    // Open the controlling TTY (or fall back to test-bypass mode).
403    let tty_handles = if pipeline::test_bypass_tty_enabled() {
404        None
405    } else {
406        match tty::open_controlling_tty() {
407            Ok(handles) => Some(handles),
408            Err(Error::NoControllingTty) => {
409                eprintln!("rusty-vipe: no controlling terminal; cannot launch editor");
410                return ExitCode::from(1);
411            }
412            Err(e) => {
413                eprintln!("rusty-vipe: {e}");
414                return ExitCode::from(1);
415            }
416        }
417    };
418
419    // Spawn editor and wait.
420    let extras: Vec<OsString> = cli_args.editor_extras.iter().map(OsString::from).collect();
421    let status = match pipeline::spawn_editor(
422        &editor_resolved.argv,
423        &extras,
424        tempfile.path(),
425        tty_handles,
426    ) {
427        Ok(s) => s,
428        Err(Error::EditorNotFound(name)) => {
429            eprintln!("rusty-vipe: editor not found: {name}");
430            return ExitCode::from(127);
431        }
432        Err(e) => {
433            eprintln!("rusty-vipe: {e}");
434            return ExitCode::from(1);
435        }
436    };
437
438    // FR-006: non-zero editor exit aborts; writer (preserved stdout) is NOT touched.
439    if !status.success() {
440        let code = pipeline::clamp_exit_code(status);
441        // Clamp code to u8 for ExitCode::from (codes 1-255). Already clamped
442        // upstream; this is just the final type conversion.
443        let byte = if (1..=255).contains(&code) {
444            code as u8
445        } else {
446            1u8
447        };
448        return ExitCode::from(byte);
449    }
450
451    // Read tempfile and write to preserved stdout.
452    match pipeline::write_back_to_saved_stdout(tempfile.path(), preserved_stdout) {
453        Ok(()) => ExitCode::SUCCESS,
454        Err(Error::TempFileDeleted(_)) => {
455            eprintln!("rusty-vipe: tempfile no longer exists after editor exited");
456            ExitCode::from(1)
457        }
458        Err(e) => {
459            eprintln!("rusty-vipe: {e}");
460            ExitCode::from(1)
461        }
462    }
463    // tempfile drops here → cleanup
464}