Skip to main content

standout_input/sources/
editor.rs

1//! Editor-based input source.
2//!
3//! Opens the user's preferred text editor for multi-line input.
4
5use std::fs;
6use std::io::{self, Write};
7use std::path::Path;
8use std::process::Command;
9use std::sync::Arc;
10use std::time::SystemTime;
11
12use clap::ArgMatches;
13
14use crate::collector::InputCollector;
15use crate::InputError;
16
17/// Abstraction over editor invocation for testability.
18pub trait EditorRunner: Send + Sync {
19    /// Detect the editor to use.
20    ///
21    /// Returns `None` if no editor is available.
22    fn detect_editor(&self) -> Option<String>;
23
24    /// Run the editor on the given file path.
25    ///
26    /// Returns `Ok(())` if the editor exited successfully, `Err` otherwise.
27    fn run(&self, editor: &str, path: &Path) -> io::Result<()>;
28}
29
30/// Real editor runner using system commands.
31#[derive(Debug, Default, Clone, Copy)]
32pub struct RealEditorRunner;
33
34impl EditorRunner for RealEditorRunner {
35    fn detect_editor(&self) -> Option<String> {
36        // Try VISUAL first (supports GUI editors)
37        if let Ok(editor) = std::env::var("VISUAL") {
38            if !editor.is_empty() && editor_exists(&editor) {
39                return Some(editor);
40            }
41        }
42
43        // Fall back to EDITOR
44        if let Ok(editor) = std::env::var("EDITOR") {
45            if !editor.is_empty() && editor_exists(&editor) {
46                return Some(editor);
47            }
48        }
49
50        // Platform-specific fallbacks
51        #[cfg(unix)]
52        {
53            for fallback in ["vim", "vi", "nano"] {
54                if editor_exists(fallback) {
55                    return Some(fallback.to_string());
56                }
57            }
58        }
59
60        #[cfg(windows)]
61        {
62            if editor_exists("notepad") {
63                return Some("notepad".to_string());
64            }
65        }
66
67        None
68    }
69
70    fn run(&self, editor: &str, path: &Path) -> io::Result<()> {
71        // Parse the editor command to handle cases like "code --wait" or "vim -u NONE"
72        let parts = shell_words::split(editor).map_err(|e| {
73            io::Error::other(format!(
74                "Failed to parse editor command '{}': {}",
75                editor, e
76            ))
77        })?;
78
79        if parts.is_empty() {
80            return Err(io::Error::other("Editor command is empty"));
81        }
82
83        let (cmd, args) = parts.split_first().unwrap();
84        let status = Command::new(cmd).args(args).arg(path).status()?;
85
86        if status.success() {
87            Ok(())
88        } else {
89            Err(io::Error::other(format!(
90                "Editor exited with status: {}",
91                status
92            )))
93        }
94    }
95}
96
97/// Check if an editor command exists.
98fn editor_exists(editor: &str) -> bool {
99    // Extract the command name (first word) in case of "vim -u NONE" etc.
100    let cmd = editor.split_whitespace().next().unwrap_or(editor);
101    which::which(cmd).is_ok()
102}
103
104/// Collect input via an external text editor.
105///
106/// Opens the user's preferred editor (from `$VISUAL` or `$EDITOR`) with a
107/// temporary file, waits for the user to save and close, then reads the result.
108///
109/// # Editor Detection
110///
111/// Editors are detected in this order:
112/// 1. `$VISUAL` environment variable (supports GUI editors)
113/// 2. `$EDITOR` environment variable
114/// 3. Platform fallbacks: `vim`, `vi`, `nano` on Unix; `notepad` on Windows
115///
116/// # Example
117///
118/// ```ignore
119/// use standout_input::{InputChain, ArgSource, EditorSource};
120///
121/// // Fall back to editor if no CLI argument
122/// let chain = InputChain::<String>::new()
123///     .try_source(ArgSource::new("message"))
124///     .try_source(EditorSource::new());
125///
126/// let message = chain.resolve(&matches)?;
127/// ```
128///
129/// # Configuration
130///
131/// ```ignore
132/// let source = EditorSource::new()
133///     .initial_content("# Enter your message\n\n")
134///     .extension(".md")
135///     .require_save(true);
136/// ```
137#[derive(Clone)]
138pub struct EditorSource<R: EditorRunner = RealEditorRunner> {
139    runner: Arc<R>,
140    initial_content: Option<String>,
141    extension: String,
142    require_save: bool,
143    trim: bool,
144}
145
146impl EditorSource<RealEditorRunner> {
147    /// Create a new editor source using the system editor.
148    pub fn new() -> Self {
149        Self {
150            runner: Arc::new(RealEditorRunner),
151            initial_content: None,
152            extension: ".txt".to_string(),
153            require_save: false,
154            trim: true,
155        }
156    }
157}
158
159impl Default for EditorSource<RealEditorRunner> {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl<R: EditorRunner> EditorSource<R> {
166    /// Create an editor source with a custom runner.
167    ///
168    /// Primarily used for testing to mock editor invocation.
169    pub fn with_runner(runner: R) -> Self {
170        Self {
171            runner: Arc::new(runner),
172            initial_content: None,
173            extension: ".txt".to_string(),
174            require_save: false,
175            trim: true,
176        }
177    }
178
179    /// Set initial content to populate the editor with.
180    ///
181    /// This can be used to provide a template or instructions.
182    pub fn initial_content(mut self, content: impl Into<String>) -> Self {
183        self.initial_content = Some(content.into());
184        self
185    }
186
187    /// Set the file extension for the temporary file.
188    ///
189    /// This affects syntax highlighting in the editor.
190    /// Default is `.txt`.
191    pub fn extension(mut self, ext: impl Into<String>) -> Self {
192        self.extension = ext.into();
193        self
194    }
195
196    /// Require the user to actually save the file.
197    ///
198    /// If `true`, the source will return `None` if the file's modification
199    /// time hasn't changed (i.e., the user closed without saving).
200    /// Default is `false`.
201    pub fn require_save(mut self, require: bool) -> Self {
202        self.require_save = require;
203        self
204    }
205
206    /// Control whether to trim whitespace from the result.
207    ///
208    /// Default is `true`.
209    pub fn trim(mut self, trim: bool) -> Self {
210        self.trim = trim;
211        self
212    }
213}
214
215impl<R: EditorRunner + 'static> EditorSource<R> {
216    /// Open the editor and return the saved content.
217    ///
218    /// This is the standalone counterpart to [`InputCollector::collect`]:
219    /// it skips the chain machinery (no `&ArgMatches` to plumb through) and
220    /// is intended for wizard or REPL flows that drive standout themselves.
221    ///
222    /// Returns [`InputError::NoInput`] if stdin is not a TTY or no editor
223    /// can be detected (the same conditions under which a chain would skip
224    /// this source). User cancellation is reported as
225    /// [`InputError::EditorCancelled`] when `require_save` is set and the
226    /// user exits without saving.
227    ///
228    /// Routes through any installed
229    /// [`PromptResponder`](crate::PromptResponder), so wizard tests can
230    /// supply the editor's "saved" content directly without launching
231    /// `$EDITOR`.
232    pub fn prompt(&self) -> Result<String, InputError> {
233        if let Some(value) = crate::responder::intercept_text(
234            crate::PromptKind::Editor,
235            // EditorSource has no user-facing "message" — use the file
236            // extension as the diagnostic hint so panic messages still
237            // identify which editor source failed.
238            &self.extension,
239        )? {
240            return Ok(value);
241        }
242        let matches = crate::collector::empty_matches();
243        if !self.is_available(matches) {
244            return Err(InputError::NoInput);
245        }
246        self.collect(matches)?.ok_or(InputError::NoInput)
247    }
248}
249
250impl<R: EditorRunner + 'static> InputCollector<String> for EditorSource<R> {
251    fn name(&self) -> &'static str {
252        "editor"
253    }
254
255    fn is_available(&self, _matches: &ArgMatches) -> bool {
256        // Editor is available if we can detect one and we have a TTY
257        self.runner.detect_editor().is_some() && std::io::stdin().is_terminal()
258    }
259
260    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
261        let editor = self.runner.detect_editor().ok_or(InputError::NoEditor)?;
262
263        // Create a temporary file with the specified extension
264        let mut builder = tempfile::Builder::new();
265        builder.suffix(&self.extension);
266        let temp_file = builder.tempfile().map_err(InputError::EditorFailed)?;
267
268        let path = temp_file.path();
269
270        // Write initial content if provided
271        if let Some(content) = &self.initial_content {
272            fs::write(path, content).map_err(InputError::EditorFailed)?;
273        }
274
275        // Record initial modification time if we need to check for save
276        let initial_mtime = if self.require_save {
277            get_mtime(path).ok()
278        } else {
279            None
280        };
281
282        // Run the editor
283        self.runner
284            .run(&editor, path)
285            .map_err(InputError::EditorFailed)?;
286
287        // Check if user actually saved (if required)
288        if let Some(initial) = initial_mtime {
289            if let Ok(final_mtime) = get_mtime(path) {
290                if initial == final_mtime {
291                    return Err(InputError::EditorCancelled);
292                }
293            }
294        }
295
296        // Read the result
297        let content = fs::read_to_string(path).map_err(InputError::EditorFailed)?;
298
299        let result = if self.trim {
300            content.trim().to_string()
301        } else {
302            content
303        };
304
305        if result.is_empty() {
306            Ok(None)
307        } else {
308            Ok(Some(result))
309        }
310    }
311
312    fn can_retry(&self) -> bool {
313        // Editor is interactive, so we can retry on validation failure
314        true
315    }
316}
317
318/// Get the modification time of a file.
319fn get_mtime(path: &Path) -> io::Result<SystemTime> {
320    fs::metadata(path)?.modified()
321}
322
323use std::io::IsTerminal;
324
325/// Mock editor runner for testing.
326///
327/// Simulates editor behavior without actually launching an editor.
328#[derive(Debug, Clone)]
329pub struct MockEditorRunner {
330    editor: Option<String>,
331    result: MockEditorResult,
332}
333
334/// The result of a mock editor invocation.
335#[derive(Debug, Clone)]
336pub enum MockEditorResult {
337    /// Editor writes this content and exits successfully.
338    Success(String),
339    /// Editor fails with an error.
340    Failure(String),
341    /// Editor exits without saving (for require_save tests).
342    NoSave,
343}
344
345impl MockEditorRunner {
346    /// Create a mock that simulates no editor available.
347    pub fn no_editor() -> Self {
348        Self {
349            editor: None,
350            result: MockEditorResult::Failure("no editor".to_string()),
351        }
352    }
353
354    /// Create a mock that simulates successful editor input.
355    pub fn with_result(content: impl Into<String>) -> Self {
356        Self {
357            editor: Some("mock-editor".to_string()),
358            result: MockEditorResult::Success(content.into()),
359        }
360    }
361
362    /// Create a mock that simulates editor failure.
363    pub fn failure(message: impl Into<String>) -> Self {
364        Self {
365            editor: Some("mock-editor".to_string()),
366            result: MockEditorResult::Failure(message.into()),
367        }
368    }
369
370    /// Create a mock that simulates closing without saving.
371    pub fn no_save() -> Self {
372        Self {
373            editor: Some("mock-editor".to_string()),
374            result: MockEditorResult::NoSave,
375        }
376    }
377}
378
379impl EditorRunner for MockEditorRunner {
380    fn detect_editor(&self) -> Option<String> {
381        self.editor.clone()
382    }
383
384    fn run(&self, _editor: &str, path: &Path) -> io::Result<()> {
385        match &self.result {
386            MockEditorResult::Success(content) => {
387                // Write the mock content to the file
388                let mut file = fs::OpenOptions::new()
389                    .write(true)
390                    .truncate(true)
391                    .open(path)?;
392                file.write_all(content.as_bytes())?;
393                Ok(())
394            }
395            MockEditorResult::Failure(msg) => Err(io::Error::other(msg.clone())),
396            MockEditorResult::NoSave => {
397                // Don't modify the file at all
398                Ok(())
399            }
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use clap::Command;
408
409    fn empty_matches() -> ArgMatches {
410        Command::new("test").try_get_matches_from(["test"]).unwrap()
411    }
412
413    #[test]
414    fn editor_unavailable_when_no_editor() {
415        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
416        assert!(!source.is_available(&empty_matches()));
417    }
418
419    #[test]
420    fn editor_collects_input() {
421        let source = EditorSource::with_runner(MockEditorRunner::with_result("hello from editor"));
422        let result = source.collect(&empty_matches()).unwrap();
423        assert_eq!(result, Some("hello from editor".to_string()));
424    }
425
426    #[test]
427    fn editor_trims_whitespace() {
428        let source = EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n\n"));
429        let result = source.collect(&empty_matches()).unwrap();
430        assert_eq!(result, Some("hello".to_string()));
431    }
432
433    #[test]
434    fn editor_no_trim() {
435        let source =
436            EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n")).trim(false);
437        let result = source.collect(&empty_matches()).unwrap();
438        assert_eq!(result, Some("  hello  \n".to_string()));
439    }
440
441    #[test]
442    fn editor_returns_none_for_empty() {
443        let source = EditorSource::with_runner(MockEditorRunner::with_result(""));
444        let result = source.collect(&empty_matches()).unwrap();
445        assert_eq!(result, None);
446    }
447
448    #[test]
449    fn editor_returns_none_for_whitespace_only() {
450        let source = EditorSource::with_runner(MockEditorRunner::with_result("   \n\t  "));
451        let result = source.collect(&empty_matches()).unwrap();
452        assert_eq!(result, None);
453    }
454
455    #[test]
456    fn editor_handles_failure() {
457        let source = EditorSource::with_runner(MockEditorRunner::failure("editor crashed"));
458        let result = source.collect(&empty_matches());
459        assert!(matches!(result, Err(InputError::EditorFailed(_))));
460    }
461
462    #[test]
463    fn editor_with_initial_content() {
464        // The mock runner ignores initial content since it writes its own result
465        let source = EditorSource::with_runner(MockEditorRunner::with_result("user input"))
466            .initial_content("# Template\n\n");
467        let result = source.collect(&empty_matches()).unwrap();
468        assert_eq!(result, Some("user input".to_string()));
469    }
470
471    #[test]
472    fn editor_can_retry() {
473        let source = EditorSource::with_runner(MockEditorRunner::with_result("test"));
474        assert!(source.can_retry());
475    }
476
477    #[test]
478    fn editor_no_editor_error() {
479        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
480        let result = source.collect(&empty_matches());
481        assert!(matches!(result, Err(InputError::NoEditor)));
482    }
483
484    // === .prompt() shortcut ===
485    //
486    // EditorSource::is_available checks std::io::stdin().is_terminal() directly,
487    // so under `cargo test` (no TTY) prompt() always short-circuits to NoInput.
488    // The happy path with the mock runner is covered by the existing
489    // editor_collects_content / editor_failure / editor_no_editor_error tests
490    // on collect(), which prompt() delegates to once the TTY gate passes.
491    //
492    // Every test that calls .prompt() shares one #[serial] axis
493    // (`prompt_responder`) because the global responder override is
494    // process-wide; without serialization a responder installed by a
495    // parallel responder-using test would leak into these vanilla
496    // shortcut tests.
497
498    use crate::{
499        reset_default_prompt_responder, set_default_prompt_responder, PromptResponse,
500        ScriptedResponder,
501    };
502    use serial_test::serial;
503    use std::sync::Arc;
504
505    #[test]
506    #[serial(prompt_responder)]
507    fn editor_prompt_shortcut_returns_no_input_in_non_tty() {
508        let source = EditorSource::with_runner(MockEditorRunner::with_result("hello"));
509        let err = source.prompt().unwrap_err();
510        assert!(matches!(err, InputError::NoInput));
511    }
512
513    #[test]
514    #[serial(prompt_responder)]
515    fn editor_prompt_shortcut_no_input_when_no_editor_detected() {
516        // No TTY *and* no editor — both fail is_available, so NoInput either way.
517        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
518        let err = source.prompt().unwrap_err();
519        assert!(matches!(err, InputError::NoInput));
520    }
521
522    // === .prompt() via PromptResponder ===
523
524    struct ResponderGuard;
525    impl ResponderGuard {
526        fn install(responder: ScriptedResponder) -> Self {
527            set_default_prompt_responder(Arc::new(responder));
528            Self
529        }
530    }
531    impl Drop for ResponderGuard {
532        fn drop(&mut self) {
533            reset_default_prompt_responder();
534        }
535    }
536
537    #[test]
538    #[serial(prompt_responder)]
539    fn editor_prompt_routes_through_responder_without_launching_editor() {
540        let _g = ResponderGuard::install(ScriptedResponder::new([PromptResponse::text(
541            "edited body",
542        )]));
543        // Even with a no-editor mock runner, the responder gate runs first
544        // and wins — the editor is never launched in tests.
545        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
546        let value = source.prompt().unwrap();
547        assert_eq!(value, "edited body");
548    }
549
550    #[test]
551    #[serial(prompt_responder)]
552    fn editor_prompt_responder_cancel_propagates() {
553        let _g = ResponderGuard::install(ScriptedResponder::new([PromptResponse::Cancel]));
554        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
555        let err = source.prompt().unwrap_err();
556        assert!(matches!(err, InputError::PromptCancelled));
557    }
558}