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}