use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use std::time::{Duration, Instant};
use tempfile::{Builder, TempDir};
use thiserror::Error;
pub const SCISSORS: &str = "# ------------------------ >8 ------------------------";
const MIN_ELAPSED_MS: u128 = 500;
#[derive(Debug)]
pub enum Outcome {
Approved(String),
Aborted { draft_path: PathBuf },
}
#[derive(Debug, Error)]
pub enum ScissorsError {
#[error("no editor available ($VISUAL, $EDITOR unset and editor not found)")]
NoEditor,
#[error("editor exited with code {code}; draft at {draft_path}")]
EditorFailed { code: i32, draft_path: PathBuf },
#[error(
"editor returned in {elapsed_ms}ms without changes; it likely never \
opened. Causes: $EDITOR missing a wait flag (e.g. `code --wait`), a \
sandbox blocking the editor's IPC, or the binary not on PATH; \
draft at {draft_path}"
)]
SilentFailure {
elapsed_ms: u32,
draft_path: PathBuf,
},
#[error("io error: {0}")]
Io(#[from] io::Error),
}
#[derive(Debug)]
pub enum FileOutcome {
Approved,
Aborted,
}
#[derive(Debug, Error)]
pub enum FileError {
#[error("no editor available ($VISUAL, $EDITOR unset and editor not found)")]
NoEditor,
#[error("cannot read {}: {source}", path.display())]
Read { path: PathBuf, source: io::Error },
#[error(
"{} already contains the scissors line (line {line}); refusing to edit \
it in place to avoid discarding content below it",
path.display()
)]
MarkerInInput { path: PathBuf, line: usize },
#[error("editor exited with code {code}")]
EditorExited { code: i32 },
#[error(
"editor returned in {elapsed_ms}ms without changes; it likely never \
opened. Causes: $EDITOR missing a wait flag (e.g. `code --wait`), a \
sandbox blocking the editor's IPC, or the binary not on PATH"
)]
SilentFailure { elapsed_ms: u32 },
#[error(
"failed to replace {}: {source}; your edited draft is kept at {}",
target.display(),
sidecar.display()
)]
Persist {
target: PathBuf,
sidecar: PathBuf,
source: io::Error,
},
#[error("io error: {0}")]
Io(#[from] io::Error),
}
pub fn strip_scissors(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
let cut = lines
.iter()
.rposition(|l| l.trim_end() == SCISSORS)
.unwrap_or(lines.len());
lines[..cut].join("\n").trim_end().to_string()
}
pub fn build_draft(content: &str, context: Option<&str>) -> String {
let mut out = String::new();
out.push_str(content.trim_end_matches('\n'));
out.push_str("\n\n");
out.push_str(SCISSORS);
out.push('\n');
out.push_str("# Do not modify or remove the line above.\n");
out.push_str("# Everything below is context and will be stripped from the final content.\n");
out.push_str("# Save and close the editor when done.\n");
out.push_str("# Empty all content above this line to abort without submitting.\n");
if let Some(ctx) = context {
out.push_str("#\n");
out.push_str(&format!("# Context: {ctx}\n"));
}
out
}
pub fn resolve_editor() -> Result<Vec<String>, ScissorsError> {
for var in ["VISUAL", "EDITOR"] {
if let Ok(val) = std::env::var(var) {
let trimmed = val.trim();
if !trimmed.is_empty() {
let parts = shell_words::split(trimmed)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
if !parts.is_empty() {
return Ok(parts);
}
}
}
}
Ok(vec!["vi".to_string()])
}
fn launch_editor(cmd: &[String], path: &Path) -> Result<(ExitStatus, Duration), ScissorsError> {
let (program, args) = cmd.split_first().ok_or(ScissorsError::NoEditor)?;
let start = Instant::now();
let status = Command::new(program)
.args(args)
.arg(path)
.status()
.map_err(|e| match e.kind() {
io::ErrorKind::NotFound => ScissorsError::NoEditor,
_ => ScissorsError::Io(e),
})?;
Ok((status, start.elapsed()))
}
const COMMIT_EDITMSG: &str = "COMMIT_EDITMSG";
enum Verdict {
Approved(String),
Aborted,
SilentFailure { elapsed_ms: u32 },
EditorFailed { code: i32 },
}
fn edit_and_classify(
editor: &[String],
path: &Path,
original: &str,
) -> Result<Verdict, ScissorsError> {
let (status, elapsed) = launch_editor(editor, path)?;
if !status.success() {
return Ok(Verdict::EditorFailed {
code: status.code().unwrap_or(-1),
});
}
let raw = fs::read_to_string(path)?;
let final_content = strip_scissors(&raw);
if final_content == original.trim_end() && elapsed.as_millis() < MIN_ELAPSED_MS {
return Ok(Verdict::SilentFailure {
elapsed_ms: elapsed.as_millis() as u32,
});
}
if final_content.trim().is_empty() {
return Ok(Verdict::Aborted);
}
Ok(Verdict::Approved(final_content))
}
fn keep_draft(dir: TempDir, path: PathBuf) -> PathBuf {
let _ = dir.keep();
path
}
pub fn approve_in_editor(content: &str, context: Option<&str>) -> Result<Outcome, ScissorsError> {
let editor = resolve_editor()?;
let dir = Builder::new().prefix("scissors-").tempdir()?;
let path = dir.path().join(COMMIT_EDITMSG);
fs::write(&path, build_draft(content, context).as_bytes())?;
match edit_and_classify(&editor, &path, content)? {
Verdict::Approved(approved) => Ok(Outcome::Approved(approved)),
Verdict::Aborted => Ok(Outcome::Aborted {
draft_path: keep_draft(dir, path),
}),
Verdict::SilentFailure { elapsed_ms } => Err(ScissorsError::SilentFailure {
elapsed_ms,
draft_path: keep_draft(dir, path),
}),
Verdict::EditorFailed { code } => Err(ScissorsError::EditorFailed {
code,
draft_path: keep_draft(dir, path),
}),
}
}
pub fn approve_file_in_place(path: &Path, context: Option<&str>) -> Result<FileOutcome, FileError> {
let original = fs::read_to_string(path).map_err(|source| FileError::Read {
path: path.to_path_buf(),
source,
})?;
if let Some(i) = original.lines().position(|l| l.trim_end() == SCISSORS) {
return Err(FileError::MarkerInInput {
path: path.to_path_buf(),
line: i + 1,
});
}
let target = fs::canonicalize(path).map_err(|source| FileError::Read {
path: path.to_path_buf(),
source,
})?;
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let editor = resolve_editor().map_err(|e| match e {
ScissorsError::Io(io) => FileError::Io(io),
other => FileError::Io(io::Error::other(other.to_string())),
})?;
let dir = Builder::new().prefix(".scissors-").tempdir_in(parent)?;
let sidecar = dir.path().join(COMMIT_EDITMSG);
fs::write(&sidecar, build_draft(&original, context).as_bytes())?;
fs::set_permissions(&sidecar, fs::metadata(&target)?.permissions())?;
eprintln!("scissors: editing {}", sidecar.display());
let verdict = edit_and_classify(&editor, &sidecar, &original).map_err(|e| match e {
ScissorsError::NoEditor => FileError::NoEditor,
ScissorsError::Io(io) => FileError::Io(io),
other => FileError::Io(io::Error::other(other.to_string())),
})?;
match verdict {
Verdict::Approved(content) => {
fs::write(&sidecar, &content)?;
match fs::rename(&sidecar, &target) {
Ok(()) => Ok(FileOutcome::Approved),
Err(source) => {
let _ = dir.keep();
Err(FileError::Persist {
target,
sidecar,
source,
})
}
}
}
Verdict::Aborted => Ok(FileOutcome::Aborted),
Verdict::SilentFailure { elapsed_ms } => Err(FileError::SilentFailure { elapsed_ms }),
Verdict::EditorFailed { code } => Err(FileError::EditorExited { code }),
}
}
#[cfg(test)]
mod strip_tests {
use super::*;
#[test]
fn no_scissors_returns_trimmed_whole() {
assert_eq!(strip_scissors("hello world\n\n"), "hello world");
}
#[test]
fn scissors_at_start_returns_empty() {
let input = format!("{SCISSORS}\nfooter stuff");
assert_eq!(strip_scissors(&input), "");
}
#[test]
fn scissors_in_middle_returns_content_above() {
let input = format!("my draft\nsecond line\n{SCISSORS}\n# context\n");
assert_eq!(strip_scissors(&input), "my draft\nsecond line");
}
#[test]
fn scissors_like_but_not_exact_is_ignored() {
let input = "draft\n# --- >8 --- not the real one\nmore";
assert_eq!(strip_scissors(input), input.trim_end());
}
#[test]
fn round_trips_a_body_that_contains_a_marker() {
let body = format!("keep this\n{SCISSORS}\nand this too");
assert_eq!(strip_scissors(&build_draft(&body, None)), body);
}
#[test]
fn cuts_at_the_last_marker_not_the_first() {
let input = format!("a\n{SCISSORS}\nb\n{SCISSORS}\nc");
assert_eq!(strip_scissors(&input), format!("a\n{SCISSORS}\nb"));
}
}
#[cfg(test)]
mod build_tests {
use super::*;
#[test]
fn includes_content_and_scissors() {
let draft = build_draft("my content", None);
assert!(draft.starts_with("my content\n"));
assert!(draft.contains(SCISSORS));
assert!(draft.contains("# Empty all content above this line to abort"));
}
#[test]
fn context_appears_when_provided() {
let draft = build_draft("x", Some("Issue #26 reply"));
assert!(draft.contains("# Context: Issue #26 reply"));
}
#[test]
fn no_context_line_when_absent() {
let draft = build_draft("x", None);
assert!(!draft.contains("# Context:"));
}
#[test]
fn round_trips_through_strip() {
let draft = build_draft("hello\nworld", None);
assert_eq!(strip_scissors(&draft), "hello\nworld");
}
}
#[cfg(test)]
mod editor_tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn visual_takes_priority() {
std::env::set_var("VISUAL", "myvisual --wait");
std::env::set_var("EDITOR", "myeditor");
assert_eq!(resolve_editor().unwrap(), vec!["myvisual", "--wait"]);
}
#[test]
#[serial]
fn editor_used_when_visual_unset() {
std::env::remove_var("VISUAL");
std::env::set_var("EDITOR", "nano");
assert_eq!(resolve_editor().unwrap(), vec!["nano"]);
}
#[test]
#[serial]
fn falls_back_to_vi_when_both_unset() {
std::env::remove_var("VISUAL");
std::env::remove_var("EDITOR");
assert_eq!(resolve_editor().unwrap(), vec!["vi"]);
}
}