1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result, bail};
7
8use crate::config::{PickerBindings, PickerPreviewSettings};
9use crate::process::{CommandRunner, default_runner};
10use crate::ui::DisplayStyle;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum EntryKind {
14 Session,
15 Directory,
16 Project,
17 InvalidProject,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum SelectAction {
22 Open,
23 Delete,
24 SaveProject,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct Entry {
29 pub kind: EntryKind,
30 pub label: String,
31 pub value: String,
32 pub preview: Option<String>,
33}
34
35impl Entry {
36 pub fn session(style: DisplayStyle, value: String) -> Self {
37 Self {
38 kind: EntryKind::Session,
39 label: style.session_label(&value),
40 value,
41 preview: None,
42 }
43 }
44
45 pub fn directory(style: DisplayStyle, value: String) -> Self {
46 Self {
47 kind: EntryKind::Directory,
48 label: style.directory_label(&value),
49 value,
50 preview: None,
51 }
52 }
53
54 pub fn project(
55 style: DisplayStyle,
56 value: String,
57 label_value: String,
58 preview: Option<String>,
59 ) -> Self {
60 Self {
61 kind: EntryKind::Project,
62 label: style.project_label(&label_value),
63 value,
64 preview,
65 }
66 }
67
68 pub fn invalid_project(
69 style: DisplayStyle,
70 value: String,
71 error: &str,
72 preview: Option<String>,
73 ) -> Self {
74 Self {
75 kind: EntryKind::InvalidProject,
76 label: style.invalid_project_label(&value, error),
77 value,
78 preview,
79 }
80 }
81
82 fn encode(&self) -> String {
83 let kind = match self.kind {
84 EntryKind::Session => "session",
85 EntryKind::Directory => "folder",
86 EntryKind::Project => "project",
87 EntryKind::InvalidProject => "project-broken",
88 };
89
90 let preview = self
91 .preview
92 .as_deref()
93 .unwrap_or_default()
94 .replace(['\t', '\n', '\r'], " ");
95 format!("{kind}\t{}\t{}\t{preview}", self.value, self.label)
96 }
97
98 fn decode(line: &str) -> Result<Self> {
99 let mut parts = line.splitn(4, '\t');
100 let kind = parts.next().context("missing entry kind")?;
101 let value = parts.next().context("missing entry value")?.to_owned();
102 let label = parts.next().context("missing entry label")?.to_owned();
103 let preview = parts
104 .next()
105 .filter(|value| !value.is_empty())
106 .map(ToOwned::to_owned);
107
108 let kind = match kind {
109 "session" => EntryKind::Session,
110 "folder" => EntryKind::Directory,
111 "project" => EntryKind::Project,
112 "project-broken" => EntryKind::InvalidProject,
113 other => bail!("unknown picker entry kind: {other}"),
114 };
115
116 Ok(Self {
117 kind,
118 label,
119 value,
120 preview,
121 })
122 }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct Selection {
127 pub action: SelectAction,
128 pub entry: Entry,
129}
130
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct Choice {
133 pub kind: String,
134 pub label: String,
135 pub value: String,
136}
137
138impl Choice {
139 pub fn new(kind: impl Into<String>, label: String, value: String) -> Self {
140 Self {
141 kind: kind.into(),
142 label,
143 value,
144 }
145 }
146
147 fn encode(&self) -> String {
148 format!("{}\t{}\t{}", self.kind, self.value, self.label)
149 }
150
151 fn decode(line: &str) -> Result<Self> {
152 let mut parts = line.splitn(3, '\t');
153 let kind = parts.next().context("missing choice kind")?.to_owned();
154 let value = parts.next().context("missing choice value")?.to_owned();
155 let label = parts.next().context("missing choice label")?.to_owned();
156 Ok(Self { kind, label, value })
157 }
158}
159
160pub fn select(
161 entries: Vec<Entry>,
162 bindings: &PickerBindings,
163 preview: &PickerPreviewSettings,
164) -> Result<Option<Selection>> {
165 select_with_runner(default_runner(), entries, "smux> ", bindings, preview)
166}
167
168pub fn select_value(prompt: &str, choices: Vec<Choice>) -> Result<Option<String>> {
169 select_value_with_runner(default_runner(), prompt, choices)
170}
171
172struct TempInputFile {
173 path: PathBuf,
174}
175
176impl TempInputFile {
177 fn new(contents: &str) -> Result<Self> {
178 let mut path = std::env::temp_dir();
179 let nanos = SystemTime::now()
180 .duration_since(UNIX_EPOCH)
181 .context("system clock should be after unix epoch")?
182 .as_nanos();
183 path.push(format!("smux-fzf-{}-{nanos}.tsv", std::process::id()));
184 fs::write(&path, contents)
185 .with_context(|| format!("failed to write {}", path.display()))?;
186 Ok(Self { path })
187 }
188
189 fn shell_quoted_path(&self) -> String {
190 shell_quote(&self.path)
191 }
192}
193
194impl Drop for TempInputFile {
195 fn drop(&mut self) {
196 let _ = fs::remove_file(&self.path);
197 }
198}
199
200fn shell_quote(path: &Path) -> String {
201 shell_quote_str(&path.to_string_lossy())
202}
203
204fn shell_quote_str(value: &str) -> String {
205 format!("'{}'", value.replace('\'', "'\\''"))
206}
207
208fn cat_command(file: &TempInputFile) -> String {
209 format!("cat {}", file.shell_quoted_path())
210}
211
212fn filter_command(file: &TempInputFile, kind: &str) -> String {
213 if kind == "project" {
214 format!(
215 "awk -F '\\t' '$1 == \"project\" || $1 == \"project-broken\"' {}",
216 file.shell_quoted_path()
217 )
218 } else {
219 format!(
220 "awk -F '\\t' '$1 == \"{kind}\"' {}",
221 file.shell_quoted_path()
222 )
223 }
224}
225
226fn add_common_picker_args(args: &mut Vec<String>, prompt: &str, header: &str, bindings: &str) {
227 args.extend([
228 "--ansi".to_owned(),
229 "--delimiter".to_owned(),
230 "\t".to_owned(),
231 "--layout".to_owned(),
232 "reverse".to_owned(),
233 "--header".to_owned(),
234 header.to_owned(),
235 "--bind".to_owned(),
236 "tab:down,btab:up".to_owned(),
237 "--bind".to_owned(),
238 bindings.to_owned(),
239 "--with-nth".to_owned(),
240 "3".to_owned(),
241 "--nth".to_owned(),
242 "1,2,4".to_owned(),
243 "--prompt".to_owned(),
244 prompt.to_owned(),
245 "--no-sort".to_owned(),
246 ]);
247}
248
249fn add_preview_args(args: &mut Vec<String>, preview: &PickerPreviewSettings) {
250 let session_preview = preview.sessions.as_deref().unwrap_or(
251 "tmux list-panes -s -t \"$SMUX_PREVIEW_SESSION\" -F \"#{window_index}\t#{window_name}\t#{?window_active,1,0}\t#{pane_index}\t#{?pane_active,1,0}\t#{pane_current_command}\t#{pane_current_path}\" 2>/dev/null | awk -F '\t' 'BEGIN { current = \"\"; first_pane = 1; esc = sprintf(\"%c\", 27); reset = esc \"[0m\"; window_box = esc \"[38;2;26;33;36;48;2;149;192;202m\"; window_active_box = esc \"[38;2;26;33;36;48;2;81;156;174m\"; pane_box = esc \"[38;2;26;33;36;48;2;231;198;100m\"; pane_active_box = esc \"[38;2;26;33;36;48;2;243;150;96m\"; window_name = esc \"[38;2;149;192;202m\"; window_name_active = esc \"[1;38;2;118;204;224m\"; pane_text = esc \"[38;2;231;198;100m\"; pane_text_active = esc \"[1;38;2;243;150;96m\"; path_color = esc \"[2;38;2;149;192;202m\" } { if ($1 != current) { if (NR > 1) print \"\"; box = ($3 == \"1\" ? window_active_box : window_box); name = ($3 == \"1\" ? window_name_active : window_name); printf \"%s %s %s %s%s%s\\n\", box, $1, reset, name, $2, reset; current = $1; first_pane = 1 } pbox = ($5 == \"1\" ? pane_active_box : pane_box); ptext = ($5 == \"1\" ? pane_text_active : pane_text); printf \"\\n %s %s %s %s%s%s\\n\", pbox, $4, reset, ptext, $6, reset; printf \" %s%s%s\\n\", path_color, $7, reset }'",
252 );
253 let folder_preview = preview.folders.as_deref().unwrap_or(
254 "if command -v eza >/dev/null 2>&1; then eza --tree --level=2 --color=always --icons=always \"$SMUX_PREVIEW_PATH\"; else ls -la \"$SMUX_PREVIEW_PATH\"; fi",
255 );
256 let project_preview = preview.projects.as_deref().unwrap_or(
257 "if command -v bat >/dev/null 2>&1; then bat --style=plain --color=always --language=toml \"$SMUX_PREVIEW_FILE\"; else sed -n '1,200p' \"$SMUX_PREVIEW_FILE\"; fi",
258 );
259 let session_preview = shell_quote_str(session_preview);
260 let folder_preview = shell_quote_str(folder_preview);
261 let project_preview = shell_quote_str(project_preview);
262 let preview_command = format!(
263 "SMUX_SESSION_PREVIEW={session_preview} SMUX_FOLDER_PREVIEW={folder_preview} SMUX_PROJECT_PREVIEW={project_preview} sh -c 'kind=\"$1\"; value=\"$2\"; extra=\"$3\"; case \"$kind\" in session) SMUX_PREVIEW_KIND=\"$kind\" SMUX_PREVIEW_SESSION=\"$value\" sh -lc \"$SMUX_SESSION_PREVIEW\" ;; folder) SMUX_PREVIEW_PATH=\"$value\" SMUX_PREVIEW_KIND=\"$kind\" sh -lc \"$SMUX_FOLDER_PREVIEW\" ;; project|project-broken) if [ -n \"$extra\" ] && [ -f \"$extra\" ]; then SMUX_PREVIEW_KIND=\"$kind\" SMUX_PREVIEW_FILE=\"$extra\" sh -lc \"$SMUX_PROJECT_PREVIEW\"; else printf \"No preview available\\n\"; fi ;; *) printf \"No preview available\\n\" ;; esac' _ {{1}} {{2}} {{4}}"
264 );
265 args.extend([
266 "--preview".to_owned(),
267 preview_command,
268 "--preview-window".to_owned(),
269 "right:55%".to_owned(),
270 ]);
271}
272
273fn select_value_with_runner(
274 runner: Arc<dyn CommandRunner>,
275 prompt: &str,
276 choices: Vec<Choice>,
277) -> Result<Option<String>> {
278 let mut args = Vec::new();
279 let input = choices
280 .into_iter()
281 .map(|choice| choice.encode())
282 .collect::<Vec<_>>()
283 .join("\n")
284 + "\n";
285 let input_file = TempInputFile::new(&input)?;
286 let all_command = cat_command(&input_file);
287 let template_command = filter_command(&input_file, "template");
288 add_common_picker_args(
289 &mut args,
290 prompt,
291 "ctrl-x all ctrl-t templates",
292 &format!(
293 "ctrl-x:change-prompt(template> )+clear-query+reload({all_command}),ctrl-t:change-prompt(template> )+clear-query+reload({template_command})"
294 ),
295 );
296 let output = runner
297 .run_capture_with_input("fzf", &args, &input)
298 .context("failed to launch fzf")?;
299
300 if output.status.code == Some(130) {
301 return Ok(None);
302 }
303
304 if !output.status.success {
305 bail!("fzf exited with status {:?}", output.status.code);
306 }
307
308 let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
309 let selection = selection.trim_end();
310
311 if selection.is_empty() {
312 return Ok(None);
313 }
314
315 Ok(Some(Choice::decode(selection)?.value))
316}
317
318fn select_with_runner(
319 runner: Arc<dyn CommandRunner>,
320 entries: Vec<Entry>,
321 prompt: &str,
322 bindings: &PickerBindings,
323 preview: &PickerPreviewSettings,
324) -> Result<Option<Selection>> {
325 let mut args = Vec::new();
326 let input = entries
327 .into_iter()
328 .map(|entry| entry.encode())
329 .collect::<Vec<_>>()
330 .join("\n")
331 + "\n";
332 let input_file = TempInputFile::new(&input)?;
333 let all_command = cat_command(&input_file);
334 let session_command = filter_command(&input_file, "session");
335 let folder_command = filter_command(&input_file, "folder");
336 let project_command = filter_command(&input_file, "project");
337 add_common_picker_args(
338 &mut args,
339 prompt,
340 &format!(
341 "enter open {delete} delete {save} save project {reset} all {sessions} sessions {folders} folders {projects} projects",
342 delete = bindings.delete_session,
343 save = bindings.save_project,
344 reset = bindings.reset,
345 sessions = bindings.sessions,
346 folders = bindings.folders,
347 projects = bindings.projects,
348 ),
349 &format!(
350 "{reset}:change-prompt(smux> )+clear-query+reload({all_command}),{sessions}:change-prompt(session> )+clear-query+reload({session_command}),{folders}:change-prompt(folder> )+clear-query+reload({folder_command}),{projects}:change-prompt(project> )+clear-query+reload({project_command})",
351 reset = bindings.reset,
352 sessions = bindings.sessions,
353 folders = bindings.folders,
354 projects = bindings.projects,
355 ),
356 );
357 add_preview_args(&mut args, preview);
358 args.extend([
359 "--expect".to_owned(),
360 format!("{},{}", bindings.delete_session, bindings.save_project),
361 ]);
362 let output = runner
363 .run_capture_with_input("fzf", &args, &input)
364 .context("failed to launch fzf")?;
365
366 if output.status.code == Some(130) {
367 return Ok(None);
368 }
369
370 if !output.status.success {
371 bail!("fzf exited with status {:?}", output.status.code);
372 }
373
374 let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
375 let selection = selection.trim_end();
376
377 if selection.is_empty() {
378 return Ok(None);
379 }
380
381 let mut lines = selection.lines();
382 let first = lines
383 .next()
384 .context("fzf selection output was unexpectedly empty")?;
385 let (action, encoded_entry) = match lines.next() {
386 Some(encoded_entry) if !first.is_empty() => {
387 let action = match first {
388 key if key == bindings.delete_session => SelectAction::Delete,
389 key if key == bindings.save_project => SelectAction::SaveProject,
390 other => bail!("unknown picker action: {other}"),
391 };
392 (action, encoded_entry)
393 }
394 Some(encoded_entry) => (SelectAction::Open, encoded_entry),
395 None => (SelectAction::Open, first),
396 };
397
398 Ok(Some(Selection {
399 action,
400 entry: Entry::decode(encoded_entry)?,
401 }))
402}
403
404#[cfg(test)]
405mod tests {
406 use std::sync::Arc;
407
408 use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
409
410 use super::{
411 Choice, Entry, EntryKind, SelectAction, select_value_with_runner, select_with_runner,
412 };
413 use crate::config::{IconMode, PickerBindings, PickerPreviewSettings};
414 use crate::ui::DisplayStyle;
415
416 #[test]
417 fn entry_round_trip() {
418 let entry = Entry {
419 kind: EntryKind::Directory,
420 label: "dir /tmp/example".to_owned(),
421 value: "/tmp/example".to_owned(),
422 preview: None,
423 };
424
425 let decoded = Entry::decode(&entry.encode()).expect("entry should decode");
426 assert_eq!(decoded, entry);
427 }
428
429 #[test]
430 fn selector_passes_entries_to_fzf() {
431 let runner = Arc::new(FakeCommandRunner::new());
432 runner.push_capture(Ok(CommandOutput {
433 status: CommandStatus {
434 success: true,
435 code: Some(0),
436 },
437 stdout: b"folder\t/tmp/example\tdir /tmp/example\n".to_vec(),
438 stderr: Vec::new(),
439 }));
440
441 let result = select_with_runner(
442 runner.clone(),
443 vec![Entry::directory(
444 DisplayStyle::from_icon_mode(IconMode::Never),
445 "/tmp/example".to_owned(),
446 )],
447 "smux> ",
448 &PickerBindings::default(),
449 &PickerPreviewSettings::default(),
450 )
451 .expect("selection should succeed");
452
453 assert!(result.is_some());
454 let recorded = runner.recorded();
455 assert_eq!(recorded[0].program, "fzf");
456 assert!(recorded[0].args.contains(&"--ansi".to_owned()));
457 assert!(recorded[0].args.contains(&"reverse".to_owned()));
458 assert!(recorded[0].args.contains(&"3".to_owned()));
459 assert!(recorded[0].args.contains(&"1,2,4".to_owned()));
460 assert!(recorded[0].args.contains(&"--expect".to_owned()));
461 assert!(recorded[0].args.contains(&"--preview".to_owned()));
462 assert!(recorded[0].args.contains(&"ctrl-x,ctrl-y".to_owned()));
463 assert!(
464 recorded[0]
465 .args
466 .iter()
467 .any(|arg| arg.contains("enter open ctrl-x delete ctrl-y save project"))
468 );
469 assert!(
470 recorded[0]
471 .args
472 .iter()
473 .any(|arg| arg.contains("ctrl-c:change-prompt(smux> )+clear-query+reload("))
474 );
475 assert!(
476 recorded[0]
477 .args
478 .iter()
479 .any(|arg| arg.contains("ctrl-p projects"))
480 );
481 assert!(
482 recorded[0]
483 .args
484 .iter()
485 .any(|arg| arg.contains("ctrl-p:change-prompt(project> )+clear-query+reload("))
486 );
487 assert_eq!(
488 recorded[0].stdin.as_deref(),
489 Some("folder\t/tmp/example\tdir /tmp/example\t\n")
490 );
491 let selection = result.expect("selection should be present");
492 assert_eq!(selection.action, SelectAction::Open);
493 assert_eq!(selection.entry.kind, EntryKind::Directory);
494 }
495
496 #[test]
497 fn selector_supports_delete_action_for_sessions() {
498 let runner = Arc::new(FakeCommandRunner::new());
499 runner.push_capture(Ok(CommandOutput {
500 status: CommandStatus {
501 success: true,
502 code: Some(0),
503 },
504 stdout: b"ctrl-x\nsession\tdemo\tsession demo\n".to_vec(),
505 stderr: Vec::new(),
506 }));
507
508 let result = select_with_runner(
509 runner,
510 vec![Entry::session(
511 DisplayStyle::from_icon_mode(IconMode::Never),
512 "demo".to_owned(),
513 )],
514 "smux> ",
515 &PickerBindings::default(),
516 &PickerPreviewSettings::default(),
517 )
518 .expect("selection should succeed")
519 .expect("selection should be present");
520
521 assert_eq!(result.action, SelectAction::Delete);
522 assert_eq!(result.entry.kind, EntryKind::Session);
523 assert_eq!(result.entry.value, "demo");
524 }
525
526 #[test]
527 fn selector_supports_save_project_action_for_sessions() {
528 let runner = Arc::new(FakeCommandRunner::new());
529 runner.push_capture(Ok(CommandOutput {
530 status: CommandStatus {
531 success: true,
532 code: Some(0),
533 },
534 stdout: b"ctrl-y\nsession\tdemo\tsession demo\n".to_vec(),
535 stderr: Vec::new(),
536 }));
537
538 let result = select_with_runner(
539 runner,
540 vec![Entry::session(
541 DisplayStyle::from_icon_mode(IconMode::Never),
542 "demo".to_owned(),
543 )],
544 "smux> ",
545 &PickerBindings::default(),
546 &PickerPreviewSettings::default(),
547 )
548 .expect("selection should succeed")
549 .expect("selection should be present");
550
551 assert_eq!(result.action, SelectAction::SaveProject);
552 assert_eq!(result.entry.kind, EntryKind::Session);
553 assert_eq!(result.entry.value, "demo");
554 }
555
556 #[test]
557 fn selector_treats_empty_expect_key_as_open() {
558 let runner = Arc::new(FakeCommandRunner::new());
559 runner.push_capture(Ok(CommandOutput {
560 status: CommandStatus {
561 success: true,
562 code: Some(0),
563 },
564 stdout: b"\nfolder\t/tmp/example\tdir /tmp/example\n".to_vec(),
565 stderr: Vec::new(),
566 }));
567
568 let result = select_with_runner(
569 runner,
570 vec![Entry::directory(
571 DisplayStyle::from_icon_mode(IconMode::Never),
572 "/tmp/example".to_owned(),
573 )],
574 "smux> ",
575 &PickerBindings::default(),
576 &PickerPreviewSettings::default(),
577 )
578 .expect("selection should succeed")
579 .expect("selection should be present");
580
581 assert_eq!(result.action, SelectAction::Open);
582 assert_eq!(result.entry.kind, EntryKind::Directory);
583 assert_eq!(result.entry.value, "/tmp/example");
584 }
585
586 #[test]
587 fn selector_uses_configured_picker_bindings() {
588 let runner = Arc::new(FakeCommandRunner::new());
589 runner.push_capture(Ok(CommandOutput {
590 status: CommandStatus {
591 success: true,
592 code: Some(0),
593 },
594 stdout: b"\nfolder\t/tmp/example\tdir /tmp/example\n".to_vec(),
595 stderr: Vec::new(),
596 }));
597
598 let bindings = PickerBindings {
599 reset: "alt-a".to_owned(),
600 sessions: "alt-s".to_owned(),
601 folders: "alt-f".to_owned(),
602 projects: "alt-p".to_owned(),
603 delete_session: "alt-x".to_owned(),
604 save_project: "alt-y".to_owned(),
605 };
606
607 let _ = select_with_runner(
608 runner.clone(),
609 vec![Entry::directory(
610 DisplayStyle::from_icon_mode(IconMode::Never),
611 "/tmp/example".to_owned(),
612 )],
613 "smux> ",
614 &bindings,
615 &PickerPreviewSettings::default(),
616 )
617 .expect("selection should succeed");
618
619 let recorded = runner.recorded();
620 assert!(recorded[0].args.contains(&"alt-x,alt-y".to_owned()));
621 assert!(
622 recorded[0]
623 .args
624 .iter()
625 .any(|arg| arg.contains("alt-a:change-prompt(smux> )+clear-query+reload("))
626 );
627 assert!(
628 recorded[0]
629 .args
630 .iter()
631 .any(|arg| arg.contains("alt-s:change-prompt(session> )+clear-query+reload("))
632 );
633 assert!(
634 recorded[0]
635 .args
636 .iter()
637 .any(|arg| arg.contains("alt-f:change-prompt(folder> )+clear-query+reload("))
638 );
639 assert!(
640 recorded[0]
641 .args
642 .iter()
643 .any(|arg| arg.contains("alt-p:change-prompt(project> )+clear-query+reload("))
644 );
645 }
646
647 #[test]
648 fn selector_uses_configured_folder_preview_command() {
649 let runner = Arc::new(FakeCommandRunner::new());
650 runner.push_capture(Ok(CommandOutput {
651 status: CommandStatus {
652 success: true,
653 code: Some(0),
654 },
655 stdout: b"\nfolder\t/tmp/example\tdir /tmp/example\t\n".to_vec(),
656 stderr: Vec::new(),
657 }));
658
659 let _ = select_with_runner(
660 runner.clone(),
661 vec![Entry::directory(
662 DisplayStyle::from_icon_mode(IconMode::Never),
663 "/tmp/example".to_owned(),
664 )],
665 "smux> ",
666 &PickerBindings::default(),
667 &PickerPreviewSettings {
668 folders: Some("eza --tree --level=2 \"$SMUX_PREVIEW_PATH\"".to_owned()),
669 ..Default::default()
670 },
671 )
672 .expect("selection should succeed");
673
674 let recorded = runner.recorded();
675 assert!(
676 recorded[0]
677 .args
678 .iter()
679 .any(|arg| arg.contains("eza --tree --level=2"))
680 );
681 }
682
683 #[test]
684 fn template_selector_returns_selected_value() {
685 let runner = Arc::new(FakeCommandRunner::new());
686 runner.push_capture(Ok(CommandOutput {
687 status: CommandStatus {
688 success: true,
689 code: Some(0),
690 },
691 stdout: b"template\trust\ttemplate rust\n".to_vec(),
692 stderr: Vec::new(),
693 }));
694
695 let result = select_value_with_runner(
696 runner.clone(),
697 "template> ",
698 vec![
699 Choice::new(
700 "template",
701 "template default".to_owned(),
702 "default".to_owned(),
703 ),
704 Choice::new("template", "template rust".to_owned(), "rust".to_owned()),
705 ],
706 )
707 .expect("selection should succeed");
708
709 assert_eq!(result.as_deref(), Some("rust"));
710 let recorded = runner.recorded();
711 assert!(recorded[0].args.contains(&"--ansi".to_owned()));
712 assert!(recorded[0].args.contains(&"reverse".to_owned()));
713 assert!(recorded[0].args.contains(&"3".to_owned()));
714 assert!(recorded[0].args.contains(&"1,2,4".to_owned()));
715 assert!(
716 recorded[0]
717 .args
718 .iter()
719 .any(|arg| arg.contains("ctrl-t templates"))
720 );
721 assert!(
722 recorded[0]
723 .args
724 .iter()
725 .any(|arg| arg.contains("ctrl-t:change-prompt(template> )+clear-query+reload("))
726 );
727 assert_eq!(
728 recorded[0].stdin.as_deref(),
729 Some("template\tdefault\ttemplate default\ntemplate\trust\ttemplate rust\n")
730 );
731 }
732}