1use std::future::Future;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::process::Stdio;
8
9use thiserror::Error;
10use tokio::process::Command;
11use tokio_util::sync::CancellationToken;
12use uuid::Uuid;
13
14use super::command::CommandSpec;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub enum EditOutcome {
19 Unchanged,
21 Changed(String),
23 Aborted,
25}
26
27#[derive(Debug, Error)]
29pub enum EditorError {
30 #[error("external editor command is empty")]
32 EmptyCommand,
33 #[error("external editor temporary file {path}: {source}")]
35 TemporaryFile {
36 path: PathBuf,
38 #[source]
40 source: io::Error,
41 },
42 #[error("external editor process failed: {0}")]
44 Process(#[from] io::Error),
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum EditorExit {
50 Code(i32),
52 Aborted,
54}
55
56pub trait EditorRunner: Send {
58 fn run<'a>(
60 &'a mut self,
61 command: &'a CommandSpec,
62 cancel: &'a CancellationToken,
63 ) -> Pin<Box<dyn Future<Output = io::Result<EditorExit>> + Send + 'a>>;
64}
65
66#[derive(Default)]
68pub struct TokioEditorRunner;
69
70impl EditorRunner for TokioEditorRunner {
71 fn run<'a>(
72 &'a mut self,
73 command: &'a CommandSpec,
74 cancel: &'a CancellationToken,
75 ) -> Pin<Box<dyn Future<Output = io::Result<EditorExit>> + Send + 'a>> {
76 Box::pin(async move {
77 let mut child = Command::new(&command.program)
78 .args(&command.args)
79 .stdin(Stdio::inherit())
80 .stdout(Stdio::inherit())
81 .stderr(Stdio::inherit())
82 .kill_on_drop(true)
83 .spawn()?;
84 tokio::select! {
85 status = child.wait() => {
86 let status = status?;
87 Ok(EditorExit::Code(status.code().unwrap_or(-1)))
88 }
89 () = cancel.cancelled() => {
90 let _ = child.kill().await;
91 let _ = child.wait().await;
92 Ok(EditorExit::Aborted)
93 }
94 }
95 })
96 }
97}
98
99pub fn external_editor_command(
108 editor_command: &str,
109 temporary_path: &Path,
110) -> Result<CommandSpec, EditorError> {
111 let mut words = editor_command.split_whitespace();
112 let program = words.next().ok_or(EditorError::EmptyCommand)?;
113 let mut args: Vec<String> = words.map(str::to_owned).collect();
114 args.push(temporary_path.to_string_lossy().into_owned());
115 Ok(CommandSpec::new(program, args))
116}
117
118pub async fn edit_text_in_external_editor(
125 editor_command: &str,
126 initial: &str,
127 cancel: &CancellationToken,
128) -> Result<EditOutcome, EditorError> {
129 let mut runner = TokioEditorRunner;
130 edit_text_in_external_editor_with(
131 editor_command,
132 initial,
133 cancel,
134 &std::env::temp_dir(),
135 &Uuid::new_v4().to_string(),
136 &mut runner,
137 )
138 .await
139}
140
141pub async fn edit_text_in_external_editor_with(
150 editor_command: &str,
151 initial: &str,
152 cancel: &CancellationToken,
153 temp_dir: &Path,
154 unique_id: &str,
155 runner: &mut dyn EditorRunner,
156) -> Result<EditOutcome, EditorError> {
157 let temp_path = temp_dir.join(format!("pi-editor-{unique_id}.pi.md"));
158 std::fs::create_dir_all(temp_dir).map_err(|source| EditorError::TemporaryFile {
159 path: temp_dir.to_path_buf(),
160 source,
161 })?;
162 std::fs::write(&temp_path, initial).map_err(|source| EditorError::TemporaryFile {
163 path: temp_path.clone(),
164 source,
165 })?;
166 let cleanup = TemporaryFileGuard(temp_path.clone());
167 let command = external_editor_command(editor_command, &temp_path)?;
168 let exit = runner.run(&command, cancel).await?;
169 if exit == EditorExit::Aborted {
170 return Ok(EditOutcome::Aborted);
171 }
172 if exit != EditorExit::Code(0) {
173 return Ok(EditOutcome::Unchanged);
174 }
175 let edited =
176 std::fs::read_to_string(&temp_path).map_err(|source| EditorError::TemporaryFile {
177 path: temp_path,
178 source,
179 })?;
180 let edited = edited.strip_suffix('\n').unwrap_or(&edited).to_owned();
181 let outcome = if edited == initial {
182 EditOutcome::Unchanged
183 } else {
184 EditOutcome::Changed(edited)
185 };
186 drop(cleanup);
187 Ok(outcome)
188}
189
190struct TemporaryFileGuard(PathBuf);
191
192impl Drop for TemporaryFileGuard {
193 fn drop(&mut self) {
194 drop(std::fs::remove_file(&self.0));
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 type TestResult = Result<(), Box<dyn std::error::Error>>;
203
204 struct FakeRunner {
205 exit: EditorExit,
206 replacement: Option<String>,
207 seen: Vec<CommandSpec>,
208 }
209
210 impl EditorRunner for FakeRunner {
211 fn run<'a>(
212 &'a mut self,
213 command: &'a CommandSpec,
214 _cancel: &'a CancellationToken,
215 ) -> Pin<Box<dyn Future<Output = io::Result<EditorExit>> + Send + 'a>> {
216 self.seen.push(command.clone());
217 let replacement = self.replacement.clone();
218 let path = command.args.last().map(PathBuf::from);
219 let exit = self.exit;
220 Box::pin(async move {
221 if let (Some(text), Some(path)) = (replacement, path) {
222 std::fs::write(path, text)?;
223 }
224 Ok(exit)
225 })
226 }
227 }
228
229 #[test]
230 fn argv_is_cross_platform_and_shell_free() -> TestResult {
231 let path = Path::new("/tmp/message with spaces.md");
232 let command = external_editor_command("code --wait", path)?;
233 assert_eq!(command.program, "code");
234 assert_eq!(command.args, vec!["--wait", "/tmp/message with spaces.md"]);
235 Ok(())
236 }
237
238 #[tokio::test]
239 async fn changed_unchanged_abort_and_cleanup() -> TestResult {
240 let dir = tempfile::tempdir()?;
241 let cancel = CancellationToken::new();
242 let mut changed = FakeRunner {
243 exit: EditorExit::Code(0),
244 replacement: Some("after\n".to_owned()),
245 seen: Vec::new(),
246 };
247 let result = edit_text_in_external_editor_with(
248 "editor",
249 "before",
250 &cancel,
251 dir.path(),
252 "changed",
253 &mut changed,
254 )
255 .await?;
256 assert_eq!(result, EditOutcome::Changed("after".to_owned()));
257 assert!(!dir.path().join("pi-editor-changed.pi.md").exists());
258
259 let mut unchanged = FakeRunner {
260 exit: EditorExit::Code(3),
261 replacement: Some("ignored".to_owned()),
262 seen: Vec::new(),
263 };
264 let result = edit_text_in_external_editor_with(
265 "editor",
266 "before",
267 &cancel,
268 dir.path(),
269 "unchanged",
270 &mut unchanged,
271 )
272 .await?;
273 assert_eq!(result, EditOutcome::Unchanged);
274 assert!(!dir.path().join("pi-editor-unchanged.pi.md").exists());
275
276 let mut aborted = FakeRunner {
277 exit: EditorExit::Aborted,
278 replacement: None,
279 seen: Vec::new(),
280 };
281 let result = edit_text_in_external_editor_with(
282 "editor",
283 "before",
284 &cancel,
285 dir.path(),
286 "aborted",
287 &mut aborted,
288 )
289 .await?;
290 assert_eq!(result, EditOutcome::Aborted);
291 assert!(!dir.path().join("pi-editor-aborted.pi.md").exists());
292 Ok(())
293 }
294}