Skip to main content

runtime_cli/
editor.rs

1//! `$EDITOR` integration for commands that compose a body (issue /
2//! PR / release create). Writes a temp file with `prefill`, launches
3//! the editor, then reads the file back. Empty contents ⇒ abort,
4//! matching `gh`'s behavior.
5
6use std::io::Write;
7use std::process::Command;
8
9use anyhow::{anyhow, bail, Context, Result};
10
11/// Open `$EDITOR` (defaulting to `vi`) on a temp file containing
12/// `prefill`. Returns the saved contents, or an error if the user
13/// saved an empty file.
14pub fn compose(prefill: &str) -> Result<String> {
15    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
16    let tmp = tempfile::Builder::new()
17        .prefix("runtime-edit-")
18        .suffix(".md")
19        .tempfile()
20        .context("create tempfile")?;
21    let path = tmp.path().to_path_buf();
22    {
23        let mut f = std::fs::File::create(&path)?;
24        f.write_all(prefill.as_bytes())?;
25    }
26    // Run editor.
27    let mut parts = editor.split_whitespace();
28    let bin = parts.next().ok_or_else(|| anyhow!("empty $EDITOR"))?;
29    let status = Command::new(bin)
30        .args(parts)
31        .arg(&path)
32        .status()
33        .with_context(|| format!("launch editor `{editor}`"))?;
34    if !status.success() {
35        bail!("editor exited with status {status}");
36    }
37    let body = std::fs::read_to_string(&path).context("read edited file")?;
38    if body.trim().is_empty() {
39        bail!("aborted: empty body");
40    }
41    Ok(body)
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use std::sync::Mutex;
48
49    static ENV_LOCK: Mutex<()> = Mutex::new(());
50
51    fn with_editor(editor: Option<&str>, f: impl FnOnce()) {
52        let _guard = ENV_LOCK.lock().unwrap();
53        let saved = std::env::var("EDITOR").ok();
54        match editor {
55            Some(value) => std::env::set_var("EDITOR", value),
56            None => std::env::remove_var("EDITOR"),
57        }
58        f();
59        match saved {
60            Some(value) => std::env::set_var("EDITOR", value),
61            None => std::env::remove_var("EDITOR"),
62        }
63    }
64
65    #[test]
66    fn compose_returns_prefill_when_editor_succeeds_without_changes() {
67        with_editor(Some("true"), || {
68            assert_eq!(compose("Issue body\n").unwrap(), "Issue body\n");
69        });
70    }
71
72    #[test]
73    fn compose_rejects_empty_body_after_successful_editor() {
74        with_editor(Some("true"), || {
75            let err = compose(" \n").expect_err("empty body aborts");
76            assert!(err.to_string().contains("aborted: empty body"));
77        });
78    }
79
80    #[test]
81    fn compose_reports_failing_editor_status() {
82        with_editor(Some("false"), || {
83            let err = compose("Issue body\n").expect_err("editor failure");
84            assert!(err.to_string().contains("editor exited with status"));
85        });
86    }
87}