Skip to main content

upstream_rs/services/integration/
completion_manager.rs

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}