1use std::fs;
12use std::io::{self, Read, Write};
13use std::path::PathBuf;
14use std::process::Command;
15
16pub const MAX_COMMIT_MSG_BYTES: u64 = 1024 * 1024;
18
19pub 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 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 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 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
90fn pick_editor() -> String {
93 pick_editor_with(|name| std::env::var(name).ok())
94}
95
96fn 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#[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
128pub 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}