use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::file_discovery::{ExcludeError, ExcludeFilter};
use crate::formatter::{FormatStyle, LineEnding};
pub const CONFIG_FILE_NAME: &str = "arity.toml";
const MIN_WIDTH: u32 = 1;
const MAX_WIDTH: u32 = 1000;
const DEFAULT_LINE_WIDTH: u32 = 80;
const DEFAULT_INDENT_WIDTH: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Config {
#[serde(default = "default_exclude")]
pub exclude: Vec<String>,
#[serde(default)]
pub extend_exclude: Vec<String>,
#[serde(default)]
pub format: FormatConfig,
#[serde(default)]
pub lint: LintConfig,
#[serde(default)]
pub index: IndexConfig,
#[serde(default)]
pub compat: CompatConfig,
#[serde(default = "default_true")]
pub cache: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
exclude: default_exclude(),
extend_exclude: Vec::new(),
format: FormatConfig::default(),
lint: LintConfig::default(),
index: IndexConfig::default(),
compat: CompatConfig::default(),
cache: true,
}
}
}
pub const DEFAULT_EXCLUDE: &[&str] = &[
".git/",
"renv/",
"revdep/",
"cpp11.R",
"RcppExports.R",
"extendr-wrappers.R",
"import-standalone-*.R",
];
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct FormatConfig {
#[serde(default = "default_line_width")]
pub line_width: u32,
#[serde(default = "default_indent_width")]
pub indent_width: u32,
#[serde(default)]
pub line_ending: LineEndingConfig,
}
impl Default for FormatConfig {
fn default() -> Self {
Self {
line_width: DEFAULT_LINE_WIDTH,
indent_width: DEFAULT_INDENT_WIDTH,
line_ending: LineEndingConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum LineEndingConfig {
#[default]
Auto,
Lf,
Crlf,
Native,
}
impl From<LineEndingConfig> for LineEnding {
fn from(value: LineEndingConfig) -> Self {
match value {
LineEndingConfig::Auto => LineEnding::Auto,
LineEndingConfig::Lf => LineEnding::Lf,
LineEndingConfig::Crlf => LineEnding::Crlf,
LineEndingConfig::Native => LineEnding::Native,
}
}
}
impl FormatConfig {
pub fn validate(&self, path: Option<&Path>) -> Result<(), ConfigError> {
validate_width("line-width", self.line_width, path)?;
validate_width("indent-width", self.indent_width, path)?;
Ok(())
}
}
fn default_line_width() -> u32 {
DEFAULT_LINE_WIDTH
}
fn default_indent_width() -> u32 {
DEFAULT_INDENT_WIDTH
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct LintConfig {
#[serde(default)]
pub select: Option<Vec<String>>,
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default)]
pub rules: RulesConfig,
#[serde(skip)]
pub compat: CompatConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct RulesConfig {
#[serde(default)]
pub undesirable_function: UndesirableFunctionConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct UndesirableFunctionConfig {
#[serde(default = "default_undesirable_functions")]
pub functions: BTreeMap<String, String>,
#[serde(default)]
pub extend_functions: BTreeMap<String, String>,
}
impl Default for UndesirableFunctionConfig {
fn default() -> Self {
Self {
functions: default_undesirable_functions(),
extend_functions: BTreeMap::new(),
}
}
}
impl UndesirableFunctionConfig {
pub fn lookup(&self, name: &str) -> Option<&str> {
self.extend_functions
.get(name)
.or_else(|| self.functions.get(name))
.map(String::as_str)
}
pub fn resolved(&self) -> BTreeMap<String, String> {
let mut out = self.functions.clone();
out.extend(
self.extend_functions
.iter()
.map(|(k, v)| (k.clone(), v.clone())),
);
out
}
}
fn default_undesirable_functions() -> BTreeMap<String, String> {
[
("attach", "use `with()` or refer to columns explicitly"),
("detach", "avoid modifying the search path"),
(".libPaths", "set `R_LIBS` outside the script"),
(
"install.packages",
"declare dependencies in DESCRIPTION or renv",
),
("setwd", "use paths relative to the project root"),
("sink", "use `capture.output()` or an explicit connection"),
("source", "make the code a package or use `box::use()`"),
("options", "set options in the session, not in library code"),
("par", "restore graphical parameters with `on.exit()`"),
("Sys.setenv", "set the environment outside the script"),
("Sys.setlocale", "set the locale outside the script"),
("debug", "remove the debugging call"),
("debugonce", "remove the debugging call"),
("undebug", "remove the debugging call"),
("trace", "remove the debugging call"),
("untrace", "remove the debugging call"),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct CompatConfig {
#[serde(default)]
pub r: Option<String>,
#[serde(default)]
pub roxygen2: Option<String>,
}
impl CompatConfig {
fn validate(&self, path: Option<&Path>) -> Result<(), ConfigError> {
for (field, value) in [("compat.r", &self.r), ("compat.roxygen2", &self.roxygen2)] {
if let Some(text) = value
&& CompatVersion::parse(text).is_none()
{
return Err(ConfigError::InvalidValue {
path: path.map(Path::to_path_buf),
field,
message: format!(
"`{text}` is not a version string (expected dot- or \
dash-separated numbers, e.g. \"4.1\" or \"7.3.2\")"
),
});
}
}
Ok(())
}
pub fn r_version(&self) -> Option<CompatVersion> {
self.r.as_deref().and_then(CompatVersion::parse)
}
pub fn roxygen2_version(&self) -> Option<CompatVersion> {
self.roxygen2.as_deref().and_then(CompatVersion::parse)
}
}
#[derive(Debug, Clone, Eq)]
pub struct CompatVersion(Vec<u32>);
impl PartialEq for CompatVersion {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == std::cmp::Ordering::Equal
}
}
impl Ord for CompatVersion {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
let len = self.0.len().max(other.0.len());
for i in 0..len {
let a = self.0.get(i).copied().unwrap_or(0);
let b = other.0.get(i).copied().unwrap_or(0);
match a.cmp(&b) {
std::cmp::Ordering::Equal => continue,
order => return order,
}
}
std::cmp::Ordering::Equal
}
}
impl PartialOrd for CompatVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl CompatVersion {
pub fn parse(text: &str) -> Option<Self> {
let components: Option<Vec<u32>> = text
.split(['.', '-'])
.map(|c| {
(!c.is_empty() && c.bytes().all(|b| b.is_ascii_digit()))
.then(|| c.parse().ok())
.flatten()
})
.collect();
components.filter(|c| !c.is_empty()).map(CompatVersion)
}
}
impl fmt::Display for CompatVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for c in &self.0 {
if !first {
write!(f, ".")?;
}
write!(f, "{c}")?;
first = false;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct IndexConfig {
#[serde(default)]
pub library_paths: Vec<PathBuf>,
#[serde(default)]
pub cache_dir: Option<PathBuf>,
#[serde(default = "default_true")]
pub auto_build: bool,
#[serde(default = "default_true")]
pub help: bool,
#[serde(skip)]
pub remote_url: Option<String>,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
library_paths: Vec::new(),
cache_dir: None,
auto_build: true,
help: true,
remote_url: None,
}
}
}
fn default_true() -> bool {
true
}
fn default_exclude() -> Vec<String> {
DEFAULT_EXCLUDE.iter().map(|p| p.to_string()).collect()
}
impl From<&FormatConfig> for FormatStyle {
fn from(config: &FormatConfig) -> Self {
FormatStyle {
line_width: config.line_width as usize,
indent_width: config.indent_width as usize,
line_ending: config.line_ending.into(),
}
}
}
#[derive(Debug)]
pub enum ConfigError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
line: usize,
column: usize,
message: String,
},
InvalidValue {
path: Option<PathBuf>,
field: &'static str,
message: String,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "failed to read {}: {source}", path.display())
}
Self::Parse {
path,
line,
column,
message,
} => write!(f, "{}:{line}:{column}: {message}", path.display()),
Self::InvalidValue {
path,
field,
message,
} => match path {
Some(path) => write!(f, "{}: invalid `{field}`: {message}", path.display()),
None => write!(f, "invalid `{field}`: {message}"),
},
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl Config {
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
let text = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
Self::parse_str(&text, path)
}
fn parse_str(text: &str, path: &Path) -> Result<Self, ConfigError> {
let mut config: Self = toml::from_str(text).map_err(|err| {
let (line, column) = match err.span() {
Some(span) => byte_offset_to_line_col(text, span.start),
None => (1, 1),
};
ConfigError::Parse {
path: path.to_path_buf(),
line,
column,
message: err.message().to_string(),
}
})?;
config.validate(Some(path))?;
config.lint.compat = config.compat.clone();
Ok(config)
}
fn validate(&self, path: Option<&Path>) -> Result<(), ConfigError> {
self.format.validate(path)?;
self.compat.validate(path)
}
pub fn discover(start: &Path) -> Result<Option<(PathBuf, Self)>, ConfigError> {
let Some(canonical) = deepest_existing_ancestor(start) else {
return Ok(None);
};
for dir in canonical.ancestors() {
let candidate = dir.join(CONFIG_FILE_NAME);
if candidate.is_file() {
let config = Self::load_from(&candidate)?;
return Ok(Some((candidate, config)));
}
if dir.join(".git").exists() {
return Ok(None);
}
}
Ok(None)
}
pub fn resolve(
explicit: Option<&Path>,
no_config: bool,
anchor: &Path,
) -> Result<(Self, Option<PathBuf>), ConfigError> {
if no_config {
return Ok((Self::default(), None));
}
if let Some(path) = explicit {
let config = Self::load_from(path)?;
return Ok((config, Some(path.to_path_buf())));
}
match Self::discover(anchor)? {
Some((path, config)) => Ok((config, Some(path))),
None => Ok((Self::default(), None)),
}
}
pub fn exclude_filter(
&self,
source: Option<&Path>,
anchor: &Path,
extra: &[String],
) -> Result<ExcludeFilter, ExcludeError> {
let root = source.and_then(Path::parent).unwrap_or(anchor);
let mut patterns = self.exclude.clone();
patterns.extend(self.extend_exclude.iter().cloned());
patterns.extend(extra.iter().cloned());
ExcludeFilter::new(root, &patterns)
}
}
fn deepest_existing_ancestor(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|dir| dir.canonicalize().ok())
}
fn validate_width(field: &'static str, value: u32, path: Option<&Path>) -> Result<(), ConfigError> {
if !(MIN_WIDTH..=MAX_WIDTH).contains(&value) {
return Err(ConfigError::InvalidValue {
path: path.map(Path::to_path_buf),
field,
message: format!("must be between {MIN_WIDTH} and {MAX_WIDTH}, got {value}"),
});
}
Ok(())
}
fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
let mut line = 1usize;
let mut column = 1usize;
let clamped = offset.min(source.len());
for ch in source[..clamped].chars() {
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn parse(text: &str) -> Result<Config, ConfigError> {
Config::parse_str(text, Path::new("arity.toml"))
}
#[test]
fn compat_table_parses_and_resolves() {
let config = parse("[compat]\nr = \"4.1\"\nroxygen2 = \"7.3.2\"\n").unwrap();
assert_eq!(config.compat.r_version(), CompatVersion::parse("4.1"));
assert_eq!(
config.compat.roxygen2_version(),
CompatVersion::parse("7.3.2")
);
let config = parse("").unwrap();
assert_eq!(config.compat.r_version(), None);
assert_eq!(config.compat.roxygen2_version(), None);
}
#[test]
fn compat_invalid_version_is_a_config_error() {
let err = parse("[compat]\nr = \"latest\"\n").unwrap_err();
assert!(
matches!(
err,
ConfigError::InvalidValue {
field: "compat.r",
..
}
),
"{err}"
);
assert!(parse("[compat]\nroxygen = \"7.0\"\n").is_err());
}
#[test]
fn compat_version_ordering_zero_pads() {
let v = |s: &str| CompatVersion::parse(s).unwrap();
assert!(v("4.1") == v("4.1.0"));
assert!(v("4.1") < v("4.1.1"));
assert!(v("4.1.0") < v("4.2"));
assert!(v("4.10") > v("4.9"));
assert!(v("1.2-3") == v("1.2.3"));
assert!(v("7.3.2") < v("7.3.2.9000"));
assert_eq!(CompatVersion::parse(""), None);
assert_eq!(CompatVersion::parse("4."), None);
assert_eq!(CompatVersion::parse("v4.1"), None);
}
#[test]
fn exclude_filter_from_config_applies_patterns() {
use std::fs;
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("keep.R"), "x <- 1\n").unwrap();
fs::create_dir(root.join("vendor")).unwrap();
fs::write(root.join("vendor").join("skip.R"), "y <- 2\n").unwrap();
let config = Config {
exclude: vec!["vendor/".to_string()],
..Config::default()
};
let filter = config.exclude_filter(None, root, &[]).unwrap();
let files = crate::file_discovery::collect_r_files(&[root.to_path_buf()], &filter).unwrap();
let names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["keep.R".to_string()]);
}
#[test]
fn exclude_filter_extra_and_extend_apply_together() {
use std::fs;
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("keep.R"), "x <- 1\n").unwrap();
fs::create_dir(root.join("gen")).unwrap();
fs::write(root.join("gen").join("a.R"), "y <- 2\n").unwrap();
fs::create_dir(root.join("cli")).unwrap();
fs::write(root.join("cli").join("b.R"), "z <- 3\n").unwrap();
let config = Config {
exclude: Vec::new(),
extend_exclude: vec!["gen/".to_string()],
..Config::default()
};
let filter = config
.exclude_filter(None, root, &["cli/".to_string()])
.unwrap();
let files = crate::file_discovery::collect_r_files(&[root.to_path_buf()], &filter).unwrap();
let names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["keep.R".to_string()]);
}
#[test]
fn default_config_matches_format_style_default() {
let config = Config::default();
let style = FormatStyle::from(&config.format);
assert_eq!(style, FormatStyle::default());
}
#[test]
fn cache_defaults_true_and_parses_false() {
assert!(parse("").expect("parse").cache);
assert!(!parse("cache = false\n").expect("parse").cache);
}
#[test]
fn parses_minimal_format_section() {
let config = parse("[format]\nline-width = 100\n").expect("parse");
let style = FormatStyle::from(&config.format);
assert_eq!(style.line_width, 100);
assert_eq!(style.indent_width, 2);
}
#[test]
fn parses_indent_width() {
let config = parse("[format]\nindent-width = 4\n").expect("parse");
let style = FormatStyle::from(&config.format);
assert_eq!(style.indent_width, 4);
assert_eq!(style.line_width, 80);
}
#[test]
fn line_ending_defaults_to_auto() {
let config = parse("[format]\n").expect("parse");
assert_eq!(config.format.line_ending, LineEndingConfig::Auto);
let style = FormatStyle::from(&config.format);
assert_eq!(style.line_ending, LineEnding::Auto);
}
#[test]
fn parses_line_ending_variants() {
for (key, expected) in [
("auto", LineEndingConfig::Auto),
("lf", LineEndingConfig::Lf),
("crlf", LineEndingConfig::Crlf),
("native", LineEndingConfig::Native),
] {
let text = format!("[format]\nline-ending = \"{key}\"\n");
let config = parse(&text).unwrap_or_else(|e| panic!("parse {key}: {e}"));
assert_eq!(config.format.line_ending, expected, "for {key}");
}
}
#[test]
fn rejects_unknown_line_ending() {
let err = parse("[format]\nline-ending = \"mac\"\n").expect_err("unknown variant");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn empty_file_yields_defaults() {
let config = parse("").expect("parse");
assert_eq!(config, Config::default());
}
#[test]
fn rejects_unknown_top_level_table() {
let err = parse("[formatt]\nline-width = 80\n").expect_err("unknown table");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("formatt"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn rejects_unknown_field_in_format() {
let err = parse("[format]\nline-widht = 80\n").expect_err("unknown field");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("line-widht"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn rejects_snake_case_keys() {
let err = parse("[format]\nline_width = 80\n").expect_err("snake_case");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn rejects_zero_line_width() {
let err = parse("[format]\nline-width = 0\n").expect_err("zero width");
match err {
ConfigError::InvalidValue { field, message, .. } => {
assert_eq!(field, "line-width");
assert!(message.contains('0'));
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn rejects_huge_line_width() {
let err = parse("[format]\nline-width = 10000\n").expect_err("too big");
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "line-width",
..
}
));
}
#[test]
fn rejects_negative_width_as_parse_error() {
let err = parse("[format]\nline-width = -1\n").expect_err("negative");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn exclude_defaults_to_builtin_set_and_extend_is_empty() {
let config = Config::default();
assert_eq!(config.exclude, default_exclude());
assert!(config.extend_exclude.is_empty());
}
#[test]
fn parses_top_level_exclude_and_extend_exclude() {
let config =
parse("exclude = [\"vendor/\", \"*.gen.R\"]\nextend-exclude = [\"generated/\"]\n")
.expect("parse");
assert_eq!(
config.exclude,
vec!["vendor/".to_string(), "*.gen.R".to_string()]
);
assert_eq!(config.extend_exclude, vec!["generated/".to_string()]);
}
#[test]
fn extend_exclude_keeps_defaults() {
let config = parse("extend-exclude = [\"generated/\"]\n").expect("parse");
assert_eq!(config.exclude, default_exclude());
assert_eq!(config.extend_exclude, vec!["generated/".to_string()]);
}
#[test]
fn rejects_exclude_under_format() {
let err = parse("[format]\nexclude = [\"x\"]\n").expect_err("exclude is top-level");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn accepts_empty_lint_section() {
let config = parse("[lint]\n").expect("parse");
assert_eq!(config.lint, LintConfig::default());
}
#[test]
fn rejects_unknown_field_in_lint() {
let err = parse("[lint]\nstyle = \"strict\"\n").expect_err("unknown field");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn parses_lint_select() {
let config = parse("[lint]\nselect = [\"unused-binding\"]\n").expect("parse");
assert_eq!(
config.lint.select.as_deref(),
Some(&["unused-binding".to_string()][..])
);
}
#[test]
fn parses_index_section() {
let config = parse(concat!(
"[index]\n",
"library-paths = [\"/opt/R/lib\", \"~/rlibs\"]\n",
"cache-dir = \"/tmp/arity-cache\"\n",
"auto-build = false\n",
"help = false\n",
))
.expect("parse");
assert_eq!(
config.index.library_paths,
vec![PathBuf::from("/opt/R/lib"), PathBuf::from("~/rlibs")]
);
assert_eq!(
config.index.cache_dir.as_deref(),
Some(Path::new("/tmp/arity-cache"))
);
assert!(!config.index.auto_build);
assert!(!config.index.help);
}
#[test]
fn index_section_defaults() {
let config = parse("[index]\n").expect("parse");
assert_eq!(config.index, IndexConfig::default());
assert!(config.index.auto_build);
assert!(config.index.help);
assert!(config.index.library_paths.is_empty());
assert_eq!(config.index.cache_dir, None);
}
#[test]
fn rejects_unknown_field_in_index() {
let err = parse("[index]\nlibrary-path = [\"/x\"]\n").expect_err("unknown field");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("library-path"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn index_remote_url_defaults_to_none() {
let config = parse("[index]\n").expect("parse");
assert_eq!(config.index.remote_url, None);
assert_eq!(config.index, IndexConfig::default());
}
#[test]
fn rejects_remote_url_in_config() {
let err = parse("[index]\nremote-url = \"https://sidecar.example/cran\"\n")
.expect_err("remote-url is not a config key");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("remote-url"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn parses_lint_ignore() {
let config = parse("[lint]\nignore = [\"undefined-symbol\"]\n").expect("parse");
assert_eq!(config.lint.ignore, vec!["undefined-symbol".to_string()]);
}
#[test]
fn accepts_empty_lint_rules_section() {
let config = parse("[lint.rules]\n").expect("parse");
assert_eq!(config.lint.rules, RulesConfig::default());
}
#[test]
fn accepts_empty_undesirable_function_table() {
let config = parse("[lint.rules.undesirable-function]\n").expect("parse");
assert_eq!(
config.lint.rules.undesirable_function,
UndesirableFunctionConfig::default()
);
assert_eq!(
config.lint.rules.undesirable_function.resolved(),
default_undesirable_functions()
);
}
#[test]
fn undesirable_function_functions_replaces_the_builtin_set() {
let config = parse(concat!(
"[lint.rules.undesirable-function]\n",
"functions = { sapply = \"use `vapply()`\" }\n",
))
.expect("parse");
let resolved = config.lint.rules.undesirable_function.resolved();
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved.get("sapply").map(String::as_str),
Some("use `vapply()`")
);
assert!(
!resolved.contains_key("attach"),
"`functions` must replace, not extend: {resolved:?}"
);
}
#[test]
fn undesirable_function_extend_functions_adds_to_the_builtin_set() {
let config = parse(concat!(
"[lint.rules.undesirable-function]\n",
"extend-functions = { sapply = \"use `vapply()`\" }\n",
))
.expect("parse");
let resolved = config.lint.rules.undesirable_function.resolved();
assert_eq!(
resolved.get("sapply").map(String::as_str),
Some("use `vapply()`")
);
assert!(
resolved.contains_key("attach"),
"`extend-functions` must keep the defaults: {resolved:?}"
);
}
#[test]
fn undesirable_function_extend_overrides_a_default_entry() {
let config = parse(concat!(
"[lint.rules.undesirable-function]\n",
"extend-functions = { attach = \"custom advice\" }\n",
))
.expect("parse");
let resolved = config.lint.rules.undesirable_function.resolved();
assert_eq!(
resolved.get("attach").map(String::as_str),
Some("custom advice")
);
}
#[test]
fn undesirable_function_empty_functions_table_disables_the_rule() {
let config = parse(concat!(
"[lint.rules.undesirable-function]\n",
"functions = {}\n",
))
.expect("parse");
assert!(config.lint.rules.undesirable_function.resolved().is_empty());
}
#[test]
fn rejects_unknown_rule_id_table() {
let err = parse("[lint.rules.undesirabl-function]\nfunctions = {}\n")
.expect_err("unknown rule table");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("undesirabl-function"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn rejects_unknown_field_in_undesirable_function() {
let err =
parse("[lint.rules.undesirable-function]\nfunction = {}\n").expect_err("unknown field");
match err {
ConfigError::Parse { message, .. } => {
assert!(message.contains("function"), "got: {message}");
}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn rejects_snake_case_in_undesirable_function() {
let err = parse("[lint.rules.undesirable-function]\nextend_functions = {}\n")
.expect_err("keys are kebab-case");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn undesirable_function_lookup_agrees_with_resolved() {
let config = parse(concat!(
"[lint.rules.undesirable-function]\n",
"functions = { attach = \"a\", sapply = \"b\" }\n",
"extend-functions = { attach = \"override\", setwd = \"c\" }\n",
))
.expect("parse")
.lint
.rules
.undesirable_function;
let resolved = config.resolved();
for name in ["attach", "sapply", "setwd", "absent"] {
assert_eq!(
config.lookup(name),
resolved.get(name).map(String::as_str),
"lookup/resolved disagree on {name:?}"
);
}
assert_eq!(config.lookup("attach"), Some("override"));
}
#[test]
fn default_undesirable_functions_excludes_rules_with_their_own_id() {
let defaults = default_undesirable_functions();
assert!(!defaults.contains_key("browser"), "{defaults:?}");
assert!(defaults.contains_key("attach"), "{defaults:?}");
}
#[test]
fn parse_error_reports_file_path_and_line() {
let path = Path::new("/tmp/oops.toml");
let err = Config::parse_str("[format]\nbogus = 1\n", path).expect_err("bad field");
let rendered = err.to_string();
assert!(rendered.starts_with("/tmp/oops.toml:"));
}
#[test]
fn load_from_missing_file_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nope.toml");
let err = Config::load_from(&path).expect_err("missing file");
assert!(matches!(err, ConfigError::Io { .. }));
}
#[test]
fn discover_finds_arity_toml_in_parent() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 70\n",
)
.unwrap();
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
let (path, config) = Config::discover(&nested).expect("discover").expect("found");
assert_eq!(
path,
dir.path().canonicalize().unwrap().join(CONFIG_FILE_NAME)
);
assert_eq!(config.format.line_width, 70);
}
#[test]
fn discover_stops_at_git_boundary() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 70\n",
)
.unwrap();
let repo = dir.path().join("repo");
fs::create_dir_all(repo.join(".git")).unwrap();
let nested = repo.join("src");
fs::create_dir_all(&nested).unwrap();
let found = Config::discover(&nested).expect("discover");
assert!(
found.is_none(),
"should stop at .git boundary, got {found:?}"
);
}
#[test]
fn discover_prefers_config_at_repo_root() {
let dir = tempdir().unwrap();
let repo = dir.path().join("repo");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::write(repo.join(CONFIG_FILE_NAME), "[format]\nline-width = 70\n").unwrap();
let nested = repo.join("src");
fs::create_dir_all(&nested).unwrap();
let (path, config) = Config::discover(&nested).expect("discover").expect("found");
assert_eq!(path, repo.canonicalize().unwrap().join(CONFIG_FILE_NAME));
assert_eq!(config.format.line_width, 70);
}
#[test]
fn discover_tolerates_a_missing_anchor_directory() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 70\n",
)
.unwrap();
let missing = dir.path().join("no").join("such").join("dir");
assert!(!missing.exists(), "fixture directory must not exist");
let (path, config) = Config::discover(&missing)
.expect("discovery must not fail on a missing anchor")
.expect("an existing ancestor still supplies the config");
assert_eq!(
path,
dir.path().canonicalize().unwrap().join(CONFIG_FILE_NAME)
);
assert_eq!(config.format.line_width, 70);
}
#[test]
fn discover_on_a_missing_anchor_without_config_returns_none() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
let missing = dir.path().join("no").join("such").join("dir");
assert!(
Config::discover(&missing)
.expect("discovery must not fail on a missing anchor")
.is_none()
);
}
#[test]
fn resolve_no_config_returns_defaults() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 20\n",
)
.unwrap();
let (config, source) = Config::resolve(None, true, dir.path()).expect("resolve");
assert_eq!(config, Config::default());
assert!(source.is_none());
}
#[test]
fn resolve_explicit_overrides_discovery() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 20\n",
)
.unwrap();
let explicit = dir.path().join("custom.toml");
fs::write(&explicit, "[format]\nline-width = 40\n").unwrap();
let (config, source) =
Config::resolve(Some(&explicit), false, dir.path()).expect("resolve");
assert_eq!(config.format.line_width, 40);
assert_eq!(source.as_deref(), Some(explicit.as_path()));
}
#[test]
fn resolve_discovers_when_no_explicit_and_not_disabled() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(CONFIG_FILE_NAME),
"[format]\nline-width = 50\n",
)
.unwrap();
let (config, source) = Config::resolve(None, false, dir.path()).expect("resolve");
assert_eq!(config.format.line_width, 50);
assert!(source.is_some());
}
}