1use std::ffi::OsString;
27use std::io::Write;
28use std::process::ExitCode;
29
30use crate::editor;
31use crate::pipeline;
32use crate::tty;
33use crate::{CompatibilityMode, DEFAULT_SUFFIX, Error, validate_suffix};
34
35pub fn format_unknown_flag(flag: &UnknownFlag) -> String {
37 match flag {
38 UnknownFlag::Short(c) => format!("rusty-vipe: invalid option -- '{c}'"),
39 UnknownFlag::Long(name) => format!("rusty-vipe: unknown option -- '{name}'"),
40 }
41}
42
43pub fn format_editor_died(argv: &[OsString]) -> String {
46 let joined = argv
47 .iter()
48 .map(|a| a.to_string_lossy().into_owned())
49 .collect::<Vec<_>>()
50 .join(" ");
51 format!("{joined} exited nonzero, aborting")
52}
53
54pub fn run(argv: &[OsString]) -> ExitCode {
62 let parsed = match parse_argv(argv) {
63 Ok(p) => p,
64 Err(ParseError::Unknown(unk)) => {
65 let msg = format_unknown_flag(&unk);
66 let _ = writeln!(std::io::stderr().lock(), "{msg}");
67 return ExitCode::from(2);
68 }
69 Err(ParseError::InvalidSuffix(reason)) => {
70 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {reason}");
71 return ExitCode::from(2);
72 }
73 };
74
75 let env_visual = std::env::var("VISUAL").ok();
78 let env_editor = std::env::var("EDITOR").ok();
79 let resolved = match editor::resolve(
80 None,
81 env_visual.as_deref(),
82 env_editor.as_deref(),
83 CompatibilityMode::Strict,
84 ) {
85 Ok(r) => r,
86 Err(Error::InvalidEditorCommand(raw)) => {
87 let _ = writeln!(
88 std::io::stderr().lock(),
89 "rusty-vipe: invalid EDITOR/VISUAL value: {raw}"
90 );
91 return ExitCode::from(127);
92 }
93 Err(e) => {
94 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
95 return ExitCode::from(127);
96 }
97 };
98
99 let suffix = parsed.suffix.as_deref().unwrap_or(DEFAULT_SUFFIX);
101 let stdin = std::io::stdin();
102 let tempfile = match pipeline::drain_to_tempfile(stdin.lock(), suffix) {
103 Ok(tf) => tf,
104 Err(e) => {
105 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
106 return ExitCode::from(1);
107 }
108 };
109
110 let preserved_stdout = match tty::preserve_stdout() {
111 Ok(p) => p,
112 Err(e) => {
113 let _ = writeln!(
114 std::io::stderr().lock(),
115 "rusty-vipe: failed to preserve stdout: {e}"
116 );
117 return ExitCode::from(1);
118 }
119 };
120
121 let tty_handles = if pipeline::test_bypass_tty_enabled() {
122 None
123 } else {
124 match tty::open_controlling_tty() {
125 Ok(handles) => Some(handles),
126 Err(Error::NoControllingTty) => {
127 let _ = writeln!(
128 std::io::stderr().lock(),
129 "rusty-vipe: no controlling terminal; cannot launch editor"
130 );
131 return ExitCode::from(1);
132 }
133 Err(e) => {
134 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
135 return ExitCode::from(1);
136 }
137 }
138 };
139
140 let extras: Vec<OsString> = parsed.editor_extras;
141 let status = match pipeline::spawn_editor(&resolved.argv, &extras, tempfile.path(), tty_handles)
142 {
143 Ok(s) => s,
144 Err(Error::EditorNotFound(name)) => {
145 let _ = writeln!(
146 std::io::stderr().lock(),
147 "rusty-vipe: editor not found: {name}"
148 );
149 return ExitCode::from(127);
150 }
151 Err(e) => {
152 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
153 return ExitCode::from(1);
154 }
155 };
156
157 if !status.success() {
158 let mut full_argv: Vec<OsString> = resolved.argv.clone();
161 full_argv.extend(extras.iter().cloned());
162 full_argv.push(tempfile.path().to_path_buf().into_os_string());
163 let _ = writeln!(
164 std::io::stderr().lock(),
165 "{}",
166 format_editor_died(&full_argv)
167 );
168
169 let code = pipeline::clamp_exit_code(status);
170 let byte = if (1..=255).contains(&code) {
171 code as u8
172 } else {
173 1u8
174 };
175 return ExitCode::from(byte);
176 }
177
178 match pipeline::write_back_to_saved_stdout(tempfile.path(), preserved_stdout) {
179 Ok(()) => ExitCode::SUCCESS,
180 Err(Error::TempFileDeleted(_)) => {
181 let _ = writeln!(
182 std::io::stderr().lock(),
183 "rusty-vipe: tempfile no longer exists after editor exited"
184 );
185 ExitCode::from(1)
186 }
187 Err(e) => {
188 let _ = writeln!(std::io::stderr().lock(), "rusty-vipe: {e}");
189 ExitCode::from(1)
190 }
191 }
192}
193
194pub fn pre_scan_strict_flag(argv: &[OsString]) -> Option<bool> {
197 let mut chosen: Option<bool> = None;
198 for arg in argv.iter().skip(1) {
199 let s = arg.to_string_lossy();
200 if s == "--strict" {
201 chosen = Some(true);
202 } else if s == "--no-strict" {
203 chosen = Some(false);
204 } else if s == "--" {
205 break;
206 }
207 }
208 chosen
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum UnknownFlag {
214 Short(char),
216 Long(String),
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222enum ParseError {
223 Unknown(UnknownFlag),
225 InvalidSuffix(&'static str),
227}
228
229#[derive(Debug, Default)]
231struct StrictArgs {
232 suffix: Option<String>,
233 editor_extras: Vec<OsString>,
234}
235
236fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, ParseError> {
244 let mut out = StrictArgs::default();
245 let mut iter = argv.iter().skip(1);
246
247 while let Some(arg) = iter.next() {
248 let s = arg.to_string_lossy();
249
250 if s == "--strict" || s == "--no-strict" {
252 continue;
253 }
254
255 if s == "--" {
257 for rest in iter.by_ref() {
258 out.editor_extras.push(rest.clone());
259 }
260 break;
261 }
262
263 if s == "completions" {
265 return Err(ParseError::Unknown(UnknownFlag::Long(String::from(
266 "completions",
267 ))));
268 }
269
270 if let Some(rest) = s.strip_prefix("--") {
272 if let Some(value) = rest.strip_prefix("suffix=") {
274 validate_suffix(value).map_err(ParseError::InvalidSuffix)?;
275 out.suffix = Some(value.to_string());
276 continue;
277 }
278 if rest == "suffix" {
280 let value = iter
281 .next()
282 .map(|v| v.to_string_lossy().into_owned())
283 .unwrap_or_default();
284 validate_suffix(&value).map_err(ParseError::InvalidSuffix)?;
285 out.suffix = Some(value);
286 continue;
287 }
288 let flag_name = rest.split('=').next().unwrap_or(rest).to_string();
291 return Err(ParseError::Unknown(UnknownFlag::Long(flag_name)));
292 }
293
294 if let Some(rest) = s.strip_prefix('-') {
296 if !rest.is_empty() {
297 let first = rest.chars().next().expect("non-empty after strip_prefix");
299 return Err(ParseError::Unknown(UnknownFlag::Short(first)));
300 }
301 }
302
303 out.editor_extras.push(arg.clone());
305 }
306
307 Ok(out)
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn argv(parts: &[&str]) -> Vec<OsString> {
315 parts.iter().map(|s| OsString::from(*s)).collect()
316 }
317
318 #[test]
319 fn pre_scan_detects_strict() {
320 assert_eq!(
321 pre_scan_strict_flag(&argv(&["rusty-vipe", "--strict"])),
322 Some(true)
323 );
324 }
325
326 #[test]
327 fn pre_scan_detects_no_strict() {
328 assert_eq!(
329 pre_scan_strict_flag(&argv(&["rusty-vipe", "--no-strict"])),
330 Some(false)
331 );
332 }
333
334 #[test]
335 fn pre_scan_returns_none_when_neither() {
336 assert_eq!(pre_scan_strict_flag(&argv(&["rusty-vipe"])), None);
337 }
338
339 #[test]
340 fn pre_scan_last_occurrence_wins() {
341 assert_eq!(
342 pre_scan_strict_flag(&argv(&["rusty-vipe", "--strict", "--no-strict"])),
343 Some(false)
344 );
345 }
346
347 #[test]
348 fn pre_scan_stops_at_double_dash() {
349 assert_eq!(
350 pre_scan_strict_flag(&argv(&["rusty-vipe", "--", "--strict"])),
351 None,
352 "--strict after -- is a positional, not the strict flag"
353 );
354 }
355
356 #[test]
357 fn parse_no_flags_yields_defaults() {
358 let r = parse_argv(&argv(&["vipe"])).unwrap();
359 assert_eq!(r.suffix, None);
360 assert!(r.editor_extras.is_empty());
361 }
362
363 #[test]
364 fn parse_suffix_equals_value() {
365 let r = parse_argv(&argv(&["vipe", "--suffix=.md"])).unwrap();
366 assert_eq!(r.suffix.as_deref(), Some(".md"));
367 }
368
369 #[test]
370 fn parse_suffix_separate_value() {
371 let r = parse_argv(&argv(&["vipe", "--suffix", ".json"])).unwrap();
372 assert_eq!(r.suffix.as_deref(), Some(".json"));
373 }
374
375 fn unwrap_unknown(err: ParseError) -> UnknownFlag {
376 match err {
377 ParseError::Unknown(u) => u,
378 other => panic!("expected ParseError::Unknown, got {other:?}"),
379 }
380 }
381
382 #[test]
383 fn parse_rejects_help() {
384 let err = parse_argv(&argv(&["vipe", "--help"])).unwrap_err();
385 assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("help")));
386 }
387
388 #[test]
389 fn parse_rejects_version() {
390 let err = parse_argv(&argv(&["vipe", "--version"])).unwrap_err();
391 assert_eq!(
392 unwrap_unknown(err),
393 UnknownFlag::Long(String::from("version"))
394 );
395 }
396
397 #[test]
398 fn parse_rejects_editor_flag() {
399 let err = parse_argv(&argv(&["vipe", "--editor=foo"])).unwrap_err();
400 assert_eq!(
401 unwrap_unknown(err),
402 UnknownFlag::Long(String::from("editor"))
403 );
404 }
405
406 #[test]
407 fn parse_rejects_editor_flag_empty() {
408 let err = parse_argv(&argv(&["vipe", "--editor="])).unwrap_err();
409 assert_eq!(
410 unwrap_unknown(err),
411 UnknownFlag::Long(String::from("editor"))
412 );
413 }
414
415 #[test]
416 fn parse_rejects_completions_subcommand() {
417 let err = parse_argv(&argv(&["vipe", "completions", "bash"])).unwrap_err();
418 assert_eq!(
419 unwrap_unknown(err),
420 UnknownFlag::Long(String::from("completions"))
421 );
422 }
423
424 #[test]
425 fn parse_rejects_unknown_long_flag() {
426 let err = parse_argv(&argv(&["vipe", "--foo"])).unwrap_err();
427 assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("foo")));
428 }
429
430 #[test]
431 fn parse_rejects_unknown_short_flag() {
432 let err = parse_argv(&argv(&["vipe", "-x"])).unwrap_err();
433 assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
434 }
435
436 #[test]
437 fn parse_first_unknown_wins_when_short_and_long_both_present() {
438 let err = parse_argv(&argv(&["vipe", "-x", "--foo"])).unwrap_err();
440 assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
441
442 let err = parse_argv(&argv(&["vipe", "--foo", "-x"])).unwrap_err();
443 assert_eq!(unwrap_unknown(err), UnknownFlag::Long(String::from("foo")));
444 }
445
446 #[test]
447 fn parse_grouped_short_unknown_reports_first_char() {
448 let err = parse_argv(&argv(&["vipe", "-xyz"])).unwrap_err();
450 assert_eq!(unwrap_unknown(err), UnknownFlag::Short('x'));
451 }
452
453 #[test]
454 fn parse_positional_becomes_editor_extra() {
455 let r = parse_argv(&argv(&["vipe", "--wait", "extra"])).unwrap_err();
456 assert_eq!(unwrap_unknown(r), UnknownFlag::Long(String::from("wait")));
458
459 let r = parse_argv(&argv(&["vipe", "extra-arg"])).unwrap();
461 assert_eq!(r.editor_extras, vec![OsString::from("extra-arg")]);
462 }
463
464 #[test]
465 fn parse_rejects_invalid_suffix_path_separator() {
466 let err = parse_argv(&argv(&["vipe", "--suffix=/foo"])).unwrap_err();
467 assert!(
468 matches!(err, ParseError::InvalidSuffix(_)),
469 "/foo should fail suffix validation, got {err:?}"
470 );
471 }
472
473 #[test]
474 fn parse_rejects_invalid_suffix_too_long() {
475 let long = "a".repeat(300);
476 let arg = format!("--suffix={long}");
477 let argv_vec: Vec<OsString> = vec![OsString::from("vipe"), OsString::from(arg)];
478 let err = parse_argv(&argv_vec).unwrap_err();
479 assert!(matches!(err, ParseError::InvalidSuffix(_)));
480 }
481
482 #[test]
483 fn parse_double_dash_treats_rest_as_extras() {
484 let r = parse_argv(&argv(&["vipe", "--", "--help", "-x"])).unwrap();
485 assert_eq!(
486 r.editor_extras,
487 vec![OsString::from("--help"), OsString::from("-x")],
488 "after `--` everything is an editor extra"
489 );
490 }
491
492 #[test]
493 fn parse_strict_and_no_strict_are_silently_consumed() {
494 let r = parse_argv(&argv(&["vipe", "--strict", "--suffix=.md", "--no-strict"])).unwrap();
495 assert_eq!(r.suffix.as_deref(), Some(".md"));
496 }
497
498 #[test]
499 fn format_unknown_short_flag_matches_spec_text() {
500 let msg = format_unknown_flag(&UnknownFlag::Short('x'));
501 assert_eq!(msg, "rusty-vipe: invalid option -- 'x'");
502 }
503
504 #[test]
505 fn format_unknown_long_flag_matches_spec_text() {
506 let msg = format_unknown_flag(&UnknownFlag::Long(String::from("foo")));
507 assert_eq!(msg, "rusty-vipe: unknown option -- 'foo'");
508 }
509
510 #[test]
511 fn format_editor_died_joins_argv_with_spaces() {
512 let argv = vec![
513 OsString::from("vi"),
514 OsString::from("--wait"),
515 OsString::from("/tmp/foo.txt"),
516 ];
517 assert_eq!(
518 format_editor_died(&argv),
519 "vi --wait /tmp/foo.txt exited nonzero, aborting"
520 );
521 }
522
523 #[test]
524 fn format_editor_died_single_arg() {
525 let argv = vec![OsString::from("vi")];
526 assert_eq!(format_editor_died(&argv), "vi exited nonzero, aborting");
527 }
528}