Skip to main content

standout_input/sources/
editor.rs

1use std::fs;
2use std::io::{self, Write};
3use std::ops::ControlFlow;
4use std::path::Path;
5use std::process::Command;
6use std::sync::Arc;
7use std::time::SystemTime;
8
9use clap::ArgMatches;
10
11use crate::collector::InputCollector;
12use crate::responder::PromptResponder;
13use crate::InputError;
14use crate::InputSources;
15
16pub trait EditorRunner: Send + Sync {
17    fn detect_editor(&self) -> Option<String>;
18
19    fn run(&self, editor: &str, path: &Path) -> io::Result<()>;
20}
21
22#[derive(Debug, Default, Clone, Copy)]
23pub struct RealEditorRunner;
24
25impl EditorRunner for RealEditorRunner {
26    fn detect_editor(&self) -> Option<String> {
27        if let Ok(editor) = std::env::var("VISUAL") {
28            if !editor.is_empty() && editor_exists(&editor) {
29                return Some(editor);
30            }
31        }
32
33        if let Ok(editor) = std::env::var("EDITOR") {
34            if !editor.is_empty() && editor_exists(&editor) {
35                return Some(editor);
36            }
37        }
38
39        #[cfg(unix)]
40        {
41            for fallback in ["vim", "vi", "nano"] {
42                if editor_exists(fallback) {
43                    return Some(fallback.to_string());
44                }
45            }
46        }
47
48        #[cfg(windows)]
49        {
50            if editor_exists("notepad") {
51                return Some("notepad".to_string());
52            }
53        }
54
55        None
56    }
57
58    fn run(&self, editor: &str, path: &Path) -> io::Result<()> {
59        let parts = shell_words::split(editor).map_err(|e| {
60            io::Error::other(format!(
61                "Failed to parse editor command '{}': {}",
62                editor, e
63            ))
64        })?;
65
66        if parts.is_empty() {
67            return Err(io::Error::other("Editor command is empty"));
68        }
69
70        let (cmd, args) = parts.split_first().unwrap();
71        let status = Command::new(cmd).args(args).arg(path).status()?;
72
73        if status.success() {
74            Ok(())
75        } else {
76            Err(io::Error::other(format!(
77                "Editor exited with status: {}",
78                status
79            )))
80        }
81    }
82}
83
84fn editor_exists(editor: &str) -> bool {
85    let cmd = editor.split_whitespace().next().unwrap_or(editor);
86    which::which(cmd).is_ok()
87}
88
89#[derive(Clone)]
90pub struct EditorSource<R: EditorRunner = RealEditorRunner> {
91    runner: Arc<R>,
92    initial_content: Option<String>,
93    extension: String,
94    require_save: bool,
95    trim: bool,
96    responder: Option<Arc<dyn PromptResponder>>,
97}
98
99impl EditorSource<RealEditorRunner> {
100    pub fn new() -> Self {
101        Self {
102            runner: Arc::new(RealEditorRunner),
103            initial_content: None,
104            extension: ".txt".to_string(),
105            require_save: false,
106            trim: true,
107            responder: None,
108        }
109    }
110}
111
112impl Default for EditorSource<RealEditorRunner> {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl<R: EditorRunner> EditorSource<R> {
119    pub fn with_runner(runner: R) -> Self {
120        Self {
121            runner: Arc::new(runner),
122            initial_content: None,
123            extension: ".txt".to_string(),
124            require_save: false,
125            trim: true,
126            responder: None,
127        }
128    }
129
130    pub fn initial_content(mut self, content: impl Into<String>) -> Self {
131        self.initial_content = Some(content.into());
132        self
133    }
134
135    pub fn extension(mut self, ext: impl Into<String>) -> Self {
136        self.extension = ext.into();
137        self
138    }
139
140    pub fn require_save(mut self, require: bool) -> Self {
141        self.require_save = require;
142        self
143    }
144
145    pub fn trim(mut self, trim: bool) -> Self {
146        self.trim = trim;
147        self
148    }
149}
150
151impl<R: EditorRunner + 'static> EditorSource<R> {
152    pub fn prompt(&self) -> Result<String, InputError> {
153        self.prompt_from(&InputSources::from_process())
154    }
155
156    pub fn prompt_from(&self, sources: &InputSources) -> Result<String, InputError> {
157        crate::collector::prompt_value_from(self, sources)
158    }
159}
160
161impl<R: EditorRunner + 'static> InputCollector<String> for EditorSource<R> {
162    fn name(&self) -> &'static str {
163        "editor"
164    }
165
166    fn is_available(&self, _matches: &ArgMatches) -> bool {
167        self.responder.is_some()
168            || (self.runner.detect_editor().is_some() && std::io::stdin().is_terminal())
169    }
170
171    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
172        if let ControlFlow::Break(value) =
173            crate::responder::collect_intercept(crate::responder::intercept_text(
174                crate::PromptKind::Editor,
175                &self.extension,
176                self.responder.as_deref(),
177            ))?
178        {
179            return Ok(value);
180        }
181
182        let editor = self.runner.detect_editor().ok_or(InputError::NoEditor)?;
183
184        let mut builder = tempfile::Builder::new();
185        builder.suffix(&self.extension);
186        let temp_file = builder.tempfile().map_err(InputError::EditorFailed)?;
187
188        let path = temp_file.path();
189
190        if let Some(content) = &self.initial_content {
191            fs::write(path, content).map_err(InputError::EditorFailed)?;
192        }
193
194        let initial_mtime = if self.require_save {
195            get_mtime(path).ok()
196        } else {
197            None
198        };
199
200        self.runner
201            .run(&editor, path)
202            .map_err(InputError::EditorFailed)?;
203
204        if let Some(initial) = initial_mtime {
205            if let Ok(final_mtime) = get_mtime(path) {
206                if initial == final_mtime {
207                    return Err(InputError::EditorCancelled);
208                }
209            }
210        }
211
212        let content = fs::read_to_string(path).map_err(InputError::EditorFailed)?;
213
214        let result = if self.trim {
215            content.trim().to_string()
216        } else {
217            content
218        };
219
220        if result.is_empty() {
221            Ok(None)
222        } else {
223            Ok(Some(result))
224        }
225    }
226
227    fn bind_sources(&self, sources: &InputSources) -> Option<Box<dyn InputCollector<String>>> {
228        Some(Box::new(Self {
229            runner: Arc::clone(&self.runner),
230            initial_content: self.initial_content.clone(),
231            extension: self.extension.clone(),
232            require_save: self.require_save,
233            trim: self.trim,
234            responder: Some(sources.responder_arc()?),
235        }))
236    }
237
238    fn can_retry(&self) -> bool {
239        true
240    }
241}
242
243fn get_mtime(path: &Path) -> io::Result<SystemTime> {
244    fs::metadata(path)?.modified()
245}
246
247use std::io::IsTerminal;
248
249#[derive(Debug, Clone)]
250pub struct MockEditorRunner {
251    editor: Option<String>,
252    result: MockEditorResult,
253}
254
255#[derive(Debug, Clone)]
256pub enum MockEditorResult {
257    Success(String),
258    Failure(String),
259    NoSave,
260}
261
262impl MockEditorRunner {
263    pub fn no_editor() -> Self {
264        Self {
265            editor: None,
266            result: MockEditorResult::Failure("no editor".to_string()),
267        }
268    }
269
270    pub fn with_result(content: impl Into<String>) -> Self {
271        Self {
272            editor: Some("mock-editor".to_string()),
273            result: MockEditorResult::Success(content.into()),
274        }
275    }
276
277    pub fn failure(message: impl Into<String>) -> Self {
278        Self {
279            editor: Some("mock-editor".to_string()),
280            result: MockEditorResult::Failure(message.into()),
281        }
282    }
283
284    pub fn no_save() -> Self {
285        Self {
286            editor: Some("mock-editor".to_string()),
287            result: MockEditorResult::NoSave,
288        }
289    }
290}
291
292impl EditorRunner for MockEditorRunner {
293    fn detect_editor(&self) -> Option<String> {
294        self.editor.clone()
295    }
296
297    fn run(&self, _editor: &str, path: &Path) -> io::Result<()> {
298        match &self.result {
299            MockEditorResult::Success(content) => {
300                let mut file = fs::OpenOptions::new()
301                    .write(true)
302                    .truncate(true)
303                    .open(path)?;
304                file.write_all(content.as_bytes())?;
305                Ok(())
306            }
307            MockEditorResult::Failure(msg) => Err(io::Error::other(msg.clone())),
308            MockEditorResult::NoSave => Ok(()),
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use clap::Command;
317
318    fn empty_matches() -> ArgMatches {
319        Command::new("test").try_get_matches_from(["test"]).unwrap()
320    }
321
322    #[test]
323    fn editor_unavailable_when_no_editor() {
324        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
325        assert!(!source.is_available(&empty_matches()));
326    }
327
328    #[test]
329    fn editor_collects_input() {
330        let source = EditorSource::with_runner(MockEditorRunner::with_result("hello from editor"));
331        let result = source.collect(&empty_matches()).unwrap();
332        assert_eq!(result, Some("hello from editor".to_string()));
333    }
334
335    #[test]
336    fn editor_trims_whitespace() {
337        let source = EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n\n"));
338        let result = source.collect(&empty_matches()).unwrap();
339        assert_eq!(result, Some("hello".to_string()));
340    }
341
342    #[test]
343    fn editor_no_trim() {
344        let source =
345            EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n")).trim(false);
346        let result = source.collect(&empty_matches()).unwrap();
347        assert_eq!(result, Some("  hello  \n".to_string()));
348    }
349
350    #[test]
351    fn editor_returns_none_for_empty() {
352        let source = EditorSource::with_runner(MockEditorRunner::with_result(""));
353        let result = source.collect(&empty_matches()).unwrap();
354        assert_eq!(result, None);
355    }
356
357    #[test]
358    fn editor_returns_none_for_whitespace_only() {
359        let source = EditorSource::with_runner(MockEditorRunner::with_result("   \n\t  "));
360        let result = source.collect(&empty_matches()).unwrap();
361        assert_eq!(result, None);
362    }
363
364    #[test]
365    fn editor_handles_failure() {
366        let source = EditorSource::with_runner(MockEditorRunner::failure("editor crashed"));
367        let result = source.collect(&empty_matches());
368        assert!(matches!(result, Err(InputError::EditorFailed(_))));
369    }
370
371    #[test]
372    fn editor_with_initial_content() {
373        let source = EditorSource::with_runner(MockEditorRunner::with_result("user input"))
374            .initial_content("# Template\n\n");
375        let result = source.collect(&empty_matches()).unwrap();
376        assert_eq!(result, Some("user input".to_string()));
377    }
378
379    #[test]
380    fn editor_can_retry() {
381        let source = EditorSource::with_runner(MockEditorRunner::with_result("test"));
382        assert!(source.can_retry());
383    }
384
385    #[test]
386    fn editor_no_editor_error() {
387        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
388        let result = source.collect(&empty_matches());
389        assert!(matches!(result, Err(InputError::NoEditor)));
390    }
391
392    use crate::{InputSources, PromptResponse, ScriptedResponder};
393    use std::sync::Arc;
394
395    fn sources_with(responder: ScriptedResponder) -> InputSources {
396        InputSources::from_process().with_responder(Arc::new(responder))
397    }
398
399    #[test]
400    fn editor_prompt_shortcut_returns_no_input_in_non_tty() {
401        let source = EditorSource::with_runner(MockEditorRunner::with_result("hello"));
402        let err = source.prompt().unwrap_err();
403        assert!(matches!(err, InputError::NoInput));
404    }
405
406    #[test]
407    fn editor_prompt_shortcut_no_input_when_no_editor_detected() {
408        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
409        let err = source.prompt().unwrap_err();
410        assert!(matches!(err, InputError::NoInput));
411    }
412
413    #[test]
414    fn editor_prompt_routes_through_responder_without_launching_editor() {
415        let sources = sources_with(ScriptedResponder::new([PromptResponse::text(
416            "edited body",
417        )]));
418        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
419        let value = source.prompt_from(&sources).unwrap();
420        assert_eq!(value, "edited body");
421    }
422
423    #[test]
424    fn editor_prompt_responder_cancel_propagates() {
425        let sources = sources_with(ScriptedResponder::new([PromptResponse::Cancel]));
426        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
427        let err = source.prompt_from(&sources).unwrap_err();
428        assert!(matches!(err, InputError::PromptCancelled));
429    }
430}