standout_input/sources/
editor.rs1use 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
17pub trait EditorRunner: Send + Sync {
19 fn detect_editor(&self) -> Option<String>;
23
24 fn run(&self, editor: &str, path: &Path) -> io::Result<()>;
28}
29
30#[derive(Debug, Default, Clone, Copy)]
32pub struct RealEditorRunner;
33
34impl EditorRunner for RealEditorRunner {
35 fn detect_editor(&self) -> Option<String> {
36 if let Ok(editor) = std::env::var("VISUAL") {
38 if !editor.is_empty() && editor_exists(&editor) {
39 return Some(editor);
40 }
41 }
42
43 if let Ok(editor) = std::env::var("EDITOR") {
45 if !editor.is_empty() && editor_exists(&editor) {
46 return Some(editor);
47 }
48 }
49
50 #[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 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
97fn editor_exists(editor: &str) -> bool {
99 let cmd = editor.split_whitespace().next().unwrap_or(editor);
101 which::which(cmd).is_ok()
102}
103
104#[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 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 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 pub fn initial_content(mut self, content: impl Into<String>) -> Self {
183 self.initial_content = Some(content.into());
184 self
185 }
186
187 pub fn extension(mut self, ext: impl Into<String>) -> Self {
192 self.extension = ext.into();
193 self
194 }
195
196 pub fn require_save(mut self, require: bool) -> Self {
202 self.require_save = require;
203 self
204 }
205
206 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 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 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 if let Some(content) = &self.initial_content {
237 fs::write(path, content).map_err(InputError::EditorFailed)?;
238 }
239
240 let initial_mtime = if self.require_save {
242 get_mtime(path).ok()
243 } else {
244 None
245 };
246
247 self.runner
249 .run(&editor, path)
250 .map_err(InputError::EditorFailed)?;
251
252 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 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 true
280 }
281}
282
283fn get_mtime(path: &Path) -> io::Result<SystemTime> {
285 fs::metadata(path)?.modified()
286}
287
288use std::io::IsTerminal;
289
290#[derive(Debug, Clone)]
294pub struct MockEditorRunner {
295 editor: Option<String>,
296 result: MockEditorResult,
297}
298
299#[derive(Debug, Clone)]
301pub enum MockEditorResult {
302 Success(String),
304 Failure(String),
306 NoSave,
308}
309
310impl MockEditorRunner {
311 pub fn no_editor() -> Self {
313 Self {
314 editor: None,
315 result: MockEditorResult::Failure("no editor".to_string()),
316 }
317 }
318
319 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 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 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 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 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 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}