pub mod cli;
pub mod migrate;
pub mod resolver;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::review::AutoFixMode;
use crate::rules::RulesConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub languages: HashSet<String>,
#[serde(default)]
pub includes: Vec<String>,
#[serde(default, alias = "exclude")]
pub excludes: Vec<String>,
#[serde(default)]
pub max_complexity: Option<u32>,
#[serde(default)]
pub preset: Option<String>,
#[serde(default)]
pub verbose: Option<bool>,
#[serde(default)]
pub source: Option<SourceConfig>,
#[serde(default, flatten)]
pub language_overrides: LanguageOverrides,
#[serde(default, alias = "plugin")]
pub plugins: Option<PluginConfig>,
#[serde(default)]
pub plugin_auto_sync: Option<crate::plugin::AutoSyncConfig>,
#[serde(default)]
pub self_auto_update: Option<crate::self_update::SelfUpdateConfig>,
#[serde(default)]
pub tool_auto_install: Option<ToolAutoInstallConfig>,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub hook: HookConfig,
#[serde(default)]
pub cmsg: CmsgConfig,
#[serde(default)]
pub rules: RulesConfig,
#[serde(default)]
pub ai: AiConfig,
#[serde(default)]
pub review: ReviewConfig,
#[serde(default)]
pub retention: RetentionConfig,
#[serde(default)]
pub checks: ChecksConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FailOn {
Error,
#[default]
Warning,
Info,
None,
}
impl FailOn {
pub fn exit_code(&self, errors: usize, warnings: usize, infos: usize) -> i32 {
match self {
FailOn::None => 0,
FailOn::Error => {
if errors > 0 {
1
} else {
0
}
}
FailOn::Warning => {
if errors > 0 {
1
} else if warnings > 0 {
2
} else {
0
}
}
FailOn::Info => {
if errors > 0 {
1
} else if warnings > 0 {
2
} else if infos > 0 {
3
} else {
0
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecksConfig {
#[serde(default = "default_checks")]
pub run: Vec<String>,
#[serde(default)]
pub lint: Option<LintChecksConfig>,
#[serde(default)]
pub security: Option<SecurityChecksConfig>,
#[serde(default)]
pub complexity: Option<ComplexityChecksConfig>,
}
impl Default for ChecksConfig {
fn default() -> Self {
Self {
run: default_checks(),
lint: None,
security: None,
complexity: None,
}
}
}
fn default_checks() -> Vec<String> {
vec![
"lint".to_string(),
"security".to_string(),
"complexity".to_string(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LintChecksConfig {
#[serde(default)]
pub fail_on: Option<FailOn>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecurityChecksConfig {
#[serde(default)]
pub scan_type: Option<String>,
#[serde(default)]
pub fail_on: Option<FailOn>,
#[serde(default)]
pub sast_config: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ComplexityChecksConfig {
#[serde(default)]
pub threshold: Option<u32>,
#[serde(default)]
pub warning_threshold: Option<u32>,
#[serde(default)]
pub error_threshold: Option<u32>,
#[serde(default)]
pub fail_on: Option<FailOn>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginConfig {
#[serde(default)]
pub sources: Vec<PluginSourceConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSourceConfig {
pub name: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default, rename = "ref")]
pub git_ref: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig {
#[serde(default = "default_large_file_threshold")]
pub large_file_threshold: u64,
#[serde(default)]
pub skip_large_files: bool,
#[serde(default = "default_cache_max_age_days")]
pub cache_max_age_days: u32,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
large_file_threshold: default_large_file_threshold(),
skip_large_files: false,
cache_max_age_days: default_cache_max_age_days(),
}
}
}
fn default_large_file_threshold() -> u64 {
1048576 }
fn default_cache_max_age_days() -> u32 {
7
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HookSource {
Marketplace {
marketplace: String,
plugin: String,
file: String,
},
Git {
git: String,
#[serde(rename = "ref", default)]
git_ref: Option<String>,
path: String,
},
Plugin { plugin: String, file: String },
Url { url: String },
File { file: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentTargetConfig {
pub skills: Option<String>,
pub memory: Option<String>,
pub commands: Option<String>,
pub settings: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookSourceEntry {
pub source: HookSource,
#[serde(default)]
pub target: Option<AgentTargetConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
#[serde(default = "default_hook_timeout")]
pub timeout: u32,
#[serde(default = "default_hook_parallel")]
pub parallel: bool,
#[serde(default)]
pub output_width: Option<u32>,
#[serde(default)]
pub marketplaces: HashMap<String, String>,
#[serde(default)]
pub git: HashMap<String, HookSourceEntry>,
#[serde(default, rename = "git-with-agent")]
pub git_with_agent: HashMap<String, HookSourceEntry>,
#[serde(default)]
pub prek: HashMap<String, HookSourceEntry>,
#[serde(default, rename = "prek-with-agent")]
pub prek_with_agent: HashMap<String, HookSourceEntry>,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub review: HookReviewConfig,
#[serde(default)]
pub pre_commit: HookEventFixConfig,
#[serde(default)]
pub pre_push: HookEventFixConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentConfig {
#[serde(default)]
pub plugins: HashMap<String, HashMap<String, HookSourceEntry>>,
#[serde(default, rename = "skill-names")]
pub skill_names: AgentSkillNamesConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookReviewConfig {
#[serde(default = "default_hook_review_auto_fix_mode")]
pub auto_fix_mode: AutoFixMode,
}
impl Default for HookReviewConfig {
fn default() -> Self {
Self {
auto_fix_mode: default_hook_review_auto_fix_mode(),
}
}
}
fn default_hook_review_auto_fix_mode() -> AutoFixMode {
AutoFixMode::Pr
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookEventFixConfig {
#[serde(default = "default_fix_commit_mode_one_commit")]
pub fix_commit_mode: String,
}
fn default_fix_commit_mode_one_commit() -> String {
"squash".to_string()
}
impl Default for HookEventFixConfig {
fn default() -> Self {
Self {
fix_commit_mode: default_fix_commit_mode_one_commit(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentSkillNamesConfig {
#[serde(default, rename = "pre-commit")]
pub pre_commit: Option<String>,
#[serde(default, rename = "commit-msg")]
pub commit_msg: Option<String>,
#[serde(default, rename = "pre-push")]
pub pre_push: Option<String>,
}
impl Default for HookConfig {
fn default() -> Self {
Self {
timeout: default_hook_timeout(),
parallel: default_hook_parallel(),
output_width: None,
marketplaces: HashMap::new(),
git: HashMap::new(),
git_with_agent: HashMap::new(),
prek: HashMap::new(),
prek_with_agent: HashMap::new(),
agent: AgentConfig::default(),
review: HookReviewConfig::default(),
pre_commit: HookEventFixConfig {
fix_commit_mode: "squash".to_string(),
},
pre_push: HookEventFixConfig {
fix_commit_mode: "dirty".to_string(),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CmsgConfig {
#[serde(default = "default_commit_msg_pattern")]
pub commit_msg_pattern: String,
#[serde(default)]
pub require_ticket: bool,
#[serde(default)]
pub ticket_pattern: Option<String>,
}
impl Default for CmsgConfig {
fn default() -> Self {
Self {
commit_msg_pattern: default_commit_msg_pattern(),
require_ticket: false,
ticket_pattern: None,
}
}
}
fn default_hook_timeout() -> u32 {
60 }
fn default_hook_parallel() -> bool {
true
}
fn default_commit_msg_pattern() -> String {
r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,72}".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewConfig {
#[serde(default = "default_review_enabled")]
pub enabled: bool,
#[serde(default)]
pub auto_fix: bool,
#[serde(default)]
pub auto_fix_mode: AutoFixMode,
#[serde(default)]
pub provider: Option<String>,
#[serde(default = "default_retention_days")]
pub retention_days: u32,
#[serde(default)]
pub platforms: std::collections::HashMap<String, PlatformConfig>,
#[serde(default)]
pub reviewers: ReviewerConfig,
#[serde(default)]
pub notifications: Vec<NotificationConfig>,
}
impl Default for ReviewConfig {
fn default() -> Self {
Self {
enabled: default_review_enabled(),
auto_fix: false,
auto_fix_mode: AutoFixMode::default(),
provider: None,
retention_days: default_retention_days(),
platforms: std::collections::HashMap::new(),
reviewers: ReviewerConfig::default(),
notifications: Vec::new(),
}
}
}
fn default_review_enabled() -> bool {
true
}
fn default_retention_days() -> u32 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionConfig {
#[serde(default = "default_retention_results")]
pub results: usize,
#[serde(default = "default_retention_backups")]
pub backups: usize,
#[serde(default = "default_retention_reviews")]
pub reviews: usize,
#[serde(default = "default_retention_cache_days")]
pub cache_days: u32,
#[serde(default = "default_retention_diffs")]
pub diffs: usize,
}
fn default_retention_results() -> usize {
10
}
fn default_retention_backups() -> usize {
5
}
fn default_retention_reviews() -> usize {
10
}
fn default_retention_cache_days() -> u32 {
30
}
fn default_retention_diffs() -> usize {
5
}
impl Default for RetentionConfig {
fn default() -> Self {
Self {
results: default_retention_results(),
backups: default_retention_backups(),
reviews: default_retention_reviews(),
cache_days: default_retention_cache_days(),
diffs: default_retention_diffs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformConfig {
pub pr_create: String,
#[serde(default)]
pub pr_list: Option<String>,
#[serde(default = "default_reviewer_flag")]
pub reviewer_flag: String,
#[serde(default)]
pub install_cmd: Option<String>,
#[serde(default)]
pub install_hint: Option<String>,
}
fn default_reviewer_flag() -> String {
"--reviewer".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewerConfig {
#[serde(default)]
pub default: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum NotificationConfig {
#[serde(rename = "system")]
System,
#[serde(rename = "webhook")]
Webhook {
url: String,
#[serde(default = "default_webhook_method")]
method: String,
#[serde(default)]
headers: std::collections::HashMap<String, String>,
#[serde(default)]
body_template: Option<String>,
},
#[serde(rename = "custom")]
Custom { command: String },
}
fn default_webhook_method() -> String {
"POST".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolAutoInstallConfig {
#[serde(default = "default_tool_auto_install_enabled")]
pub enabled: bool,
#[serde(default = "default_tool_auto_install_mode")]
pub mode: String,
}
fn default_tool_auto_install_enabled() -> bool {
true
}
fn default_tool_auto_install_mode() -> String {
"auto".to_string()
}
impl Default for ToolAutoInstallConfig {
fn default() -> Self {
Self {
enabled: default_tool_auto_install_enabled(),
mode: default_tool_auto_install_mode(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AiConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub custom_providers: std::collections::HashMap<String, CustomProvider>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomProvider {
#[serde(default = "default_provider_kind")]
pub kind: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub cli_style: Option<String>,
#[serde(default)]
pub prompt_args: Option<Vec<String>>,
#[serde(default)]
pub fix_args: Option<Vec<String>>,
#[serde(default)]
pub system_prompt_arg: Option<String>,
#[serde(default)]
pub api_style: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub fallback: Option<String>,
}
fn default_provider_kind() -> String {
"cli".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SourceConfig {
#[serde(default)]
pub test_source: PathPatterns,
#[serde(default)]
pub auto_generate_source: PathPatterns,
#[serde(default)]
pub third_party_source: PathPatterns,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PathPatterns {
#[serde(default)]
pub filepath_regex: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LanguageOverrides {
#[serde(default)]
pub rust: Option<LanguageConfig>,
#[serde(default)]
pub python: Option<LanguageConfig>,
#[serde(default)]
pub typescript: Option<LanguageConfig>,
#[serde(default)]
pub javascript: Option<LanguageConfig>,
#[serde(default)]
pub go: Option<LanguageConfig>,
#[serde(default)]
pub java: Option<LanguageConfig>,
#[serde(default)]
pub cpp: Option<CppLanguageConfig>,
#[serde(default, alias = "objectivec")]
pub oc: Option<CppLanguageConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LanguageConfig {
#[serde(default, alias = "exclude")]
pub excludes: Vec<String>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub max_complexity: Option<u32>,
#[serde(default)]
pub rules: Option<RulesConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CppLanguageConfig {
#[serde(default, alias = "exclude")]
pub excludes: Vec<String>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub max_complexity: Option<u32>,
#[serde(default)]
pub linelength: Option<u32>,
#[serde(default)]
pub cpplint_filter: Option<String>,
#[serde(default)]
pub clang_tidy_ignored_checks: Option<Vec<String>>,
#[serde(default)]
pub fn_length: Option<u32>,
#[serde(default)]
pub rules: Option<RulesConfig>,
}
impl LanguageOverrides {
pub fn merge(&mut self, other: LanguageOverrides) {
macro_rules! merge_lang {
($field:ident) => {
if other.$field.is_some() {
self.$field = other.$field;
}
};
}
merge_lang!(rust);
merge_lang!(python);
merge_lang!(typescript);
merge_lang!(javascript);
merge_lang!(go);
merge_lang!(java);
merge_lang!(cpp);
merge_lang!(oc);
}
}
const KNOWN_FIELDS: &[&str] = &[
"languages",
"includes",
"excludes",
"max_complexity",
"preset",
"verbose",
"quiet",
"plugins",
"self_auto_update",
"plugin_auto_sync",
"tool_auto_install",
"rules",
"rust",
"python",
"go",
"typescript",
"javascript",
"java",
"cpp",
"oc",
];
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn load(path: &Path) -> crate::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
crate::LintisError::Config(format!(
"Failed to read config file '{}': {}",
path.display(),
e
))
})?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"yml" | "yaml" => serde_yaml::from_str(&content)
.map_err(|e| crate::LintisError::Config(format_yaml_error(path, &e))),
"toml" => toml::from_str(&content)
.map_err(|e| crate::LintisError::Config(format_toml_error(path, &content, &e))),
"json" => serde_json::from_str(&content)
.map_err(|e| crate::LintisError::Config(format_json_error(path, &e))),
_ => Err(crate::LintisError::Config(format!(
"Unsupported config format: '{}'\n\nSupported formats: toml, yaml, json",
ext
))),
}
}
pub fn built_in_defaults() -> Self {
Config {
max_complexity: Some(20),
..Default::default()
}
}
pub fn load_user_config() -> Option<Self> {
let home = dirs::home_dir()?;
let config_path = home.join(".linthis").join("config.toml");
if config_path.exists() {
Self::load(&config_path).ok()
} else {
None
}
}
pub fn load_project_config(start_dir: &Path) -> Option<Self> {
let mut current = start_dir.to_path_buf();
loop {
let config_path = current.join(".linthis").join("config.toml");
if config_path.exists() {
if let Ok(config) = Self::load(&config_path) {
return Some(config);
}
}
if !current.pop() {
break;
}
}
None
}
pub fn merge(&mut self, other: Config) {
if !other.languages.is_empty() {
self.languages = other.languages;
}
self.includes.extend(other.includes);
self.excludes.extend(other.excludes);
if other.max_complexity.is_some() {
self.max_complexity = other.max_complexity;
}
if other.preset.is_some() {
self.preset = other.preset;
}
if other.verbose.is_some() {
self.verbose = other.verbose;
}
if other.source.is_some() {
self.source = other.source;
}
self.language_overrides.merge(other.language_overrides);
if other.plugins.is_some() {
self.plugins = other.plugins;
}
self.rules.merge(other.rules);
if !other.hook.marketplaces.is_empty() {
self.hook.marketplaces.extend(other.hook.marketplaces);
}
if !other.hook.git.is_empty() {
self.hook.git.extend(other.hook.git);
}
if !other.hook.git_with_agent.is_empty() {
self.hook.git_with_agent.extend(other.hook.git_with_agent);
}
if !other.hook.prek.is_empty() {
self.hook.prek.extend(other.hook.prek);
}
if !other.hook.prek_with_agent.is_empty() {
self.hook.prek_with_agent.extend(other.hook.prek_with_agent);
}
for (provider, plugins) in other.hook.agent.plugins {
self.hook
.agent
.plugins
.entry(provider)
.or_default()
.extend(plugins);
}
if other.hook.timeout != default_hook_timeout() {
self.hook.timeout = other.hook.timeout;
}
if other.hook.pre_commit.fix_commit_mode != default_fix_commit_mode_one_commit() {
self.hook.pre_commit.fix_commit_mode = other.hook.pre_commit.fix_commit_mode;
}
if other.hook.pre_push.fix_commit_mode != default_fix_commit_mode_one_commit() {
self.hook.pre_push.fix_commit_mode = other.hook.pre_push.fix_commit_mode;
}
}
pub fn get_plugin_sources(&self) -> Vec<crate::plugin::PluginSource> {
self.plugins
.as_ref()
.map(|p| {
p.sources
.iter()
.map(|s| crate::plugin::PluginSource {
name: s.name.clone(),
url: s.url.clone(),
git_ref: s.git_ref.clone(),
enabled: s.enabled,
})
.collect()
})
.unwrap_or_default()
}
fn load_plugin_linthis_toml(source: &crate::plugin::PluginSource) -> Option<Self> {
use crate::plugin::PluginCache;
let cache = if let Ok(dir) = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR") {
PluginCache::with_dir(std::path::PathBuf::from(dir))
} else {
PluginCache::new().ok()?
};
let url = source.url.as_ref()?;
let cache_path = cache.url_to_cache_path(url);
let linthis_toml = cache_path.join("linthis.toml");
if linthis_toml.exists() {
Self::load(&linthis_toml).ok()
} else {
None
}
}
pub fn load_merged(project_dir: &Path) -> Self {
let mut config = Self::built_in_defaults();
let global_plugin_sources = Self::load_user_config()
.map(|c| c.get_plugin_sources())
.unwrap_or_default();
let project_plugin_sources = Self::load_project_config(project_dir)
.map(|c| c.get_plugin_sources())
.unwrap_or_default();
for source in &global_plugin_sources {
if let Some(plugin_config) = Self::load_plugin_linthis_toml(source) {
config.merge(plugin_config);
}
}
for source in &project_plugin_sources {
if let Some(plugin_config) = Self::load_plugin_linthis_toml(source) {
config.merge(plugin_config);
}
}
if let Some(user_config) = Self::load_user_config() {
config.merge(user_config);
}
if let Some(project_config) = Self::load_project_config(project_dir) {
config.merge(project_config);
}
config
}
pub fn get_active_config_paths(project_dir: &Path) -> Vec<std::path::PathBuf> {
use crate::plugin::PluginCache;
let mut paths = Vec::new();
let global_plugin_sources = Self::load_user_config()
.map(|c| c.get_plugin_sources())
.unwrap_or_default();
let project_plugin_sources = Self::load_project_config(project_dir)
.map(|c| c.get_plugin_sources())
.unwrap_or_default();
let cache_opt = if let Ok(dir) = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR") {
Some(PluginCache::with_dir(std::path::PathBuf::from(dir)))
} else {
PluginCache::new().ok()
};
let mut seen = std::collections::HashSet::new();
let mut push_unique = |paths: &mut Vec<_>, p: std::path::PathBuf| {
if seen.insert(p.clone()) {
paths.push(p);
}
};
for source in global_plugin_sources
.iter()
.chain(project_plugin_sources.iter())
{
if let (Some(cache), Some(url)) = (cache_opt.as_ref(), source.url.as_ref()) {
let p = cache.url_to_cache_path(url).join("linthis.toml");
if p.exists() {
push_unique(&mut paths, p);
}
}
}
if let Some(home) = dirs::home_dir() {
let p = home.join(".linthis").join("config.toml");
if p.exists() {
push_unique(&mut paths, p);
}
}
let mut current = project_dir.to_path_buf();
loop {
let p = current.join(".linthis").join("config.toml");
if p.exists() {
push_unique(&mut paths, p);
break;
}
if !current.pop() {
break;
}
}
paths
}
pub fn generate_default_toml() -> String {
r#"# Linthis Configuration
# See https://github.com/zhlinh/linthis for documentation
# Languages to check (empty = auto-detect all supported languages)
# languages = ["rust", "python", "typescript"]
# Files or directories to include (glob patterns)
# includes = ["src/**", "lib/**"]
# Patterns to exclude (in addition to defaults)
excludes = []
# Maximum cyclomatic complexity allowed
max_complexity = 20
# Format preset: "google", "standard", or "airbnb"
# preset = "google"
# Plugin configuration
# [plugins]
# sources = [
# { name = "official" },
# { name = "myplugin", url = "https://github.com/zhlinh/linthis-plugin.git", ref = "main" }
# ]
# Rules configuration
# [rules]
# disable = ["E501", "whitespace/*"] # Disable specific rules or prefixes
#
# [rules.severity]
# "W0612" = "error" # Override severity (error, warning, info, off)
#
# [[rules.custom]]
# code = "custom/no-todo"
# pattern = "TODO|FIXME"
# message = "Found TODO/FIXME comment"
# severity = "warning"
# suggestion = "Address or convert to tracking issue"
# Language-specific overrides
# [rust]
# max_complexity = 15
# [python]
# excludes = ["*_test.py"]
# Tool auto-install configuration
# [tool_auto_install]
# enabled = true
# mode = "auto" # auto = install silently (default); prompt = ask before installing; disabled = never install
# Objective-C specific overrides
# [oc]
# fn_length = 80 # Max method SLOC (non-blank, non-comment lines). Default: 80.
"#
.to_string()
}
pub fn project_config_path(project_dir: &Path) -> PathBuf {
project_dir.join(".linthis").join("config.toml")
}
}
fn format_toml_error(path: &Path, content: &str, err: &toml::de::Error) -> String {
let mut msg = format!("Invalid TOML in '{}'", path.display());
let err_str = err.to_string();
if err_str.contains("unknown field") {
if let Some(field) = extract_unknown_field(&err_str) {
msg.push_str(&format!("\n\nUnknown field: '{}'", field));
if let Some(suggestion) = find_similar_field(&field, KNOWN_FIELDS) {
msg.push_str(&format!("\n\nDid you mean: '{}'?", suggestion));
} else {
msg.push_str("\n\nValid top-level fields: languages, includes, excludes, preset, verbose, quiet, plugins");
}
}
}
if let Some(line_info) = extract_line_from_error(&err_str) {
msg.push_str(&format!("\n\nError at line {}", line_info));
if let Ok(line_num) = line_info.parse::<usize>() {
if let Some(line) = content.lines().nth(line_num.saturating_sub(1)) {
msg.push_str(&format!(":\n {} | {}", line_num, line.trim()));
}
}
}
msg.push_str(&format!("\n\nDetails: {}", err));
msg.push_str(&format!("\n\nHint: {}", get_toml_hint(&err_str)));
msg
}
fn extract_line_from_error(err_str: &str) -> Option<String> {
let patterns = ["at line ", "line "];
for pattern in patterns {
if let Some(start) = err_str.find(pattern) {
let remaining = &err_str[start + pattern.len()..];
let end = remaining
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(remaining.len());
if end > 0 {
return Some(remaining[..end].to_string());
}
}
}
None
}
fn format_yaml_error(path: &Path, err: &serde_yaml::Error) -> String {
let mut msg = format!("Invalid YAML in '{}'", path.display());
if let Some(location) = err.location() {
msg.push_str(&format!(
"\n\nError at line {}, column {}",
location.line(),
location.column()
));
}
msg.push_str(&format!("\n\nDetails: {}", err));
msg.push_str("\n\nHint: Check indentation and ensure proper YAML syntax");
msg
}
fn format_json_error(path: &Path, err: &serde_json::Error) -> String {
let mut msg = format!("Invalid JSON in '{}'", path.display());
msg.push_str(&format!(
"\n\nError at line {}, column {}",
err.line(),
err.column()
));
msg.push_str(&format!("\n\nDetails: {}", err));
msg.push_str("\n\nHint: Check for missing commas, unclosed brackets, or trailing commas");
msg
}
fn extract_unknown_field(err_str: &str) -> Option<String> {
if let Some(start) = err_str.find("unknown field `") {
let remaining = &err_str[start + 15..];
if let Some(end) = remaining.find('`') {
return Some(remaining[..end].to_string());
}
}
None
}
fn find_similar_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_lowercase();
let mut best_match = None;
let mut best_distance = usize::MAX;
for &candidate in candidates {
let distance = levenshtein_distance(&input_lower, &candidate.to_lowercase());
if distance < best_distance && distance <= 3 {
best_distance = distance;
best_match = Some(candidate.to_string());
}
}
best_match
}
#[allow(clippy::needless_range_loop)]
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
fn get_toml_hint(err_str: &str) -> &'static str {
if err_str.contains("expected") && err_str.contains("found") {
"Check the value type - strings need quotes, arrays use [], tables use [section]"
} else if err_str.contains("missing field") {
"Some required fields may be missing from your configuration"
} else if err_str.contains("duplicate key") {
"Remove the duplicate field definition"
} else if err_str.contains("invalid type") {
"Check that the value type matches what's expected (string, number, boolean, array, etc.)"
} else if err_str.contains("unknown field") {
"Check spelling or remove the unrecognized field"
} else {
"Run 'linthis init' to generate a valid configuration file"
}
}
mod dirs {
use std::path::PathBuf;
pub fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_merge() {
let mut base = Config {
max_complexity: Some(20),
excludes: vec!["*.log".to_string()],
..Default::default()
};
let override_config = Config {
max_complexity: Some(15),
excludes: vec!["*.tmp".to_string()],
preset: Some("google".to_string()),
..Default::default()
};
base.merge(override_config);
assert_eq!(base.max_complexity, Some(15));
assert_eq!(
base.excludes,
vec!["*.log".to_string(), "*.tmp".to_string()]
);
assert_eq!(base.preset, Some("google".to_string()));
}
#[test]
fn test_built_in_defaults() {
let defaults = Config::built_in_defaults();
assert_eq!(defaults.max_complexity, Some(20));
}
#[test]
fn test_backward_compatibility() {
let toml_with_old_names = r#"
exclude = ["*.log", "target/**"]
[plugin]
sources = [
{ name = "test", enabled = true }
]
"#;
let config: Config = toml::from_str(toml_with_old_names).unwrap();
assert_eq!(
config.excludes,
vec!["*.log".to_string(), "target/**".to_string()]
);
assert!(config.plugins.is_some());
assert_eq!(config.plugins.as_ref().unwrap().sources.len(), 1);
assert_eq!(config.plugins.as_ref().unwrap().sources[0].name, "test");
}
#[test]
fn test_new_field_names() {
let toml_with_new_names = r#"
includes = ["src/**", "lib/**"]
excludes = ["*.log", "target/**"]
[plugins]
sources = [
{ name = "test", enabled = true }
]
"#;
let config: Config = toml::from_str(toml_with_new_names).unwrap();
assert_eq!(
config.includes,
vec!["src/**".to_string(), "lib/**".to_string()]
);
assert_eq!(
config.excludes,
vec!["*.log".to_string(), "target/**".to_string()]
);
assert!(config.plugins.is_some());
assert_eq!(config.plugins.as_ref().unwrap().sources.len(), 1);
}
#[test]
fn test_language_overrides_simplified_syntax() {
let toml_with_simplified = r#"
max_complexity = 20
[rust]
max_complexity = 15
excludes = ["target/**"]
[python]
max_complexity = 10
excludes = ["*_test.py"]
"#;
let config: Config = toml::from_str(toml_with_simplified).unwrap();
assert_eq!(config.max_complexity, Some(20));
assert!(config.language_overrides.rust.is_some());
let rust_config = config.language_overrides.rust.as_ref().unwrap();
assert_eq!(rust_config.max_complexity, Some(15));
assert_eq!(rust_config.excludes, vec!["target/**".to_string()]);
assert!(config.language_overrides.python.is_some());
let python_config = config.language_overrides.python.as_ref().unwrap();
assert_eq!(python_config.max_complexity, Some(10));
assert_eq!(python_config.excludes, vec!["*_test.py".to_string()]);
}
#[test]
fn test_language_overrides_merge() {
let mut base = LanguageOverrides {
rust: Some(LanguageConfig {
max_complexity: Some(15),
..Default::default()
}),
python: Some(LanguageConfig {
max_complexity: Some(10),
..Default::default()
}),
..Default::default()
};
let other = LanguageOverrides {
rust: Some(LanguageConfig {
max_complexity: Some(20),
excludes: vec!["target/**".to_string()],
..Default::default()
}),
go: Some(LanguageConfig {
max_complexity: Some(25),
..Default::default()
}),
..Default::default()
};
base.merge(other);
assert!(base.rust.is_some());
assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(20));
assert!(base.python.is_some());
assert_eq!(base.python.as_ref().unwrap().max_complexity, Some(10));
assert!(base.go.is_some());
assert_eq!(base.go.as_ref().unwrap().max_complexity, Some(25));
}
#[test]
fn test_language_overrides_merge_none_preserves() {
let mut base = LanguageOverrides {
rust: Some(LanguageConfig {
max_complexity: Some(15),
..Default::default()
}),
..Default::default()
};
let other = LanguageOverrides::default();
base.merge(other);
assert!(base.rust.is_some());
assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(15));
}
#[test]
fn test_config_new() {
let config = Config::new();
assert!(config.languages.is_empty());
assert!(config.includes.is_empty());
assert!(config.excludes.is_empty());
assert!(config.max_complexity.is_none());
assert!(config.preset.is_none());
}
#[test]
fn test_config_default() {
let config = Config::default();
assert!(config.languages.is_empty());
assert!(config.plugins.is_none());
}
#[test]
fn test_generate_default_toml_is_valid() {
let toml_content = Config::generate_default_toml();
let result: Result<Config, _> = toml::from_str(&toml_content);
assert!(result.is_ok());
}
#[test]
fn test_generate_default_toml_has_expected_values() {
let toml_content = Config::generate_default_toml();
let config: Config = toml::from_str(&toml_content).unwrap();
assert_eq!(config.max_complexity, Some(20));
assert!(config.excludes.is_empty());
}
#[test]
fn test_project_config_path() {
let project_dir = Path::new("/home/user/project");
let config_path = Config::project_config_path(project_dir);
assert_eq!(
config_path,
PathBuf::from("/home/user/project/.linthis/config.toml")
);
}
#[test]
fn test_config_merge_languages() {
let mut base = Config {
languages: ["rust".to_string()].into_iter().collect(),
..Default::default()
};
let other = Config {
languages: ["python".to_string(), "go".to_string()]
.into_iter()
.collect(),
..Default::default()
};
base.merge(other);
assert_eq!(base.languages.len(), 2);
assert!(base.languages.contains("python"));
assert!(base.languages.contains("go"));
assert!(!base.languages.contains("rust"));
}
#[test]
fn test_config_merge_empty_languages_preserves() {
let mut base = Config {
languages: ["rust".to_string()].into_iter().collect(),
..Default::default()
};
let other = Config {
languages: HashSet::new(),
..Default::default()
};
base.merge(other);
assert_eq!(base.languages.len(), 1);
assert!(base.languages.contains("rust"));
}
#[test]
fn test_config_merge_includes_extends() {
let mut base = Config {
includes: vec!["src/**".to_string()],
..Default::default()
};
let other = Config {
includes: vec!["lib/**".to_string()],
..Default::default()
};
base.merge(other);
assert_eq!(
base.includes,
vec!["src/**".to_string(), "lib/**".to_string()]
);
}
#[test]
fn test_config_merge_verbose() {
let mut base = Config::default();
let other = Config {
verbose: Some(true),
..Default::default()
};
base.merge(other);
assert_eq!(base.verbose, Some(true));
}
#[test]
fn test_plugin_config_default() {
let config = PluginConfig::default();
assert!(config.sources.is_empty());
}
#[test]
fn test_plugin_source_enabled_default() {
let toml_str = r#"
[plugins]
sources = [
{ name = "test" }
]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let sources = &config.plugins.unwrap().sources;
assert_eq!(sources.len(), 1);
assert!(sources[0].enabled); }
#[test]
fn test_plugin_source_with_all_fields() {
let toml_str = r#"
[plugins]
sources = [
{ name = "test", url = "https://example.com/repo.git", ref = "v1.0", enabled = false }
]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let sources = &config.plugins.unwrap().sources;
assert_eq!(sources[0].name, "test");
assert_eq!(
sources[0].url,
Some("https://example.com/repo.git".to_string())
);
assert_eq!(sources[0].git_ref, Some("v1.0".to_string()));
assert!(!sources[0].enabled);
}
#[test]
fn test_source_config_default() {
let config = SourceConfig::default();
assert!(config.test_source.filepath_regex.is_empty());
assert!(config.auto_generate_source.filepath_regex.is_empty());
assert!(config.third_party_source.filepath_regex.is_empty());
}
#[test]
fn test_source_config_from_toml() {
let toml_str = r#"
[source.test_source]
filepath_regex = [".*_test\\.py$", "test_.*\\.py$"]
[source.third_party_source]
filepath_regex = ["vendor/.*"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.source.unwrap();
assert_eq!(source.test_source.filepath_regex.len(), 2);
assert_eq!(source.third_party_source.filepath_regex.len(), 1);
}
#[test]
fn test_cpp_language_config_from_toml() {
let toml_str = r#"
[cpp]
linelength = 120
cpplint_filter = "-build/c++11,-whitespace/tab"
max_complexity = 25
[oc]
linelength = 150
cpplint_filter = "-build/header_guard"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let cpp = config.language_overrides.cpp.unwrap();
assert_eq!(cpp.linelength, Some(120));
assert_eq!(
cpp.cpplint_filter,
Some("-build/c++11,-whitespace/tab".to_string())
);
assert_eq!(cpp.max_complexity, Some(25));
let oc = config.language_overrides.oc.unwrap();
assert_eq!(oc.linelength, Some(150));
assert_eq!(oc.cpplint_filter, Some("-build/header_guard".to_string()));
}
#[test]
fn test_oc_fn_length_from_toml() {
let toml = r#"
[oc]
fn_length = 100
"#;
let config: Config = toml::from_str(toml).unwrap();
let oc = config.language_overrides.oc.unwrap();
assert_eq!(oc.fn_length, Some(100));
}
#[test]
fn test_cpp_fn_length_from_toml() {
let toml = r#"
[cpp]
fn_length = 50
"#;
let config: Config = toml::from_str(toml).unwrap();
let cpp = config.language_overrides.cpp.unwrap();
assert_eq!(cpp.fn_length, Some(50));
}
#[test]
fn test_oc_fn_length_default_is_none() {
let toml = r#"
[oc]
linelength = 150
"#;
let config: Config = toml::from_str(toml).unwrap();
let oc = config.language_overrides.oc.unwrap();
assert_eq!(oc.fn_length, None);
}
#[test]
fn test_objectivec_alias() {
let toml_str = r#"
[objectivec]
linelength = 200
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let oc = config.language_overrides.oc.unwrap();
assert_eq!(oc.linelength, Some(200));
}
#[test]
fn test_get_plugin_sources_empty() {
let config = Config::default();
let sources = config.get_plugin_sources();
assert!(sources.is_empty());
}
#[test]
fn test_get_plugin_sources_with_plugins() {
let config = Config {
plugins: Some(PluginConfig {
sources: vec![PluginSourceConfig {
name: "test".to_string(),
url: Some("https://example.com".to_string()),
git_ref: Some("main".to_string()),
enabled: true,
}],
}),
..Default::default()
};
let sources = config.get_plugin_sources();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "test");
assert_eq!(sources[0].url, Some("https://example.com".to_string()));
assert_eq!(sources[0].git_ref, Some("main".to_string()));
assert!(sources[0].enabled);
}
fn setup_fake_plugin_cache(linthis_toml_content: &str) -> (tempfile::TempDir, String) {
let cache_root = tempfile::TempDir::new().unwrap();
let plugin_dir = cache_root.path().join("example.com").join("test-plugin");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("linthis.toml"), linthis_toml_content).unwrap();
let url = "https://example.com/test-plugin.git".to_string();
(cache_root, url)
}
fn setup_project_with_plugin(project_toml: &str, plugin_url: &str) -> tempfile::TempDir {
let project_dir = tempfile::TempDir::new().unwrap();
let linthis_dir = project_dir.path().join(".linthis");
std::fs::create_dir_all(&linthis_dir).unwrap();
let config_content = format!(
r#"[plugins]
sources = [{{ name = "test-plugin", url = "{}" }}]
{}
"#,
plugin_url, project_toml
);
std::fs::write(linthis_dir.join("config.toml"), config_content).unwrap();
project_dir
}
#[test]
fn test_fn_length_plugin_linthis_toml_loaded() {
let (cache_root, url) =
setup_fake_plugin_cache("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n");
let project_dir = setup_project_with_plugin("", &url);
std::env::set_var("LINTHIS_TEST_PLUGIN_CACHE_DIR", cache_root.path());
let merged = Config::load_merged(project_dir.path());
std::env::remove_var("LINTHIS_TEST_PLUGIN_CACHE_DIR");
assert_eq!(
merged
.language_overrides
.cpp
.as_ref()
.and_then(|c| c.fn_length),
Some(60)
);
assert_eq!(
merged
.language_overrides
.oc
.as_ref()
.and_then(|c| c.fn_length),
Some(60)
);
}
#[test]
fn test_fn_length_project_config_overrides_plugin() {
let (cache_root, url) =
setup_fake_plugin_cache("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n");
let project_dir =
setup_project_with_plugin("\n[cpp]\nfn_length = 40\n[oc]\nfn_length = 40\n", &url);
std::env::set_var("LINTHIS_TEST_PLUGIN_CACHE_DIR", cache_root.path());
let merged = Config::load_merged(project_dir.path());
std::env::remove_var("LINTHIS_TEST_PLUGIN_CACHE_DIR");
assert_eq!(
merged
.language_overrides
.cpp
.as_ref()
.and_then(|c| c.fn_length),
Some(40)
);
assert_eq!(
merged
.language_overrides
.oc
.as_ref()
.and_then(|c| c.fn_length),
Some(40)
);
}
#[test]
fn test_fn_length_no_plugin_falls_back_to_none() {
let empty_cache = tempfile::TempDir::new().unwrap();
std::env::set_var("LINTHIS_TEST_PLUGIN_CACHE_DIR", empty_cache.path());
let project_dir = tempfile::TempDir::new().unwrap();
let merged = Config::load_merged(project_dir.path());
std::env::remove_var("LINTHIS_TEST_PLUGIN_CACHE_DIR");
assert!(
merged
.language_overrides
.cpp
.as_ref()
.and_then(|c| c.fn_length)
.is_none()
|| merged.language_overrides.cpp.is_none()
);
}
#[test]
fn test_fn_length_project_config_without_plugin() {
let project_dir = tempfile::TempDir::new().unwrap();
let linthis_dir = project_dir.path().join(".linthis");
std::fs::create_dir_all(&linthis_dir).unwrap();
std::fs::write(
linthis_dir.join("config.toml"),
"[cpp]\nfn_length = 50\n[oc]\nfn_length = 50\n",
)
.unwrap();
let merged = Config::load_merged(project_dir.path());
assert_eq!(
merged
.language_overrides
.cpp
.as_ref()
.and_then(|c| c.fn_length),
Some(50)
);
assert_eq!(
merged
.language_overrides
.oc
.as_ref()
.and_then(|c| c.fn_length),
Some(50)
);
}
#[test]
fn test_fn_length_merge_order_plugin_then_project() {
let mut plugin_config: Config =
toml::from_str("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n").unwrap();
let project_config: Config =
toml::from_str("[cpp]\nfn_length = 30\n[oc]\nfn_length = 30\n").unwrap();
plugin_config.merge(project_config);
assert_eq!(
plugin_config
.language_overrides
.cpp
.as_ref()
.and_then(|c| c.fn_length),
Some(30)
);
assert_eq!(
plugin_config
.language_overrides
.oc
.as_ref()
.and_then(|c| c.fn_length),
Some(30)
);
}
}