Skip to main content

mkit_cli/
editor.rs

1//! `$EDITOR` / `$VISUAL` spawn helper for `mkit commit`.
2//!
3//! Behaviour:
4//! * Honour `$GIT_EDITOR` first, then `$EDITOR`, then `$VISUAL`.
5//! * Fall back to `vi` when no env var is set, so `mkit commit` works
6//!   out-of-the-box.
7//! * Write `template` to a tempfile, spawn the editor, wait, read the
8//!   file back, strip any line whose first non-whitespace byte is `#`,
9//!   and return the trimmed message.
10
11use std::fs;
12use std::io::{self, Read, Write};
13use std::path::PathBuf;
14use std::process::Command;
15
16/// Maximum commit-message file size read back from the editor (1 MiB).
17pub const MAX_COMMIT_MSG_BYTES: u64 = 1024 * 1024;
18
19/// Spawn the user's editor on a tempfile pre-populated with `template`,
20/// then read the file back, strip `#`-comment lines, and return the
21/// trimmed result.
22///
23/// The editor is chosen in this order:
24/// 1. `$GIT_EDITOR`
25/// 2. `$EDITOR`
26/// 3. `$VISUAL`
27/// 4. platform default (`vi`)
28///
29/// The editor invocation parses the chosen value as a shell-like
30/// command: the first whitespace-separated token is the program, the
31/// rest are arguments, and the tempfile path is appended as a final
32/// argument. This matches how `git`'s `GIT_EDITOR` is conventionally
33/// parsed (e.g. `EDITOR="vim -c 'set nowrap'"`).
34///
35/// # Errors
36/// - [`io::ErrorKind::NotFound`] if the editor binary cannot be spawned.
37/// - [`io::ErrorKind::Other`] with a descriptive message if the editor
38///   exits non-zero or the file exceeds [`MAX_COMMIT_MSG_BYTES`].
39pub fn spawn_editor(template: &str) -> io::Result<String> {
40    let editor = pick_editor();
41    if editor.is_empty() {
42        return Err(io::Error::other(
43            "no editor configured; set $EDITOR or pass -m <msg>",
44        ));
45    }
46
47    // Write template to a tempfile. We use `tempfile` to get a
48    // predictable cleanup path — the file's dropped when `file` goes
49    // out of scope even if the editor crashed.
50    let tmp = tempfile::NamedTempFile::with_suffix(".mkit-commit.txt")?;
51    {
52        let mut f = fs::OpenOptions::new().write(true).open(tmp.path())?;
53        f.write_all(template.as_bytes())?;
54        f.sync_all()?;
55    }
56
57    // Parse the editor string into [program, args...] and append the
58    // tempfile path. Whitespace-split is intentionally crude — quoted
59    // arguments with embedded whitespace are rare in practice and
60    // require a full shell to handle correctly. The test suite uses
61    // the simple `sh -c '…' sh` idiom which works under this split.
62    let mut parts = editor.split_whitespace();
63    let program = parts.next().unwrap_or("");
64    let extra_args: Vec<&str> = parts.collect();
65    let path_arg: PathBuf = tmp.path().to_path_buf();
66
67    let status = Command::new(program)
68        .args(&extra_args)
69        .arg(&path_arg)
70        .status()?;
71    if !status.success() {
72        return Err(io::Error::other(format!(
73            "editor exited with status {status:?}"
74        )));
75    }
76
77    // Read the file back. Bounded to MAX_COMMIT_MSG_BYTES.
78    let f = fs::File::open(tmp.path())?;
79    let mut buf = Vec::new();
80    f.take(MAX_COMMIT_MSG_BYTES + 1)
81        .read_to_end(&mut buf)
82        .map_err(io::Error::other)?;
83    if buf.len() as u64 > MAX_COMMIT_MSG_BYTES {
84        return Err(io::Error::other("commit message file too large (>1 MiB)"));
85    }
86    let raw = String::from_utf8_lossy(&buf).into_owned();
87    Ok(strip_comments_and_trim(&raw))
88}
89
90/// Pick an editor from the environment. Returns an empty string if
91/// nothing usable is set AND no platform default applies.
92fn pick_editor() -> String {
93    pick_editor_with(|name| std::env::var(name).ok())
94}
95
96/// Test-friendly variant of [`pick_editor`] — `resolver` is called
97/// once per candidate env var, and the first non-empty trimmed value
98/// wins. On a fully-empty environment, falls back to the platform
99/// default (`vi`).
100fn pick_editor_with(resolver: impl Fn(&str) -> Option<String>) -> String {
101    for var in ["GIT_EDITOR", "EDITOR", "VISUAL"] {
102        if let Some(v) = resolver(var)
103            && !v.trim().is_empty()
104        {
105            return v;
106        }
107    }
108    "vi".to_string()
109}
110
111/// Strip all lines whose first non-whitespace byte is `#`, then trim
112/// trailing whitespace.
113#[must_use]
114pub fn strip_comments_and_trim(input: &str) -> String {
115    let mut out = String::with_capacity(input.len());
116    for line in input.split('\n') {
117        let first_nws = line.trim_start();
118        if first_nws.starts_with('#') {
119            continue;
120        }
121        out.push_str(line);
122        out.push('\n');
123    }
124    out.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\r' || c == '\n')
125        .to_string()
126}
127
128/// Template rendered into the tempfile before spawning the editor.
129pub const COMMIT_EDITMSG_TEMPLATE: &str = "\n\
130# Please enter the commit message for your changes. Lines starting\n\
131# with '#' will be ignored, and an empty message aborts the commit.\n";
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn strip_comments_drops_hash_lines() {
139        let input = "\nhello\n# a comment\nworld\n   # indented comment\n\n";
140        let out = strip_comments_and_trim(input);
141        assert_eq!(out, "hello\nworld");
142    }
143
144    #[test]
145    fn strip_comments_all_comment_yields_empty() {
146        let out = strip_comments_and_trim("# foo\n# bar\n");
147        assert!(out.is_empty());
148    }
149
150    #[test]
151    fn strip_comments_trims_trailing_crlf() {
152        let out = strip_comments_and_trim("hello\r\n# drop\r\n\r\n");
153        assert_eq!(out, "hello");
154    }
155
156    #[test]
157    fn pick_editor_prefers_git_editor_over_editor() {
158        let got = pick_editor_with(|name| match name {
159            "GIT_EDITOR" => Some("from-git".to_string()),
160            "EDITOR" => Some("from-editor".to_string()),
161            _ => None,
162        });
163        assert_eq!(got, "from-git");
164    }
165
166    #[test]
167    fn pick_editor_prefers_editor_over_visual() {
168        let got = pick_editor_with(|name| match name {
169            "EDITOR" => Some("from-editor".to_string()),
170            "VISUAL" => Some("from-visual".to_string()),
171            _ => None,
172        });
173        assert_eq!(got, "from-editor");
174    }
175
176    #[test]
177    fn pick_editor_skips_empty_strings() {
178        let got = pick_editor_with(|name| match name {
179            "GIT_EDITOR" => Some(String::new()),
180            "EDITOR" => Some("   ".to_string()),
181            "VISUAL" => Some("nano".to_string()),
182            _ => None,
183        });
184        assert_eq!(got, "nano");
185    }
186
187    #[test]
188    fn pick_editor_falls_back_to_platform_default() {
189        let got = pick_editor_with(|_| None);
190        assert_eq!(got, "vi");
191    }
192}