pub mod error;
pub use error::Error;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum EditorSource {
Override(String),
EnvLookup,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CompatibilityMode {
#[default]
Default,
Strict,
}
pub const DEFAULT_SUFFIX: &str = ".txt";
pub const MAX_SUFFIX_LEN: usize = 255;
pub fn validate_suffix(value: &str) -> Result<(), &'static str> {
if value.len() > MAX_SUFFIX_LEN {
return Err("--suffix value too long (max 255 bytes)");
}
if value.contains('\0') {
return Err("--suffix must not contain a NUL byte");
}
if value.contains('/') || value.contains('\\') {
return Err("--suffix must not contain path separators ('/' or '\\\\')");
}
Ok(())
}
#[non_exhaustive]
#[derive(Debug)]
pub struct Vipe {
editor: EditorSource,
suffix: String,
compat: CompatibilityMode,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct VipeBuilder {
editor: EditorSource,
suffix: String,
compat: CompatibilityMode,
}
impl Default for VipeBuilder {
fn default() -> Self {
Self::new()
}
}
impl VipeBuilder {
#[must_use]
pub fn new() -> Self {
Self {
editor: EditorSource::EnvLookup,
suffix: DEFAULT_SUFFIX.to_string(),
compat: CompatibilityMode::Default,
}
}
#[must_use]
pub fn editor(mut self, editor: EditorSource) -> Self {
self.editor = editor;
self
}
#[must_use]
pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
self.suffix = suffix.into();
self
}
#[must_use]
pub fn compat(mut self, compat: CompatibilityMode) -> Self {
self.compat = compat;
self
}
pub fn build(self) -> Result<Vipe, Error> {
if self.compat == CompatibilityMode::Strict
&& matches!(self.editor, EditorSource::Override(_))
{
return Err(Error::CompatibilityViolation(
"--editor not honored in Strict mode",
));
}
if let EditorSource::Override(ref s) = self.editor {
if s.is_empty() {
return Err(Error::InvalidBuilderConfiguration("empty editor override"));
}
}
validate_suffix(&self.suffix).map_err(Error::InvalidBuilderConfiguration)?;
Ok(Vipe {
editor: self.editor,
suffix: self.suffix,
compat: self.compat,
})
}
}
impl Vipe {
pub fn run<R: std::io::Read, W: std::io::Write>(
&mut self,
reader: R,
mut writer: W,
) -> Result<(), Error> {
let argv = self.resolve_editor_argv()?;
let tempfile = pipeline::drain_to_tempfile(reader, &self.suffix)?;
let tty_handles = if pipeline::test_bypass_tty_enabled() {
None
} else {
Some(tty::open_controlling_tty()?)
};
let extras: Vec<std::ffi::OsString> = Vec::new();
let status = pipeline::spawn_editor(&argv, &extras, tempfile.path(), tty_handles)?;
if !status.success() {
let code = pipeline::clamp_exit_code(status);
return Err(Error::EditorNonZeroExit(code));
}
let bytes = match std::fs::read(tempfile.path()) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::TempFileDeleted(tempfile.path().to_path_buf()));
}
Err(e) => return Err(Error::Io(e)),
};
writer.write_all(&bytes)?;
writer.flush()?;
Ok(())
}
fn resolve_editor_argv(&self) -> Result<Vec<std::ffi::OsString>, Error> {
match &self.editor {
EditorSource::Override(cmd) => {
let argv = editor::parse_editor_value(cmd)?;
if argv.is_empty() {
return Err(Error::InvalidBuilderConfiguration(
"editor override resolved to empty argv",
));
}
Ok(argv)
}
EditorSource::EnvLookup => {
let env_visual = std::env::var("VISUAL").ok();
let env_editor = std::env::var("EDITOR").ok();
let resolved = editor::resolve(
None,
env_visual.as_deref(),
env_editor.as_deref(),
self.compat,
)?;
Ok(resolved.argv)
}
}
}
}
pub mod editor;
pub mod pipeline;
pub mod tty;
#[cfg(feature = "cli")]
pub mod cli;
#[cfg(feature = "cli")]
pub mod mode;
#[cfg(feature = "cli")]
pub mod signal;
#[cfg(feature = "cli")]
pub mod strict;
#[cfg(feature = "cli")]
pub fn run() -> std::process::ExitCode {
use clap::Parser;
use std::ffi::OsString;
use std::process::ExitCode;
if let Err(e) = signal::install_handlers() {
eprintln!("warning: could not install signal handlers: {e}");
}
let raw_argv: Vec<OsString> = std::env::args_os().collect();
let pre_strict = strict::pre_scan_strict_flag(&raw_argv);
let env_strict = std::env::var_os("RUSTY_VIPE_STRICT");
let argv0 = raw_argv.first().cloned();
let resolved_mode = mode::resolve(pre_strict, env_strict.as_deref(), argv0.as_deref());
if resolved_mode == CompatibilityMode::Strict {
return strict::run(&raw_argv);
}
let cli_args = match cli::Cli::try_parse() {
Ok(args) => args,
Err(e) => {
e.print().ok();
return match e.kind() {
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
ExitCode::SUCCESS
}
_ => ExitCode::from(2),
};
}
};
if let Some(cli::Subcommand::Completions { shell }) = cli_args.command {
use clap::CommandFactory;
let mut cmd = cli::Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
return ExitCode::SUCCESS;
}
let env_visual = std::env::var("VISUAL").ok();
let env_editor = std::env::var("EDITOR").ok();
let editor_resolved = match editor::resolve(
cli_args.editor.as_deref(),
env_visual.as_deref(),
env_editor.as_deref(),
CompatibilityMode::Default,
) {
Ok(r) => r,
Err(Error::InvalidEditorCommand(raw)) => {
eprintln!("rusty-vipe: invalid EDITOR/VISUAL value: {raw}");
return ExitCode::from(127);
}
Err(e) => {
eprintln!("rusty-vipe: {e}");
return ExitCode::from(127);
}
};
let suffix = cli_args.suffix.as_deref().unwrap_or(DEFAULT_SUFFIX);
let stdin = std::io::stdin();
let tempfile = match pipeline::drain_to_tempfile(stdin.lock(), suffix) {
Ok(tf) => tf,
Err(e) => {
eprintln!("rusty-vipe: {e}");
return ExitCode::from(1);
}
};
let preserved_stdout = match tty::preserve_stdout() {
Ok(p) => p,
Err(e) => {
eprintln!("rusty-vipe: failed to preserve stdout: {e}");
return ExitCode::from(1);
}
};
let tty_handles = if pipeline::test_bypass_tty_enabled() {
None
} else {
match tty::open_controlling_tty() {
Ok(handles) => Some(handles),
Err(Error::NoControllingTty) => {
eprintln!("rusty-vipe: no controlling terminal; cannot launch editor");
return ExitCode::from(1);
}
Err(e) => {
eprintln!("rusty-vipe: {e}");
return ExitCode::from(1);
}
}
};
let extras: Vec<OsString> = cli_args.editor_extras.iter().map(OsString::from).collect();
let status = match pipeline::spawn_editor(
&editor_resolved.argv,
&extras,
tempfile.path(),
tty_handles,
) {
Ok(s) => s,
Err(Error::EditorNotFound(name)) => {
eprintln!("rusty-vipe: editor not found: {name}");
return ExitCode::from(127);
}
Err(e) => {
eprintln!("rusty-vipe: {e}");
return ExitCode::from(1);
}
};
if !status.success() {
let code = pipeline::clamp_exit_code(status);
let byte = if (1..=255).contains(&code) {
code as u8
} else {
1u8
};
return ExitCode::from(byte);
}
match pipeline::write_back_to_saved_stdout(tempfile.path(), preserved_stdout) {
Ok(()) => ExitCode::SUCCESS,
Err(Error::TempFileDeleted(_)) => {
eprintln!("rusty-vipe: tempfile no longer exists after editor exited");
ExitCode::from(1)
}
Err(e) => {
eprintln!("rusty-vipe: {e}");
ExitCode::from(1)
}
}
}