1use crate::report::Severity;
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23pub 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#[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(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 pub fail_on: Severity,
86 pub severity_overrides: HashMap<String, Severity>,
87 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
131pub 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#[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
184fn 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
246fn 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
309pub 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}