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> EditorSource<R> {
216 pub fn prompt(&self) -> Result<String, InputError> {
233 if let Some(value) = crate::responder::intercept_text(
234 crate::PromptKind::Editor,
235 &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 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 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 if let Some(content) = &self.initial_content {
272 fs::write(path, content).map_err(InputError::EditorFailed)?;
273 }
274
275 let initial_mtime = if self.require_save {
277 get_mtime(path).ok()
278 } else {
279 None
280 };
281
282 self.runner
284 .run(&editor, path)
285 .map_err(InputError::EditorFailed)?;
286
287 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 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 true
315 }
316}
317
318fn get_mtime(path: &Path) -> io::Result<SystemTime> {
320 fs::metadata(path)?.modified()
321}
322
323use std::io::IsTerminal;
324
325#[derive(Debug, Clone)]
329pub struct MockEditorRunner {
330 editor: Option<String>,
331 result: MockEditorResult,
332}
333
334#[derive(Debug, Clone)]
336pub enum MockEditorResult {
337 Success(String),
339 Failure(String),
341 NoSave,
343}
344
345impl MockEditorRunner {
346 pub fn no_editor() -> Self {
348 Self {
349 editor: None,
350 result: MockEditorResult::Failure("no editor".to_string()),
351 }
352 }
353
354 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 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 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 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 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 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 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 let source = EditorSource::with_runner(MockEditorRunner::no_editor());
518 let err = source.prompt().unwrap_err();
519 assert!(matches!(err, InputError::NoInput));
520 }
521
522 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 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}