Skip to main content

cargo_spellcheck/config/
args.rs

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/// Checker types to be derived from the stringly typed arguments.
27#[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    /// Provide a configuration.
97    pub cfg: Option<PathBuf>,
98
99    #[clap(flatten)]
100    pub verbosity: clap_verbosity_flag::Verbosity,
101
102    // is required, but we use `subcommand_negates_reqs`, so it's not
103    // when a command exists
104    #[clap(flatten)]
105    /// Short-cut for `cargo spellcheck check`.
106    pub common: Common,
107
108    #[clap(short, long)]
109    /// Alt for `cargo spellcheck fix` [deprecated].
110    pub fix: bool,
111
112    #[clap(subcommand)]
113    /// Available sub-commands.
114    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    /// Recurse based on the current directory, or all given
122    /// argument paths, and also declared modules in rust files.
123    pub recursive: bool,
124
125    // with fallback from config, so it has to be tri-state
126    #[clap(long)]
127    /// Execute the given subset of checkers.
128    pub checkers: Option<MultipleCheckerTypes>,
129
130    #[clap(short, long)]
131    /// Do not check the referenced key `readme=` or default `README.md`.
132    pub skip_readme: bool,
133
134    #[clap(short, long)]
135    /// Also check developer comments besides documentation comments.
136    pub dev_comments: bool,
137
138    #[clap(short, long)]
139    /// The number of worker threads to spawn for the actual processing text.
140    pub jobs: Option<usize>,
141
142    #[clap(short = 'm', long, default_value_t = 0_u8)]
143    /// Return code of the application if spelling mistakes were found.
144    pub code: u8,
145
146    /// A list of files and directories to check. See `--recursive`.
147    pub paths: Vec<PathBuf>,
148}
149
150#[derive(Debug, PartialEq, Eq, clap::Subcommand)]
151#[clap(rename_all = "kebab-case")]
152pub enum Sub {
153    /// Only show check errors, but do not request user input.
154    // `cargo spellcheck` is short for checking.
155    Check {
156        #[clap(flatten)]
157        common: Common,
158    },
159
160    /// Interactively choose from checker provided suggestions.
161    Fix {
162        #[clap(flatten)]
163        common: Common,
164    },
165
166    /// Reflow doc comments, so they adhere to a given maximum column width.
167    Reflow {
168        #[clap(flatten)]
169        common: Common,
170    },
171
172    /// Print the config being in use, default config if none.
173    Config {
174        #[clap(short, long)]
175        /// Write to the default user configuration file path.
176        user: bool,
177
178        #[clap(short, long)]
179        /// Force overwrite an existing user config.
180        overwrite: bool,
181
182        #[clap(short, long)]
183        /// Write to `stdout`.
184        stdout: bool,
185
186        #[clap(long)]
187        // Deprecated alias, will be removed in the future.
188        #[clap(alias = "checkers")]
189        /// Limit checkers to enable in the generated configuration.
190        filter: Option<MultipleCheckerTypes>,
191    },
192
193    /// List all files in depth-first-sorted-order in which they would be
194    /// checked.
195    ListFiles {
196        #[clap(short, long)]
197        /// Recurse down directories and module declaration derived paths.
198        recursive: bool,
199
200        #[clap(short, long)]
201        /// Do not check the referenced key `readme=` or default `README.md`.
202        skip_readme: bool,
203
204        /// A list of files and directories to check. See `--recursive`.
205        paths: Vec<PathBuf>,
206    },
207
208    /// Print completions.
209    Completions {
210        #[clap(long, env="SHELL", value_parser = load_shell_name)]
211        /// Provide the `shell` for which to generate the completion script.
212        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
225/// Either provided as a path with `/` as separator or directly.
226fn 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    /// Extract the verbosity level
268    pub fn verbosity(&self) -> log::LevelFilter {
269        self.verbosity.log_level_filter()
270    }
271
272    /// Extract the required action.
273    pub fn action(&self) -> Action {
274        // extract operation mode
275        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    /// Adjust the raw arguments for call variants.
294    ///
295    /// The program could be called like `cargo-spellcheck`, `cargo spellcheck`
296    /// or `cargo spellcheck check` and even ``cargo-spellcheck check`.
297    pub fn parse(argv_iter: impl IntoIterator<Item = String>) -> Result<Self, clap::Error> {
298        <Args as clap::Parser>::try_parse_from({
299            // if ends with file name `cargo-spellcheck`
300            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                        // allow all variants to be parsed
305                        // cargo spellcheck ...
306                        // cargo-spellcheck ...
307                        // cargo-spellcheck spellcheck ...
308                        //
309                        // so preprocess them to unified `cargo-spellcheck`
310                        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                                // drop the first arg `spellcheck`
318                            }
319                            Some(arg) if file_name.starts_with("cargo") && &arg == "spellcheck" => {
320                                // drop it, we replace it with `cargo-spellcheck`
321                            }
322                            Some(arg) if arg == "spellcheck" => {
323                                // "spellcheck" but the binary got renamed
324                                // drop the "spellcheck" part
325                            }
326                            Some(arg) => {
327                                // not "spellcheck" so retain it
328                                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    /// Overrides the enablement status of checkers in the configuration based
343    /// on the checkers enabled by argument, if it is set.
344    ///
345    /// Errors of no checkers are left.
346    pub fn checker_selection_override(
347        filter_set: Option<&[CheckerType]>,
348        config: &mut Config,
349    ) -> Result<()> {
350        // overwrite checkers
351        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    /// Load configuration with fallbacks.
376    ///
377    /// Does IO checks if files exist.
378    ///
379    /// Provides a config and where it was retrieved from, if no config file
380    /// exists, a default is provided and the config path becomes `None`.
381    ///
382    /// 1. explicitly specified cli flag, error if it does not exist or parse
383    /// 2. `Cargo.toml` metadata (unimplemented), error if it does not exist or parse
384    /// 3. find a `Cargo.toml` and try to find `.config/spellcheck.toml` error if it does not parse
385    /// 4. Fallback to per-user config, error if it does not parse
386    /// 5. Default config, error if it does not parse
387    ///
388    // TODO split the IO operations and lookup dirs.
389    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        // 1. explicitly specified
393        let explicit_cfg = self.cfg.as_ref().map(|config_path| {
394            if config_path.is_absolute() {
395                config_path.to_owned()
396            } else {
397                // TODO make sure this is sane behavior
398                // to use `cwd`.
399                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        // (prep) determine if there should be an attempt to read a cargo manifest from the target dir
416        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        // 2. manifest meta in target dir
425        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        // 3. manifest meta in current working dir
437        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        // 4. load from `.config/spellcheck.toml` from the current working directory.
444        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        // mask all disabled checkers, use the default config
467        // for those which have one if not enabled already.
468
469        // FIXME: Due to an increase adoption, having `NlpRules` enabled by default,
470        // causes friction for users, especially in presence of inline codes which are
471        // elided, and cause even worse suggestions.
472        // ISSUE: https://github.com/drahnr/cargo-spellcheck/issues/242
473        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            // reflow is a different subcommand, not relevant
506        }
507
508        Ok((config, config_path))
509    }
510
511    /// Evaluate the configuration flags, overwrite config values as needed and
512    /// provide a new, unified config struct.
513    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, // not relevant
544                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/// Unified arguments with configuration fallbacks.
588///
589/// Only contains options which are either only present in the arguments, or are
590/// present in the arguments and have a fallback in the configuration.
591#[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    /// Extract the action.
610    pub fn action(&self) -> Action {
611        match self {
612            Self::Operate { action, .. } => *action,
613            _ => unreachable!(),
614        }
615    }
616}
617
618/// Try to find a cargo manifest, given a path, that can either be a directory
619/// or a path to a manifest.
620fn 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
693/// Set the worker pool job/thread count.
694///
695/// Affects the parallel processing for a particular checker. Checkers are
696/// always executed in sequence.
697pub 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            // commonly we are not the only process
720            // on the machine, so use the physical cores.
721            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            // check (implicit)
740            "cargo spellcheck" => Action::Check,
741            "cargo spellcheck -vvvv" => Action::Check,
742            "cargo-spellcheck" => Action::Check,
743            "cargo-spellcheck -vvvv" => Action::Check,
744            // check (explicit)
745            "cargo spellcheck check -m 11" => Action::Check,
746            "cargo-spellcheck check -m 9" => Action::Check,
747            // reflow
748            "cargo spellcheck reflow" => Action::Reflow,
749            "cargo-spellcheck reflow" => Action::Reflow,
750            // fix (deprecated)
751            "cargo spellcheck --fix" => Action::Fix,
752            "cargo-spellcheck --fix" => Action::Fix,
753            // fix
754            "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            // FIXME check it fully, against the unified args
761            // TODO must implement an abstraction for the config file source for that
762            // "cargo spellcheck completions --shell zsh" => Sub::Completions { shell: Shell::Zsh },
763            // "cargo spellcheck completions --shell bash" => Sub::Completions { shell: Shell::Bash },
764            // "cargo-spellcheck completions --shell zsh" => Sub::Completions { shell: Shell::Zsh },
765            // "cargo-spellcheck completions --shell bash" => Sub::Completions { shell: Shell::Bash },
766            // "cargo-spellcheck completions" => Sub::Completions { .. },
767        };
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    // FIXME checkers interpretation seems to have changed XXX
836    #[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        // `SHELL` is builtin, and hence cannot be unset
879        // so a negative test is impossible, an override works though
880        // `std::env::remove_var("SHELL");`
881        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}