1pub mod cli;
69pub mod migrate;
70pub mod resolver;
71
72use serde::{Deserialize, Serialize};
73use std::collections::{HashMap, HashSet};
74use std::path::{Path, PathBuf};
75
76use crate::review::AutoFixMode;
77use crate::rules::RulesConfig;
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98pub struct Config {
99 #[serde(default)]
101 pub languages: HashSet<String>,
102
103 #[serde(default)]
105 pub includes: Vec<String>,
106
107 #[serde(default, alias = "exclude")]
109 pub excludes: Vec<String>,
110
111 #[serde(default)]
113 pub max_complexity: Option<u32>,
114
115 #[serde(default)]
117 pub preset: Option<String>,
118
119 #[serde(default)]
121 pub verbose: Option<bool>,
122
123 #[serde(default)]
125 pub source: Option<SourceConfig>,
126
127 #[serde(default, flatten)]
129 pub language_overrides: LanguageOverrides,
130
131 #[serde(default, alias = "plugin")]
133 pub plugins: Option<PluginConfig>,
134
135 #[serde(default)]
137 pub plugin_auto_sync: Option<crate::plugin::AutoSyncConfig>,
138
139 #[serde(default)]
141 pub self_auto_update: Option<crate::self_update::SelfUpdateConfig>,
142
143 #[serde(default)]
145 pub tool_auto_install: Option<ToolAutoInstallConfig>,
146
147 #[serde(default)]
149 pub performance: PerformanceConfig,
150
151 #[serde(default)]
153 pub hook: HookConfig,
154
155 #[serde(default)]
157 pub cmsg: CmsgConfig,
158
159 #[serde(default)]
161 pub rules: RulesConfig,
162
163 #[serde(default)]
165 pub ai: AiConfig,
166
167 #[serde(default)]
169 pub review: ReviewConfig,
170
171 #[serde(default)]
173 pub retention: RetentionConfig,
174
175 #[serde(default)]
190 pub checks: ChecksConfig,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "lowercase")]
199pub enum FailOn {
200 Error,
202 #[default]
204 Warning,
205 Info,
207 None,
209}
210
211impl FailOn {
212 pub fn exit_code(&self, errors: usize, warnings: usize, infos: usize) -> i32 {
221 match self {
222 FailOn::None => 0,
223 FailOn::Error => {
224 if errors > 0 {
225 1
226 } else {
227 0
228 }
229 }
230 FailOn::Warning => {
231 if errors > 0 {
232 1
233 } else if warnings > 0 {
234 2
235 } else {
236 0
237 }
238 }
239 FailOn::Info => {
240 if errors > 0 {
241 1
242 } else if warnings > 0 {
243 2
244 } else if infos > 0 {
245 3
246 } else {
247 0
248 }
249 }
250 }
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ChecksConfig {
257 #[serde(default = "default_checks")]
259 pub run: Vec<String>,
260 #[serde(default)]
262 pub lint: Option<LintChecksConfig>,
263 #[serde(default)]
265 pub security: Option<SecurityChecksConfig>,
266 #[serde(default)]
268 pub complexity: Option<ComplexityChecksConfig>,
269}
270
271impl Default for ChecksConfig {
272 fn default() -> Self {
273 Self {
274 run: default_checks(),
275 lint: None,
276 security: None,
277 complexity: None,
278 }
279 }
280}
281
282fn default_checks() -> Vec<String> {
283 vec![
284 "lint".to_string(),
285 "security".to_string(),
286 "complexity".to_string(),
287 ]
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, Default)]
292pub struct LintChecksConfig {
293 #[serde(default)]
295 pub fail_on: Option<FailOn>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct SecurityChecksConfig {
301 #[serde(default)]
303 pub scan_type: Option<String>,
304 #[serde(default)]
306 pub fail_on: Option<FailOn>,
307 #[serde(default)]
309 pub sast_config: Option<PathBuf>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, Default)]
314pub struct ComplexityChecksConfig {
315 #[serde(default)]
317 pub threshold: Option<u32>,
318 #[serde(default)]
320 pub warning_threshold: Option<u32>,
321 #[serde(default)]
323 pub error_threshold: Option<u32>,
324 #[serde(default)]
326 pub fail_on: Option<FailOn>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, Default)]
331pub struct PluginConfig {
332 #[serde(default)]
334 pub sources: Vec<PluginSourceConfig>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct PluginSourceConfig {
340 pub name: String,
342 #[serde(default)]
344 pub url: Option<String>,
345 #[serde(default, rename = "ref")]
347 pub git_ref: Option<String>,
348 #[serde(default = "default_enabled")]
350 pub enabled: bool,
351}
352
353fn default_enabled() -> bool {
354 true
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct PerformanceConfig {
360 #[serde(default = "default_large_file_threshold")]
362 pub large_file_threshold: u64,
363 #[serde(default)]
365 pub skip_large_files: bool,
366 #[serde(default = "default_cache_max_age_days")]
368 pub cache_max_age_days: u32,
369}
370
371impl Default for PerformanceConfig {
372 fn default() -> Self {
373 Self {
374 large_file_threshold: default_large_file_threshold(),
375 skip_large_files: false,
376 cache_max_age_days: default_cache_max_age_days(),
377 }
378 }
379}
380
381fn default_large_file_threshold() -> u64 {
382 1048576 }
384
385fn default_cache_max_age_days() -> u32 {
386 7
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(untagged)]
395pub enum HookSource {
396 Marketplace {
402 marketplace: String,
403 plugin: String,
404 file: String,
405 },
406 Git {
409 git: String,
410 #[serde(rename = "ref", default)]
411 git_ref: Option<String>,
412 path: String,
413 },
414 Plugin { plugin: String, file: String },
417 Url { url: String },
420 File { file: String },
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, Default)]
431pub struct AgentTargetConfig {
432 pub skills: Option<String>,
433 pub memory: Option<String>,
434 pub commands: Option<String>,
435 pub settings: Option<String>,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct HookSourceEntry {
441 pub source: HookSource,
442 #[serde(default)]
443 pub target: Option<AgentTargetConfig>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct HookConfig {
449 #[serde(default = "default_hook_timeout")]
451 pub timeout: u32,
452 #[serde(default = "default_hook_parallel")]
454 pub parallel: bool,
455 #[serde(default)]
457 pub output_width: Option<u32>,
458
459 #[serde(default)]
464 pub marketplaces: HashMap<String, String>,
465
466 #[serde(default)]
469 pub git: HashMap<String, HookSourceEntry>,
470 #[serde(default, rename = "git-with-agent")]
472 pub git_with_agent: HashMap<String, HookSourceEntry>,
473 #[serde(default)]
475 pub prek: HashMap<String, HookSourceEntry>,
476 #[serde(default, rename = "prek-with-agent")]
478 pub prek_with_agent: HashMap<String, HookSourceEntry>,
479
480 #[serde(default)]
483 pub agent: AgentConfig,
484
485 #[serde(default)]
488 pub review: HookReviewConfig,
489
490 #[serde(default)]
493 pub pre_commit: HookEventFixConfig,
494 #[serde(default)]
496 pub pre_push: HookEventFixConfig,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, Default)]
501pub struct AgentConfig {
502 #[serde(default)]
505 pub plugins: HashMap<String, HashMap<String, HookSourceEntry>>,
506 #[serde(default, rename = "skill-names")]
508 pub skill_names: AgentSkillNamesConfig,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct HookReviewConfig {
514 #[serde(default = "default_hook_review_auto_fix_mode")]
516 pub auto_fix_mode: AutoFixMode,
517}
518
519impl Default for HookReviewConfig {
520 fn default() -> Self {
521 Self {
522 auto_fix_mode: default_hook_review_auto_fix_mode(),
523 }
524 }
525}
526
527fn default_hook_review_auto_fix_mode() -> AutoFixMode {
528 AutoFixMode::Pr
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct HookEventFixConfig {
539 #[serde(default = "default_fix_commit_mode_one_commit")]
541 pub fix_commit_mode: String,
542}
543
544fn default_fix_commit_mode_one_commit() -> String {
545 "squash".to_string()
546}
547
548pub fn expand_fix_commit_mode_alias(input: &str) -> Option<&'static str> {
560 match input {
561 "s" | "squash" => Some("squash"),
562 "d" | "dirty" => Some("dirty"),
563 "f" | "fixup" => Some("fixup"),
564 _ => None,
565 }
566}
567
568impl Default for HookEventFixConfig {
569 fn default() -> Self {
570 Self {
571 fix_commit_mode: default_fix_commit_mode_one_commit(),
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, Default)]
588pub struct AgentSkillNamesConfig {
589 #[serde(default, rename = "pre-commit")]
591 pub pre_commit: Option<String>,
592 #[serde(default, rename = "commit-msg")]
594 pub commit_msg: Option<String>,
595 #[serde(default, rename = "pre-push")]
597 pub pre_push: Option<String>,
598}
599
600impl Default for HookConfig {
601 fn default() -> Self {
602 Self {
603 timeout: default_hook_timeout(),
604 parallel: default_hook_parallel(),
605 output_width: None,
606 marketplaces: HashMap::new(),
607 git: HashMap::new(),
608 git_with_agent: HashMap::new(),
609 prek: HashMap::new(),
610 prek_with_agent: HashMap::new(),
611 agent: AgentConfig::default(),
612 review: HookReviewConfig::default(),
613 pre_commit: HookEventFixConfig {
614 fix_commit_mode: "squash".to_string(),
615 },
616 pre_push: HookEventFixConfig {
617 fix_commit_mode: "dirty".to_string(),
618 },
619 }
620 }
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct CmsgConfig {
626 #[serde(default = "default_commit_msg_pattern")]
628 pub commit_msg_pattern: String,
629 #[serde(default)]
631 pub require_ticket: bool,
632 #[serde(default)]
634 pub ticket_pattern: Option<String>,
635}
636
637impl Default for CmsgConfig {
638 fn default() -> Self {
639 Self {
640 commit_msg_pattern: default_commit_msg_pattern(),
641 require_ticket: false,
642 ticket_pattern: None,
643 }
644 }
645}
646
647fn default_hook_timeout() -> u32 {
648 60 }
650
651fn default_hook_parallel() -> bool {
652 true
653}
654
655fn default_commit_msg_pattern() -> String {
656 r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,72}".to_string()
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct ReviewConfig {
662 #[serde(default = "default_review_enabled")]
664 pub enabled: bool,
665 #[serde(default)]
667 pub auto_fix: bool,
668 #[serde(default)]
670 pub auto_fix_mode: AutoFixMode,
671 #[serde(default)]
673 pub provider: Option<String>,
674 #[serde(default = "default_retention_days")]
676 pub retention_days: u32,
677 #[serde(default)]
679 pub platforms: std::collections::HashMap<String, PlatformConfig>,
680 #[serde(default)]
682 pub reviewers: ReviewerConfig,
683 #[serde(default)]
685 pub notifications: Vec<NotificationConfig>,
686}
687
688impl Default for ReviewConfig {
689 fn default() -> Self {
690 Self {
691 enabled: default_review_enabled(),
692 auto_fix: false,
693 auto_fix_mode: AutoFixMode::default(),
694 provider: None,
695 retention_days: default_retention_days(),
696 platforms: std::collections::HashMap::new(),
697 reviewers: ReviewerConfig::default(),
698 notifications: Vec::new(),
699 }
700 }
701}
702
703fn default_review_enabled() -> bool {
704 true
705}
706
707fn default_retention_days() -> u32 {
708 30
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct RetentionConfig {
724 #[serde(default = "default_retention_results")]
726 pub results: usize,
727 #[serde(default = "default_retention_backups")]
729 pub backups: usize,
730 #[serde(default = "default_retention_reviews")]
732 pub reviews: usize,
733 #[serde(default = "default_retention_cache_days")]
735 pub cache_days: u32,
736 #[serde(default = "default_retention_diffs")]
738 pub diffs: usize,
739}
740
741fn default_retention_results() -> usize {
742 10
743}
744fn default_retention_backups() -> usize {
745 5
746}
747fn default_retention_reviews() -> usize {
748 10
749}
750fn default_retention_cache_days() -> u32 {
751 30
752}
753fn default_retention_diffs() -> usize {
754 5
755}
756
757impl Default for RetentionConfig {
758 fn default() -> Self {
759 Self {
760 results: default_retention_results(),
761 backups: default_retention_backups(),
762 reviews: default_retention_reviews(),
763 cache_days: default_retention_cache_days(),
764 diffs: default_retention_diffs(),
765 }
766 }
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct PlatformConfig {
772 pub pr_create: String,
774 #[serde(default)]
776 pub pr_list: Option<String>,
777 #[serde(default = "default_reviewer_flag")]
779 pub reviewer_flag: String,
780 #[serde(default)]
782 pub install_cmd: Option<String>,
783 #[serde(default)]
785 pub install_hint: Option<String>,
786}
787
788fn default_reviewer_flag() -> String {
789 "--reviewer".to_string()
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, Default)]
794pub struct ReviewerConfig {
795 #[serde(default)]
797 pub default: Vec<String>,
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize)]
802#[serde(tag = "type")]
803pub enum NotificationConfig {
804 #[serde(rename = "system")]
806 System,
807 #[serde(rename = "webhook")]
809 Webhook {
810 url: String,
811 #[serde(default = "default_webhook_method")]
812 method: String,
813 #[serde(default)]
814 headers: std::collections::HashMap<String, String>,
815 #[serde(default)]
816 body_template: Option<String>,
817 },
818 #[serde(rename = "custom")]
820 Custom { command: String },
821}
822
823fn default_webhook_method() -> String {
824 "POST".to_string()
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
829pub struct ToolAutoInstallConfig {
830 #[serde(default = "default_tool_auto_install_enabled")]
832 pub enabled: bool,
833
834 #[serde(default = "default_tool_auto_install_mode")]
836 pub mode: String,
837}
838
839fn default_tool_auto_install_enabled() -> bool {
840 true
841}
842
843fn default_tool_auto_install_mode() -> String {
844 "auto".to_string()
845}
846
847impl Default for ToolAutoInstallConfig {
848 fn default() -> Self {
849 Self {
850 enabled: default_tool_auto_install_enabled(),
851 mode: default_tool_auto_install_mode(),
852 }
853 }
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, Default)]
858pub struct AiConfig {
859 #[serde(default)]
862 pub provider: Option<String>,
863 #[serde(default)]
865 pub model: Option<String>,
866 #[serde(default)]
868 pub max_tokens: Option<u32>,
869 #[serde(default)]
871 pub temperature: Option<f32>,
872 #[serde(default)]
874 pub timeout_secs: Option<u64>,
875 #[serde(default)]
877 pub custom_providers: std::collections::HashMap<String, CustomProvider>,
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct CustomProvider {
915 #[serde(default = "default_provider_kind")]
917 pub kind: String,
918
919 #[serde(default)]
922 pub command: Option<String>,
923 #[serde(default)]
925 pub cli_style: Option<String>,
926 #[serde(default)]
929 pub prompt_args: Option<Vec<String>>,
930 #[serde(default)]
933 pub fix_args: Option<Vec<String>>,
934 #[serde(default)]
936 pub system_prompt_arg: Option<String>,
937
938 #[serde(default)]
941 pub api_style: Option<String>,
942 #[serde(default)]
944 pub endpoint: Option<String>,
945 #[serde(default)]
947 pub model: Option<String>,
948
949 #[serde(default)]
952 pub api_key_env: Option<String>,
953 #[serde(default)]
955 pub fallback: Option<String>,
956}
957
958fn default_provider_kind() -> String {
959 "cli".to_string()
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize, Default)]
964pub struct SourceConfig {
965 #[serde(default)]
967 pub test_source: PathPatterns,
968
969 #[serde(default)]
971 pub auto_generate_source: PathPatterns,
972
973 #[serde(default)]
975 pub third_party_source: PathPatterns,
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, Default)]
979pub struct PathPatterns {
980 #[serde(default)]
982 pub filepath_regex: Vec<String>,
983}
984
985#[derive(Debug, Clone, Serialize, Deserialize, Default)]
987pub struct LanguageOverrides {
988 #[serde(default)]
989 pub rust: Option<LanguageConfig>,
990 #[serde(default)]
991 pub python: Option<LanguageConfig>,
992 #[serde(default)]
993 pub typescript: Option<LanguageConfig>,
994 #[serde(default)]
995 pub javascript: Option<LanguageConfig>,
996 #[serde(default)]
997 pub go: Option<LanguageConfig>,
998 #[serde(default)]
999 pub java: Option<LanguageConfig>,
1000 #[serde(default)]
1001 pub cpp: Option<CppLanguageConfig>,
1002 #[serde(default, alias = "objectivec")]
1003 pub oc: Option<CppLanguageConfig>,
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1008pub struct LanguageConfig {
1009 #[serde(default, alias = "exclude")]
1011 pub excludes: Vec<String>,
1012 #[serde(default)]
1014 pub enabled: Option<bool>,
1015 #[serde(default)]
1017 pub max_complexity: Option<u32>,
1018 #[serde(default)]
1020 pub rules: Option<RulesConfig>,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025pub struct CppLanguageConfig {
1026 #[serde(default, alias = "exclude")]
1028 pub excludes: Vec<String>,
1029 #[serde(default)]
1031 pub enabled: Option<bool>,
1032 #[serde(default)]
1034 pub max_complexity: Option<u32>,
1035 #[serde(default)]
1037 pub linelength: Option<u32>,
1038 #[serde(default)]
1040 pub cpplint_filter: Option<String>,
1041 #[serde(default)]
1043 pub clang_tidy_ignored_checks: Option<Vec<String>>,
1044 #[serde(default)]
1048 pub fn_length: Option<u32>,
1049 #[serde(default)]
1051 pub rules: Option<RulesConfig>,
1052}
1053
1054impl LanguageOverrides {
1055 pub fn merge(&mut self, other: LanguageOverrides) {
1057 macro_rules! merge_lang {
1058 ($field:ident) => {
1059 if other.$field.is_some() {
1060 self.$field = other.$field;
1061 }
1062 };
1063 }
1064
1065 merge_lang!(rust);
1066 merge_lang!(python);
1067 merge_lang!(typescript);
1068 merge_lang!(javascript);
1069 merge_lang!(go);
1070 merge_lang!(java);
1071 merge_lang!(cpp);
1072 merge_lang!(oc);
1073 }
1074}
1075
1076const KNOWN_FIELDS: &[&str] = &[
1078 "languages",
1079 "includes",
1080 "excludes",
1081 "max_complexity",
1082 "preset",
1083 "verbose",
1084 "quiet",
1085 "plugins",
1086 "self_auto_update",
1087 "plugin_auto_sync",
1088 "tool_auto_install",
1089 "rules",
1090 "rust",
1091 "python",
1092 "go",
1093 "typescript",
1094 "javascript",
1095 "java",
1096 "cpp",
1097 "oc",
1098];
1099
1100impl Config {
1101 pub fn new() -> Self {
1103 Self::default()
1104 }
1105
1106 pub fn load(path: &Path) -> crate::Result<Self> {
1108 let content = std::fs::read_to_string(path).map_err(|e| {
1109 crate::LintisError::Config(format!(
1110 "Failed to read config file '{}': {}",
1111 path.display(),
1112 e
1113 ))
1114 })?;
1115
1116 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1117
1118 match ext {
1119 "yml" | "yaml" => serde_yaml::from_str(&content)
1120 .map_err(|e| crate::LintisError::Config(format_yaml_error(path, &e))),
1121 "toml" => toml::from_str(&content)
1122 .map_err(|e| crate::LintisError::Config(format_toml_error(path, &content, &e))),
1123 "json" => serde_json::from_str(&content)
1124 .map_err(|e| crate::LintisError::Config(format_json_error(path, &e))),
1125 _ => Err(crate::LintisError::Config(format!(
1126 "Unsupported config format: '{}'\n\nSupported formats: toml, yaml, json",
1127 ext
1128 ))),
1129 }
1130 }
1131
1132 pub fn built_in_defaults() -> Self {
1134 Config {
1135 max_complexity: Some(20),
1136 ..Default::default()
1137 }
1138 }
1139
1140 pub fn load_user_config() -> Option<Self> {
1142 let home = crate::utils::home_dir()?;
1143 let config_path = home.join(".linthis").join("config.toml");
1144 if config_path.exists() {
1145 Self::load(&config_path).ok()
1146 } else {
1147 None
1148 }
1149 }
1150
1151 pub fn load_project_config(start_dir: &Path) -> Option<Self> {
1154 let mut current = start_dir.to_path_buf();
1155 loop {
1156 let config_path = current.join(".linthis").join("config.toml");
1157 if config_path.exists() {
1158 if let Ok(config) = Self::load(&config_path) {
1159 return Some(config);
1160 }
1161 }
1162
1163 if !current.pop() {
1164 break;
1165 }
1166 }
1167
1168 None
1169 }
1170
1171 pub fn merge(&mut self, other: Config) {
1174 if !other.languages.is_empty() {
1176 self.languages = other.languages;
1177 }
1178
1179 self.includes.extend(other.includes);
1181
1182 self.excludes.extend(other.excludes);
1184
1185 if other.max_complexity.is_some() {
1187 self.max_complexity = other.max_complexity;
1188 }
1189 if other.preset.is_some() {
1190 self.preset = other.preset;
1191 }
1192 if other.verbose.is_some() {
1193 self.verbose = other.verbose;
1194 }
1195 if other.source.is_some() {
1196 self.source = other.source;
1197 }
1198
1199 self.language_overrides.merge(other.language_overrides);
1201
1202 if other.plugins.is_some() {
1203 self.plugins = other.plugins;
1204 }
1205
1206 self.rules.merge(other.rules);
1208
1209 if !other.hook.marketplaces.is_empty() {
1211 self.hook.marketplaces.extend(other.hook.marketplaces);
1212 }
1213 if !other.hook.git.is_empty() {
1214 self.hook.git.extend(other.hook.git);
1215 }
1216 if !other.hook.git_with_agent.is_empty() {
1217 self.hook.git_with_agent.extend(other.hook.git_with_agent);
1218 }
1219 if !other.hook.prek.is_empty() {
1220 self.hook.prek.extend(other.hook.prek);
1221 }
1222 if !other.hook.prek_with_agent.is_empty() {
1223 self.hook.prek_with_agent.extend(other.hook.prek_with_agent);
1224 }
1225 for (provider, plugins) in other.hook.agent.plugins {
1227 self.hook
1228 .agent
1229 .plugins
1230 .entry(provider)
1231 .or_default()
1232 .extend(plugins);
1233 }
1234 if other.hook.timeout != default_hook_timeout() {
1235 self.hook.timeout = other.hook.timeout;
1236 }
1237
1238 if other.hook.pre_commit.fix_commit_mode != default_fix_commit_mode_one_commit() {
1240 self.hook.pre_commit.fix_commit_mode = other.hook.pre_commit.fix_commit_mode;
1241 }
1242 if other.hook.pre_push.fix_commit_mode != default_fix_commit_mode_one_commit() {
1243 self.hook.pre_push.fix_commit_mode = other.hook.pre_push.fix_commit_mode;
1244 }
1245 }
1246
1247 pub fn get_plugin_sources(&self) -> Vec<crate::plugin::PluginSource> {
1249 self.plugins
1250 .as_ref()
1251 .map(|p| {
1252 p.sources
1253 .iter()
1254 .map(|s| crate::plugin::PluginSource {
1255 name: s.name.clone(),
1256 url: s.url.clone(),
1257 git_ref: s.git_ref.clone(),
1258 enabled: s.enabled,
1259 })
1260 .collect()
1261 })
1262 .unwrap_or_default()
1263 }
1264
1265 fn load_plugin_linthis_toml(source: &crate::plugin::PluginSource) -> Option<Self> {
1268 use crate::plugin::PluginCache;
1269 let cache = if let Ok(dir) = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR") {
1271 PluginCache::with_dir(std::path::PathBuf::from(dir))
1272 } else {
1273 PluginCache::new().ok()?
1274 };
1275 let url = source.url.as_ref()?;
1276 let cache_path = cache.url_to_cache_path(url);
1277 let linthis_toml = cache_path.join("linthis.toml");
1278 if linthis_toml.exists() {
1279 Self::load(&linthis_toml).ok()
1280 } else {
1281 None
1282 }
1283 }
1284
1285 pub fn load_merged(project_dir: &Path) -> Self {
1294 let mut config = Self::built_in_defaults();
1295
1296 let global_plugin_sources = Self::load_user_config()
1300 .map(|c| c.get_plugin_sources())
1301 .unwrap_or_default();
1302 let project_plugin_sources = Self::load_project_config(project_dir)
1303 .map(|c| c.get_plugin_sources())
1304 .unwrap_or_default();
1305
1306 for source in &global_plugin_sources {
1308 if let Some(plugin_config) = Self::load_plugin_linthis_toml(source) {
1309 config.merge(plugin_config);
1310 }
1311 }
1312
1313 for source in &project_plugin_sources {
1315 if let Some(plugin_config) = Self::load_plugin_linthis_toml(source) {
1316 config.merge(plugin_config);
1317 }
1318 }
1319
1320 if let Some(user_config) = Self::load_user_config() {
1322 config.merge(user_config);
1323 }
1324
1325 if let Some(project_config) = Self::load_project_config(project_dir) {
1327 config.merge(project_config);
1328 }
1329
1330 config
1331 }
1332
1333 pub fn get_active_config_paths(project_dir: &Path) -> Vec<std::path::PathBuf> {
1339 use crate::plugin::PluginCache;
1340 let mut paths = Vec::new();
1341
1342 let global_plugin_sources = Self::load_user_config()
1343 .map(|c| c.get_plugin_sources())
1344 .unwrap_or_default();
1345 let project_plugin_sources = Self::load_project_config(project_dir)
1346 .map(|c| c.get_plugin_sources())
1347 .unwrap_or_default();
1348
1349 let cache_opt = if let Ok(dir) = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR") {
1350 Some(PluginCache::with_dir(std::path::PathBuf::from(dir)))
1351 } else {
1352 PluginCache::new().ok()
1353 };
1354
1355 let mut seen = std::collections::HashSet::new();
1356 let mut push_unique = |paths: &mut Vec<_>, p: std::path::PathBuf| {
1357 if seen.insert(p.clone()) {
1358 paths.push(p);
1359 }
1360 };
1361
1362 for source in global_plugin_sources
1363 .iter()
1364 .chain(project_plugin_sources.iter())
1365 {
1366 if let (Some(cache), Some(url)) = (cache_opt.as_ref(), source.url.as_ref()) {
1367 let p = cache.url_to_cache_path(url).join("linthis.toml");
1368 if p.exists() {
1369 push_unique(&mut paths, p);
1370 }
1371 }
1372 }
1373
1374 if let Some(home) = crate::utils::home_dir() {
1375 let p = home.join(".linthis").join("config.toml");
1376 if p.exists() {
1377 push_unique(&mut paths, p);
1378 }
1379 }
1380
1381 let mut current = project_dir.to_path_buf();
1382 loop {
1383 let p = current.join(".linthis").join("config.toml");
1384 if p.exists() {
1385 push_unique(&mut paths, p);
1386 break;
1387 }
1388 if !current.pop() {
1389 break;
1390 }
1391 }
1392
1393 paths
1394 }
1395
1396 pub fn generate_default_toml() -> String {
1398 r#"# Linthis Configuration
1399# See https://github.com/zhlinh/linthis for documentation
1400
1401# Languages to check (empty = auto-detect all supported languages)
1402# languages = ["rust", "python", "typescript"]
1403
1404# Files or directories to include (glob patterns)
1405# includes = ["src/**", "lib/**"]
1406
1407# Patterns to exclude (in addition to defaults)
1408excludes = []
1409
1410# Maximum cyclomatic complexity allowed
1411max_complexity = 20
1412
1413# Format preset: "google", "standard", or "airbnb"
1414# preset = "google"
1415
1416# Plugin configuration
1417# [plugins]
1418# sources = [
1419# { name = "official" },
1420# { name = "myplugin", url = "https://github.com/zhlinh/linthis-plugin.git", ref = "main" }
1421# ]
1422
1423# Rules configuration
1424# [rules]
1425# disable = ["E501", "whitespace/*"] # Disable specific rules or prefixes
1426#
1427# [rules.severity]
1428# "W0612" = "error" # Override severity (error, warning, info, off)
1429#
1430# [[rules.custom]]
1431# code = "custom/no-todo"
1432# pattern = "TODO|FIXME"
1433# message = "Found TODO/FIXME comment"
1434# severity = "warning"
1435# suggestion = "Address or convert to tracking issue"
1436
1437# Language-specific overrides
1438# [rust]
1439# max_complexity = 15
1440
1441# [python]
1442# excludes = ["*_test.py"]
1443
1444# Tool auto-install configuration
1445# [tool_auto_install]
1446# enabled = true
1447# mode = "auto" # auto = install silently (default); prompt = ask before installing; disabled = never install
1448
1449# Objective-C specific overrides
1450# [oc]
1451# fn_length = 80 # Max method SLOC (non-blank, non-comment lines). Default: 80.
1452"#
1453 .to_string()
1454 }
1455
1456 pub fn project_config_path(project_dir: &Path) -> PathBuf {
1458 project_dir.join(".linthis").join("config.toml")
1459 }
1460}
1461
1462fn format_toml_error(path: &Path, content: &str, err: &toml::de::Error) -> String {
1464 let mut msg = format!("Invalid TOML in '{}'", path.display());
1465
1466 let err_str = err.to_string();
1468
1469 if err_str.contains("unknown field") {
1471 if let Some(field) = extract_unknown_field(&err_str) {
1472 msg.push_str(&format!("\n\nUnknown field: '{}'", field));
1473
1474 if let Some(suggestion) = find_similar_field(&field, KNOWN_FIELDS) {
1476 msg.push_str(&format!("\n\nDid you mean: '{}'?", suggestion));
1477 } else {
1478 msg.push_str("\n\nValid top-level fields: languages, includes, excludes, preset, verbose, quiet, plugins");
1479 }
1480 }
1481 }
1482
1483 if let Some(line_info) = extract_line_from_error(&err_str) {
1486 msg.push_str(&format!("\n\nError at line {}", line_info));
1487
1488 if let Ok(line_num) = line_info.parse::<usize>() {
1490 if let Some(line) = content.lines().nth(line_num.saturating_sub(1)) {
1491 msg.push_str(&format!(":\n {} | {}", line_num, line.trim()));
1492 }
1493 }
1494 }
1495
1496 msg.push_str(&format!("\n\nDetails: {}", err));
1498
1499 msg.push_str(&format!("\n\nHint: {}", get_toml_hint(&err_str)));
1501
1502 msg
1503}
1504
1505fn extract_line_from_error(err_str: &str) -> Option<String> {
1507 let patterns = ["at line ", "line "];
1509 for pattern in patterns {
1510 if let Some(start) = err_str.find(pattern) {
1511 let remaining = &err_str[start + pattern.len()..];
1512 let end = remaining
1513 .find(|c: char| !c.is_ascii_digit())
1514 .unwrap_or(remaining.len());
1515 if end > 0 {
1516 return Some(remaining[..end].to_string());
1517 }
1518 }
1519 }
1520 None
1521}
1522
1523fn format_yaml_error(path: &Path, err: &serde_yaml::Error) -> String {
1525 let mut msg = format!("Invalid YAML in '{}'", path.display());
1526
1527 if let Some(location) = err.location() {
1528 msg.push_str(&format!(
1529 "\n\nError at line {}, column {}",
1530 location.line(),
1531 location.column()
1532 ));
1533 }
1534
1535 msg.push_str(&format!("\n\nDetails: {}", err));
1536 msg.push_str("\n\nHint: Check indentation and ensure proper YAML syntax");
1537
1538 msg
1539}
1540
1541fn format_json_error(path: &Path, err: &serde_json::Error) -> String {
1543 let mut msg = format!("Invalid JSON in '{}'", path.display());
1544
1545 msg.push_str(&format!(
1546 "\n\nError at line {}, column {}",
1547 err.line(),
1548 err.column()
1549 ));
1550
1551 msg.push_str(&format!("\n\nDetails: {}", err));
1552 msg.push_str("\n\nHint: Check for missing commas, unclosed brackets, or trailing commas");
1553
1554 msg
1555}
1556
1557fn extract_unknown_field(err_str: &str) -> Option<String> {
1559 if let Some(start) = err_str.find("unknown field `") {
1561 let remaining = &err_str[start + 15..];
1562 if let Some(end) = remaining.find('`') {
1563 return Some(remaining[..end].to_string());
1564 }
1565 }
1566 None
1567}
1568
1569fn find_similar_field(input: &str, candidates: &[&str]) -> Option<String> {
1571 let input_lower = input.to_lowercase();
1572 let mut best_match = None;
1573 let mut best_distance = usize::MAX;
1574
1575 for &candidate in candidates {
1576 let distance = levenshtein_distance(&input_lower, &candidate.to_lowercase());
1577 if distance < best_distance && distance <= 3 {
1579 best_distance = distance;
1580 best_match = Some(candidate.to_string());
1581 }
1582 }
1583
1584 best_match
1585}
1586
1587#[allow(clippy::needless_range_loop)]
1589fn levenshtein_distance(a: &str, b: &str) -> usize {
1590 let a_chars: Vec<char> = a.chars().collect();
1591 let b_chars: Vec<char> = b.chars().collect();
1592 let m = a_chars.len();
1593 let n = b_chars.len();
1594
1595 if m == 0 {
1596 return n;
1597 }
1598 if n == 0 {
1599 return m;
1600 }
1601
1602 let mut dp = vec![vec![0usize; n + 1]; m + 1];
1603
1604 for i in 0..=m {
1605 dp[i][0] = i;
1606 }
1607 for j in 0..=n {
1608 dp[0][j] = j;
1609 }
1610
1611 for i in 1..=m {
1612 for j in 1..=n {
1613 let cost = if a_chars[i - 1] == b_chars[j - 1] {
1614 0
1615 } else {
1616 1
1617 };
1618 dp[i][j] = (dp[i - 1][j] + 1)
1619 .min(dp[i][j - 1] + 1)
1620 .min(dp[i - 1][j - 1] + cost);
1621 }
1622 }
1623
1624 dp[m][n]
1625}
1626
1627fn get_toml_hint(err_str: &str) -> &'static str {
1629 if err_str.contains("expected") && err_str.contains("found") {
1630 "Check the value type - strings need quotes, arrays use [], tables use [section]"
1631 } else if err_str.contains("missing field") {
1632 "Some required fields may be missing from your configuration"
1633 } else if err_str.contains("duplicate key") {
1634 "Remove the duplicate field definition"
1635 } else if err_str.contains("invalid type") {
1636 "Check that the value type matches what's expected (string, number, boolean, array, etc.)"
1637 } else if err_str.contains("unknown field") {
1638 "Check spelling or remove the unrecognized field"
1639 } else {
1640 "Run 'linthis init' to generate a valid configuration file"
1641 }
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use super::*;
1647
1648 #[test]
1649 fn expand_fix_commit_mode_alias_accepts_short_and_full() {
1650 assert_eq!(expand_fix_commit_mode_alias("s"), Some("squash"));
1652 assert_eq!(expand_fix_commit_mode_alias("d"), Some("dirty"));
1653 assert_eq!(expand_fix_commit_mode_alias("f"), Some("fixup"));
1654 assert_eq!(expand_fix_commit_mode_alias("squash"), Some("squash"));
1656 assert_eq!(expand_fix_commit_mode_alias("dirty"), Some("dirty"));
1657 assert_eq!(expand_fix_commit_mode_alias("fixup"), Some("fixup"));
1658 assert_eq!(expand_fix_commit_mode_alias("S"), None);
1661 assert_eq!(expand_fix_commit_mode_alias("Squash"), None);
1662 assert_eq!(expand_fix_commit_mode_alias(""), None);
1664 assert_eq!(expand_fix_commit_mode_alias("x"), None);
1665 assert_eq!(expand_fix_commit_mode_alias("commit"), None);
1666 }
1667
1668 #[test]
1669 fn test_config_merge() {
1670 let mut base = Config {
1671 max_complexity: Some(20),
1672 excludes: vec!["*.log".to_string()],
1673 ..Default::default()
1674 };
1675
1676 let override_config = Config {
1677 max_complexity: Some(15),
1678 excludes: vec!["*.tmp".to_string()],
1679 preset: Some("google".to_string()),
1680 ..Default::default()
1681 };
1682
1683 base.merge(override_config);
1684
1685 assert_eq!(base.max_complexity, Some(15));
1686 assert_eq!(
1687 base.excludes,
1688 vec!["*.log".to_string(), "*.tmp".to_string()]
1689 );
1690 assert_eq!(base.preset, Some("google".to_string()));
1691 }
1692
1693 #[test]
1694 fn test_built_in_defaults() {
1695 let defaults = Config::built_in_defaults();
1696 assert_eq!(defaults.max_complexity, Some(20));
1697 }
1698
1699 #[test]
1700 fn test_backward_compatibility() {
1701 let toml_with_old_names = r#"
1703 exclude = ["*.log", "target/**"]
1704
1705 [plugin]
1706 sources = [
1707 { name = "test", enabled = true }
1708 ]
1709 "#;
1710
1711 let config: Config = toml::from_str(toml_with_old_names).unwrap();
1712 assert_eq!(
1713 config.excludes,
1714 vec!["*.log".to_string(), "target/**".to_string()]
1715 );
1716 assert!(config.plugins.is_some());
1717 assert_eq!(config.plugins.as_ref().unwrap().sources.len(), 1);
1718 assert_eq!(config.plugins.as_ref().unwrap().sources[0].name, "test");
1719 }
1720
1721 #[test]
1722 fn test_new_field_names() {
1723 let toml_with_new_names = r#"
1725 includes = ["src/**", "lib/**"]
1726 excludes = ["*.log", "target/**"]
1727
1728 [plugins]
1729 sources = [
1730 { name = "test", enabled = true }
1731 ]
1732 "#;
1733
1734 let config: Config = toml::from_str(toml_with_new_names).unwrap();
1735 assert_eq!(
1736 config.includes,
1737 vec!["src/**".to_string(), "lib/**".to_string()]
1738 );
1739 assert_eq!(
1740 config.excludes,
1741 vec!["*.log".to_string(), "target/**".to_string()]
1742 );
1743 assert!(config.plugins.is_some());
1744 assert_eq!(config.plugins.as_ref().unwrap().sources.len(), 1);
1745 }
1746
1747 #[test]
1748 fn test_language_overrides_simplified_syntax() {
1749 let toml_with_simplified = r#"
1751 max_complexity = 20
1752
1753 [rust]
1754 max_complexity = 15
1755 excludes = ["target/**"]
1756
1757 [python]
1758 max_complexity = 10
1759 excludes = ["*_test.py"]
1760 "#;
1761
1762 let config: Config = toml::from_str(toml_with_simplified).unwrap();
1763 assert_eq!(config.max_complexity, Some(20));
1764
1765 assert!(config.language_overrides.rust.is_some());
1767 let rust_config = config.language_overrides.rust.as_ref().unwrap();
1768 assert_eq!(rust_config.max_complexity, Some(15));
1769 assert_eq!(rust_config.excludes, vec!["target/**".to_string()]);
1770
1771 assert!(config.language_overrides.python.is_some());
1773 let python_config = config.language_overrides.python.as_ref().unwrap();
1774 assert_eq!(python_config.max_complexity, Some(10));
1775 assert_eq!(python_config.excludes, vec!["*_test.py".to_string()]);
1776 }
1777
1778 #[test]
1781 fn test_language_overrides_merge() {
1782 let mut base = LanguageOverrides {
1783 rust: Some(LanguageConfig {
1784 max_complexity: Some(15),
1785 ..Default::default()
1786 }),
1787 python: Some(LanguageConfig {
1788 max_complexity: Some(10),
1789 ..Default::default()
1790 }),
1791 ..Default::default()
1792 };
1793
1794 let other = LanguageOverrides {
1795 rust: Some(LanguageConfig {
1796 max_complexity: Some(20),
1797 excludes: vec!["target/**".to_string()],
1798 ..Default::default()
1799 }),
1800 go: Some(LanguageConfig {
1801 max_complexity: Some(25),
1802 ..Default::default()
1803 }),
1804 ..Default::default()
1805 };
1806
1807 base.merge(other);
1808
1809 assert!(base.rust.is_some());
1811 assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(20));
1812
1813 assert!(base.python.is_some());
1815 assert_eq!(base.python.as_ref().unwrap().max_complexity, Some(10));
1816
1817 assert!(base.go.is_some());
1819 assert_eq!(base.go.as_ref().unwrap().max_complexity, Some(25));
1820 }
1821
1822 #[test]
1823 fn test_language_overrides_merge_none_preserves() {
1824 let mut base = LanguageOverrides {
1825 rust: Some(LanguageConfig {
1826 max_complexity: Some(15),
1827 ..Default::default()
1828 }),
1829 ..Default::default()
1830 };
1831
1832 let other = LanguageOverrides::default();
1833 base.merge(other);
1834
1835 assert!(base.rust.is_some());
1837 assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(15));
1838 }
1839
1840 #[test]
1843 fn test_config_new() {
1844 let config = Config::new();
1845 assert!(config.languages.is_empty());
1846 assert!(config.includes.is_empty());
1847 assert!(config.excludes.is_empty());
1848 assert!(config.max_complexity.is_none());
1849 assert!(config.preset.is_none());
1850 }
1851
1852 #[test]
1853 fn test_config_default() {
1854 let config = Config::default();
1855 assert!(config.languages.is_empty());
1856 assert!(config.plugins.is_none());
1857 }
1858
1859 #[test]
1862 fn test_generate_default_toml_is_valid() {
1863 let toml_content = Config::generate_default_toml();
1864 let result: Result<Config, _> = toml::from_str(&toml_content);
1866 assert!(result.is_ok());
1867 }
1868
1869 #[test]
1870 fn test_generate_default_toml_has_expected_values() {
1871 let toml_content = Config::generate_default_toml();
1872 let config: Config = toml::from_str(&toml_content).unwrap();
1873 assert_eq!(config.max_complexity, Some(20));
1874 assert!(config.excludes.is_empty());
1875 }
1876
1877 #[test]
1880 fn test_project_config_path() {
1881 let project_dir = Path::new("/home/user/project");
1882 let config_path = Config::project_config_path(project_dir);
1883 assert_eq!(
1884 config_path,
1885 PathBuf::from("/home/user/project/.linthis/config.toml")
1886 );
1887 }
1888
1889 #[test]
1892 fn test_config_merge_languages() {
1893 let mut base = Config {
1894 languages: ["rust".to_string()].into_iter().collect(),
1895 ..Default::default()
1896 };
1897
1898 let other = Config {
1899 languages: ["python".to_string(), "go".to_string()]
1900 .into_iter()
1901 .collect(),
1902 ..Default::default()
1903 };
1904
1905 base.merge(other);
1906
1907 assert_eq!(base.languages.len(), 2);
1909 assert!(base.languages.contains("python"));
1910 assert!(base.languages.contains("go"));
1911 assert!(!base.languages.contains("rust"));
1912 }
1913
1914 #[test]
1915 fn test_config_merge_empty_languages_preserves() {
1916 let mut base = Config {
1917 languages: ["rust".to_string()].into_iter().collect(),
1918 ..Default::default()
1919 };
1920
1921 let other = Config {
1922 languages: HashSet::new(),
1923 ..Default::default()
1924 };
1925
1926 base.merge(other);
1927
1928 assert_eq!(base.languages.len(), 1);
1930 assert!(base.languages.contains("rust"));
1931 }
1932
1933 #[test]
1934 fn test_config_merge_includes_extends() {
1935 let mut base = Config {
1936 includes: vec!["src/**".to_string()],
1937 ..Default::default()
1938 };
1939
1940 let other = Config {
1941 includes: vec!["lib/**".to_string()],
1942 ..Default::default()
1943 };
1944
1945 base.merge(other);
1946
1947 assert_eq!(
1949 base.includes,
1950 vec!["src/**".to_string(), "lib/**".to_string()]
1951 );
1952 }
1953
1954 #[test]
1955 fn test_config_merge_verbose() {
1956 let mut base = Config::default();
1957 let other = Config {
1958 verbose: Some(true),
1959 ..Default::default()
1960 };
1961
1962 base.merge(other);
1963 assert_eq!(base.verbose, Some(true));
1964 }
1965
1966 #[test]
1969 fn test_plugin_config_default() {
1970 let config = PluginConfig::default();
1971 assert!(config.sources.is_empty());
1972 }
1973
1974 #[test]
1975 fn test_plugin_source_enabled_default() {
1976 let toml_str = r#"
1977 [plugins]
1978 sources = [
1979 { name = "test" }
1980 ]
1981 "#;
1982
1983 let config: Config = toml::from_str(toml_str).unwrap();
1984 let sources = &config.plugins.unwrap().sources;
1985 assert_eq!(sources.len(), 1);
1986 assert!(sources[0].enabled); }
1988
1989 #[test]
1990 fn test_plugin_source_with_all_fields() {
1991 let toml_str = r#"
1992 [plugins]
1993 sources = [
1994 { name = "test", url = "https://example.com/repo.git", ref = "v1.0", enabled = false }
1995 ]
1996 "#;
1997
1998 let config: Config = toml::from_str(toml_str).unwrap();
1999 let sources = &config.plugins.unwrap().sources;
2000 assert_eq!(sources[0].name, "test");
2001 assert_eq!(
2002 sources[0].url,
2003 Some("https://example.com/repo.git".to_string())
2004 );
2005 assert_eq!(sources[0].git_ref, Some("v1.0".to_string()));
2006 assert!(!sources[0].enabled);
2007 }
2008
2009 #[test]
2012 fn test_source_config_default() {
2013 let config = SourceConfig::default();
2014 assert!(config.test_source.filepath_regex.is_empty());
2015 assert!(config.auto_generate_source.filepath_regex.is_empty());
2016 assert!(config.third_party_source.filepath_regex.is_empty());
2017 }
2018
2019 #[test]
2020 fn test_source_config_from_toml() {
2021 let toml_str = r#"
2022 [source.test_source]
2023 filepath_regex = [".*_test\\.py$", "test_.*\\.py$"]
2024
2025 [source.third_party_source]
2026 filepath_regex = ["vendor/.*"]
2027 "#;
2028
2029 let config: Config = toml::from_str(toml_str).unwrap();
2030 let source = config.source.unwrap();
2031 assert_eq!(source.test_source.filepath_regex.len(), 2);
2032 assert_eq!(source.third_party_source.filepath_regex.len(), 1);
2033 }
2034
2035 #[test]
2038 fn test_cpp_language_config_from_toml() {
2039 let toml_str = r#"
2040 [cpp]
2041 linelength = 120
2042 cpplint_filter = "-build/c++11,-whitespace/tab"
2043 max_complexity = 25
2044
2045 [oc]
2046 linelength = 150
2047 cpplint_filter = "-build/header_guard"
2048 "#;
2049
2050 let config: Config = toml::from_str(toml_str).unwrap();
2051
2052 let cpp = config.language_overrides.cpp.unwrap();
2053 assert_eq!(cpp.linelength, Some(120));
2054 assert_eq!(
2055 cpp.cpplint_filter,
2056 Some("-build/c++11,-whitespace/tab".to_string())
2057 );
2058 assert_eq!(cpp.max_complexity, Some(25));
2059
2060 let oc = config.language_overrides.oc.unwrap();
2061 assert_eq!(oc.linelength, Some(150));
2062 assert_eq!(oc.cpplint_filter, Some("-build/header_guard".to_string()));
2063 }
2064
2065 #[test]
2066 fn test_oc_fn_length_from_toml() {
2067 let toml = r#"
2068 [oc]
2069 fn_length = 100
2070 "#;
2071 let config: Config = toml::from_str(toml).unwrap();
2072 let oc = config.language_overrides.oc.unwrap();
2073 assert_eq!(oc.fn_length, Some(100));
2074 }
2075
2076 #[test]
2077 fn test_cpp_fn_length_from_toml() {
2078 let toml = r#"
2079 [cpp]
2080 fn_length = 50
2081 "#;
2082 let config: Config = toml::from_str(toml).unwrap();
2083 let cpp = config.language_overrides.cpp.unwrap();
2084 assert_eq!(cpp.fn_length, Some(50));
2085 }
2086
2087 #[test]
2088 fn test_oc_fn_length_default_is_none() {
2089 let toml = r#"
2090 [oc]
2091 linelength = 150
2092 "#;
2093 let config: Config = toml::from_str(toml).unwrap();
2094 let oc = config.language_overrides.oc.unwrap();
2095 assert_eq!(oc.fn_length, None);
2096 }
2097
2098 #[test]
2099 fn test_objectivec_alias() {
2100 let toml_str = r#"
2102 [objectivec]
2103 linelength = 200
2104 "#;
2105
2106 let config: Config = toml::from_str(toml_str).unwrap();
2107 let oc = config.language_overrides.oc.unwrap();
2108 assert_eq!(oc.linelength, Some(200));
2109 }
2110
2111 #[test]
2114 fn test_get_plugin_sources_empty() {
2115 let config = Config::default();
2116 let sources = config.get_plugin_sources();
2117 assert!(sources.is_empty());
2118 }
2119
2120 #[test]
2121 fn test_get_plugin_sources_with_plugins() {
2122 let config = Config {
2123 plugins: Some(PluginConfig {
2124 sources: vec![PluginSourceConfig {
2125 name: "test".to_string(),
2126 url: Some("https://example.com".to_string()),
2127 git_ref: Some("main".to_string()),
2128 enabled: true,
2129 }],
2130 }),
2131 ..Default::default()
2132 };
2133
2134 let sources = config.get_plugin_sources();
2135 assert_eq!(sources.len(), 1);
2136 assert_eq!(sources[0].name, "test");
2137 assert_eq!(sources[0].url, Some("https://example.com".to_string()));
2138 assert_eq!(sources[0].git_ref, Some("main".to_string()));
2139 assert!(sources[0].enabled);
2140 }
2141
2142 fn setup_fake_plugin_cache(linthis_toml_content: &str) -> (tempfile::TempDir, String) {
2147 let cache_root = tempfile::TempDir::new().unwrap();
2148 let plugin_dir = cache_root.path().join("example.com").join("test-plugin");
2151 std::fs::create_dir_all(&plugin_dir).unwrap();
2152 std::fs::write(plugin_dir.join("linthis.toml"), linthis_toml_content).unwrap();
2153 let url = "https://example.com/test-plugin.git".to_string();
2154 (cache_root, url)
2155 }
2156
2157 fn setup_project_with_plugin(project_toml: &str, plugin_url: &str) -> tempfile::TempDir {
2159 let project_dir = tempfile::TempDir::new().unwrap();
2160 let linthis_dir = project_dir.path().join(".linthis");
2161 std::fs::create_dir_all(&linthis_dir).unwrap();
2162 let config_content = format!(
2163 r#"[plugins]
2164sources = [{{ name = "test-plugin", url = "{}" }}]
2165{}
2166"#,
2167 plugin_url, project_toml
2168 );
2169 std::fs::write(linthis_dir.join("config.toml"), config_content).unwrap();
2170 project_dir
2171 }
2172
2173 static PLUGIN_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2182
2183 struct PluginCacheEnvGuard {
2189 _lock: std::sync::MutexGuard<'static, ()>,
2190 original: Option<String>,
2191 }
2192
2193 impl PluginCacheEnvGuard {
2194 fn new(path: &std::path::Path) -> Self {
2195 let lock = PLUGIN_CACHE_TEST_LOCK
2196 .lock()
2197 .unwrap_or_else(|poison| poison.into_inner());
2198 let original = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR").ok();
2199 std::env::set_var("LINTHIS_TEST_PLUGIN_CACHE_DIR", path);
2200 Self {
2201 _lock: lock,
2202 original,
2203 }
2204 }
2205
2206 fn new_unset() -> Self {
2208 let lock = PLUGIN_CACHE_TEST_LOCK
2209 .lock()
2210 .unwrap_or_else(|poison| poison.into_inner());
2211 let original = std::env::var("LINTHIS_TEST_PLUGIN_CACHE_DIR").ok();
2212 std::env::remove_var("LINTHIS_TEST_PLUGIN_CACHE_DIR");
2213 Self {
2214 _lock: lock,
2215 original,
2216 }
2217 }
2218 }
2219
2220 impl Drop for PluginCacheEnvGuard {
2221 fn drop(&mut self) {
2222 match &self.original {
2223 Some(v) => std::env::set_var("LINTHIS_TEST_PLUGIN_CACHE_DIR", v),
2224 None => std::env::remove_var("LINTHIS_TEST_PLUGIN_CACHE_DIR"),
2225 }
2226 }
2227 }
2228
2229 #[test]
2230 fn test_fn_length_plugin_linthis_toml_loaded() {
2231 let (cache_root, url) =
2234 setup_fake_plugin_cache("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n");
2235 let project_dir = setup_project_with_plugin("", &url);
2236
2237 let _guard = PluginCacheEnvGuard::new(cache_root.path());
2240 let merged = Config::load_merged(project_dir.path());
2241
2242 assert_eq!(
2243 merged
2244 .language_overrides
2245 .cpp
2246 .as_ref()
2247 .and_then(|c| c.fn_length),
2248 Some(60)
2249 );
2250 assert_eq!(
2251 merged
2252 .language_overrides
2253 .oc
2254 .as_ref()
2255 .and_then(|c| c.fn_length),
2256 Some(60)
2257 );
2258 }
2259
2260 #[test]
2261 fn test_fn_length_project_config_overrides_plugin() {
2262 let (cache_root, url) =
2265 setup_fake_plugin_cache("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n");
2266 let project_dir =
2267 setup_project_with_plugin("\n[cpp]\nfn_length = 40\n[oc]\nfn_length = 40\n", &url);
2268
2269 let _guard = PluginCacheEnvGuard::new(cache_root.path());
2270 let merged = Config::load_merged(project_dir.path());
2271
2272 assert_eq!(
2273 merged
2274 .language_overrides
2275 .cpp
2276 .as_ref()
2277 .and_then(|c| c.fn_length),
2278 Some(40)
2279 );
2280 assert_eq!(
2281 merged
2282 .language_overrides
2283 .oc
2284 .as_ref()
2285 .and_then(|c| c.fn_length),
2286 Some(40)
2287 );
2288 }
2289
2290 #[test]
2291 fn test_fn_length_no_plugin_falls_back_to_none() {
2292 let empty_cache = tempfile::TempDir::new().unwrap();
2297 let _guard = PluginCacheEnvGuard::new(empty_cache.path());
2298
2299 let project_dir = tempfile::TempDir::new().unwrap();
2300 let merged = Config::load_merged(project_dir.path());
2301
2302 assert!(
2304 merged
2305 .language_overrides
2306 .cpp
2307 .as_ref()
2308 .and_then(|c| c.fn_length)
2309 .is_none()
2310 || merged.language_overrides.cpp.is_none()
2311 );
2312 }
2313
2314 #[test]
2315 fn test_fn_length_project_config_without_plugin() {
2316 let _guard = PluginCacheEnvGuard::new_unset();
2320
2321 let project_dir = tempfile::TempDir::new().unwrap();
2322 let linthis_dir = project_dir.path().join(".linthis");
2323 std::fs::create_dir_all(&linthis_dir).unwrap();
2324 std::fs::write(
2325 linthis_dir.join("config.toml"),
2326 "[cpp]\nfn_length = 50\n[oc]\nfn_length = 50\n",
2327 )
2328 .unwrap();
2329
2330 let merged = Config::load_merged(project_dir.path());
2331 assert_eq!(
2332 merged
2333 .language_overrides
2334 .cpp
2335 .as_ref()
2336 .and_then(|c| c.fn_length),
2337 Some(50)
2338 );
2339 assert_eq!(
2340 merged
2341 .language_overrides
2342 .oc
2343 .as_ref()
2344 .and_then(|c| c.fn_length),
2345 Some(50)
2346 );
2347 }
2348
2349 #[test]
2350 fn test_fn_length_merge_order_plugin_then_project() {
2351 let mut plugin_config: Config =
2353 toml::from_str("[cpp]\nfn_length = 60\n[oc]\nfn_length = 60\n").unwrap();
2354 let project_config: Config =
2355 toml::from_str("[cpp]\nfn_length = 30\n[oc]\nfn_length = 30\n").unwrap();
2356 plugin_config.merge(project_config);
2357 assert_eq!(
2358 plugin_config
2359 .language_overrides
2360 .cpp
2361 .as_ref()
2362 .and_then(|c| c.fn_length),
2363 Some(30)
2364 );
2365 assert_eq!(
2366 plugin_config
2367 .language_overrides
2368 .oc
2369 .as_ref()
2370 .and_then(|c| c.fn_length),
2371 Some(30)
2372 );
2373 }
2374}