Repository: PlasmaFAIR/fortitude (language: python)

Issue:
Errors in toml config file are silently ignored
Added the following, one after the other.

```bash
$ grep -B 1 -F "line-length" *.toml
fortitude.toml-[check]
fortitude.toml:line-length = 132
--
fpm.toml-[extra.fortitude.check]
fpm.toml:line-length = 132
```

```bash
(.venv) $ fortitude check app/
...
-- Number of errors: 22

(.venv) $ fortitude check app/ --line-length 132
...
-- Number of errors: 6
```

The command line option is respected, but the files are not. 


Discussion / hints:
Thanks for the bug report @MatthAlex! It looks like we're expecting the setting in the config file to be `line_length` with an underscore, but we're not raising an error on unknown variables in the file.

We should probably use `line-length` for consistency with the CLI, and definitely error on unknown settings.
Yup, that works, confirmed!

Having it be `line-length` also matches the README example.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 76fc0d3..8b1236e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -150,21 +150,6 @@ dependencies = [
  "anstyle",
  "clap_lex",
  "strsim",
- "terminal_size",
-]
-
-[[package]]
-name = "clap_config"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46efb9cbf691f5505d0b7b2c8055aec0c9a770eaac8a06834b6d84b5be93279a"
-dependencies = [
- "clap",
- "heck",
- "proc-macro2",
- "quote",
- "serde",
- "syn",
 ]
 
 [[package]]
@@ -294,7 +279,6 @@ dependencies = [
  "anyhow",
  "assert_cmd",
  "clap",
- "clap_config",
  "colored",
  "fortitude_macros",
  "insta",
@@ -855,16 +839,6 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
-[[package]]
-name = "terminal_size"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef"
-dependencies = [
- "rustix",
- "windows-sys 0.59.0",
-]
-
 [[package]]
 name = "termtree"
 version = "0.4.1"
diff --git a/fortitude/Cargo.toml b/fortitude/Cargo.toml
index b038039..e0a68c1 100644
--- a/fortitude/Cargo.toml
+++ b/fortitude/Cargo.toml
@@ -18,7 +18,6 @@ exclude = [".*", "test.f90"]
 annotate-snippets = "0.11.4"
 anyhow = "1.0.79"
 clap = { version = "4.4.16", features = ["derive", "string"] }
-clap_config = "0.1.1"
 colored = "2.1.0"
 fortitude_macros = { version = "0.1.0", path = "../fortitude_macros" }
 is-macro = "0.3.7"
diff --git a/fortitude/src/check.rs b/fortitude/src/check.rs
index 605b3aa..23354d9 100644
--- a/fortitude/src/check.rs
+++ b/fortitude/src/check.rs
@@ -1,29 +1,158 @@
 use crate::ast::{parse, FortitudeNode};
-use crate::cli::CheckArgs;
-use crate::rule_selector::{PreviewOptions, Specificity};
+use crate::cli::{CheckArgs, GlobalConfigArgs, FORTRAN_EXTS};
+use crate::rule_selector::{PreviewOptions, RuleSelector, Specificity};
 use crate::rules::Rule;
 use crate::rules::{error::ioerror::IoError, AstRuleEnum, PathRuleEnum, TextRuleEnum};
 use crate::settings::{Settings, DEFAULT_SELECTORS};
 use crate::DiagnosticMessage;
-use anyhow::Result;
+
+use anyhow::{Context, Result};
 use colored::Colorize;
 use itertools::{join, Itertools};
 use ruff_diagnostics::Diagnostic;
 use ruff_source_file::{SourceFile, SourceFileBuilder};
 use ruff_text_size::TextRange;
+use serde::{Deserialize, Serialize};
 use std::collections::{BTreeMap, BTreeSet};
 use std::path::{Path, PathBuf};
 use std::process::ExitCode;
 use strum::IntoEnumIterator;
+use toml::Table;
 use walkdir::WalkDir;
 
+// These are just helper structs to let us quickly work out if there's
+// a fortitude section in an fpm.toml file
+#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+struct Fpm {
+    extra: Option<Extra>,
+}
+
+#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+struct Extra {
+    fortitude: Option<CheckSection>,
+}
+
+#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+struct CheckSection {
+    check: Option<CheckArgs>,
+}
+
+// Adapted from ruff
+fn parse_fpm_toml<P: AsRef<Path>>(path: P) -> Result<Fpm> {
+    let contents = std::fs::read_to_string(path.as_ref())
+        .with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
+    toml::from_str(&contents)
+        .with_context(|| format!("Failed to parse {}", path.as_ref().display()))
+}
+
+pub fn fortitude_enabled<P: AsRef<Path>>(path: P) -> Result<bool> {
+    let fpm = parse_fpm_toml(path)?;
+    Ok(fpm.extra.and_then(|extra| extra.fortitude).is_some())
+}
+
+/// Return the path to the `fpm.toml` or `fortitude.toml` file in a given
+/// directory. Adapated from ruff
+pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
+    // Check for `.fortitude.toml`.
+    let fortitude_toml = path.as_ref().join(".fortitude.toml");
+    if fortitude_toml.is_file() {
+        return Ok(Some(fortitude_toml));
+    }
+
+    // Check for `fortitude.toml`.
+    let fortitude_toml = path.as_ref().join("fortitude.toml");
+    if fortitude_toml.is_file() {
+        return Ok(Some(fortitude_toml));
+    }
+
+    // Check for `fpm.toml`.
+    let fpm_toml = path.as_ref().join("fpm.toml");
+    if fpm_toml.is_file() && fortitude_enabled(&fpm_toml)? {
+        return Ok(Some(fpm_toml));
+    }
+
+    Ok(None)
+}
+
+/// Find the path to the `fpm.toml` or `fortitude.toml` file, if such a file
+/// exists. Adapated from ruff
+pub fn find_settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
+    for directory in path.as_ref().ancestors() {
+        if let Some(settings) = settings_toml(directory)? {
+            return Ok(Some(settings));
+        }
+    }
+    Ok(None)
+}
+
+/// Read either the "extra.fortitude" table from "fpm.toml", or the
+/// whole "fortitude.toml" file
+fn from_toml_subsection<P: AsRef<Path>>(path: P) -> Result<CheckSection> {
+    let config_str = if path.as_ref().ends_with("fpm.toml") {
+        let config = std::fs::read_to_string(path)?.parse::<Table>()?;
+
+        // Unwrap should be ok here because we've already checked this
+        // file has these tables
+        let extra = &config["extra"].as_table().unwrap();
+        let fortitude = &extra["fortitude"].as_table().unwrap();
+        fortitude.to_string()
+    } else {
+        std::fs::read_to_string(path)?
+    };
+
+    let config: CheckSection = toml::from_str(&config_str)?;
+
+    Ok(config)
+}
+
+// This is our "known good" intermediate settings struct after we've
+// read the config file, but before we've overridden it from the CLI
+#[derive(Default, Debug)]
+pub struct CheckSettings {
+    pub files: Vec<PathBuf>,
+    pub ignore: Vec<RuleSelector>,
+    pub select: Option<Vec<RuleSelector>>,
+    pub extend_select: Vec<RuleSelector>,
+    pub line_length: usize,
+    pub file_extensions: Vec<String>,
+}
+
+/// Read either fpm.toml or fortitude.toml into our "known good" file
+/// settings struct
+fn parse_config_file(config_file: &Option<PathBuf>) -> Result<CheckSettings> {
+    let filename = match config_file {
+        Some(filename) => filename.clone(),
+        None => match find_settings_toml(".")? {
+            Some(filename) => filename,
+            None => {
+                return Ok(CheckSettings::default());
+            }
+        },
+    };
+
+    let settings = match from_toml_subsection(filename)?.check {
+        Some(value) => CheckSettings {
+            files: value.files.unwrap_or(vec![PathBuf::from(".")]),
+            ignore: value.ignore.unwrap_or_default(),
+            select: value.select,
+            extend_select: value.extend_select.unwrap_or_default(),
+            line_length: value.line_length.unwrap_or(Settings::default().line_length),
+            file_extensions: value
+                .file_extensions
+                .unwrap_or(FORTRAN_EXTS.iter().map(|ext| ext.to_string()).collect_vec()),
+        },
+        None => CheckSettings::default(),
+    };
+    Ok(settings)
+}
+
 /// Get the list of active rules for this session.
-fn ruleset(args: &CheckArgs) -> anyhow::Result<Vec<Rule>> {
+fn ruleset(args: RuleSelection) -> anyhow::Result<Vec<Rule>> {
     // TODO: Take this as an option
     let preview = PreviewOptions::default();
 
     // The select_set keeps track of which rules have been selected.
-    let mut select_set: BTreeSet<Rule> = if args.select.is_empty() {
+    let mut select_set: BTreeSet<Rule> = if args.select.is_none() {
         DEFAULT_SELECTORS
             .iter()
             .flat_map(|selector| selector.rules(&preview))
@@ -37,6 +166,7 @@ fn ruleset(args: &CheckArgs) -> anyhow::Result<Vec<Rule>> {
         for selector in args
             .select
             .iter()
+            .flatten()
             .chain(args.extend_select.iter())
             .filter(|s| s.specificity() == spec)
         {
@@ -194,93 +324,115 @@ pub(crate) fn ast_entrypoint_map<'a>(rules: &[Rule]) -> BTreeMap<&'a str, Vec<As
     map
 }
 
+// Taken from Ruff
+#[derive(Clone, Debug, Default)]
+pub struct RuleSelection {
+    pub select: Option<Vec<RuleSelector>>,
+    pub ignore: Vec<RuleSelector>,
+    pub extend_select: Vec<RuleSelector>,
+}
+
 /// Check all files, report issues found, and return error code.
-pub fn check(args: CheckArgs) -> Result<ExitCode> {
+pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitCode> {
+    // First we need to find and read any config file
+    let file_settings = parse_config_file(&global_options.config_file)?;
+    // Now, we can override settings from the config file with options
+    // from the CLI
+    let files = &args.files.unwrap_or(file_settings.files);
+    let file_extensions = &args
+        .file_extensions
+        .unwrap_or(file_settings.file_extensions);
+
     let settings = Settings {
-        line_length: args.line_length,
+        line_length: args.line_length.unwrap_or(file_settings.line_length),
     };
-    match ruleset(&args) {
-        Ok(rules) => {
-            let path_rules = rules_to_path_rules(&rules);
-            let text_rules = rules_to_text_rules(&rules);
-            let ast_entrypoints = ast_entrypoint_map(&rules);
-            let mut total_errors = 0;
-            let mut total_files = 0;
-            for path in get_files(&args.files, &args.file_extensions) {
-                let filename = path.to_string_lossy();
-                let empty_file = SourceFileBuilder::new(filename.as_ref(), "").finish();
-
-                let source = match read_to_string(&path) {
-                    Ok(source) => source,
-                    Err(error) => {
-                        let message = format!("Error opening file: {error}");
-                        let diagnostic = DiagnosticMessage::from_ruff(
-                            &empty_file,
-                            Diagnostic::new(IoError { message }, TextRange::default()),
-                        );
-                        println!("{diagnostic}");
-                        total_errors += 1;
-                        continue;
-                    }
-                };
-
-                let file = SourceFileBuilder::new(filename.as_ref(), source.as_str()).finish();
-
-                match check_file(
-                    &path_rules,
-                    &text_rules,
-                    &ast_entrypoints,
-                    &path,
-                    &file,
-                    &settings,
-                ) {
-                    Ok(violations) => {
-                        let mut diagnostics: Vec<_> = violations
-                            .into_iter()
-                            .map(|v| DiagnosticMessage::from_ruff(&file, v))
-                            .collect();
-                        if !diagnostics.is_empty() {
-                            diagnostics.sort_unstable();
-                            println!("{}", join(&diagnostics, "\n"));
-                        }
-                        total_errors += diagnostics.len();
-                    }
-                    Err(msg) => {
-                        let message = format!("Failed to process: {msg}");
-                        let diagnostic = DiagnosticMessage::from_ruff(
-                            &empty_file,
-                            Diagnostic::new(IoError { message }, TextRange::default()),
-                        );
-                        println!("{diagnostic}");
-                        total_errors += 1;
-                    }
+
+    let rule_selection = RuleSelection {
+        select: args.select.or(file_settings.select),
+        // TODO: CLI ignore should _extend_ file ignore
+        ignore: args.ignore.unwrap_or(file_settings.ignore),
+        extend_select: args.extend_select.unwrap_or(file_settings.extend_select),
+    };
+
+    // At this point, we've assembled all our settings, and we're
+    // ready to check the project
+
+    let rules = ruleset(rule_selection)?;
+
+    let path_rules = rules_to_path_rules(&rules);
+    let text_rules = rules_to_text_rules(&rules);
+    let ast_entrypoints = ast_entrypoint_map(&rules);
+    let mut total_errors = 0;
+    let mut total_files = 0;
+    for path in get_files(files, file_extensions) {
+        let filename = path.to_string_lossy();
+        let empty_file = SourceFileBuilder::new(filename.as_ref(), "").finish();
+
+        let source = match read_to_string(&path) {
+            Ok(source) => source,
+            Err(error) => {
+                let message = format!("Error opening file: {error}");
+                let diagnostic = DiagnosticMessage::from_ruff(
+                    &empty_file,
+                    Diagnostic::new(IoError { message }, TextRange::default()),
+                );
+                println!("{diagnostic}");
+                total_errors += 1;
+                continue;
+            }
+        };
+
+        let file = SourceFileBuilder::new(filename.as_ref(), source.as_str()).finish();
+
+        match check_file(
+            &path_rules,
+            &text_rules,
+            &ast_entrypoints,
+            &path,
+            &file,
+            &settings,
+        ) {
+            Ok(violations) => {
+                let mut diagnostics: Vec<_> = violations
+                    .into_iter()
+                    .map(|v| DiagnosticMessage::from_ruff(&file, v))
+                    .collect();
+                if !diagnostics.is_empty() {
+                    diagnostics.sort_unstable();
+                    println!("{}", join(&diagnostics, "\n"));
                 }
-                total_files += 1;
+                total_errors += diagnostics.len();
             }
-            let file_no = format!(
-                "fortitude: {} files scanned.",
-                total_files.to_string().bold()
-            );
-            if total_errors == 0 {
-                let success = "All checks passed!".bright_green();
-                println!("\n{file_no}\n{success}\n");
-                Ok(ExitCode::SUCCESS)
-            } else {
-                let err_no = format!("Number of errors: {}", total_errors.to_string().bold());
-                let info = "For more information about specific rules, run:";
-                let explain = format!(
-                    "fortitude explain {},{},...",
-                    "X001".bold().bright_red(),
-                    "Y002".bold().bright_red()
+            Err(msg) => {
+                let message = format!("Failed to process: {msg}");
+                let diagnostic = DiagnosticMessage::from_ruff(
+                    &empty_file,
+                    Diagnostic::new(IoError { message }, TextRange::default()),
                 );
-                println!("\n{file_no}\n{err_no}\n\n{info}\n\n    {explain}\n");
-                Ok(ExitCode::FAILURE)
+                println!("{diagnostic}");
+                total_errors += 1;
             }
         }
-        Err(msg) => {
-            eprintln!("{}: {}", "ERROR".bright_red(), msg);
-            Ok(ExitCode::FAILURE)
-        }
+        total_files += 1;
+    }
+    let file_no = format!(
+        "fortitude: {} files scanned.",
+        total_files.to_string().bold()
+    );
+    if total_errors == 0 {
+        let success = "All checks passed!".bright_green();
+        println!("\n{file_no}\n{success}\n");
+        Ok(ExitCode::SUCCESS)
+    } else {
+        let err_no = format!("Number of errors: {}", total_errors.to_string().bold());
+        let info = "For more information about specific rules, run:";
+        let explain = format!(
+            "fortitude explain {},{},...",
+            "X001".bold().bright_red(),
+            "Y002".bold().bright_red()
+        );
+        println!("\n{file_no}\n{err_no}\n\n{info}\n\n    {explain}\n");
+        Ok(ExitCode::FAILURE)
     }
 }
 
@@ -294,16 +446,13 @@ mod tests {
 
     #[test]
     fn empty_select() -> anyhow::Result<()> {
-        let args = CheckArgs {
-            files: vec![],
+        let args = RuleSelection {
             ignore: vec![],
-            select: vec![],
+            select: None,
             extend_select: vec![],
-            line_length: 100,
-            file_extensions: vec![],
         };
 
-        let rules = ruleset(&args)?;
+        let rules = ruleset(args)?;
 
         let preview = PreviewOptions::default();
         let all_rules: Vec<Rule> = DEFAULT_SELECTORS
@@ -318,16 +467,13 @@ mod tests {
 
     #[test]
     fn select_one_rule() -> anyhow::Result<()> {
-        let args = CheckArgs {
-            files: vec![],
+        let args = RuleSelection {
             ignore: vec![],
-            select: vec![RuleSelector::from_str("E000")?],
+            select: Some(vec![RuleSelector::from_str("E000")?]),
             extend_select: vec![],
-            line_length: 100,
-            file_extensions: vec![],
         };
 
-        let rules = ruleset(&args)?;
+        let rules = ruleset(args)?;
         let one_rules: Vec<Rule> = vec![Rule::IoError];
 
         assert_eq!(rules, one_rules);
@@ -337,20 +483,47 @@ mod tests {
 
     #[test]
     fn extend_select() -> anyhow::Result<()> {
-        let args = CheckArgs {
-            files: vec![],
+        let args = RuleSelection {
             ignore: vec![],
-            select: vec![RuleSelector::from_str("E000")?],
+            select: Some(vec![RuleSelector::from_str("E000")?]),
             extend_select: vec![RuleSelector::from_str("E001")?],
-            line_length: 100,
-            file_extensions: vec![],
         };
 
-        let rules = ruleset(&args)?;
+        let rules = ruleset(args)?;
         let one_rules: Vec<Rule> = vec![Rule::IoError, Rule::SyntaxError];
 
         assert_eq!(rules, one_rules);
 
         Ok(())
     }
+
+    use std::fs;
+
+    use anyhow::{Context, Result};
+    use tempfile::TempDir;
+    use textwrap::dedent;
+
+    #[test]
+    fn find_and_check_fpm_toml() -> Result<()> {
+        let tempdir = TempDir::new()?;
+        let fpm_toml = tempdir.path().join("fpm.toml");
+        fs::write(
+            fpm_toml,
+            dedent(
+                r#"
+                some-stuff = 1
+                other-things = "hello"
+
+                [extra.fortitude.check]
+                ignore = ["T001"]
+                "#,
+            ),
+        )?;
+
+        let fpm = find_settings_toml(tempdir.path())?.context("Failed to find fpm.toml")?;
+        let enabled = fortitude_enabled(fpm)?;
+        assert!(enabled);
+
+        Ok(())
+    }
 }
diff --git a/fortitude/src/cli.rs b/fortitude/src/cli.rs
index f9f32fd..7ed35ac 100644
--- a/fortitude/src/cli.rs
+++ b/fortitude/src/cli.rs
@@ -1,36 +1,40 @@
-use anyhow::{Context, Result};
-use clap::{CommandFactory, Parser, Subcommand};
-use clap_config::ClapConfig;
+use clap::{Parser, Subcommand};
 use serde::{Deserialize, Serialize};
-use std::path::{Path, PathBuf};
-use toml::Table;
+use std::path::PathBuf;
 
 use crate::{rule_selector::RuleSelector, RuleSelectorParser};
 
 /// Default extensions to check
-const FORTRAN_EXTS: &[&str] = &[
+pub const FORTRAN_EXTS: &[&str] = &[
     "f90", "F90", "f95", "F95", "f03", "F03", "f08", "F08", "f18", "F18", "f23", "F23",
 ];
 
-#[derive(Debug, Parser, ClapConfig)]
+#[derive(Debug, Parser)]
 #[command(version, about)]
 pub struct Cli {
     #[clap(subcommand)]
     pub command: SubCommands,
+    #[clap(flatten)]
+    pub global_options: GlobalConfigArgs,
+}
 
+/// All configuration options that can be passed "globally",
+/// i.e., can be passed to all subcommands
+#[derive(Debug, Default, Clone, clap::Args)]
+pub struct GlobalConfigArgs {
     /// Path to a TOML configuration file
     #[arg(long)]
     pub config_file: Option<PathBuf>,
 }
 
-#[derive(Debug, Subcommand, ClapConfig, Clone, PartialEq)]
+#[derive(Debug, Subcommand, Clone, PartialEq)]
 pub enum SubCommands {
     Check(CheckArgs),
     Explain(ExplainArgs),
 }
 
 /// Get descriptions, rationales, and solutions for each rule.
-#[derive(Debug, clap::Parser, ClapConfig, Clone, PartialEq)]
+#[derive(Debug, clap::Parser, Clone, PartialEq)]
 pub struct ExplainArgs {
     /// List of rules to explain. If omitted, explains all rules.
     #[arg(
@@ -44,11 +48,12 @@ pub struct ExplainArgs {
 }
 
 /// Perform static analysis on files and report issues.
-#[derive(Debug, clap::Parser, Deserialize, Serialize, ClapConfig, Clone, PartialEq)]
+#[derive(Debug, clap::Parser, Deserialize, Serialize, Clone, PartialEq, Eq)]
+#[serde(deny_unknown_fields, rename_all = "kebab-case")]
 pub struct CheckArgs {
     /// List of files to analyze
     #[arg(default_value = ".")]
-    pub files: Vec<PathBuf>,
+    pub files: Option<Vec<PathBuf>>,
     /// Comma-separated list of rules to ignore.
     #[arg(
         long,
@@ -58,7 +63,7 @@ pub struct CheckArgs {
         help_heading = "Rule selection",
         hide_possible_values = true
     )]
-    pub ignore: Vec<RuleSelector>,
+    pub ignore: Option<Vec<RuleSelector>>,
     /// Comma-separated list of rule codes to enable (or ALL, to enable all rules).
     #[arg(
         long,
@@ -68,7 +73,7 @@ pub struct CheckArgs {
         help_heading = "Rule selection",
         hide_possible_values = true
     )]
-    pub select: Vec<RuleSelector>,
+    pub select: Option<Vec<RuleSelector>>,
     /// Like --select, but adds additional rule codes on top of those already specified.
     #[arg(
         long,
@@ -78,143 +83,11 @@ pub struct CheckArgs {
         help_heading = "Rule selection",
         hide_possible_values = true
     )]
-    pub extend_select: Vec<RuleSelector>,
+    pub extend_select: Option<Vec<RuleSelector>>,
     /// Set the maximum allowable line length.
     #[arg(long, default_value = "100")]
-    pub line_length: usize,
+    pub line_length: Option<usize>,
     /// File extensions to check
     #[arg(long, value_delimiter = ',', default_values = FORTRAN_EXTS)]
-    pub file_extensions: Vec<String>,
-}
-
-// These are just helper structs to let us quickly work out if there's
-// a fortitude section in an fpm.toml file
-#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
-struct Fpm {
-    extra: Option<Extra>,
-}
-
-#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
-struct Extra {
-    fortitude: Option<EmptyConfig>,
-}
-
-#[derive(Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
-struct EmptyConfig {}
-
-// Adapted from ruff
-fn parse_fpm_toml<P: AsRef<Path>>(path: P) -> Result<Fpm> {
-    let contents = std::fs::read_to_string(path.as_ref())
-        .with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
-    toml::from_str(&contents)
-        .with_context(|| format!("Failed to parse {}", path.as_ref().display()))
-}
-
-pub fn fortitude_enabled<P: AsRef<Path>>(path: P) -> Result<bool> {
-    let fpm = parse_fpm_toml(path)?;
-    Ok(fpm.extra.and_then(|extra| extra.fortitude).is_some())
-}
-
-/// Return the path to the `fpm.toml` or `fortitude.toml` file in a given
-/// directory. Adapated from ruff
-pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
-    // Check for `.fortitude.toml`.
-    let fortitude_toml = path.as_ref().join(".fortitude.toml");
-    if fortitude_toml.is_file() {
-        return Ok(Some(fortitude_toml));
-    }
-
-    // Check for `fortitude.toml`.
-    let fortitude_toml = path.as_ref().join("fortitude.toml");
-    if fortitude_toml.is_file() {
-        return Ok(Some(fortitude_toml));
-    }
-
-    // Check for `fpm.toml`.
-    let fpm_toml = path.as_ref().join("fpm.toml");
-    if fpm_toml.is_file() && fortitude_enabled(&fpm_toml)? {
-        return Ok(Some(fpm_toml));
-    }
-
-    Ok(None)
-}
-
-/// Find the path to the `fpm.toml` or `fortitude.toml` file, if such a file
-/// exists. Adapated from ruff
-pub fn find_settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
-    for directory in path.as_ref().ancestors() {
-        if let Some(settings) = settings_toml(directory)? {
-            return Ok(Some(settings));
-        }
-    }
-    Ok(None)
-}
-
-fn from_clap_config_subsection<P: AsRef<Path>>(path: P) -> Result<Cli> {
-    let matches = <Cli as CommandFactory>::command().get_matches();
-
-    let config_str = if path.as_ref().ends_with("fpm.toml") {
-        let config = std::fs::read_to_string(path)?.parse::<Table>()?;
-
-        // Unwrap should be ok here because we've already checked this
-        // file has these tables
-        let extra = &config["extra"].as_table().unwrap();
-        let fortitude = &extra["fortitude"].as_table().unwrap();
-        fortitude.to_string()
-    } else {
-        std::fs::read_to_string(path)?
-    };
-
-    let config: CliConfig = toml::from_str(&config_str)?;
-
-    Ok(Cli::from_merged(matches, Some(config)))
-}
-
-pub fn parse_args() -> Result<Cli> {
-    let args = Cli::parse();
-
-    if args.config_file.is_some() {
-        return from_clap_config_subsection(args.config_file.unwrap());
-    }
-
-    if let Some(toml_file) = find_settings_toml(".")? {
-        from_clap_config_subsection(toml_file)
-    } else {
-        Ok(args)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::fs;
-
-    use anyhow::{Context, Result};
-    use tempfile::TempDir;
-    use textwrap::dedent;
-
-    use super::*;
-
-    #[test]
-    fn find_and_check_fpm_toml() -> Result<()> {
-        let tempdir = TempDir::new()?;
-        let fpm_toml = tempdir.path().join("fpm.toml");
-        fs::write(
-            fpm_toml,
-            dedent(
-                r#"
-                some-stuff = 1
-                other-things = "hello"
-
-                [extra.fortitude.check]
-                ignore = ["T001"]
-                "#,
-            ),
-        )?;
-
-        let fpm = find_settings_toml(tempdir.path())?.context("Failed to find fpm.toml")?;
-        let enabled = fortitude_enabled(fpm)?;
-        assert!(enabled);
-
-        Ok(())
-    }
+    pub file_extensions: Option<Vec<String>>,
 }
diff --git a/fortitude/src/explain.rs b/fortitude/src/explain.rs
index 907053e..6c54079 100644
--- a/fortitude/src/explain.rs
+++ b/fortitude/src/explain.rs
@@ -38,55 +38,49 @@ fn ruleset(args: &ExplainArgs) -> anyhow::Result<Vec<Rule>> {
 
 /// Check all files, report issues found, and return error code.
 pub fn explain(args: ExplainArgs) -> Result<ExitCode> {
-    match ruleset(&args) {
-        Ok(rules) => {
-            let mut outputs = Vec::new();
-            for rule in rules {
-                let mut body = String::new();
-                let fix_availability = rule.fixable();
-                if matches!(
-                    fix_availability,
-                    FixAvailability::Always | FixAvailability::Sometimes
-                ) {
-                    body.push_str(&fix_availability.to_string());
-                    body.push('\n');
-                    body.push('\n');
-                }
-                if rule.is_preview() {
-                    body.push_str(
-                        r"This rule is in preview and is not stable. The `--preview` flag is required for use.",
-                    );
-                    body.push('\n');
-                    body.push('\n');
-                }
+    let rules = ruleset(&args)?;
 
-                if let Some(explanation) = rule.explanation() {
-                    body.push_str(explanation);
-                } else {
-                    body.push_str("Message formats:");
-                    for format in rule.message_formats() {
-                        body.push('\n');
-                        body.push_str(&format!("* {format}"));
-                    }
-                }
+    let mut outputs = Vec::new();
+    for rule in rules {
+        let mut body = String::new();
+        let fix_availability = rule.fixable();
+        if matches!(
+            fix_availability,
+            FixAvailability::Always | FixAvailability::Sometimes
+        ) {
+            body.push_str(&fix_availability.to_string());
+            body.push('\n');
+            body.push('\n');
+        }
+        if rule.is_preview() {
+            body.push_str(
+                r"This rule is in preview and is not stable. The `--preview` flag is required for use.",
+            );
+            body.push('\n');
+            body.push('\n');
+        }
 
-                let code = rule.noqa_code().to_string();
-                let name = rule.as_ref();
-                let title = format!("# {code}: {name}\n");
-                outputs.push((title.bright_red(), dedent(body.as_str())));
-            }
-            outputs.sort_by(|a, b| {
-                let ((a_code, _), (b_code, _)) = (a, b);
-                a_code.cmp(b_code)
-            });
-            for (code, desc) in outputs {
-                println!("{}\n{}", code, desc);
+        if let Some(explanation) = rule.explanation() {
+            body.push_str(explanation);
+        } else {
+            body.push_str("Message formats:");
+            for format in rule.message_formats() {
+                body.push('\n');
+                body.push_str(&format!("* {format}"));
             }
-            Ok(ExitCode::SUCCESS)
-        }
-        Err(msg) => {
-            eprintln!("{}: {}", "ERROR".bright_red(), msg);
-            Ok(ExitCode::FAILURE)
         }
+
+        let code = rule.noqa_code().to_string();
+        let name = rule.as_ref();
+        let title = format!("# {code}: {name}\n");
+        outputs.push((title.bright_red(), dedent(body.as_str())));
+    }
+    outputs.sort_by(|a, b| {
+        let ((a_code, _), (b_code, _)) = (a, b);
+        a_code.cmp(b_code)
+    });
+    for (code, desc) in outputs {
+        println!("{}\n{}", code, desc);
     }
+    Ok(ExitCode::SUCCESS)
 }
diff --git a/fortitude/src/lib.rs b/fortitude/src/lib.rs
index 2dca7c7..6e14984 100644
--- a/fortitude/src/lib.rs
+++ b/fortitude/src/lib.rs
@@ -18,7 +18,7 @@ use colored::{ColoredString, Colorize};
 use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
 use ruff_source_file::{OneIndexed, SourceFile, SourceLocation};
 use ruff_text_size::{Ranged, TextRange, TextSize};
-use settings::{default_settings, Settings};
+use settings::Settings;
 use std::cmp::Ordering;
 use std::fmt;
 use std::path::Path;
@@ -96,12 +96,11 @@ pub trait AstRule {
     /// Apply a rule over some text, generating all violations raised as a result.
     fn apply(source: &SourceFile) -> anyhow::Result<Vec<Diagnostic>> {
         let entrypoints = Self::entrypoints();
-        let default_settings = default_settings();
         Ok(parse(source.source_text())?
             .root_node()
             .named_descendants()
             .filter(|x| entrypoints.contains(&x.kind()))
-            .filter_map(|x| Self::check(&default_settings, &x, source))
+            .filter_map(|x| Self::check(&Settings::default(), &x, source))
             .flatten()
             .collect())
     }
diff --git a/fortitude/src/main.rs b/fortitude/src/main.rs
index bbde7a5..82de8db 100644
--- a/fortitude/src/main.rs
+++ b/fortitude/src/main.rs
@@ -1,20 +1,19 @@
 use std::process::ExitCode;
 
+use anyhow::Result;
+use clap::Parser;
 use fortitude::check::check;
-use fortitude::cli::{parse_args, SubCommands};
+use fortitude::cli::{Cli, SubCommands};
 use fortitude::explain::explain;
 
-fn main() -> ExitCode {
-    let args = match parse_args() {
-        Ok(args) => args,
-        Err(_) => return ExitCode::FAILURE,
-    };
+fn main() -> Result<ExitCode> {
+    let args = Cli::parse();
     let status = match args.command {
-        SubCommands::Check(args) => check(args),
+        SubCommands::Check(check_args) => check(check_args, &args.global_options),
         SubCommands::Explain(args) => explain(args),
     };
     match status {
-        Ok(code) => code,
-        Err(_) => ExitCode::FAILURE,
+        Ok(code) => Ok(code),
+        Err(_) => status,
     }
 }
diff --git a/fortitude/src/rules/filesystem/extensions.rs b/fortitude/src/rules/filesystem/extensions.rs
index aba3132..16ec7fb 100644
--- a/fortitude/src/rules/filesystem/extensions.rs
+++ b/fortitude/src/rules/filesystem/extensions.rs
@@ -42,13 +42,12 @@ impl PathRule for NonStandardFileExtension {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::settings::default_settings;
 
     #[test]
     fn test_bad_file_extension() {
         let path = Path::new("my/dir/to/file.f95");
         assert_eq!(
-            NonStandardFileExtension::check(&default_settings(), path),
+            NonStandardFileExtension::check(&Settings::default(), path),
             Some(Diagnostic::new(
                 NonStandardFileExtension {},
                 TextRange::default()
@@ -60,7 +59,7 @@ mod tests {
     fn test_missing_file_extension() {
         let path = Path::new("my/dir/to/file");
         assert_eq!(
-            NonStandardFileExtension::check(&default_settings(), path),
+            NonStandardFileExtension::check(&Settings::default(), path),
             Some(Diagnostic::new(
                 NonStandardFileExtension {},
                 TextRange::default()
@@ -73,11 +72,11 @@ mod tests {
         let path1 = Path::new("my/dir/to/file.f90");
         let path2 = Path::new("my/dir/to/file.F90");
         assert_eq!(
-            NonStandardFileExtension::check(&default_settings(), path1),
+            NonStandardFileExtension::check(&Settings::default(), path1),
             None
         );
         assert_eq!(
-            NonStandardFileExtension::check(&default_settings(), path2),
+            NonStandardFileExtension::check(&Settings::default(), path2),
             None
         );
     }
diff --git a/fortitude/src/rules/modules/mod.rs b/fortitude/src/rules/modules/mod.rs
index b5a4cfd..d4ff743 100644
--- a/fortitude/src/rules/modules/mod.rs
+++ b/fortitude/src/rules/modules/mod.rs
@@ -15,7 +15,7 @@ mod tests {
     use test_case::test_case;
 
     use crate::registry::Rule;
-    use crate::settings::default_settings;
+    use crate::settings::Settings;
     use crate::test::test_path;
 
     #[test_case(Rule::ExternalFunction, Path::new("M001.f90"))]
@@ -25,7 +25,7 @@ mod tests {
         let diagnostics = test_path(
             Path::new("modules").join(path).as_path(),
             &[rule_code],
-            &default_settings(),
+            &Settings::default(),
         )?;
         assert_snapshot!(snapshot, diagnostics);
         Ok(())
diff --git a/fortitude/src/rules/precision/mod.rs b/fortitude/src/rules/precision/mod.rs
index 5dbf3ea..333c5e7 100644
--- a/fortitude/src/rules/precision/mod.rs
+++ b/fortitude/src/rules/precision/mod.rs
@@ -12,7 +12,7 @@ mod tests {
     use test_case::test_case;
 
     use crate::registry::Rule;
-    use crate::settings::default_settings;
+    use crate::settings::Settings;
     use crate::test::test_path;
 
     #[test_case(Rule::NoRealSuffix, Path::new("P001.f90"))]
@@ -23,7 +23,7 @@ mod tests {
         let diagnostics = test_path(
             Path::new("precision").join(path).as_path(),
             &[rule_code],
-            &default_settings(),
+            &Settings::default(),
         )?;
         assert_snapshot!(snapshot, diagnostics);
         Ok(())
diff --git a/fortitude/src/rules/style/mod.rs b/fortitude/src/rules/style/mod.rs
index 3ca2a1c..e82a4c2 100644
--- a/fortitude/src/rules/style/mod.rs
+++ b/fortitude/src/rules/style/mod.rs
@@ -15,7 +15,7 @@ mod tests {
     use test_case::test_case;
 
     use crate::registry::Rule;
-    use crate::settings::{default_settings, Settings};
+    use crate::settings::Settings;
     use crate::test::test_path;
 
     #[test_case(Rule::LineTooLong, Path::new("S001.f90"))]
@@ -29,7 +29,7 @@ mod tests {
         let diagnostics = test_path(
             Path::new("style").join(path).as_path(),
             &[rule_code],
-            &default_settings(),
+            &Settings::default(),
         )?;
         assert_snapshot!(snapshot, diagnostics);
         Ok(())
@@ -41,7 +41,7 @@ mod tests {
         #[allow(clippy::needless_update)]
         let settings = Settings {
             line_length: 20,
-            ..default_settings()
+            ..Settings::default()
         };
         let diagnostics = test_path(
             Path::new("style").join(path).as_path(),
diff --git a/fortitude/src/rules/typing/mod.rs b/fortitude/src/rules/typing/mod.rs
index 82cd759..bd7a9d9 100644
--- a/fortitude/src/rules/typing/mod.rs
+++ b/fortitude/src/rules/typing/mod.rs
@@ -15,7 +15,7 @@ mod tests {
     use test_case::test_case;
 
     use crate::registry::Rule;
-    use crate::settings::default_settings;
+    use crate::settings::Settings;
     use crate::test::test_path;
 
     #[test_case(Rule::ImplicitTyping, Path::new("T001.f90"))]
@@ -34,7 +34,7 @@ mod tests {
         let diagnostics = test_path(
             Path::new("typing").join(path).as_path(),
             &[rule_code],
-            &default_settings(),
+            &Settings::default(),
         )?;
         assert_snapshot!(snapshot, diagnostics);
         Ok(())
@@ -47,7 +47,7 @@ mod tests {
         let diagnostics = test_path(
             Path::new("typing").join(path).as_path(),
             &[rule_code],
-            &default_settings(),
+            &Settings::default(),
         )?;
         assert!(
             diagnostics.is_empty(),
diff --git a/fortitude/src/settings.rs b/fortitude/src/settings.rs
index 06f2d61..13cbf1b 100644
--- a/fortitude/src/settings.rs
+++ b/fortitude/src/settings.rs
@@ -8,9 +8,10 @@ pub struct Settings {
     pub line_length: usize,
 }
 
-#[allow(dead_code)]
-pub fn default_settings() -> Settings {
-    Settings { line_length: 100 }
+impl Default for Settings {
+    fn default() -> Self {
+        Self { line_length: 100 }
+    }
 }
 
 /// Toggle for rules still in preview


Test patch:
diff --git a/fortitude/tests/check.rs b/fortitude/tests/check.rs
index 8fd19ef..f016cec 100644
--- a/fortitude/tests/check.rs
+++ b/fortitude/tests/check.rs
@@ -44,6 +44,37 @@ fn check_file_doesnt_exist() -> anyhow::Result<()> {
     Ok(())
 }
 
+#[test]
+fn unknown_name_in_config() -> anyhow::Result<()> {
+    let tempdir = TempDir::new()?;
+    let config_file = tempdir.path().join("fpm.toml");
+    fs::write(
+        &config_file,
+        r#"
+[extra.fortitude.check]
+unknown-key = 1
+"#,
+    )?;
+    apply_common_filters!();
+    assert_cmd_snapshot!(Command::cargo_bin(BIN_NAME)?
+                         .args(["--config-file", config_file.as_os_str().to_string_lossy().as_ref()])
+                         .arg("check")
+                         .arg("no-file.f90"),
+                         @r"
+    success: false
+    exit_code: 1
+    ----- stdout -----
+
+    ----- stderr -----
+    Error: TOML parse error at line 2, column 1
+      |
+    2 | unknown-key = 1
+      | ^^^^^^^^^^^
+    unknown field `unknown-key`, expected one of `files`, `ignore`, `select`, `extend-select`, `line-length`, `file-extensions`
+    ");
+    Ok(())
+}
+
 #[test]
 fn check_all() -> anyhow::Result<()> {
     let tempdir = TempDir::new()?;