1use std::ffi::OsString;
2use std::io::IsTerminal;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process;
6use std::{env, fs, io};
7
8pub struct Editor {
10 path: PathBuf,
11 truncate: bool,
12 cleanup: bool,
13}
14
15impl Default for Editor {
16 fn default() -> Self {
17 Self::comment()
18 }
19}
20
21impl Drop for Editor {
22 fn drop(&mut self) {
23 if self.cleanup {
24 fs::remove_file(&self.path).ok();
25 }
26 }
27}
28
29impl Editor {
30 pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
32 let path = path.as_ref();
33 if path.try_exists()? {
34 let meta = fs::metadata(path)?;
35 if !meta.is_file() {
36 return Err(io::Error::new(
37 io::ErrorKind::InvalidInput,
38 "must be used to edit a file",
39 ));
40 }
41 }
42 Ok(Self {
43 path: path.to_path_buf(),
44 truncate: false,
45 cleanup: false,
46 })
47 }
48
49 pub fn comment() -> Self {
50 const COMMENT_FILE: &str = "RAD_COMMENT";
51
52 let path = env::temp_dir().join(COMMENT_FILE);
53
54 Self {
55 path,
56 truncate: true,
57 cleanup: true,
58 }
59 }
60
61 pub fn extension(mut self, ext: &str) -> Self {
63 let ext = ext.trim_start_matches('.');
64
65 self.path.set_extension(ext);
66 self
67 }
68
69 pub fn truncate(mut self, truncate: bool) -> Self {
71 self.truncate = truncate;
72 self
73 }
74
75 pub fn cleanup(mut self, cleanup: bool) -> Self {
77 self.cleanup = cleanup;
78 self
79 }
80
81 #[allow(clippy::byte_char_slices)]
84 pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
85 let content = content.as_ref();
86 let mut file = fs::OpenOptions::new()
87 .write(true)
88 .create(true)
89 .truncate(self.truncate)
90 .open(&self.path)?;
91
92 if file.metadata()?.len() == 0 {
93 file.write_all(content)?;
94 if !content.ends_with(&[b'\n']) {
95 file.write_all(b"\n")?;
96 }
97 file.flush()?;
98 }
99 Ok(self)
100 }
101
102 pub fn edit(&mut self) -> io::Result<Option<String>> {
107 let Some(cmd) = self::default_editor() else {
108 return Err(io::Error::new(
109 io::ErrorKind::NotFound,
110 "editor not configured: the `EDITOR` environment variable is not set",
111 ));
112 };
113
114 let lossy = cmd.to_string_lossy();
115
116 #[cfg(unix)]
117 let Some(parts) = shlex::split(&lossy) else {
118 return Err(io::Error::new(
119 io::ErrorKind::InvalidInput,
120 format!("invalid editor command {cmd:?}"),
121 ));
122 };
123
124 #[cfg(windows)]
125 let parts = winsplit::split(&lossy);
126
127 let Some((program, args)) = parts.split_first() else {
128 return Err(io::Error::new(
129 io::ErrorKind::InvalidInput,
130 format!("invalid editor command {cmd:?}"),
131 ));
132 };
133
134 let stdout: process::Stdio = {
135 #[cfg(unix)]
136 {
137 use std::os::fd::{AsRawFd as _, FromRawFd as _};
138
139 let stderr = io::stderr().as_raw_fd();
144 unsafe { process::Stdio::from_raw_fd(libc::dup(stderr)) }
145 }
146 #[cfg(not(unix))]
147 {
148 io::stderr().into()
152 }
153 };
154
155 let stdin = if io::stdin().is_terminal() {
156 process::Stdio::inherit()
157 } else if cfg!(unix) {
158 let tty = fs::OpenOptions::new()
161 .read(true)
162 .write(true)
163 .open("/dev/tty")?;
164 process::Stdio::from(tty)
165 } else if cfg!(windows) {
166 let tty = fs::OpenOptions::new().read(true).open("CONIN$")?;
167 process::Stdio::from(tty)
168 } else {
169 return Err(io::Error::new(
170 io::ErrorKind::Unsupported,
171 format!("standard input is not a terminal, refusing to execute editor {cmd:?}"),
172 ));
173 };
174
175 process::Command::new(program)
176 .stdout(stdout)
177 .stderr(process::Stdio::inherit())
178 .stdin(stdin)
179 .args(args)
180 .arg(&self.path)
181 .spawn()
182 .map_err(|e| {
183 io::Error::new(
184 e.kind(),
185 format!("failed to spawn editor command {cmd:?}: {e}"),
186 )
187 })?
188 .wait()
189 .map_err(|e| {
190 io::Error::new(
191 e.kind(),
192 format!("editor command {cmd:?} didn't spawn: {e}"),
193 )
194 })?;
195
196 let text = fs::read_to_string(&self.path)?;
197 if text.trim().is_empty() {
198 return Ok(None);
199 }
200 Ok(Some(text))
201 }
202}
203
204fn default_editor() -> Option<OsString> {
206 if let Ok(visual) = env::var("VISUAL") {
208 if !visual.is_empty() {
209 return Some(visual.into());
210 }
211 }
212 if let Ok(editor) = env::var("EDITOR") {
213 if !editor.is_empty() {
214 return Some(editor.into());
215 }
216 }
217
218 #[cfg(all(feature = "git2", not(windows)))]
223 if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
224 return Some(path.into_os_string());
225 }
226
227 #[cfg(target_os = "macos")]
230 if exists("nano") {
231 return Some("nano".into());
232 }
233
234 #[cfg(windows)]
236 if exists("edit.exe") {
237 return Some("edit.exe".into());
238 }
239
240 #[cfg(windows)]
242 if exists("notepad.exe") {
243 return Some("notepad.exe".into());
244 }
245
246 if exists("vi") {
248 return Some("vi".into());
249 }
250
251 None
252}
253
254#[cfg(unix)]
258fn exists(cmd: &str) -> bool {
259 const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
261
262 for dir in PATHS {
263 if Path::new(dir).join(cmd).exists() {
264 return true;
265 }
266 }
267 false
268}
269
270#[cfg(windows)]
275fn exists(cmd: &str) -> bool {
276 std::process::Command::new("where.exe")
277 .arg("/q")
278 .arg("$PATH:".to_owned() + cmd)
279 .output()
280 .map(|output| output.status.success())
281 .unwrap_or_default()
282}