Skip to main content

jsslint_core/
config.rs

1//! Tool configuration — mirrors `texlint.api.ToolConfig` (the fields
2//! Phase 4 needs, plus `doi_resolver`) and `texlint.config`'s
3//! defaults-then-`.jss-lint.toml`-then-CLI merge (`load`).
4//!
5//! `doi_resolver` is a plain injection hook — the type alias below
6//! requires no networking dependency to *define*, only to *implement*.
7//! Mirrors `texlint.crossref.Resolver`: `jss-lint --crossref` builds
8//! the real (network-backed) resolver and hands it to `cfg.doi_resolver`;
9//! with no resolver, `JSS-REFS-003` keeps its offline-advisory behavior.
10//! The live implementation lives in the separate `jsslint-crossref`
11//! crate (depends on `jsslint-core`, not the reverse) precisely so that
12//! `jsslint-wasm` — which depends on `jsslint-core` but never on
13//! `jsslint-crossref` — can't reach the network even by accident: this
14//! field being `Option<Arc<DoiResolver>>` rather than a concrete
15//! network client is what makes that guarantee structural. See
16//! `jsslint-wasm/src/lib.rs`'s module doc and `jsslint-crossref/src/lib.rs`'s.
17
18use crate::report::Severity;
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23/// `(fields, entry_type) -> Option<doi>`. `fields` is a lowercase-keyed
24/// field map (title/author/year/url…); `entry_type` is the lowercase
25/// BibTeX entry type. Mirrors `texlint.crossref.Resolver`.
26pub type DoiResolver = dyn Fn(&HashMap<String, String>, &str) -> Option<String> + Send + Sync;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Mode {
30    Author,
31    Reviewer,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum OutputFormat {
36    Terminal,
37    Json,
38    Html,
39    Sarif,
40}
41
42/// Confidence floor: rules whose measured-precision tier sits below
43/// this are skipped. Ordered `Low < Medium < High` so `>=` comparisons
44/// mirror Python's `_CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2}`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum ConfidenceTier {
47    Low,
48    Medium,
49    High,
50}
51
52impl ConfidenceTier {
53    pub fn parse(s: &str) -> Option<Self> {
54        match s {
55            "low" => Some(Self::Low),
56            "medium" => Some(Self::Medium),
57            "high" => Some(Self::High),
58            _ => None,
59        }
60    }
61
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Self::Low => "low",
65            Self::Medium => "medium",
66            Self::High => "high",
67        }
68    }
69}
70
71/// `#[derive(Debug)]` doesn't reach here — `dyn Fn` isn't `Debug` — so
72/// `Debug` is hand-written just below, printing `doi_resolver` as
73/// present/absent rather than skipping the derive on the whole struct.
74#[derive(Clone)]
75pub struct ToolConfig {
76    pub journal: String,
77    pub mode: Mode,
78    pub output: OutputFormat,
79    pub ignore_rules: HashSet<String>,
80    pub verbose: bool,
81    pub code_width: u32,
82    pub source_root: PathBuf,
83    pub min_confidence: ConfidenceTier,
84    /// Exit-code policy: the minimum severity that makes the CLI exit 1.
85    pub fail_on: Severity,
86    pub severity_overrides: HashMap<String, Severity>,
87    /// Online DOI verification hook (`jss-lint --crossref`). `None`
88    /// (the default, always true on the wasm target) keeps
89    /// `JSS-REFS-003` offline. Not part of `.jss-lint.toml`/CLI-flag
90    /// merging below — injected directly by the caller, same as Python's
91    /// `cfg = replace(cfg, doi_resolver=...)` in `cli.py`.
92    pub doi_resolver: Option<Arc<DoiResolver>>,
93}
94
95impl std::fmt::Debug for ToolConfig {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("ToolConfig")
98            .field("journal", &self.journal)
99            .field("mode", &self.mode)
100            .field("output", &self.output)
101            .field("ignore_rules", &self.ignore_rules)
102            .field("verbose", &self.verbose)
103            .field("code_width", &self.code_width)
104            .field("source_root", &self.source_root)
105            .field("min_confidence", &self.min_confidence)
106            .field("fail_on", &self.fail_on)
107            .field("severity_overrides", &self.severity_overrides)
108            .field("doi_resolver", &self.doi_resolver.is_some())
109            .finish()
110    }
111}
112
113impl Default for ToolConfig {
114    fn default() -> Self {
115        Self {
116            journal: "jss".to_string(),
117            mode: Mode::Author,
118            output: OutputFormat::Terminal,
119            ignore_rules: HashSet::new(),
120            verbose: false,
121            code_width: 80,
122            source_root: PathBuf::from("."),
123            min_confidence: ConfidenceTier::Low,
124            fail_on: Severity::Warning,
125            severity_overrides: HashMap::new(),
126            doi_resolver: None,
127        }
128    }
129}
130
131/// `.jss-lint.toml`'s filename, relative to the current working
132/// directory. Mirrors `config.py::_CONFIG_FILENAME`.
133pub const CONFIG_FILENAME: &str = ".jss-lint.toml";
134
135const KNOWN_FIELDS: &[&str] = &[
136    "journal",
137    "mode",
138    "output",
139    "ignore_rules",
140    "verbose",
141    "code_width",
142    "source_root",
143    "min_confidence",
144    "fail_on",
145    "severity_overrides",
146];
147
148/// Values a caller (CLI flags today; any other binding tomorrow) wants
149/// to layer on top of `.jss-lint.toml`. `None` means "the caller
150/// didn't set this" and leaves a file-provided value (or the default)
151/// untouched — mirrors `config.py::load`'s `if value is None: continue`
152/// rule for `cli_overrides`.
153#[derive(Debug, Clone, Default)]
154pub struct RawOverrides {
155    pub journal: Option<String>,
156    pub mode: Option<String>,
157    pub output: Option<String>,
158    pub ignore_rules: Option<Vec<String>>,
159    pub verbose: Option<bool>,
160    pub code_width: Option<u32>,
161    pub source_root: Option<PathBuf>,
162    pub min_confidence: Option<String>,
163    pub fail_on: Option<String>,
164    pub severity_overrides: Option<HashMap<String, String>>,
165}
166
167fn toml_value_to_string_list(value: &toml::Value) -> Vec<String> {
168    match value {
169        toml::Value::String(s) => s
170            .split(',')
171            .map(|p| p.trim().to_string())
172            .filter(|p| !p.is_empty())
173            .collect(),
174        toml::Value::Array(items) => items
175            .iter()
176            .filter_map(|v| v.as_str())
177            .map(|s| s.trim().to_string())
178            .filter(|s| !s.is_empty())
179            .collect(),
180        _ => Vec::new(),
181    }
182}
183
184/// Reads and parses `<cwd>/.jss-lint.toml` (absent or unparsable ⇒
185/// empty, silently — mirrors `config.py::_read_toml`'s "file doesn't
186/// exist ⇒ `{}`"; a malformed file has no real-corpus fixture to match
187/// against, so it's treated the same way rather than surfacing a
188/// separate error path). Returns the known-field overrides plus any
189/// top-level keys the loader doesn't recognise (sorted, for the
190/// unrecognised-keys warning).
191fn read_toml_overrides(cwd: &Path) -> (RawOverrides, Vec<String>) {
192    let path = cwd.join(CONFIG_FILENAME);
193    let Ok(contents) = std::fs::read_to_string(&path) else {
194        return (RawOverrides::default(), Vec::new());
195    };
196    let Ok(table) = contents.parse::<toml::Table>() else {
197        return (RawOverrides::default(), Vec::new());
198    };
199
200    let mut unknown_keys: Vec<String> = table
201        .keys()
202        .filter(|k| !KNOWN_FIELDS.contains(&k.as_str()))
203        .cloned()
204        .collect();
205    unknown_keys.sort();
206
207    let mut out = RawOverrides::default();
208    if let Some(v) = table.get("journal").and_then(|v| v.as_str()) {
209        out.journal = Some(v.to_string());
210    }
211    if let Some(v) = table.get("mode").and_then(|v| v.as_str()) {
212        out.mode = Some(v.to_string());
213    }
214    if let Some(v) = table.get("output").and_then(|v| v.as_str()) {
215        out.output = Some(v.to_string());
216    }
217    if let Some(v) = table.get("ignore_rules") {
218        out.ignore_rules = Some(toml_value_to_string_list(v));
219    }
220    if let Some(v) = table.get("verbose").and_then(|v| v.as_bool()) {
221        out.verbose = Some(v);
222    }
223    if let Some(v) = table.get("code_width").and_then(|v| v.as_integer()) {
224        out.code_width = Some(v.max(0) as u32);
225    }
226    if let Some(v) = table.get("source_root").and_then(|v| v.as_str()) {
227        out.source_root = Some(PathBuf::from(v));
228    }
229    if let Some(v) = table.get("min_confidence").and_then(|v| v.as_str()) {
230        out.min_confidence = Some(v.to_string());
231    }
232    if let Some(v) = table.get("fail_on").and_then(|v| v.as_str()) {
233        out.fail_on = Some(v.to_string());
234    }
235    if let Some(toml::Value::Table(t)) = table.get("severity_overrides") {
236        let map = t
237            .iter()
238            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
239            .collect();
240        out.severity_overrides = Some(map);
241    }
242
243    (out, unknown_keys)
244}
245
246/// Mirrors `config.py::_normalise_severity_overrides` + `load`'s
247/// merge: unknown/invalid severity strings drop the entry (the rule
248/// keeps its catalogue severity) rather than failing the whole config.
249/// `mode`/`output`/`min_confidence`/`fail_on` fall back to their
250/// current value on an unrecognised string — Python's dataclass fields
251/// are untyped `Literal`s at runtime and would just store the bad
252/// string (later comparisons silently treat it as "not that variant");
253/// falling back to the current value is this port's typed-enum
254/// equivalent of that same "malformed config degrades gracefully,
255/// doesn't crash" intent, not a byte-parity target (no fixture
256/// exercises an invalid `.jss-lint.toml`).
257fn apply_overrides(cfg: &mut ToolConfig, overrides: &RawOverrides) {
258    if let Some(v) = &overrides.journal {
259        cfg.journal = v.clone();
260    }
261    if let Some(v) = &overrides.mode {
262        cfg.mode = if v == "reviewer" {
263            Mode::Reviewer
264        } else {
265            Mode::Author
266        };
267    }
268    if let Some(v) = &overrides.output {
269        cfg.output = match v.as_str() {
270            "json" => OutputFormat::Json,
271            "html" => OutputFormat::Html,
272            "sarif" => OutputFormat::Sarif,
273            _ => OutputFormat::Terminal,
274        };
275    }
276    if let Some(v) = &overrides.ignore_rules {
277        cfg.ignore_rules = v.iter().cloned().collect();
278    }
279    if let Some(v) = overrides.verbose {
280        cfg.verbose = v;
281    }
282    if let Some(v) = overrides.code_width {
283        cfg.code_width = v;
284    }
285    if let Some(v) = &overrides.source_root {
286        cfg.source_root = v.clone();
287    }
288    if let Some(v) = &overrides.min_confidence {
289        if let Some(tier) = ConfidenceTier::parse(v) {
290            cfg.min_confidence = tier;
291        }
292    }
293    if let Some(v) = &overrides.fail_on {
294        if let Some(sev) = Severity::parse(v) {
295            cfg.fail_on = sev;
296        }
297    }
298    if let Some(v) = &overrides.severity_overrides {
299        cfg.severity_overrides = v
300            .iter()
301            .filter_map(|(rule_id, sev)| {
302                Severity::parse(&sev.trim().to_lowercase())
303                    .map(|sev| (rule_id.trim().to_uppercase(), sev))
304            })
305            .collect();
306    }
307}
308
309/// Builds a `ToolConfig` by merging, lowest to highest precedence:
310/// `ToolConfig::default()`, then `<cwd>/.jss-lint.toml`, then
311/// `cli_overrides`. Mirrors `config.py::load`, including the
312/// unrecognised-TOML-keys warning (printed only when the *final*
313/// merged config is verbose, matching Python's post-merge check).
314pub fn load(cwd: &Path, cli_overrides: &RawOverrides) -> ToolConfig {
315    let mut cfg = ToolConfig::default();
316    let (file_overrides, unknown_keys) = read_toml_overrides(cwd);
317    apply_overrides(&mut cfg, &file_overrides);
318    apply_overrides(&mut cfg, cli_overrides);
319
320    if !unknown_keys.is_empty() && cfg.verbose {
321        eprintln!(
322            "warning: {CONFIG_FILENAME} has unrecognised keys: {}",
323            unknown_keys.join(", ")
324        );
325    }
326    cfg
327}