Skip to main content

pi/core/platform/
external_editor.rs

1//! External editor lifecycle with cancellation and guaranteed temporary-file cleanup.
2
3use 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/// External editor completion.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub enum EditOutcome {
19    /// Editor exited successfully but the content did not change.
20    Unchanged,
21    /// Editor exited successfully and returned replacement content.
22    Changed(String),
23    /// Caller cancelled the editor; the child was terminated and reaped.
24    Aborted,
25}
26
27/// Editor launch or temporary-file failure.
28#[derive(Debug, Error)]
29pub enum EditorError {
30    /// Empty command has no executable.
31    #[error("external editor command is empty")]
32    EmptyCommand,
33    /// Temporary-file operation failed.
34    #[error("external editor temporary file {path}: {source}")]
35    TemporaryFile {
36        /// Affected path.
37        path: PathBuf,
38        /// Underlying filesystem failure.
39        #[source]
40        source: io::Error,
41    },
42    /// Process operation failed.
43    #[error("external editor process failed: {0}")]
44    Process(#[from] io::Error),
45}
46
47/// Exit reported by an injected editor runner.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum EditorExit {
50    /// Normal process exit with its code (`-1` when unavailable).
51    Code(i32),
52    /// Cancellation terminated the process.
53    Aborted,
54}
55
56/// Injectable asynchronous editor process boundary.
57pub trait EditorRunner: Send {
58    /// Run one editor command until exit or cancellation.
59    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/// Tokio-backed editor runner. Cancellation kills and reaps the child.
67#[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
99/// Convert a configured editor command and temporary path into typed argv.
100///
101/// This deliberately does not invoke a shell. Like the reference, whitespace
102/// separates the executable and fixed arguments (`code --wait`).
103///
104/// # Errors
105///
106/// Returns [`EditorError::EmptyCommand`] for blank input.
107pub 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
118/// Edit text using the host temporary directory and Tokio process runner.
119///
120/// # Errors
121///
122/// Returns temporary-file or process failures. The temporary file is removed
123/// on success, nonzero exit, cancellation, and every error path.
124pub 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
141/// Injectable external-editor implementation.
142///
143/// # Errors
144///
145/// Returns [`EditorError::TemporaryFile`] when the temporary directory or file
146/// cannot be created, written, or read, [`EditorError::EmptyCommand`] for a
147/// blank editor command, and [`EditorError::Process`] when the editor process
148/// cannot be run.
149pub 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}