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` on Unix / `notepad` on Windows when no env var
6//!   is set, so `mkit commit` works 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` on Unix, `notepad` on Windows)
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` / `notepad`).
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    if cfg!(windows) {
109        "notepad".to_string()
110    } else {
111        "vi".to_string()
112    }
113}
114
115/// Strip all lines whose first non-whitespace byte is `#`, then trim
116/// trailing whitespace.
117#[must_use]
118pub fn strip_comments_and_trim(input: &str) -> String {
119    let mut out = String::with_capacity(input.len());
120    for line in input.split('\n') {
121        let first_nws = line.trim_start();
122        if first_nws.starts_with('#') {
123            continue;
124        }
125        out.push_str(line);
126        out.push('\n');
127    }
128    out.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\r' || c == '\n')
129        .to_string()
130}
131
132/// Template rendered into the tempfile before spawning the editor.
133pub const COMMIT_EDITMSG_TEMPLATE: &str = "\n\
134# Please enter the commit message for your changes. Lines starting\n\
135# with '#' will be ignored, and an empty message aborts the commit.\n";
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn strip_comments_drops_hash_lines() {
143        let input = "\nhello\n# a comment\nworld\n   # indented comment\n\n";
144        let out = strip_comments_and_trim(input);
145        assert_eq!(out, "hello\nworld");
146    }
147
148    #[test]
149    fn strip_comments_all_comment_yields_empty() {
150        let out = strip_comments_and_trim("# foo\n# bar\n");
151        assert!(out.is_empty());
152    }
153
154    #[test]
155    fn strip_comments_trims_trailing_crlf() {
156        let out = strip_comments_and_trim("hello\r\n# drop\r\n\r\n");
157        assert_eq!(out, "hello");
158    }
159
160    #[test]
161    fn pick_editor_prefers_git_editor_over_editor() {
162        let got = pick_editor_with(|name| match name {
163            "GIT_EDITOR" => Some("from-git".to_string()),
164            "EDITOR" => Some("from-editor".to_string()),
165            _ => None,
166        });
167        assert_eq!(got, "from-git");
168    }
169
170    #[test]
171    fn pick_editor_prefers_editor_over_visual() {
172        let got = pick_editor_with(|name| match name {
173            "EDITOR" => Some("from-editor".to_string()),
174            "VISUAL" => Some("from-visual".to_string()),
175            _ => None,
176        });
177        assert_eq!(got, "from-editor");
178    }
179
180    #[test]
181    fn pick_editor_skips_empty_strings() {
182        let got = pick_editor_with(|name| match name {
183            "GIT_EDITOR" => Some(String::new()),
184            "EDITOR" => Some("   ".to_string()),
185            "VISUAL" => Some("nano".to_string()),
186            _ => None,
187        });
188        assert_eq!(got, "nano");
189    }
190
191    #[test]
192    fn pick_editor_falls_back_to_platform_default() {
193        let got = pick_editor_with(|_| None);
194        if cfg!(windows) {
195            assert_eq!(got, "notepad");
196        } else {
197            assert_eq!(got, "vi");
198        }
199    }
200}