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}