runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! `$EDITOR` integration for commands that compose a body (issue /
//! PR / release create). Writes a temp file with `prefill`, launches
//! the editor, then reads the file back. Empty contents ⇒ abort,
//! matching `gh`'s behavior.

use std::io::Write;
use std::process::Command;

use anyhow::{anyhow, bail, Context, Result};

/// Open `$EDITOR` (defaulting to `vi`) on a temp file containing
/// `prefill`. Returns the saved contents, or an error if the user
/// saved an empty file.
pub fn compose(prefill: &str) -> Result<String> {
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
    let tmp = tempfile::Builder::new()
        .prefix("runtime-edit-")
        .suffix(".md")
        .tempfile()
        .context("create tempfile")?;
    let path = tmp.path().to_path_buf();
    {
        let mut f = std::fs::File::create(&path)?;
        f.write_all(prefill.as_bytes())?;
    }
    // Run editor.
    let mut parts = editor.split_whitespace();
    let bin = parts.next().ok_or_else(|| anyhow!("empty $EDITOR"))?;
    let status = Command::new(bin)
        .args(parts)
        .arg(&path)
        .status()
        .with_context(|| format!("launch editor `{editor}`"))?;
    if !status.success() {
        bail!("editor exited with status {status}");
    }
    let body = std::fs::read_to_string(&path).context("read edited file")?;
    if body.trim().is_empty() {
        bail!("aborted: empty body");
    }
    Ok(body)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn with_editor(editor: Option<&str>, f: impl FnOnce()) {
        let _guard = ENV_LOCK.lock().unwrap();
        let saved = std::env::var("EDITOR").ok();
        match editor {
            Some(value) => std::env::set_var("EDITOR", value),
            None => std::env::remove_var("EDITOR"),
        }
        f();
        match saved {
            Some(value) => std::env::set_var("EDITOR", value),
            None => std::env::remove_var("EDITOR"),
        }
    }

    #[test]
    fn compose_returns_prefill_when_editor_succeeds_without_changes() {
        with_editor(Some("true"), || {
            assert_eq!(compose("Issue body\n").unwrap(), "Issue body\n");
        });
    }

    #[test]
    fn compose_rejects_empty_body_after_successful_editor() {
        with_editor(Some("true"), || {
            let err = compose(" \n").expect_err("empty body aborts");
            assert!(err.to_string().contains("aborted: empty body"));
        });
    }

    #[test]
    fn compose_reports_failing_editor_status() {
        with_editor(Some("false"), || {
            let err = compose("Issue body\n").expect_err("editor failure");
            assert!(err.to_string().contains("editor exited with status"));
        });
    }
}