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> InputCollector<String> for EditorSource<R> {
216    fn name(&self) -> &'static str {
217        "editor"
218    }
219
220    fn is_available(&self, _matches: &ArgMatches) -> bool {
221        // Editor is available if we can detect one and we have a TTY
222        self.runner.detect_editor().is_some() && std::io::stdin().is_terminal()
223    }
224
225    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
226        let editor = self.runner.detect_editor().ok_or(InputError::NoEditor)?;
227
228        // Create a temporary file with the specified extension
229        let mut builder = tempfile::Builder::new();
230        builder.suffix(&self.extension);
231        let temp_file = builder.tempfile().map_err(InputError::EditorFailed)?;
232
233        let path = temp_file.path();
234
235        // Write initial content if provided
236        if let Some(content) = &self.initial_content {
237            fs::write(path, content).map_err(InputError::EditorFailed)?;
238        }
239
240        // Record initial modification time if we need to check for save
241        let initial_mtime = if self.require_save {
242            get_mtime(path).ok()
243        } else {
244            None
245        };
246
247        // Run the editor
248        self.runner
249            .run(&editor, path)
250            .map_err(InputError::EditorFailed)?;
251
252        // Check if user actually saved (if required)
253        if let Some(initial) = initial_mtime {
254            if let Ok(final_mtime) = get_mtime(path) {
255                if initial == final_mtime {
256                    return Err(InputError::EditorCancelled);
257                }
258            }
259        }
260
261        // Read the result
262        let content = fs::read_to_string(path).map_err(InputError::EditorFailed)?;
263
264        let result = if self.trim {
265            content.trim().to_string()
266        } else {
267            content
268        };
269
270        if result.is_empty() {
271            Ok(None)
272        } else {
273            Ok(Some(result))
274        }
275    }
276
277    fn can_retry(&self) -> bool {
278        // Editor is interactive, so we can retry on validation failure
279        true
280    }
281}
282
283/// Get the modification time of a file.
284fn get_mtime(path: &Path) -> io::Result<SystemTime> {
285    fs::metadata(path)?.modified()
286}
287
288use std::io::IsTerminal;
289
290/// Mock editor runner for testing.
291///
292/// Simulates editor behavior without actually launching an editor.
293#[derive(Debug, Clone)]
294pub struct MockEditorRunner {
295    editor: Option<String>,
296    result: MockEditorResult,
297}
298
299/// The result of a mock editor invocation.
300#[derive(Debug, Clone)]
301pub enum MockEditorResult {
302    /// Editor writes this content and exits successfully.
303    Success(String),
304    /// Editor fails with an error.
305    Failure(String),
306    /// Editor exits without saving (for require_save tests).
307    NoSave,
308}
309
310impl MockEditorRunner {
311    /// Create a mock that simulates no editor available.
312    pub fn no_editor() -> Self {
313        Self {
314            editor: None,
315            result: MockEditorResult::Failure("no editor".to_string()),
316        }
317    }
318
319    /// Create a mock that simulates successful editor input.
320    pub fn with_result(content: impl Into<String>) -> Self {
321        Self {
322            editor: Some("mock-editor".to_string()),
323            result: MockEditorResult::Success(content.into()),
324        }
325    }
326
327    /// Create a mock that simulates editor failure.
328    pub fn failure(message: impl Into<String>) -> Self {
329        Self {
330            editor: Some("mock-editor".to_string()),
331            result: MockEditorResult::Failure(message.into()),
332        }
333    }
334
335    /// Create a mock that simulates closing without saving.
336    pub fn no_save() -> Self {
337        Self {
338            editor: Some("mock-editor".to_string()),
339            result: MockEditorResult::NoSave,
340        }
341    }
342}
343
344impl EditorRunner for MockEditorRunner {
345    fn detect_editor(&self) -> Option<String> {
346        self.editor.clone()
347    }
348
349    fn run(&self, _editor: &str, path: &Path) -> io::Result<()> {
350        match &self.result {
351            MockEditorResult::Success(content) => {
352                // Write the mock content to the file
353                let mut file = fs::OpenOptions::new()
354                    .write(true)
355                    .truncate(true)
356                    .open(path)?;
357                file.write_all(content.as_bytes())?;
358                Ok(())
359            }
360            MockEditorResult::Failure(msg) => Err(io::Error::other(msg.clone())),
361            MockEditorResult::NoSave => {
362                // Don't modify the file at all
363                Ok(())
364            }
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use clap::Command;
373
374    fn empty_matches() -> ArgMatches {
375        Command::new("test").try_get_matches_from(["test"]).unwrap()
376    }
377
378    #[test]
379    fn editor_unavailable_when_no_editor() {
380        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
381        assert!(!source.is_available(&empty_matches()));
382    }
383
384    #[test]
385    fn editor_collects_input() {
386        let source = EditorSource::with_runner(MockEditorRunner::with_result("hello from editor"));
387        let result = source.collect(&empty_matches()).unwrap();
388        assert_eq!(result, Some("hello from editor".to_string()));
389    }
390
391    #[test]
392    fn editor_trims_whitespace() {
393        let source = EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n\n"));
394        let result = source.collect(&empty_matches()).unwrap();
395        assert_eq!(result, Some("hello".to_string()));
396    }
397
398    #[test]
399    fn editor_no_trim() {
400        let source =
401            EditorSource::with_runner(MockEditorRunner::with_result("  hello  \n")).trim(false);
402        let result = source.collect(&empty_matches()).unwrap();
403        assert_eq!(result, Some("  hello  \n".to_string()));
404    }
405
406    #[test]
407    fn editor_returns_none_for_empty() {
408        let source = EditorSource::with_runner(MockEditorRunner::with_result(""));
409        let result = source.collect(&empty_matches()).unwrap();
410        assert_eq!(result, None);
411    }
412
413    #[test]
414    fn editor_returns_none_for_whitespace_only() {
415        let source = EditorSource::with_runner(MockEditorRunner::with_result("   \n\t  "));
416        let result = source.collect(&empty_matches()).unwrap();
417        assert_eq!(result, None);
418    }
419
420    #[test]
421    fn editor_handles_failure() {
422        let source = EditorSource::with_runner(MockEditorRunner::failure("editor crashed"));
423        let result = source.collect(&empty_matches());
424        assert!(matches!(result, Err(InputError::EditorFailed(_))));
425    }
426
427    #[test]
428    fn editor_with_initial_content() {
429        // The mock runner ignores initial content since it writes its own result
430        let source = EditorSource::with_runner(MockEditorRunner::with_result("user input"))
431            .initial_content("# Template\n\n");
432        let result = source.collect(&empty_matches()).unwrap();
433        assert_eq!(result, Some("user input".to_string()));
434    }
435
436    #[test]
437    fn editor_can_retry() {
438        let source = EditorSource::with_runner(MockEditorRunner::with_result("test"));
439        assert!(source.can_retry());
440    }
441
442    #[test]
443    fn editor_no_editor_error() {
444        let source = EditorSource::with_runner(MockEditorRunner::no_editor());
445        let result = source.collect(&empty_matches());
446        assert!(matches!(result, Err(InputError::NoEditor)));
447    }
448}