1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6use crate::{
7 models::{common::enums::Provider, provider::Release},
8 providers::provider_manager::ProviderManager,
9 utils::{platform::shells::installed_shell_commands, static_paths::UpstreamPaths},
10};
11
12macro_rules! message {
13 ($cb:expr, $($arg:tt)*) => {{
14 if let Some(cb) = $cb.as_mut() {
15 cb(&format!($($arg)*));
16 }
17 }};
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CompletionShell {
22 Bash,
23 Fish,
24 Zsh,
25}
26
27impl CompletionShell {
28 fn from_extension(extension: &str) -> Option<Self> {
29 match extension {
30 "bash" => Some(Self::Bash),
31 "fish" => Some(Self::Fish),
32 "zsh" => Some(Self::Zsh),
33 _ => None,
34 }
35 }
36
37 fn from_command(command: &str) -> Option<Self> {
38 match command {
39 "bash" => Some(Self::Bash),
40 "fish" => Some(Self::Fish),
41 "zsh" => Some(Self::Zsh),
42 _ => None,
43 }
44 }
45
46 pub fn label(self) -> &'static str {
47 match self {
48 Self::Bash => "bash",
49 Self::Fish => "fish",
50 Self::Zsh => "zsh",
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
56struct CompletionCandidate {
57 shell: CompletionShell,
58 path: PathBuf,
59 priority: u8,
60}
61
62#[derive(Debug, Clone)]
63struct CachedCompletion {
64 shell: CompletionShell,
65 path: PathBuf,
66 source: PathBuf,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum CompletionCacheMismatchKind {
71 Missing,
72 Different,
73}
74
75#[derive(Debug, Clone)]
76pub struct CompletionCacheMismatch {
77 pub shell: CompletionShell,
78 pub cached_path: PathBuf,
79 pub installed_path: PathBuf,
80 pub kind: CompletionCacheMismatchKind,
81}
82
83pub struct CompletionManager<'a> {
84 paths: &'a UpstreamPaths,
85}
86
87impl<'a> CompletionManager<'a> {
88 pub fn new(paths: &'a UpstreamPaths) -> Self {
89 Self { paths }
90 }
91
92 pub fn installed_shells() -> Vec<CompletionShell> {
93 installed_completion_shells()
94 }
95
96 pub fn installed_shell_completion_dirs(&self) -> Vec<(&'static str, PathBuf)> {
97 Self::installed_shells()
98 .into_iter()
99 .map(|shell| (shell.label(), self.completion_dir(shell).to_path_buf()))
100 .collect()
101 }
102
103 pub async fn install_from_release_assets<H>(
104 &self,
105 package_name: &str,
106 release: &Release,
107 provider_manager: &ProviderManager,
108 provider: &Provider,
109 cache_dir: &Path,
110 message_callback: &mut Option<H>,
111 ) -> Result<usize>
112 where
113 H: FnMut(&str),
114 {
115 let mut candidates: Vec<_> = release
116 .assets
117 .iter()
118 .filter_map(|asset| {
119 classify_completion_path(package_name, Path::new(&asset.name))
120 .map(|candidate| (asset, candidate))
121 })
122 .collect();
123 candidates.sort_by(|(asset_a, candidate_a), (asset_b, candidate_b)| {
124 candidate_a
125 .priority
126 .cmp(&candidate_b.priority)
127 .then_with(|| asset_a.name.cmp(&asset_b.name))
128 });
129
130 let selected_assets: Vec<_> = [
131 CompletionShell::Bash,
132 CompletionShell::Fish,
133 CompletionShell::Zsh,
134 ]
135 .into_iter()
136 .filter(|shell| shell_is_available(*shell))
137 .filter_map(|shell| {
138 candidates
139 .iter()
140 .find(|(_asset, candidate)| candidate.shell == shell)
141 .map(|(asset, candidate)| (*asset, candidate.clone()))
142 })
143 .collect();
144
145 if selected_assets.is_empty() {
146 return Ok(0);
147 }
148
149 let package_cache_dir = self.prepare_package_cache_dir(package_name)?;
150 let mut cached_completions = Vec::new();
151 for (asset, candidate) in selected_assets {
152 let mut no_progress: Option<fn(u64, u64)> = None;
153 let downloaded_path = provider_manager
154 .download_asset(asset, provider, cache_dir, &mut no_progress)
155 .await
156 .with_context(|| format!("Failed to download completion asset '{}'", asset.name))?;
157
158 let cached_completion = self.cache_completion(
159 &package_cache_dir,
160 package_name,
161 candidate.shell,
162 &downloaded_path,
163 Path::new(&asset.name),
164 )?;
165 cached_completions.push(cached_completion);
166 }
167
168 self.install_cached_completions(package_name, &cached_completions, message_callback)
169 }
170
171 pub fn install_from_root<H>(
172 &self,
173 package_name: &str,
174 root: &Path,
175 message_callback: &mut Option<H>,
176 ) -> Result<usize>
177 where
178 H: FnMut(&str),
179 {
180 if !root.exists() {
181 return Ok(0);
182 }
183
184 let candidates = choose_one_per_shell(find_completion_files(package_name, root));
185 if candidates.is_empty() {
186 return Ok(0);
187 }
188
189 let package_cache_dir = self.prepare_package_cache_dir(package_name)?;
190 let cached_completions = candidates
191 .iter()
192 .map(|candidate| {
193 self.cache_completion(
194 &package_cache_dir,
195 package_name,
196 candidate.shell,
197 &candidate.path,
198 &candidate.path,
199 )
200 })
201 .collect::<Result<Vec<_>>>()?;
202
203 self.install_cached_completions(package_name, &cached_completions, message_callback)
204 }
205
206 pub fn cached_completion_mismatches(
207 &self,
208 package_name: &str,
209 ) -> Result<Vec<CompletionCacheMismatch>> {
210 self.cached_completion_mismatches_for_shells(package_name, &Self::installed_shells())
211 }
212
213 pub fn copy_cached_completions_to_shells<H>(
214 &self,
215 package_name: &str,
216 message_callback: &mut Option<H>,
217 ) -> Result<usize>
218 where
219 H: FnMut(&str),
220 {
221 self.copy_cached_completions_to_shells_for_shells(
222 package_name,
223 &Self::installed_shells(),
224 message_callback,
225 )
226 }
227
228 fn install_completion(
229 &self,
230 package_name: &str,
231 shell: CompletionShell,
232 source: &Path,
233 ) -> Result<()> {
234 let destination = self.completion_path(package_name, shell);
235
236 if let Some(parent) = destination.parent() {
237 fs::create_dir_all(parent).with_context(|| {
238 format!(
239 "Failed to create completion directory '{}'",
240 parent.display()
241 )
242 })?;
243 }
244 fs::copy(source, &destination).with_context(|| {
245 format!(
246 "Failed to copy completion from '{}' to '{}'",
247 source.display(),
248 destination.display()
249 )
250 })?;
251 Ok(())
252 }
253
254 fn prepare_package_cache_dir(&self, package_name: &str) -> Result<PathBuf> {
255 let package_cache_dir = self.package_cache_dir(package_name);
256 if package_cache_dir.exists() {
257 fs::remove_dir_all(&package_cache_dir).with_context(|| {
258 format!(
259 "Failed to clear completion cache '{}'",
260 package_cache_dir.display()
261 )
262 })?;
263 }
264 fs::create_dir_all(&package_cache_dir).with_context(|| {
265 format!(
266 "Failed to create completion cache '{}'",
267 package_cache_dir.display()
268 )
269 })?;
270 Ok(package_cache_dir)
271 }
272
273 fn cache_completion(
274 &self,
275 package_cache_dir: &Path,
276 package_name: &str,
277 shell: CompletionShell,
278 source: &Path,
279 original_source: &Path,
280 ) -> Result<CachedCompletion> {
281 let cached_path = package_cache_dir.join(cache_completion_file_name(package_name, shell));
282 fs::copy(source, &cached_path).with_context(|| {
283 format!(
284 "Failed to copy completion from '{}' to cache '{}'",
285 source.display(),
286 cached_path.display()
287 )
288 })?;
289 Ok(CachedCompletion {
290 shell,
291 path: cached_path,
292 source: original_source.to_path_buf(),
293 })
294 }
295
296 fn install_cached_completions<H>(
297 &self,
298 package_name: &str,
299 cached_completions: &[CachedCompletion],
300 message_callback: &mut Option<H>,
301 ) -> Result<usize>
302 where
303 H: FnMut(&str),
304 {
305 let installable_completions: Vec<_> = cached_completions
306 .iter()
307 .filter(|cached_completion| shell_is_available(cached_completion.shell))
308 .collect();
309
310 if installable_completions.is_empty() {
311 return Ok(0);
312 }
313
314 self.remove_installed_completion_files(package_name)?;
315
316 let mut installed = 0_usize;
317 for cached_completion in installable_completions {
318 self.install_completion(
319 package_name,
320 cached_completion.shell,
321 &cached_completion.path,
322 )
323 .with_context(|| {
324 format!(
325 "Failed to install '{}' completion from cache '{}'",
326 cached_completion.shell.label(),
327 cached_completion.path.display()
328 )
329 })?;
330 message!(
331 message_callback,
332 "Installed {} completion from '{}'",
333 cached_completion.shell.label(),
334 cached_completion.source.display()
335 );
336 installed += 1;
337 }
338
339 Ok(installed)
340 }
341
342 fn cached_completion_mismatches_for_shells(
343 &self,
344 package_name: &str,
345 shells: &[CompletionShell],
346 ) -> Result<Vec<CompletionCacheMismatch>> {
347 let mut mismatches = Vec::new();
348 for shell in shells {
349 let cached_path = self.cached_completion_path(package_name, *shell);
350 if !cached_path.exists() {
351 continue;
352 }
353
354 let installed_path = self.completion_path(package_name, *shell);
355 if !installed_path.exists() {
356 mismatches.push(CompletionCacheMismatch {
357 shell: *shell,
358 cached_path,
359 installed_path,
360 kind: CompletionCacheMismatchKind::Missing,
361 });
362 continue;
363 }
364
365 if !files_have_same_content(&cached_path, &installed_path)? {
366 mismatches.push(CompletionCacheMismatch {
367 shell: *shell,
368 cached_path,
369 installed_path,
370 kind: CompletionCacheMismatchKind::Different,
371 });
372 }
373 }
374
375 Ok(mismatches)
376 }
377
378 fn copy_cached_completions_to_shells_for_shells<H>(
379 &self,
380 package_name: &str,
381 shells: &[CompletionShell],
382 message_callback: &mut Option<H>,
383 ) -> Result<usize>
384 where
385 H: FnMut(&str),
386 {
387 let mut copied = 0_usize;
388 for shell in shells {
389 let cached_path = self.cached_completion_path(package_name, *shell);
390 if !cached_path.exists() {
391 continue;
392 }
393
394 self.install_completion(package_name, *shell, &cached_path)
395 .with_context(|| {
396 format!(
397 "Failed to copy cached '{}' completion from '{}' to shell directory",
398 shell.label(),
399 cached_path.display()
400 )
401 })?;
402 message!(
403 message_callback,
404 "Installed {} completion from '{}'",
405 shell.label(),
406 cached_path.display()
407 );
408 copied += 1;
409 }
410
411 Ok(copied)
412 }
413
414 fn completion_dir(&self, shell: CompletionShell) -> &Path {
415 match shell {
416 CompletionShell::Bash => &self.paths.integration.bash_completions_dir,
417 CompletionShell::Fish => &self.paths.integration.fish_completions_dir,
418 CompletionShell::Zsh => &self.paths.integration.zsh_completions_dir,
419 }
420 }
421
422 fn completion_path(&self, package_name: &str, shell: CompletionShell) -> PathBuf {
423 match shell {
424 CompletionShell::Bash => self.completion_dir(shell).join(package_name),
425 CompletionShell::Fish => self
426 .completion_dir(shell)
427 .join(format!("{package_name}.fish")),
428 CompletionShell::Zsh => self.completion_dir(shell).join(format!("_{package_name}")),
429 }
430 }
431
432 fn package_cache_dir(&self, package_name: &str) -> PathBuf {
433 self.paths
434 .dirs
435 .cache_dir
436 .join("completions")
437 .join(package_name)
438 }
439
440 fn cached_completion_path(&self, package_name: &str, shell: CompletionShell) -> PathBuf {
441 self.package_cache_dir(package_name)
442 .join(cache_completion_file_name(package_name, shell))
443 }
444
445 fn remove_installed_completion_files(&self, package_name: &str) -> Result<usize> {
446 let candidates = [
447 self.completion_path(package_name, CompletionShell::Bash),
448 self.completion_path(package_name, CompletionShell::Fish),
449 self.completion_path(package_name, CompletionShell::Zsh),
450 ];
451
452 let mut removed = 0_usize;
453 for path in candidates {
454 if !path.exists() {
455 continue;
456 }
457 fs::remove_file(&path).with_context(|| {
458 format!("Failed to remove completion file '{}'", path.display())
459 })?;
460 removed += 1;
461 }
462
463 Ok(removed)
464 }
465
466 pub fn remove_for_package<H>(
467 &self,
468 package_name: &str,
469 message_callback: &mut Option<H>,
470 ) -> Result<usize>
471 where
472 H: FnMut(&str),
473 {
474 let candidates = [
475 self.completion_path(package_name, CompletionShell::Bash),
476 self.completion_path(package_name, CompletionShell::Fish),
477 self.completion_path(package_name, CompletionShell::Zsh),
478 ];
479
480 let mut removed = 0_usize;
481 for path in candidates {
482 if !path.exists() {
483 continue;
484 }
485 fs::remove_file(&path).with_context(|| {
486 format!("Failed to remove completion file '{}'", path.display())
487 })?;
488 message!(message_callback, "Removed completion: {}", path.display());
489 removed += 1;
490 }
491
492 let package_cache_dir = self.package_cache_dir(package_name);
493 if package_cache_dir.exists() {
494 fs::remove_dir_all(&package_cache_dir).with_context(|| {
495 format!(
496 "Failed to remove completion cache '{}'",
497 package_cache_dir.display()
498 )
499 })?;
500 }
501
502 Ok(removed)
503 }
504}
505
506fn shell_is_available(shell: CompletionShell) -> bool {
507 installed_completion_shells().contains(&shell)
508}
509
510fn installed_completion_shells() -> Vec<CompletionShell> {
511 installed_shell_commands()
512 .into_iter()
513 .filter_map(|shell| CompletionShell::from_command(&shell))
514 .collect()
515}
516
517fn find_completion_files(package_name: &str, root: &Path) -> Vec<CompletionCandidate> {
518 WalkDir::new(root)
519 .follow_links(false)
520 .into_iter()
521 .filter_map(std::result::Result::ok)
522 .filter(|entry| entry.file_type().is_file())
523 .filter_map(|entry| classify_completion_path(package_name, entry.path()))
524 .collect()
525}
526
527fn choose_one_per_shell(mut candidates: Vec<CompletionCandidate>) -> Vec<CompletionCandidate> {
528 candidates.sort_by(|a, b| {
529 a.priority
530 .cmp(&b.priority)
531 .then_with(|| {
532 a.path
533 .components()
534 .count()
535 .cmp(&b.path.components().count())
536 })
537 .then_with(|| a.path.cmp(&b.path))
538 });
539
540 let mut selected = Vec::new();
541 for shell in [
542 CompletionShell::Bash,
543 CompletionShell::Fish,
544 CompletionShell::Zsh,
545 ] {
546 if let Some(candidate) = candidates
547 .iter()
548 .find(|candidate| candidate.shell == shell)
549 .cloned()
550 {
551 selected.push(candidate);
552 }
553 }
554 selected
555}
556
557fn classify_completion_path(package_name: &str, path: &Path) -> Option<CompletionCandidate> {
558 let file_name = path.file_name()?.to_string_lossy();
559 let extension = path.extension()?.to_string_lossy();
560 let shell = CompletionShell::from_extension(&extension)?;
561 let lower_file_name = file_name.to_ascii_lowercase();
562 let lower_package = package_name.to_ascii_lowercase();
563
564 if lower_file_name == format!("{lower_package}.{extension}") {
565 return Some(CompletionCandidate {
566 shell,
567 path: path.to_path_buf(),
568 priority: 0,
569 });
570 }
571
572 if lower_file_name == format!("completions.{extension}") {
573 return Some(CompletionCandidate {
574 shell,
575 path: path.to_path_buf(),
576 priority: 1,
577 });
578 }
579
580 if path
581 .parent()
582 .and_then(Path::file_name)
583 .map(|name| name.to_string_lossy().eq_ignore_ascii_case("completions"))
584 .unwrap_or(false)
585 {
586 return Some(CompletionCandidate {
587 shell,
588 path: path.to_path_buf(),
589 priority: 2,
590 });
591 }
592
593 None
594}
595
596fn cache_completion_file_name(package_name: &str, shell: CompletionShell) -> String {
597 match shell {
598 CompletionShell::Bash => format!("{package_name}.bash"),
599 CompletionShell::Fish => format!("{package_name}.fish"),
600 CompletionShell::Zsh => format!("_{package_name}.zsh"),
601 }
602}
603
604fn files_have_same_content(left: &Path, right: &Path) -> Result<bool> {
605 let left_metadata = fs::metadata(left)
606 .with_context(|| format!("Failed to read completion file '{}'", left.display()))?;
607 let right_metadata = fs::metadata(right)
608 .with_context(|| format!("Failed to read completion file '{}'", right.display()))?;
609 if left_metadata.len() != right_metadata.len() {
610 return Ok(false);
611 }
612
613 let left_bytes = fs::read(left)
614 .with_context(|| format!("Failed to read completion file '{}'", left.display()))?;
615 let right_bytes = fs::read(right)
616 .with_context(|| format!("Failed to read completion file '{}'", right.display()))?;
617 Ok(left_bytes == right_bytes)
618}
619
620#[cfg(test)]
621mod tests {
622 use super::{
623 CompletionCacheMismatchKind, CompletionManager, CompletionShell,
624 cache_completion_file_name, choose_one_per_shell, classify_completion_path,
625 };
626 use crate::utils::test_support;
627 use std::{fs, path::Path};
628
629 #[test]
630 fn classifies_supported_completion_names() {
631 assert_eq!(
632 classify_completion_path("rg", Path::new("rg.fish"))
633 .expect("candidate")
634 .shell,
635 CompletionShell::Fish
636 );
637 assert_eq!(
638 classify_completion_path("rg", Path::new("completions.bash"))
639 .expect("candidate")
640 .shell,
641 CompletionShell::Bash
642 );
643 assert_eq!(
644 classify_completion_path("rg", Path::new("completions/_rg.zsh"))
645 .expect("candidate")
646 .shell,
647 CompletionShell::Zsh
648 );
649 assert!(classify_completion_path("rg", Path::new("README.md")).is_none());
650 }
651
652 #[test]
653 fn chooses_best_candidate_per_shell() {
654 let candidates = vec![
655 classify_completion_path("rg", Path::new("completions/rg.fish")).expect("candidate"),
656 classify_completion_path("rg", Path::new("rg.fish")).expect("candidate"),
657 ];
658
659 let selected = choose_one_per_shell(candidates);
660 assert_eq!(selected.len(), 1);
661 assert_eq!(selected[0].path, Path::new("rg.fish"));
662 }
663
664 #[test]
665 fn maps_supported_shell_command_names() {
666 assert_eq!(
667 CompletionShell::from_command("bash"),
668 Some(CompletionShell::Bash)
669 );
670 assert_eq!(
671 CompletionShell::from_command("fish"),
672 Some(CompletionShell::Fish)
673 );
674 assert_eq!(CompletionShell::from_command("nu"), None);
675 }
676
677 #[test]
678 fn uses_flat_package_completion_cache_filenames() {
679 assert_eq!(
680 cache_completion_file_name("rg", CompletionShell::Bash),
681 "rg.bash"
682 );
683 assert_eq!(
684 cache_completion_file_name("rg", CompletionShell::Fish),
685 "rg.fish"
686 );
687 assert_eq!(
688 cache_completion_file_name("rg", CompletionShell::Zsh),
689 "_rg.zsh"
690 );
691 }
692
693 #[test]
694 fn caches_completions_from_root_under_package_directory() {
695 let root = test_support::temp_root("upstream-completion-manager", "root");
696 let paths = test_support::upstream_paths(&root);
697 let source_root = root.join("source");
698 fs::create_dir_all(source_root.join("completions")).expect("create source");
699 fs::write(
700 source_root.join("completions/rg.bash"),
701 "complete -F _rg rg\n",
702 )
703 .expect("write bash");
704 fs::write(source_root.join("completions/rg.fish"), "complete -c rg\n").expect("write fish");
705
706 let mut no_messages: Option<fn(&str)> = None;
707 CompletionManager::new(&paths)
708 .install_from_root("rg", &source_root, &mut no_messages)
709 .expect("install completions");
710
711 assert_eq!(
712 fs::read_to_string(root.join("data/cache/completions/rg/rg.bash")).expect("bash cache"),
713 "complete -F _rg rg\n"
714 );
715 assert_eq!(
716 fs::read_to_string(root.join("data/cache/completions/rg/rg.fish")).expect("fish cache"),
717 "complete -c rg\n"
718 );
719
720 fs::remove_dir_all(root).expect("cleanup");
721 }
722
723 #[test]
724 fn reports_cached_completion_mismatches_for_shell_destinations() {
725 let root = test_support::temp_root("upstream-completion-manager", "mismatch");
726 let paths = test_support::upstream_paths(&root);
727 let manager = CompletionManager::new(&paths);
728
729 fs::create_dir_all(root.join("data/cache/completions/rg")).expect("create cache");
730 fs::create_dir_all(&paths.integration.fish_completions_dir).expect("create fish dir");
731 fs::create_dir_all(&paths.integration.zsh_completions_dir).expect("create zsh dir");
732 fs::write(
733 root.join("data/cache/completions/rg/rg.bash"),
734 "bash cached\n",
735 )
736 .expect("write cached bash");
737 fs::write(
738 root.join("data/cache/completions/rg/rg.fish"),
739 "fish cached\n",
740 )
741 .expect("write cached fish");
742 fs::write(
743 root.join("data/cache/completions/rg/_rg.zsh"),
744 "zsh cached\n",
745 )
746 .expect("write cached zsh");
747 fs::write(
748 paths.integration.fish_completions_dir.join("rg.fish"),
749 "fish installed\n",
750 )
751 .expect("write installed fish");
752 fs::write(
753 paths.integration.zsh_completions_dir.join("_rg"),
754 "zsh cached\n",
755 )
756 .expect("write installed zsh");
757
758 let mismatches = manager
759 .cached_completion_mismatches_for_shells(
760 "rg",
761 &[
762 CompletionShell::Bash,
763 CompletionShell::Fish,
764 CompletionShell::Zsh,
765 ],
766 )
767 .expect("mismatches");
768
769 assert_eq!(mismatches.len(), 2);
770 assert_eq!(mismatches[0].shell, CompletionShell::Bash);
771 assert_eq!(mismatches[0].kind, CompletionCacheMismatchKind::Missing);
772 assert_eq!(mismatches[1].shell, CompletionShell::Fish);
773 assert_eq!(mismatches[1].kind, CompletionCacheMismatchKind::Different);
774
775 fs::remove_dir_all(root).expect("cleanup");
776 }
777
778 #[test]
779 fn copies_cached_completions_to_shell_destinations() {
780 let root = test_support::temp_root("upstream-completion-manager", "copy");
781 let paths = test_support::upstream_paths(&root);
782 let manager = CompletionManager::new(&paths);
783
784 fs::create_dir_all(root.join("data/cache/completions/rg")).expect("create cache");
785 fs::write(
786 root.join("data/cache/completions/rg/rg.bash"),
787 "bash cached\n",
788 )
789 .expect("write cached bash");
790 fs::write(
791 root.join("data/cache/completions/rg/rg.fish"),
792 "fish cached\n",
793 )
794 .expect("write cached fish");
795
796 let mut no_messages: Option<fn(&str)> = None;
797 let copied = manager
798 .copy_cached_completions_to_shells_for_shells(
799 "rg",
800 &[CompletionShell::Bash, CompletionShell::Fish],
801 &mut no_messages,
802 )
803 .expect("copy cached completions");
804
805 assert_eq!(copied, 2);
806 assert_eq!(
807 fs::read_to_string(paths.integration.bash_completions_dir.join("rg"))
808 .expect("bash installed"),
809 "bash cached\n"
810 );
811 assert_eq!(
812 fs::read_to_string(paths.integration.fish_completions_dir.join("rg.fish"))
813 .expect("fish installed"),
814 "fish cached\n"
815 );
816
817 fs::remove_dir_all(root).expect("cleanup");
818 }
819}