use crate::report::Severity;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub type DoiResolver = dyn Fn(&HashMap<String, String>, &str) -> Option<String> + Send + Sync;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Author,
Reviewer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Terminal,
Json,
Html,
Sarif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfidenceTier {
Low,
Medium,
High,
}
impl ConfidenceTier {
pub fn parse(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
#[derive(Clone)]
pub struct ToolConfig {
pub journal: String,
pub mode: Mode,
pub output: OutputFormat,
pub ignore_rules: HashSet<String>,
pub verbose: bool,
pub code_width: u32,
pub source_root: PathBuf,
pub min_confidence: ConfidenceTier,
pub fail_on: Severity,
pub severity_overrides: HashMap<String, Severity>,
pub doi_resolver: Option<Arc<DoiResolver>>,
}
impl std::fmt::Debug for ToolConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolConfig")
.field("journal", &self.journal)
.field("mode", &self.mode)
.field("output", &self.output)
.field("ignore_rules", &self.ignore_rules)
.field("verbose", &self.verbose)
.field("code_width", &self.code_width)
.field("source_root", &self.source_root)
.field("min_confidence", &self.min_confidence)
.field("fail_on", &self.fail_on)
.field("severity_overrides", &self.severity_overrides)
.field("doi_resolver", &self.doi_resolver.is_some())
.finish()
}
}
impl Default for ToolConfig {
fn default() -> Self {
Self {
journal: "jss".to_string(),
mode: Mode::Author,
output: OutputFormat::Terminal,
ignore_rules: HashSet::new(),
verbose: false,
code_width: 80,
source_root: PathBuf::from("."),
min_confidence: ConfidenceTier::Low,
fail_on: Severity::Warning,
severity_overrides: HashMap::new(),
doi_resolver: None,
}
}
}
pub const CONFIG_FILENAME: &str = ".jss-lint.toml";
const KNOWN_FIELDS: &[&str] = &[
"journal",
"mode",
"output",
"ignore_rules",
"verbose",
"code_width",
"source_root",
"min_confidence",
"fail_on",
"severity_overrides",
];
#[derive(Debug, Clone, Default)]
pub struct RawOverrides {
pub journal: Option<String>,
pub mode: Option<String>,
pub output: Option<String>,
pub ignore_rules: Option<Vec<String>>,
pub verbose: Option<bool>,
pub code_width: Option<u32>,
pub source_root: Option<PathBuf>,
pub min_confidence: Option<String>,
pub fail_on: Option<String>,
pub severity_overrides: Option<HashMap<String, String>>,
}
fn toml_value_to_string_list(value: &toml::Value) -> Vec<String> {
match value {
toml::Value::String(s) => s
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect(),
toml::Value::Array(items) => items
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
}
}
fn read_toml_overrides(cwd: &Path) -> (RawOverrides, Vec<String>) {
let path = cwd.join(CONFIG_FILENAME);
let Ok(contents) = std::fs::read_to_string(&path) else {
return (RawOverrides::default(), Vec::new());
};
let Ok(table) = contents.parse::<toml::Table>() else {
return (RawOverrides::default(), Vec::new());
};
let mut unknown_keys: Vec<String> = table
.keys()
.filter(|k| !KNOWN_FIELDS.contains(&k.as_str()))
.cloned()
.collect();
unknown_keys.sort();
let mut out = RawOverrides::default();
if let Some(v) = table.get("journal").and_then(|v| v.as_str()) {
out.journal = Some(v.to_string());
}
if let Some(v) = table.get("mode").and_then(|v| v.as_str()) {
out.mode = Some(v.to_string());
}
if let Some(v) = table.get("output").and_then(|v| v.as_str()) {
out.output = Some(v.to_string());
}
if let Some(v) = table.get("ignore_rules") {
out.ignore_rules = Some(toml_value_to_string_list(v));
}
if let Some(v) = table.get("verbose").and_then(|v| v.as_bool()) {
out.verbose = Some(v);
}
if let Some(v) = table.get("code_width").and_then(|v| v.as_integer()) {
out.code_width = Some(v.max(0) as u32);
}
if let Some(v) = table.get("source_root").and_then(|v| v.as_str()) {
out.source_root = Some(PathBuf::from(v));
}
if let Some(v) = table.get("min_confidence").and_then(|v| v.as_str()) {
out.min_confidence = Some(v.to_string());
}
if let Some(v) = table.get("fail_on").and_then(|v| v.as_str()) {
out.fail_on = Some(v.to_string());
}
if let Some(toml::Value::Table(t)) = table.get("severity_overrides") {
let map = t
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
out.severity_overrides = Some(map);
}
(out, unknown_keys)
}
fn apply_overrides(cfg: &mut ToolConfig, overrides: &RawOverrides) {
if let Some(v) = &overrides.journal {
cfg.journal = v.clone();
}
if let Some(v) = &overrides.mode {
cfg.mode = if v == "reviewer" {
Mode::Reviewer
} else {
Mode::Author
};
}
if let Some(v) = &overrides.output {
cfg.output = match v.as_str() {
"json" => OutputFormat::Json,
"html" => OutputFormat::Html,
"sarif" => OutputFormat::Sarif,
_ => OutputFormat::Terminal,
};
}
if let Some(v) = &overrides.ignore_rules {
cfg.ignore_rules = v.iter().cloned().collect();
}
if let Some(v) = overrides.verbose {
cfg.verbose = v;
}
if let Some(v) = overrides.code_width {
cfg.code_width = v;
}
if let Some(v) = &overrides.source_root {
cfg.source_root = v.clone();
}
if let Some(v) = &overrides.min_confidence {
if let Some(tier) = ConfidenceTier::parse(v) {
cfg.min_confidence = tier;
}
}
if let Some(v) = &overrides.fail_on {
if let Some(sev) = Severity::parse(v) {
cfg.fail_on = sev;
}
}
if let Some(v) = &overrides.severity_overrides {
cfg.severity_overrides = v
.iter()
.filter_map(|(rule_id, sev)| {
Severity::parse(&sev.trim().to_lowercase())
.map(|sev| (rule_id.trim().to_uppercase(), sev))
})
.collect();
}
}
pub fn load(cwd: &Path, cli_overrides: &RawOverrides) -> ToolConfig {
let mut cfg = ToolConfig::default();
let (file_overrides, unknown_keys) = read_toml_overrides(cwd);
apply_overrides(&mut cfg, &file_overrides);
apply_overrides(&mut cfg, cli_overrides);
if !unknown_keys.is_empty() && cfg.verbose {
eprintln!(
"warning: {CONFIG_FILENAME} has unrecognised keys: {}",
unknown_keys.join(", ")
);
}
cfg
}