1use std::ffi::OsString;
2use std::io::IsTerminal;
3use std::io::Write;
4use std::os::fd::{AsRawFd, FromRawFd};
5use std::path::{Path, PathBuf};
6use std::process;
7use std::{env, fs, io};
8
9pub const COMMENT_FILE: &str = "RAD_COMMENT";
10pub const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
12
13pub struct Editor {
15 path: PathBuf,
16 truncate: bool,
17 cleanup: bool,
18}
19
20impl Default for Editor {
21 fn default() -> Self {
22 Self::comment()
23 }
24}
25
26impl Drop for Editor {
27 fn drop(&mut self) {
28 if self.cleanup {
29 fs::remove_file(&self.path).ok();
30 }
31 }
32}
33
34impl Editor {
35 pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
37 let path = path.as_ref();
38 if path.try_exists()? {
39 let meta = fs::metadata(path)?;
40 if !meta.is_file() {
41 return Err(io::Error::new(
42 io::ErrorKind::InvalidInput,
43 "must be used to edit a file",
44 ));
45 }
46 }
47 Ok(Self {
48 path: path.to_path_buf(),
49 truncate: false,
50 cleanup: false,
51 })
52 }
53
54 pub fn comment() -> Self {
55 let path = env::temp_dir().join(COMMENT_FILE);
56
57 Self {
58 path,
59 truncate: true,
60 cleanup: true,
61 }
62 }
63
64 pub fn extension(mut self, ext: &str) -> Self {
66 let ext = ext.trim_start_matches('.');
67
68 self.path.set_extension(ext);
69 self
70 }
71
72 pub fn truncate(mut self, truncate: bool) -> Self {
74 self.truncate = truncate;
75 self
76 }
77
78 pub fn cleanup(mut self, cleanup: bool) -> Self {
80 self.cleanup = cleanup;
81 self
82 }
83
84 pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
87 let content = content.as_ref();
88 let mut file = fs::OpenOptions::new()
89 .write(true)
90 .create(true)
91 .truncate(self.truncate)
92 .open(&self.path)?;
93
94 if file.metadata()?.len() == 0 {
95 file.write_all(content)?;
96 if !content.ends_with(&[b'\n']) {
97 file.write_all(b"\n")?;
98 }
99 file.flush()?;
100 }
101 Ok(self)
102 }
103
104 pub fn edit(&mut self) -> io::Result<Option<String>> {
109 let Some(cmd) = self::default_editor() else {
110 return Err(io::Error::new(
111 io::ErrorKind::NotFound,
112 "editor not configured: the `EDITOR` environment variable is not set",
113 ));
114 };
115 let Some(parts) = shlex::split(cmd.to_string_lossy().as_ref()) else {
116 return Err(io::Error::new(
117 io::ErrorKind::InvalidInput,
118 format!("invalid editor command {cmd:?}"),
119 ));
120 };
121 let Some((program, args)) = parts.split_first() else {
122 return Err(io::Error::new(
123 io::ErrorKind::InvalidInput,
124 format!("invalid editor command {cmd:?}"),
125 ));
126 };
127
128 let stderr = io::stderr().as_raw_fd();
132 let stderr = unsafe { libc::dup(stderr) };
133 let stdin = if io::stdin().is_terminal() {
134 process::Stdio::inherit()
135 } else {
136 let tty = termion::get_tty()?;
137 process::Stdio::from(tty)
140 };
141
142 process::Command::new(program)
143 .stdout(unsafe { process::Stdio::from_raw_fd(stderr) })
144 .stderr(process::Stdio::inherit())
145 .stdin(stdin)
146 .args(args)
147 .arg(&self.path)
148 .spawn()
149 .map_err(|e| {
150 io::Error::new(
151 e.kind(),
152 format!("failed to spawn editor command {cmd:?}: {e}"),
153 )
154 })?
155 .wait()
156 .map_err(|e| {
157 io::Error::new(
158 e.kind(),
159 format!("editor command {cmd:?} didn't spawn: {e}"),
160 )
161 })?;
162
163 let text = fs::read_to_string(&self.path)?;
164 if text.trim().is_empty() {
165 return Ok(None);
166 }
167 Ok(Some(text))
168 }
169}
170
171fn default_editor() -> Option<OsString> {
173 if let Ok(visual) = env::var("VISUAL") {
175 if !visual.is_empty() {
176 return Some(visual.into());
177 }
178 }
179 if let Ok(editor) = env::var("EDITOR") {
180 if !editor.is_empty() {
181 return Some(editor.into());
182 }
183 }
184 #[cfg(feature = "git2")]
186 if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
187 return Some(path.into_os_string());
188 }
189 if cfg!(target_os = "macos") && exists("nano") {
192 return Some("nano".into());
193 }
194 if exists("vi") {
196 return Some("vi".into());
197 }
198 None
199}
200
201fn exists(cmd: &str) -> bool {
205 for dir in PATHS {
206 if Path::new(dir).join(cmd).exists() {
207 return true;
208 }
209 }
210 false
211}