1use std::path::{Path, PathBuf};
2
3use crate::errors::*;
4
5use fs_err as fs;
6use itertools::Itertools;
7use serde::Deserialize;
8use std::str::FromStr;
9
10use crate::Action;
11
12use super::Config;
13
14use clap_complete::Shell;
15
16#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
17pub struct ManifestMetadata {
18 spellcheck: Option<ManifestMetadataSpellcheck>,
19}
20
21#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
22pub struct ManifestMetadataSpellcheck {
23 config: PathBuf,
24}
25
26#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize)]
28pub enum CheckerType {
29 Hunspell,
30 ZSpell,
31 Spellbook,
32 NlpRules,
33 Reflow,
34}
35
36impl FromStr for CheckerType {
37 type Err = UnknownCheckerTypeVariant;
38 fn from_str(s: &str) -> Result<Self, Self::Err> {
39 let s = s.to_lowercase();
40 Ok(match s.as_str() {
41 "nlprules" => Self::NlpRules,
42 "zet" | "zspell" => Self::ZSpell,
43 "spellbook" | "book" => Self::Spellbook,
44 "hunspell" => Self::Hunspell,
45 "reflow" => Self::Reflow,
46 _other => return Err(UnknownCheckerTypeVariant(s)),
47 })
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct MultipleCheckerTypes(pub Vec<CheckerType>);
53
54impl AsRef<[CheckerType]> for MultipleCheckerTypes {
55 fn as_ref(&self) -> &[CheckerType] {
56 self.0.as_slice()
57 }
58}
59
60impl std::ops::Deref for MultipleCheckerTypes {
61 type Target = [CheckerType];
62 fn deref(&self) -> &Self::Target {
63 self.0.as_slice()
64 }
65}
66
67impl IntoIterator for MultipleCheckerTypes {
68 type Item = CheckerType;
69 type IntoIter = <Vec<Self::Item> as IntoIterator>::IntoIter;
70
71 fn into_iter(self) -> Self::IntoIter {
72 self.0.into_iter()
73 }
74}
75
76impl FromStr for MultipleCheckerTypes {
77 type Err = UnknownCheckerTypeVariant;
78 fn from_str(s: &str) -> Result<Self, Self::Err> {
79 s.split(',')
80 .map(<CheckerType as FromStr>::from_str)
81 .collect::<Result<Vec<_>, _>>()
82 .map(MultipleCheckerTypes)
83 }
84}
85
86#[derive(Debug, Clone, thiserror::Error)]
87#[error("Unknown checker type variant: {0}")]
88pub struct UnknownCheckerTypeVariant(String);
89
90#[derive(clap::Parser, Debug)]
91#[clap(author, version, about, long_about = None)]
92#[clap(rename_all = "kebab-case")]
93#[clap(subcommand_negates_reqs(true))]
94pub struct Args {
95 #[clap(short, long, global(true))]
96 pub cfg: Option<PathBuf>,
98
99 #[clap(flatten)]
100 pub verbosity: clap_verbosity_flag::Verbosity,
101
102 #[clap(flatten)]
105 pub common: Common,
107
108 #[clap(short, long)]
109 pub fix: bool,
111
112 #[clap(subcommand)]
113 pub command: Option<Sub>,
115}
116
117#[derive(Debug, PartialEq, Eq, clap::Parser)]
118#[clap(rename_all = "kebab-case")]
119pub struct Common {
120 #[clap(short, long)]
121 pub recursive: bool,
124
125 #[clap(long)]
127 pub checkers: Option<MultipleCheckerTypes>,
129
130 #[clap(short, long)]
131 pub skip_readme: bool,
133
134 #[clap(short, long)]
135 pub dev_comments: bool,
137
138 #[clap(short, long)]
139 pub jobs: Option<usize>,
141
142 #[clap(short = 'm', long, default_value_t = 0_u8)]
143 pub code: u8,
145
146 pub paths: Vec<PathBuf>,
148}
149
150#[derive(Debug, PartialEq, Eq, clap::Subcommand)]
151#[clap(rename_all = "kebab-case")]
152pub enum Sub {
153 Check {
156 #[clap(flatten)]
157 common: Common,
158 },
159
160 Fix {
162 #[clap(flatten)]
163 common: Common,
164 },
165
166 Reflow {
168 #[clap(flatten)]
169 common: Common,
170 },
171
172 Config {
174 #[clap(short, long)]
175 user: bool,
177
178 #[clap(short, long)]
179 overwrite: bool,
181
182 #[clap(short, long)]
183 stdout: bool,
185
186 #[clap(long)]
187 #[clap(alias = "checkers")]
189 filter: Option<MultipleCheckerTypes>,
191 },
192
193 ListFiles {
196 #[clap(short, long)]
197 recursive: bool,
199
200 #[clap(short, long)]
201 skip_readme: bool,
203
204 paths: Vec<PathBuf>,
206 },
207
208 Completions {
210 #[clap(long, env="SHELL", value_parser = load_shell_name)]
211 shell: Shell,
213 },
214}
215
216#[derive(thiserror::Error, Debug, Clone)]
217enum ShellErr {
218 #[error("Unknown shell: {shell:?}")]
219 UnknownShell { shell: String },
220
221 #[error("Missing SHELL argument")]
222 MissingArg,
223}
224
225fn load_shell_name(shell: &str) -> Result<Shell, ShellErr> {
227 shell
228 .split('/')
229 .next_back()
230 .map(|shell| {
231 Shell::from_str(shell).map_err(|unknown_shell| ShellErr::UnknownShell {
232 shell: unknown_shell,
233 })
234 })
235 .unwrap_or_else(|| Err(ShellErr::MissingArg))
236}
237
238pub fn generate_completions<G: clap_complete::Generator, W: std::io::Write>(
239 generator: G,
240 sink: &mut W,
241) {
242 let mut app = <Args as clap::CommandFactory>::command();
243 let app = &mut app;
244 clap_complete::generate(generator, app, app.get_name().to_string(), sink);
245}
246
247impl Args {
248 pub fn common(&self) -> Option<&Common> {
249 match &self.command {
250 Some(
251 Sub::Check { common, .. } | Sub::Fix { common, .. } | Sub::Reflow { common, .. },
252 ) => Some(common),
253 None => Some(&self.common),
254 Some(Sub::Completions { .. } | Sub::ListFiles { .. } | Sub::Config { .. }) => None,
255 }
256 }
257
258 pub fn checkers(&self) -> Option<Vec<CheckerType>> {
259 self.common()
260 .and_then(|common| common.checkers.as_ref().map(|checkers| checkers.0.clone()))
261 }
262
263 pub fn job_count(&self) -> usize {
264 derive_job_count(self.common().and_then(|common| common.jobs))
265 }
266
267 pub fn verbosity(&self) -> log::LevelFilter {
269 self.verbosity.log_level_filter()
270 }
271
272 pub fn action(&self) -> Action {
274 let action = if let Some(sub) = &self.command {
276 match sub {
277 Sub::Check { .. } => Action::Check,
278 Sub::Fix { .. } => Action::Fix,
279 Sub::Reflow { .. } => Action::Reflow,
280 Sub::ListFiles { .. } => Action::ListFiles,
281 Sub::Config { .. } => unreachable!(),
282 Sub::Completions { .. } => unreachable!(),
283 }
284 } else if self.fix {
285 Action::Fix
286 } else {
287 Action::Check
288 };
289 log::trace!("Derived action {action:?} from flags/args/cmds");
290 action
291 }
292
293 pub fn parse(argv_iter: impl IntoIterator<Item = String>) -> Result<Self, clap::Error> {
298 <Args as clap::Parser>::try_parse_from({
299 let mut argv_iter = argv_iter.into_iter();
301 if let Some(arg0) = argv_iter.next() {
302 match PathBuf::from(&arg0).file_name().and_then(|x| x.to_str()) {
303 Some(file_name) => {
304 let mut next = vec!["cargo-spellcheck".to_owned()];
311
312 match argv_iter.next() {
313 Some(arg)
314 if file_name.starts_with("cargo-spellcheck")
315 && arg == "spellcheck" =>
316 {
317 }
319 Some(arg) if file_name.starts_with("cargo") && &arg == "spellcheck" => {
320 }
322 Some(arg) if arg == "spellcheck" => {
323 }
326 Some(arg) => {
327 next.push(arg.to_owned())
329 }
330 None => {}
331 };
332 Vec::from_iter(next.into_iter().chain(argv_iter))
333 }
334 _ => Vec::from_iter(argv_iter),
335 }
336 } else {
337 Vec::new()
338 }
339 })
340 }
341
342 pub fn checker_selection_override(
347 filter_set: Option<&[CheckerType]>,
348 config: &mut Config,
349 ) -> Result<()> {
350 if let Some(checkers) = filter_set {
352 #[cfg(feature = "hunspell")]
353 if !checkers.contains(&CheckerType::Hunspell) && config.hunspell.take().is_none() {
354 log::warn!("Hunspell was never configured.")
355 }
356 #[cfg(feature = "nlprules")]
357 if !checkers.contains(&CheckerType::NlpRules) && config.nlprules.take().is_none() {
358 log::warn!("Nlprules checker was never configured.")
359 }
360
361 if !checkers.contains(&CheckerType::Reflow) {
362 log::warn!("Reflow is a separate sub command.")
363 }
364
365 const EXPECTED_COUNT: usize =
366 1_usize + cfg!(feature = "nlprules") as usize + cfg!(feature = "hunspell") as usize;
367
368 if checkers.iter().unique().count() == EXPECTED_COUNT {
369 bail!("Argument override for checkers disabled all checkers")
370 }
371 }
372 Ok(())
373 }
374
375 fn load_config_inner(&self) -> Result<(Config, Option<PathBuf>)> {
390 log::debug!("Attempting to load configuration by priority.");
391 let cwd = crate::traverse::cwd()?;
392 let explicit_cfg = self.cfg.as_ref().map(|config_path| {
394 if config_path.is_absolute() {
395 config_path.to_owned()
396 } else {
397 cwd.join(config_path)
400 }
401 });
402
403 if let Some(config_path) = explicit_cfg {
404 log::debug!(
405 "Using configuration file provided by flag (1) {}",
406 config_path.display()
407 );
408 let config =
409 Config::load_from(&config_path)?.ok_or_else(|| eyre!("File does not exist."))?;
410 return Ok((config, Some(config_path)));
411 }
412
413 log::debug!("No cfg flag present");
414
415 let single_target_path = self.common().and_then(|common| {
417 common
418 .paths
419 .first()
420 .filter(|_x| common.paths.len() == 1)
421 .cloned()
422 });
423
424 let manifest_path_in_target_dir = if let Some(ref base) = single_target_path {
426 look_for_cargo_manifest(base)?
427 } else {
428 None
429 };
430 if let Some(manifest_path) = &manifest_path_in_target_dir {
431 if let Some((config, config_path)) = load_from_manifest_metadata(manifest_path)? {
432 return Ok((config, Some(config_path)));
433 }
434 };
435
436 if let Some(manifest_path) = look_for_cargo_manifest(&cwd)? {
438 if let Some((config, config_path)) = load_from_manifest_metadata(&manifest_path)? {
439 return Ok((config, Some(config_path)));
440 }
441 };
442
443 let config_path = cwd.join(".config").join("spellcheck.toml");
445 if let Some(cfg) = Config::load_from(&config_path)? {
446 log::debug!("Using configuration file (4) {}", config_path.display());
447 return Ok((cfg, Some(config_path)));
448 }
449
450 let default_config_path = Config::default_path()?;
451 if let Some(cfg) = Config::load_from(&default_config_path)? {
452 log::debug!(
453 "Using configuration file (5) {}",
454 default_config_path.display()
455 );
456 return Ok((cfg, Some(default_config_path)));
457 }
458
459 log::debug!("No user config present {}", default_config_path.display());
460 log::debug!("Using configuration default, builtin configuration (5)");
461 Ok((Config::default(), None))
462 }
463
464 fn load_config(&self) -> Result<(Config, Option<PathBuf>)> {
465 let (mut config, config_path) = self.load_config_inner()?;
466 let filter_set = self
474 .checkers()
475 .unwrap_or_else(|| vec![CheckerType::Hunspell]);
476 {
477 if filter_set.contains(&CheckerType::Hunspell) {
478 if config.hunspell.is_none() {
479 config.hunspell = Some(crate::config::HunspellConfig::default());
480 }
481 } else {
482 config.hunspell = None;
483 }
484 if filter_set.contains(&CheckerType::ZSpell) {
485 if config.zet.is_none() {
486 config.zet = Some(crate::config::ZetConfig::default());
487 }
488 } else {
489 config.zet = None;
490 }
491 if filter_set.contains(&CheckerType::Spellbook) {
492 if config.spellbook.is_none() {
493 config.spellbook = Some(crate::config::SpellbookConfig::default());
494 }
495 } else {
496 config.spellbook = None;
497 }
498 if filter_set.contains(&CheckerType::NlpRules) {
499 if config.nlprules.is_none() {
500 config.nlprules = Some(crate::config::NlpRulesConfig::default());
501 }
502 } else {
503 config.nlprules = None;
504 }
505 }
507
508 Ok((config, config_path))
509 }
510
511 pub fn unified(self) -> Result<(UnifiedArgs, Config)> {
514 let (config, config_path) = self.load_config()?;
515 let unified = match self.command {
516 Some(Sub::Config {
517 stdout,
518 user,
519 overwrite,
520 filter: checkers,
521 }) => {
522 let dest_config = match self.cfg {
523 None if stdout => ConfigWriteDestination::Stdout,
524 Some(path) => ConfigWriteDestination::File { overwrite, path },
525 None if user => ConfigWriteDestination::File {
526 overwrite,
527 path: Config::default_path()?,
528 },
529 _ => bail!("Neither --user or --stdout are given, invalid flags passed."),
530 };
531 UnifiedArgs::Config {
532 dest_config,
533 checker_filter_set: checkers,
534 }
535 }
536 Some(Sub::ListFiles {
537 ref paths,
538 recursive,
539 skip_readme,
540 }) => UnifiedArgs::Operate {
541 action: self.action(),
542 config_path,
543 dev_comments: false, skip_readme,
545 recursive,
546 paths: paths.clone(),
547 exit_code_override: 1,
548 },
549 None => {
550 let common = &self.common;
551 UnifiedArgs::Operate {
552 action: Action::Check,
553 config_path,
554 dev_comments: common.dev_comments || config.dev_comments,
555 skip_readme: common.skip_readme || config.skip_readme,
556 recursive: common.recursive,
557 paths: common.paths.clone(),
558 exit_code_override: common.code,
559 }
560 }
561 Some(
562 Sub::Reflow { ref common, .. }
563 | Sub::Fix { ref common, .. }
564 | Sub::Check { ref common, .. },
565 ) => UnifiedArgs::Operate {
566 action: self.action(),
567 config_path,
568 dev_comments: common.dev_comments || config.dev_comments,
569 skip_readme: common.skip_readme || config.skip_readme,
570 recursive: common.recursive,
571 paths: common.paths.clone(),
572 exit_code_override: common.code,
573 },
574 Some(Sub::Completions { .. }) => unreachable!("Was handled earlier. qed"),
575 };
576
577 Ok((unified, config))
578 }
579}
580
581#[derive(Debug, Clone)]
582pub enum ConfigWriteDestination {
583 Stdout,
584 File { overwrite: bool, path: PathBuf },
585}
586
587#[derive(Debug, Clone)]
592pub enum UnifiedArgs {
593 Config {
594 dest_config: ConfigWriteDestination,
595 checker_filter_set: Option<MultipleCheckerTypes>,
596 },
597 Operate {
598 action: Action,
599 config_path: Option<PathBuf>,
600 dev_comments: bool,
601 skip_readme: bool,
602 recursive: bool,
603 paths: Vec<PathBuf>,
604 exit_code_override: u8,
605 },
606}
607
608impl UnifiedArgs {
609 pub fn action(&self) -> Action {
611 match self {
612 Self::Operate { action, .. } => *action,
613 _ => unreachable!(),
614 }
615 }
616}
617
618fn look_for_cargo_manifest(base: &Path) -> Result<Option<PathBuf>> {
621 Ok(if base.is_dir() {
622 let base = base.join("Cargo.toml");
623 if base.is_file() {
624 let base = base.canonicalize()?;
625 log::debug!("Using {} manifest as anchor file", base.display());
626 Some(base)
627 } else {
628 log::debug!("Cargo manifest files does not exist: {}", base.display());
629 None
630 }
631 } else if let Some(file_name) = base.file_name() {
632 if file_name == "Cargo.toml" && base.is_file() {
633 let base = base.canonicalize()?;
634 log::debug!("Using {} manifest as anchor file", base.display());
635 Some(base)
636 } else {
637 log::debug!("Cargo manifest files does not exist: {}", base.display());
638 None
639 }
640 } else {
641 log::debug!(
642 "Provided parse target is neither file or dir: {}",
643 base.display()
644 );
645 None
646 })
647}
648
649fn extract_config_path_from_metadata(
650 manifest_path: &Path,
651 metadata: ManifestMetadata,
652 ident: &str,
653) -> Result<Option<(Config, PathBuf)>> {
654 if let Some(spellcheck) = metadata.spellcheck {
655 let config_path = &spellcheck.config;
656 let config_path = if config_path.is_absolute() {
657 config_path.to_owned()
658 } else {
659 let manifest_dir = manifest_path.parent().expect("File resides in a dir. qed");
660 manifest_dir.join(config_path)
661 };
662 log::debug!(
663 "Using configuration ({}) file {}",
664 ident,
665 config_path.display()
666 );
667 return Ok(Config::load_from(&config_path)?.map(|config| (config, config_path)));
668 }
669 Ok(None)
670}
671
672fn load_from_manifest_metadata(manifest_path: &Path) -> Result<Option<(Config, PathBuf)>> {
673 let manifest = fs::read_to_string(manifest_path)?;
674 let manifest =
675 cargo_toml::Manifest::<ManifestMetadata>::from_slice_with_metadata(manifest.as_bytes())
676 .wrap_err(format!(
677 "Failed to parse cargo manifest: {}",
678 manifest_path.display()
679 ))?;
680 if let Some(metadata) = manifest.package.and_then(|package| package.metadata) {
681 if let Some(x) = extract_config_path_from_metadata(manifest_path, metadata, "package")? {
682 return Ok(Some(x));
683 }
684 }
685 if let Some(metadata) = manifest.workspace.and_then(|workspace| workspace.metadata) {
686 if let Some(x) = extract_config_path_from_metadata(manifest_path, metadata, "workspace")? {
687 return Ok(Some(x));
688 }
689 }
690 Ok(None)
691}
692
693pub fn derive_job_count(jobs: impl Into<Option<usize>>) -> usize {
698 let maybe_jobs = jobs.into();
699 match maybe_jobs {
700 _ if cfg!(debug_assertions) => {
701 log::warn!("Debug mode always uses 1 thread!");
702 1
703 }
704 Some(jobs) if jobs == 0 => {
705 log::warn!(
706 "Cannot have less than one worker thread ({jobs}). Retaining one worker thread."
707 );
708 1
709 }
710 Some(jobs) if jobs > 128 => {
711 log::warn!("Setting threads beyond 128 ({jobs}) is insane. Capping at 128");
712 128
713 }
714 Some(jobs) => {
715 log::info!("Explicitly set threads to {jobs}");
716 jobs
717 }
718 None => {
719 let jobs = num_cpus::get_physical();
722 log::debug!("Using the default physical thread count of {jobs}");
723 jobs
724 }
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use assert_matches::assert_matches;
732
733 fn commandline_to_iter(s: &'static str) -> impl Iterator<Item = String> {
734 s.split(' ').map(|s| s.to_owned()).into_iter()
735 }
736
737 lazy_static::lazy_static!(
738 static ref SAMPLES: std::collections::HashMap<&'static str, Action> = maplit::hashmap!{
739 "cargo spellcheck" => Action::Check,
741 "cargo spellcheck -vvvv" => Action::Check,
742 "cargo-spellcheck" => Action::Check,
743 "cargo-spellcheck -vvvv" => Action::Check,
744 "cargo spellcheck check -m 11" => Action::Check,
746 "cargo-spellcheck check -m 9" => Action::Check,
747 "cargo spellcheck reflow" => Action::Reflow,
749 "cargo-spellcheck reflow" => Action::Reflow,
750 "cargo spellcheck --fix" => Action::Fix,
752 "cargo-spellcheck --fix" => Action::Fix,
753 "cargo spellcheck fix" => Action::Fix,
755 "cargo-spellcheck fix" => Action::Fix,
756 "cargo-spellcheck fix -r file.rs" => Action::Fix,
757 "cargo-spellcheck -q fix Cargo.toml" => Action::Fix,
758 "cargo spellcheck -v fix Cargo.toml" => Action::Fix,
759
760 };
768 );
769
770 #[test]
771 fn args() {
772 for command in SAMPLES.keys() {
773 assert_matches!(Args::parse(commandline_to_iter(command)), Ok(_));
774 }
775 }
776
777 #[test]
778 fn deserialize_multiple_checkers() {
779 let args = Args::parse(commandline_to_iter(
780 "cargo spellcheck check --checkers=nlprules,hunspell",
781 ))
782 .expect("Parsing works. qed");
783 assert_eq!(
784 args.checkers(),
785 Some(vec![CheckerType::NlpRules, CheckerType::Hunspell])
786 );
787 }
788
789 #[test]
790 fn alt_fix_works() {
791 let args_sub = Args::parse(commandline_to_iter("cargo spellcheck fix")).unwrap();
792 let args_alt = Args::parse(commandline_to_iter("cargo spellcheck --fix")).unwrap();
793 assert_eq!(args_sub.common(), args_alt.common());
794 assert_eq!(args_sub.action(), args_alt.action());
795 }
796
797 #[test]
798 fn unify_ops_check() {
799 let args = Args::parse(
800 &mut [
801 "cargo",
802 "spellcheck",
803 "-vvvvv",
804 "check",
805 "--code=77",
806 "--dev-comments",
807 "--skip-readme",
808 ]
809 .iter()
810 .map(ToOwned::to_owned)
811 .map(ToOwned::to_owned),
812 )
813 .unwrap();
814 let (unified, _config) = args.unified().unwrap();
815 assert_matches!(unified,
816 UnifiedArgs::Operate {
817 action,
818 config_path: _,
819 dev_comments,
820 skip_readme,
821 recursive,
822 paths,
823 exit_code_override,
824 } => {
825 assert_eq!(Action::Check, action);
826 assert_eq!(exit_code_override, 77);
827 assert_eq!(dev_comments, true);
828 assert_eq!(skip_readme, true);
829 assert_eq!(recursive, false);
830 assert_eq!(paths, Vec::<PathBuf>::new());
831 }
832 );
833 }
834
835 #[test]
837 fn unify_config() {
838 let args = Args::parse(
839 &mut [
840 "cargo-spellcheck",
841 "--cfg=.config/spellcheck.toml",
842 "config",
843 "--checkers=NlpRules",
844 "--overwrite",
845 ]
846 .iter()
847 .map(ToOwned::to_owned)
848 .map(ToOwned::to_owned),
849 )
850 .unwrap();
851 let (unified, _config) = dbg!(args).unified().unwrap();
852 assert_matches!(dbg!(unified),
853 UnifiedArgs::Config {
854 dest_config: ConfigWriteDestination::File { overwrite, path },
855 checker_filter_set,
856 } => {
857 assert_eq!(path, PathBuf::from(".config/spellcheck.toml"));
858 assert_eq!(checker_filter_set, Some(MultipleCheckerTypes(vec![CheckerType::NlpRules])));
859 assert_eq!(overwrite, true);
860 }
861 );
862 }
863
864 #[test]
865 fn shell_check_env() {
866 assert_matches!(load_shell_name("/usr/bin/zsh"), Ok(Shell::Zsh));
867 assert_matches!(load_shell_name("zsh"), Ok(Shell::Zsh));
868 assert_matches!(load_shell_name("fish"), Ok(Shell::Fish));
869
870 static C1: &str = "cargo spellcheck completions --shell zsh";
871 assert_matches!(Args::parse(commandline_to_iter(C1)), Ok(Args {
872 command: Some(Sub::Completions { shell }),
873 ..
874 }) => {
875 assert_eq!(shell.to_string(), "zsh")
876 });
877
878 static C2: &str = "cargo spellcheck completions";
882
883 std::env::set_var("SHELL", "/bin/fish");
884 assert_matches!(Args::parse(commandline_to_iter(C2)), Ok(Args {
885 command: Some(Sub::Completions { shell }),
886 ..
887 }) => {
888 assert_eq!(shell.to_string(), "fish")
889 });
890 }
891}