stern4rust/config_file.rs
1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use anyhow::Context;
6use anyhow::Result;
7use serde::Deserialize;
8use std::fs::read_to_string;
9use std::path::Path;
10use std::path::PathBuf;
11use toml::from_str;
12
13// `stern4rust.toml`, beside the manifest it configures.
14//
15// Every switch this tool has had to be repeated at every invocation, which is
16// tolerable for a person running it once and useless for a repository that
17// wants the same run in a gate script, a pre-commit hook and a developer's
18// terminal. The excludes are the sharpest case: a pattern naming a vendored
19// tree is a fact about the repository, not about one command line.
20//
21// Unknown keys are rejected. A misspelled `exclude` that silently did nothing
22// would look exactly like an exclude that worked, which is the failure this
23// tool exists to refuse -- and it is the same reason an unknown `--rule` name
24// is an error rather than a switch matching nothing.
25#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
26#[serde(deny_unknown_fields, rename_all = "kebab-case")]
27pub struct ConfigFile {
28 #[serde(default)]
29 pub baseline: Option<PathBuf>,
30 #[serde(default)]
31 pub header_file: Option<PathBuf>,
32 #[serde(default)]
33 pub offence_threshold: Option<usize>,
34 #[serde(default)]
35 pub rules: Vec<String>,
36 #[serde(default)]
37 pub skip: Vec<String>,
38 #[serde(default)]
39 pub exclude: Vec<String>,
40}
41
42impl ConfigFile {
43 pub const NAME: &'static str = "stern4rust.toml";
44
45 // Ok(None) when there is no file, which is the ordinary case and not a
46 // failure. A file that exists and cannot be read or parsed is an error: it
47 // was written on purpose, and running as though it were absent would apply
48 // a configuration nobody chose.
49 pub fn load(directory: &Path) -> Result<Option<Self>> {
50 let path = directory.join(Self::NAME);
51 if !path.exists() {
52 return Ok(None);
53 }
54 let text = read_to_string(&path)
55 .with_context(|| format!("{} could not be read", path.display()))?;
56 let parsed = from_str(&text)
57 .with_context(|| format!("{} is not valid stern4rust configuration", path.display()))?;
58 Ok(Some(parsed))
59 }
60
61 // Paths in the file are relative to the file, so a repository can be checked
62 // out anywhere and cloned into any directory name.
63 pub fn baseline_from(&self, directory: &Path) -> Option<PathBuf> {
64 self.baseline
65 .as_ref()
66 .map(|relative| directory.join(relative))
67 }
68
69 pub fn header_file_from(&self, directory: &Path) -> Option<PathBuf> {
70 self.header_file
71 .as_ref()
72 .map(|relative| directory.join(relative))
73 }
74}