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