use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::Command;
pub const MAX_COMMIT_MSG_BYTES: u64 = 1024 * 1024;
pub fn spawn_editor(template: &str) -> io::Result<String> {
let editor = pick_editor();
if editor.is_empty() {
return Err(io::Error::other(
"no editor configured; set $EDITOR or pass -m <msg>",
));
}
let tmp = tempfile::NamedTempFile::with_suffix(".mkit-commit.txt")?;
{
let mut f = fs::OpenOptions::new().write(true).open(tmp.path())?;
f.write_all(template.as_bytes())?;
f.sync_all()?;
}
let mut parts = editor.split_whitespace();
let program = parts.next().unwrap_or("");
let extra_args: Vec<&str> = parts.collect();
let path_arg: PathBuf = tmp.path().to_path_buf();
let status = Command::new(program)
.args(&extra_args)
.arg(&path_arg)
.status()?;
if !status.success() {
return Err(io::Error::other(format!(
"editor exited with status {status:?}"
)));
}
let f = fs::File::open(tmp.path())?;
let mut buf = Vec::new();
f.take(MAX_COMMIT_MSG_BYTES + 1)
.read_to_end(&mut buf)
.map_err(io::Error::other)?;
if buf.len() as u64 > MAX_COMMIT_MSG_BYTES {
return Err(io::Error::other("commit message file too large (>1 MiB)"));
}
let raw = String::from_utf8_lossy(&buf).into_owned();
Ok(strip_comments_and_trim(&raw))
}
fn pick_editor() -> String {
pick_editor_with(|name| std::env::var(name).ok())
}
fn pick_editor_with(resolver: impl Fn(&str) -> Option<String>) -> String {
for var in ["GIT_EDITOR", "EDITOR", "VISUAL"] {
if let Some(v) = resolver(var)
&& !v.trim().is_empty()
{
return v;
}
}
if cfg!(windows) {
"notepad".to_string()
} else {
"vi".to_string()
}
}
#[must_use]
pub fn strip_comments_and_trim(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for line in input.split('\n') {
let first_nws = line.trim_start();
if first_nws.starts_with('#') {
continue;
}
out.push_str(line);
out.push('\n');
}
out.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\r' || c == '\n')
.to_string()
}
pub const COMMIT_EDITMSG_TEMPLATE: &str = "\n\
# Please enter the commit message for your changes. Lines starting\n\
# with '#' will be ignored, and an empty message aborts the commit.\n";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_comments_drops_hash_lines() {
let input = "\nhello\n# a comment\nworld\n # indented comment\n\n";
let out = strip_comments_and_trim(input);
assert_eq!(out, "hello\nworld");
}
#[test]
fn strip_comments_all_comment_yields_empty() {
let out = strip_comments_and_trim("# foo\n# bar\n");
assert!(out.is_empty());
}
#[test]
fn strip_comments_trims_trailing_crlf() {
let out = strip_comments_and_trim("hello\r\n# drop\r\n\r\n");
assert_eq!(out, "hello");
}
#[test]
fn pick_editor_prefers_git_editor_over_editor() {
let got = pick_editor_with(|name| match name {
"GIT_EDITOR" => Some("from-git".to_string()),
"EDITOR" => Some("from-editor".to_string()),
_ => None,
});
assert_eq!(got, "from-git");
}
#[test]
fn pick_editor_prefers_editor_over_visual() {
let got = pick_editor_with(|name| match name {
"EDITOR" => Some("from-editor".to_string()),
"VISUAL" => Some("from-visual".to_string()),
_ => None,
});
assert_eq!(got, "from-editor");
}
#[test]
fn pick_editor_skips_empty_strings() {
let got = pick_editor_with(|name| match name {
"GIT_EDITOR" => Some(String::new()),
"EDITOR" => Some(" ".to_string()),
"VISUAL" => Some("nano".to_string()),
_ => None,
});
assert_eq!(got, "nano");
}
#[test]
fn pick_editor_falls_back_to_platform_default() {
let got = pick_editor_with(|_| None);
if cfg!(windows) {
assert_eq!(got, "notepad");
} else {
assert_eq!(got, "vi");
}
}
}