Skip to main content

linthis/config/
mod.rs

1// Copyright 2024 zhlinh and linthis Project Authors. All rights reserved.
2// Use of this source code is governed by a MIT-style
3// license that can be found at
4//
5// https://opensource.org/license/MIT
6//
7// The above copyright notice and this permission
8// notice shall be included in all copies or
9// substantial portions of the Software.
10
11//! Configuration system for linthis with hierarchical precedence.
12//!
13//! This module provides configuration loading, parsing, and merging for linthis.
14//! Configuration files support TOML, YAML, and JSON formats.
15//!
16//! ## Configuration Precedence
17//!
18//! Configuration is loaded and merged from multiple sources with the following precedence
19//! (higher precedence overrides lower):
20//!
21//! 1. **CLI arguments** (highest) - Command-line flags
22//! 2. **Project config** - `.linthis/config.toml` in project root
23//! 3. **User config** - `~/.linthis/config.toml` for global settings
24//! 4. **Built-in defaults** (lowest) - Sensible defaults
25//!
26//! ## Example Configuration
27//!
28//! ```toml
29//! # .linthis/config.toml
30//!
31//! # Languages to check (empty = auto-detect)
32//! languages = ["rust", "python"]
33//!
34//! # File patterns to exclude
35//! excludes = ["vendor/**", "*.generated.*"]
36//!
37//! # Maximum cyclomatic complexity
38//! max_complexity = 20
39//!
40//! # Rules configuration
41//! [rules]
42//! disable = ["E501"]
43//!
44//! # Language-specific settings
45//! [rust]
46//! max_complexity = 15
47//!
48//! [python]
49//! excludes = ["*_test.py"]
50//! ```
51//!
52//! ## Usage
53//!
54//! ```rust,no_run
55//! use linthis::config::Config;
56//! use std::path::Path;
57//!
58//! // Load merged config from all sources
59//! let config = Config::load_merged(Path::new("."));
60//!
61//! // Load from specific file
62//! let config = Config::load(Path::new(".linthis/config.toml")).unwrap();
63//!
64//! // Generate default config
65//! let default_toml = Config::generate_default_toml();
66//! ```
67
68pub 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/// Main configuration structure for linthis.
80///
81/// Contains all configuration options for controlling lint behavior,
82/// formatting, language-specific settings, and plugin configuration.
83///
84/// # Example
85///
86/// ```rust,no_run
87/// use linthis::config::Config;
88/// use std::path::Path;
89///
90/// // Load from project directory
91/// let config = Config::load_merged(Path::new("."));
92///
93/// // Access configuration values
94/// println!("Max complexity: {:?}", config.max_complexity);
95/// println!("Excluded patterns: {:?}", config.excludes);
96/// ```
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98pub struct Config {
99    /// Languages to check (empty = auto-detect)
100    #[serde(default)]
101    pub languages: HashSet<String>,
102
103    /// Paths/patterns to include (glob patterns)
104    #[serde(default)]
105    pub includes: Vec<String>,
106
107    /// Paths/patterns to exclude (glob patterns)
108    #[serde(default, alias = "exclude")]
109    pub excludes: Vec<String>,
110
111    /// Maximum cyclomatic complexity allowed
112    #[serde(default)]
113    pub max_complexity: Option<u32>,
114
115    /// Format preset to use (google, standard, airbnb)
116    #[serde(default)]
117    pub preset: Option<String>,
118
119    /// Verbose output
120    #[serde(default)]
121    pub verbose: Option<bool>,
122
123    /// Source configuration (compatible with CodeCC .code.yml)
124    #[serde(default)]
125    pub source: Option<SourceConfig>,
126
127    /// Language-specific overrides (flattened to root level)
128    #[serde(default, flatten)]
129    pub language_overrides: LanguageOverrides,
130
131    /// Plugin configuration
132    #[serde(default, alias = "plugin")]
133    pub plugins: Option<PluginConfig>,
134
135    /// Plugin auto-sync configuration
136    #[serde(default)]
137    pub plugin_auto_sync: Option<crate::plugin::AutoSyncConfig>,
138
139    /// Self auto-update configuration
140    #[serde(default)]
141    pub self_auto_update: Option<crate::self_update::SelfUpdateConfig>,
142
143    /// Tool auto-install configuration
144    #[serde(default)]
145    pub tool_auto_install: Option<ToolAutoInstallConfig>,
146
147    /// Performance settings
148    #[serde(default)]
149    pub performance: PerformanceConfig,
150
151    /// Hook settings
152    #[serde(default)]
153    pub hook: HookConfig,
154
155    /// Commit message validation settings
156    #[serde(default)]
157    pub cmsg: CmsgConfig,
158
159    /// Custom rules, rule disable, and severity overrides
160    #[serde(default)]
161    pub rules: RulesConfig,
162
163    /// AI configuration for fix suggestions
164    #[serde(default)]
165    pub ai: AiConfig,
166
167    /// Code review settings
168    #[serde(default)]
169    pub review: ReviewConfig,
170
171    /// Retention settings for results, backups, reviews, and cache
172    #[serde(default)]
173    pub retention: RetentionConfig,
174
175    /// Checks configuration: which checks to run and per-check settings.
176    ///
177    /// ```toml
178    /// [checks]
179    /// run = ["lint"]                    # default: only lint
180    /// # run = ["lint", "security", "complexity"]  # enable all
181    ///
182    /// [checks.security]
183    /// scan_type = "sast"
184    /// fail_on = "high"
185    ///
186    /// [checks.complexity]
187    /// threshold = 15
188    /// ```
189    #[serde(default)]
190    pub checks: ChecksConfig,
191}
192
193/// Unified fail_on level for all checks.
194///
195/// Controls the minimum severity that causes a non-zero exit code.
196/// Default: "warning" (error→exit 1, warning→exit 3, info→exit 0).
197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "lowercase")]
199pub enum FailOn {
200    /// Only errors cause failure (exit 1)
201    Error,
202    /// Errors (exit 1) and warnings (exit 2) cause failure
203    #[default]
204    Warning,
205    /// All severities cause failure
206    Info,
207    /// Never fail regardless of findings
208    None,
209}
210
211impl FailOn {
212    /// Calculate exit code for given error/warning/info counts.
213    ///
214    /// Exit codes:
215    /// - 0: No issues (success)
216    /// - 1: Has errors
217    /// - 2: Has warnings (no errors)
218    /// - 3: Has info (no errors/warnings)
219    /// - 4: Formatting errors (set separately, not by this method)
220    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/// Configuration for which checks to run and per-check settings — `[checks]` in TOML
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ChecksConfig {
257    /// Which checks to run: ["lint"] (default), ["lint", "security"], etc.
258    #[serde(default = "default_checks")]
259    pub run: Vec<String>,
260    /// Lint check settings
261    #[serde(default)]
262    pub lint: Option<LintChecksConfig>,
263    /// Security check settings
264    #[serde(default)]
265    pub security: Option<SecurityChecksConfig>,
266    /// Complexity check settings
267    #[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/// Lint check configuration — `[checks.lint]` in TOML
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
292pub struct LintChecksConfig {
293    /// Minimum severity to fail on (default: warning)
294    #[serde(default)]
295    pub fail_on: Option<FailOn>,
296}
297
298/// Security check configuration — `[checks.security]` in TOML
299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct SecurityChecksConfig {
301    /// Scan type: sca, sast, all (default: all)
302    #[serde(default)]
303    pub scan_type: Option<String>,
304    /// Minimum severity to fail on (default: warning)
305    #[serde(default)]
306    pub fail_on: Option<FailOn>,
307    /// Custom SAST config path
308    #[serde(default)]
309    pub sast_config: Option<PathBuf>,
310}
311
312/// Complexity check configuration — `[checks.complexity]` in TOML
313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
314pub struct ComplexityChecksConfig {
315    /// Cyclomatic complexity threshold for info level (default: 10)
316    #[serde(default)]
317    pub threshold: Option<u32>,
318    /// Warning threshold (default: threshold + 10)
319    #[serde(default)]
320    pub warning_threshold: Option<u32>,
321    /// Error threshold (default: threshold + 20)
322    #[serde(default)]
323    pub error_threshold: Option<u32>,
324    /// Minimum severity to fail on (default: warning)
325    #[serde(default)]
326    pub fail_on: Option<FailOn>,
327}
328
329/// Plugin configuration section
330#[derive(Debug, Clone, Serialize, Deserialize, Default)]
331pub struct PluginConfig {
332    /// List of plugin sources in priority order (later overrides earlier)
333    #[serde(default)]
334    pub sources: Vec<PluginSourceConfig>,
335}
336
337/// Plugin source configuration
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct PluginSourceConfig {
340    /// Plugin name (required for registry lookup or display)
341    pub name: String,
342    /// Git repository URL (optional if name is in registry)
343    #[serde(default)]
344    pub url: Option<String>,
345    /// Git ref (tag, branch, commit hash)
346    #[serde(default, rename = "ref")]
347    pub git_ref: Option<String>,
348    /// Whether this plugin is enabled (default: true)
349    #[serde(default = "default_enabled")]
350    pub enabled: bool,
351}
352
353fn default_enabled() -> bool {
354    true
355}
356
357/// Performance configuration section
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct PerformanceConfig {
360    /// Large file threshold in bytes (default: 1MB = 1048576)
361    #[serde(default = "default_large_file_threshold")]
362    pub large_file_threshold: u64,
363    /// Skip large files instead of warning
364    #[serde(default)]
365    pub skip_large_files: bool,
366    /// Cache max age in days (default: 7)
367    #[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 // 1MB
383}
384
385fn default_cache_max_age_days() -> u32 {
386    7
387}
388
389/// Source specification for a hook or agent plugin component.
390///
391/// Used in TOML as the value of `source = { ... }` inside hook/plugin entries.
392/// Exactly one variant's fields must be present.
393#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(untagged)]
395pub enum HookSource {
396    // IMPORTANT: variants are ordered most-specific → least-specific so that serde's
397    // "first match wins" logic for untagged enums picks the right variant.
398    // e.g. `{ plugin, file }` must be tried before `{ file }` to avoid `File` stealing it.
399    /// File/directory inside a plugin fetched on-demand from a named marketplace.
400    /// `{ marketplace = "corp", plugin = "linthis-official", file = "hooks/agent/plugins/lt/lint" }`
401    Marketplace {
402        marketplace: String,
403        plugin: String,
404        file: String,
405    },
406    /// Clone a git repository and read the given path (file or directory).
407    /// `{ git = "https://github.com/org/repo.git", ref = "v1.0", path = "hooks/git/pre-commit" }`
408    Git {
409        git: String,
410        #[serde(rename = "ref", default)]
411        git_ref: Option<String>,
412        path: String,
413    },
414    /// File/directory inside an already-cached plugin (added via `linthis plugin add`).
415    /// `{ plugin = "linthis-official", file = "hooks/git/pre-commit" }`
416    Plugin { plugin: String, file: String },
417    /// Direct HTTP/HTTPS download (files only, not directories).
418    /// `{ url = "https://example.com/hooks/pre-commit" }`
419    Url { url: String },
420    /// Local filesystem path relative to project root.
421    /// `{ file = "hooks/git/pre-commit" }`
422    File { file: String },
423}
424
425/// Custom target paths for agent plugin installation.
426///
427/// When present in a `HookSourceEntry`, overrides the provider-hardcoded
428/// paths so that new providers (e.g. OpenClaw) can be supported without
429/// changing linthis source code.
430#[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/// A single source-mapped entry (wraps `HookSource`).
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct HookSourceEntry {
441    pub source: HookSource,
442    #[serde(default)]
443    pub target: Option<AgentTargetConfig>,
444}
445
446/// Hook configuration section — `[hook]` in TOML.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct HookConfig {
449    /// Hook timeout in seconds (default: 60)
450    #[serde(default = "default_hook_timeout")]
451    pub timeout: u32,
452    /// Enable parallel execution in hooks (default: true)
453    #[serde(default = "default_hook_parallel")]
454    pub parallel: bool,
455    /// Hook output box width (0 = auto-detect terminal width, min 50, max 120)
456    #[serde(default)]
457    pub output_width: Option<u32>,
458
459    // ── Tier-2 override: marketplace URLs ────────────────────────────────
460    /// Named marketplace git repositories.
461    /// `[hook.marketplaces]`  key = name, value = git URL.
462    /// The key `"default"` is used when no `marketplace` field is given in a source entry.
463    #[serde(default)]
464    pub marketplaces: HashMap<String, String>,
465
466    // ── Tier-2 override: git hook source mappings ─────────────────────────
467    /// `[hook.git]`  key = event name ("pre-commit", "commit-msg", "pre-push")
468    #[serde(default)]
469    pub git: HashMap<String, HookSourceEntry>,
470    /// `[hook.git-with-agent]`
471    #[serde(default, rename = "git-with-agent")]
472    pub git_with_agent: HashMap<String, HookSourceEntry>,
473    /// `[hook.prek]`
474    #[serde(default)]
475    pub prek: HashMap<String, HookSourceEntry>,
476    /// `[hook.prek-with-agent]`
477    #[serde(default, rename = "prek-with-agent")]
478    pub prek_with_agent: HashMap<String, HookSourceEntry>,
479
480    // ── Agent namespace ──────────────────────────────────────────────────
481    /// `[hook.agent]` — agent plugins and skill names
482    #[serde(default)]
483    pub agent: AgentConfig,
484
485    // ── Review hook configuration ────────────────────────────────────────
486    /// `[hook.review]` — auto-fix mode for background/hook context
487    #[serde(default)]
488    pub review: HookReviewConfig,
489
490    // ── Per-event fix mode ──────────────────────────────────────────────
491    /// `[hook.pre_commit]` — pre-commit hook behavior
492    #[serde(default)]
493    pub pre_commit: HookEventFixConfig,
494    /// `[hook.pre_push]` — pre-push hook behavior
495    #[serde(default)]
496    pub pre_push: HookEventFixConfig,
497}
498
499/// Agent namespace configuration — `[hook.agent]` in TOML.
500#[derive(Debug, Clone, Serialize, Deserialize, Default)]
501pub struct AgentConfig {
502    /// Provider → (plugin_id → source), `_default` is the fallback provider.
503    /// `[hook.agent.plugins._default]`, `[hook.agent.plugins.claude]`, etc.
504    #[serde(default)]
505    pub plugins: HashMap<String, HashMap<String, HookSourceEntry>>,
506    /// Custom skill directory names per hook event — `[hook.agent.skill-names]`
507    #[serde(default, rename = "skill-names")]
508    pub skill_names: AgentSkillNamesConfig,
509}
510
511/// Review hook configuration — `[hook.review]` in TOML.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct HookReviewConfig {
514    /// Auto-fix mode when triggered from background/hook context (default: pr)
515    #[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/// Per-event hook fix mode configuration.
532///
533/// Controls how auto-format and agent fix changes are applied:
534/// - `"squash"` — merge all fixes into the current commit (default for pre-commit)
535/// - `"dirty"` — leave fixes in the working tree and block (default for pre-push)
536/// - `"fixup"` — create a separate fixup commit for fixes
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct HookEventFixConfig {
539    /// Fix mode: "squash", "dirty", or "fixup"
540    #[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
548/// Expand short-form `fix_commit_mode` aliases to their canonical name.
549///
550/// Accepted inputs (case-sensitive — CLI flags and config values are ASCII):
551/// - `"s"` or `"squash"` → `"squash"`
552/// - `"d"` or `"dirty"`  → `"dirty"`
553/// - `"f"` or `"fixup"`  → `"fixup"`
554///
555/// Returns `None` for anything else — callers should surface this as a
556/// validation error. The canonical form is ALWAYS the full word so
557/// `config.toml` files stay self-documenting and downstream code can
558/// keep comparing against `"squash" | "dirty" | "fixup"`.
559pub 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/// Configurable skill directory/file names for each hook event.
577///
578/// Allows teams to map linthis hook events to custom skill directory names
579/// instead of the defaults (`lt-lint`, `lt-cmsg`, `lt-review`).
580///
581/// ```toml
582/// [hook.agent.skill-names]
583/// pre-commit = "my-team-lint"     # default: "lt-lint"
584/// commit-msg = "my-team-cmsg"     # default: "lt-cmsg"
585/// pre-push = "my-team-review"     # default: "lt-review"
586/// ```
587#[derive(Debug, Clone, Serialize, Deserialize, Default)]
588pub struct AgentSkillNamesConfig {
589    /// Custom skill name for the pre-commit event (default: "lt-lint")
590    #[serde(default, rename = "pre-commit")]
591    pub pre_commit: Option<String>,
592    /// Custom skill name for the commit-msg event (default: "lt-cmsg")
593    #[serde(default, rename = "commit-msg")]
594    pub commit_msg: Option<String>,
595    /// Custom skill name for the pre-push event (default: "lt-review")
596    #[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/// Commit message validation configuration section
624#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct CmsgConfig {
626    /// Commit message validation pattern (conventional commits by default)
627    #[serde(default = "default_commit_msg_pattern")]
628    pub commit_msg_pattern: String,
629    /// Require ticket reference in commit messages (e.g., [JIRA-123])
630    #[serde(default)]
631    pub require_ticket: bool,
632    /// Ticket pattern regex (e.g., r"\[\w+-\d+\]")
633    #[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 // 60 seconds
649}
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/// Code review configuration section
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct ReviewConfig {
662    /// Enable review feature
663    #[serde(default = "default_review_enabled")]
664    pub enabled: bool,
665    /// Auto-fix mode (create fix branch + PR/MR)
666    #[serde(default)]
667    pub auto_fix: bool,
668    /// Auto-fix mode for direct usage: pr, commit, apply (default: apply)
669    #[serde(default)]
670    pub auto_fix_mode: AutoFixMode,
671    /// AI provider override for review
672    #[serde(default)]
673    pub provider: Option<String>,
674    /// Retention days for review artifacts
675    #[serde(default = "default_retention_days")]
676    pub retention_days: u32,
677    /// Platform configurations
678    #[serde(default)]
679    pub platforms: std::collections::HashMap<String, PlatformConfig>,
680    /// Reviewer configuration
681    #[serde(default)]
682    pub reviewers: ReviewerConfig,
683    /// Notification channels
684    #[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/// Unified retention configuration for .linthis/ artifacts.
712///
713/// ```toml
714/// [retention]
715/// results = 10      # max result files (0 = unlimited)
716/// backups = 5       # max backup directories (0 = unlimited)
717/// reviews = 10      # max review files (0 = unlimited)
718/// cache_days = 30   # max age in days for lint cache entries
719/// ```
720///
721/// Values > 0 always keep at least 1 (the most recent).
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct RetentionConfig {
724    /// Maximum number of result files to keep (0 = unlimited)
725    #[serde(default = "default_retention_results")]
726    pub results: usize,
727    /// Maximum number of backup directories to keep (0 = unlimited)
728    #[serde(default = "default_retention_backups")]
729    pub backups: usize,
730    /// Maximum number of review files to keep (0 = unlimited)
731    #[serde(default = "default_retention_reviews")]
732    pub reviews: usize,
733    /// Maximum age in days for lint cache entries
734    #[serde(default = "default_retention_cache_days")]
735    pub cache_days: u32,
736    /// Maximum number of diff patch files to keep (0 = unlimited)
737    #[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/// Platform-specific PR/MR command configuration
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct PlatformConfig {
772    /// Command template for creating PR/MR
773    pub pr_create: String,
774    /// Command template for listing PRs/MRs
775    #[serde(default)]
776    pub pr_list: Option<String>,
777    /// Flag name for specifying reviewers
778    #[serde(default = "default_reviewer_flag")]
779    pub reviewer_flag: String,
780    /// Install command for the CLI tool (run via sh)
781    #[serde(default)]
782    pub install_cmd: Option<String>,
783    /// Human-readable install hint (shown when tool is missing)
784    #[serde(default)]
785    pub install_hint: Option<String>,
786}
787
788fn default_reviewer_flag() -> String {
789    "--reviewer".to_string()
790}
791
792/// Reviewer management configuration
793#[derive(Debug, Clone, Serialize, Deserialize, Default)]
794pub struct ReviewerConfig {
795    /// Default reviewers (platform usernames)
796    #[serde(default)]
797    pub default: Vec<String>,
798}
799
800/// Notification channel configuration
801#[derive(Debug, Clone, Serialize, Deserialize)]
802#[serde(tag = "type")]
803pub enum NotificationConfig {
804    /// System notification (macOS/Linux/Windows)
805    #[serde(rename = "system")]
806    System,
807    /// Webhook notification (HTTP POST)
808    #[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    /// Custom command notification
819    #[serde(rename = "custom")]
820    Custom { command: String },
821}
822
823fn default_webhook_method() -> String {
824    "POST".to_string()
825}
826
827/// Tool auto-install configuration
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
829pub struct ToolAutoInstallConfig {
830    /// Enable/disable tool auto-install
831    #[serde(default = "default_tool_auto_install_enabled")]
832    pub enabled: bool,
833
834    /// Install mode: "auto", "prompt", or "disabled"
835    #[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/// AI configuration section
857#[derive(Debug, Clone, Serialize, Deserialize, Default)]
858pub struct AiConfig {
859    /// AI provider to use: claude, claude-cli, codebuddy, codebuddy-cli, openai,
860    /// codex-cli, gemini, gemini-cli, local, mock, or a custom provider name
861    #[serde(default)]
862    pub provider: Option<String>,
863    /// Model name to use (overrides provider default)
864    #[serde(default)]
865    pub model: Option<String>,
866    /// Maximum tokens for AI response
867    #[serde(default)]
868    pub max_tokens: Option<u32>,
869    /// Temperature for AI generation (0.0 - 1.0)
870    #[serde(default)]
871    pub temperature: Option<f32>,
872    /// Request timeout in seconds
873    #[serde(default)]
874    pub timeout_secs: Option<u64>,
875    /// Custom provider definitions (CLI or API)
876    #[serde(default)]
877    pub custom_providers: std::collections::HashMap<String, CustomProvider>,
878}
879
880/// Custom provider configuration.
881///
882/// Supports CLI providers, API providers, or both:
883///
884/// ## CLI provider with cli_style
885/// ```toml
886/// [ai.custom_providers.longcat]
887/// kind = "cli"
888/// command = "longcat"
889/// cli_style = "claude"  # claude, codex, gemini
890/// ```
891///
892/// ## CLI provider with custom args
893/// ```toml
894/// [ai.custom_providers.mybot]
895/// kind = "cli"
896/// command = "mybot"
897/// prompt_args = ["ask", "--no-interactive"]
898/// fix_args = ["ask", "--no-interactive", "--auto-approve"]
899/// system_prompt_arg = "--system"
900/// ```
901///
902/// ## API provider (OpenAI-compatible)
903/// ```toml
904/// [ai.custom_providers.deepseek]
905/// kind = "api"
906/// api_style = "openai"  # openai, anthropic, gemini
907/// endpoint = "https://api.deepseek.com/v1/chat/completions"
908/// api_key_env = "DEEPSEEK_API_KEY"
909/// model = "deepseek-chat"
910/// ```
911///
912/// `cli_style` can be combined with custom args (custom args override style defaults).
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct CustomProvider {
915    /// Provider kind: "cli" or "api" (default: "cli")
916    #[serde(default = "default_provider_kind")]
917    pub kind: String,
918
919    // --- CLI fields ---
920    /// CLI command name (e.g., "longcat")
921    #[serde(default)]
922    pub command: Option<String>,
923    /// CLI style to base args on: "claude", "codex", "gemini"
924    #[serde(default)]
925    pub cli_style: Option<String>,
926    /// Args for prompt mode (non-interactive completion).
927    /// The prompt text is appended as the last argument.
928    #[serde(default)]
929    pub prompt_args: Option<Vec<String>>,
930    /// Args for fix mode (direct file editing with tools).
931    /// The prompt text is appended as the last argument.
932    #[serde(default)]
933    pub fix_args: Option<Vec<String>>,
934    /// Argument name for passing system prompt (e.g., "--system-prompt")
935    #[serde(default)]
936    pub system_prompt_arg: Option<String>,
937
938    // --- API fields ---
939    /// API style: "openai", "anthropic", "gemini" (determines request/response format)
940    #[serde(default)]
941    pub api_style: Option<String>,
942    /// API endpoint URL
943    #[serde(default)]
944    pub endpoint: Option<String>,
945    /// Model name
946    #[serde(default)]
947    pub model: Option<String>,
948
949    // --- Common fields ---
950    /// Environment variable name for API key (for availability detection and auth)
951    #[serde(default)]
952    pub api_key_env: Option<String>,
953    /// Fallback provider name when this one is unavailable
954    #[serde(default)]
955    pub fallback: Option<String>,
956}
957
958fn default_provider_kind() -> String {
959    "cli".to_string()
960}
961
962/// Source path configuration (CodeCC compatibility)
963#[derive(Debug, Clone, Serialize, Deserialize, Default)]
964pub struct SourceConfig {
965    /// Test source patterns to exclude
966    #[serde(default)]
967    pub test_source: PathPatterns,
968
969    /// Auto-generated source patterns to exclude
970    #[serde(default)]
971    pub auto_generate_source: PathPatterns,
972
973    /// Third-party source patterns to exclude
974    #[serde(default)]
975    pub third_party_source: PathPatterns,
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, Default)]
979pub struct PathPatterns {
980    /// Regex patterns for file paths
981    #[serde(default)]
982    pub filepath_regex: Vec<String>,
983}
984
985/// Language-specific configuration overrides
986#[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/// Per-language configuration
1007#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1008pub struct LanguageConfig {
1009    /// Additional exclusion patterns for this language
1010    #[serde(default, alias = "exclude")]
1011    pub excludes: Vec<String>,
1012    /// Enable/disable this language
1013    #[serde(default)]
1014    pub enabled: Option<bool>,
1015    /// Max complexity override
1016    #[serde(default)]
1017    pub max_complexity: Option<u32>,
1018    /// Language-specific rules configuration
1019    #[serde(default)]
1020    pub rules: Option<RulesConfig>,
1021}
1022
1023/// C/C++/Objective-C language configuration with cpplint support
1024#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025pub struct CppLanguageConfig {
1026    /// Additional exclusion patterns for this language
1027    #[serde(default, alias = "exclude")]
1028    pub excludes: Vec<String>,
1029    /// Enable/disable this language
1030    #[serde(default)]
1031    pub enabled: Option<bool>,
1032    /// Max complexity override
1033    #[serde(default)]
1034    pub max_complexity: Option<u32>,
1035    /// Cpplint line length (default: 80)
1036    #[serde(default)]
1037    pub linelength: Option<u32>,
1038    /// Cpplint filter rules (e.g., "-build/c++11,-build/header_guard")
1039    #[serde(default)]
1040    pub cpplint_filter: Option<String>,
1041    /// Clang-tidy checks to ignore (e.g., ["clang-analyzer-osx.cocoa.RetainCount"])
1042    #[serde(default)]
1043    pub clang_tidy_ignored_checks: Option<Vec<String>>,
1044    /// Max method/function SLOC (non-blank, non-comment lines) for method length checks.
1045    /// Only applied to Objective-C files; has no effect for C++ files.
1046    /// When `None`, the checker uses its built-in default (80).
1047    #[serde(default)]
1048    pub fn_length: Option<u32>,
1049    /// Language-specific rules configuration
1050    #[serde(default)]
1051    pub rules: Option<RulesConfig>,
1052}
1053
1054impl LanguageOverrides {
1055    /// Merge another LanguageOverrides into this one
1056    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
1076/// Known configuration field names for suggestions
1077const 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    /// Create a new empty configuration
1102    pub fn new() -> Self {
1103        Self::default()
1104    }
1105
1106    /// Load configuration from a file
1107    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    /// Load built-in default configuration
1133    pub fn built_in_defaults() -> Self {
1134        Config {
1135            max_complexity: Some(20),
1136            ..Default::default()
1137        }
1138    }
1139
1140    /// Load user-level configuration from ~/.linthis/config.toml
1141    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    /// Load project-level configuration from the given directory
1152    /// Searches for .linthis/config.toml in the start directory and parent directories
1153    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    /// Merge another configuration into this one.
1172    /// Values from `other` override values in `self`.
1173    pub fn merge(&mut self, other: Config) {
1174        // Merge languages
1175        if !other.languages.is_empty() {
1176            self.languages = other.languages;
1177        }
1178
1179        // Merge include patterns (append, don't replace)
1180        self.includes.extend(other.includes);
1181
1182        // Merge exclude patterns (append, don't replace)
1183        self.excludes.extend(other.excludes);
1184
1185        // Override scalar values
1186        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        // Merge language overrides
1200        self.language_overrides.merge(other.language_overrides);
1201
1202        if other.plugins.is_some() {
1203            self.plugins = other.plugins;
1204        }
1205
1206        // Merge rules configuration
1207        self.rules.merge(other.rules);
1208
1209        // Merge hook configuration (other's entries override self's on conflict)
1210        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        // Merge agent plugins: per-provider maps
1226        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        // Merge per-event fix_commit_mode
1239        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    /// Get plugin sources from config, converting to PluginSource type
1248    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    /// Load linthis.toml from a plugin's cache directory.
1266    /// Returns None if the plugin has no linthis.toml or the file can't be parsed.
1267    fn load_plugin_linthis_toml(source: &crate::plugin::PluginSource) -> Option<Self> {
1268        use crate::plugin::PluginCache;
1269        // Allow test override via env var LINTHIS_TEST_PLUGIN_CACHE_DIR
1270        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    /// Load and merge configuration from all sources with proper precedence.
1286    ///
1287    /// Priority chain (lowest to highest):
1288    ///   built-in defaults
1289    ///     → global plugin linthis.toml  (from plugins in ~/.linthis/config.toml)
1290    ///     → project plugin linthis.toml (from plugins in .linthis/config.toml)
1291    ///     → ~/.linthis/config.toml      (user config — overrides plugin)
1292    ///     → .linthis/config.toml        (project config — highest)
1293    pub fn load_merged(project_dir: &Path) -> Self {
1294        let mut config = Self::built_in_defaults();
1295
1296        // Pre-pass: quick-load plugin sources from user/project configs (before full merge)
1297        // This avoids the chicken-and-egg problem: plugins contribute to config,
1298        // but we need config to know which plugins are active.
1299        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        // Layer: Global plugin linthis.toml (lowest plugin priority)
1307        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        // Layer: Project plugin linthis.toml (higher than global plugin)
1314        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        // Layer: User config (~/.linthis/config.toml) — overrides plugin
1321        if let Some(user_config) = Self::load_user_config() {
1322            config.merge(user_config);
1323        }
1324
1325        // Layer: Project config (.linthis/config.toml) — highest priority
1326        if let Some(project_config) = Self::load_project_config(project_dir) {
1327            config.merge(project_config);
1328        }
1329
1330        config
1331    }
1332
1333    /// Return the ordered list of config file paths that are actually loaded
1334    /// by [`load_merged`], from lowest to highest priority.
1335    ///
1336    /// Only paths that exist on disk are included.  Built-in defaults have no
1337    /// file path and are therefore not listed.
1338    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    /// Generate a default configuration file content
1397    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    /// Get the path for a new project config file
1457    pub fn project_config_path(project_dir: &Path) -> PathBuf {
1458        project_dir.join(".linthis").join("config.toml")
1459    }
1460}
1461
1462/// Format a TOML error with helpful context and suggestions
1463fn format_toml_error(path: &Path, content: &str, err: &toml::de::Error) -> String {
1464    let mut msg = format!("Invalid TOML in '{}'", path.display());
1465
1466    // Try to extract line information from the error message
1467    let err_str = err.to_string();
1468
1469    // Check for unknown field errors
1470    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            // Suggest similar field names
1475            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    // Try to extract line number from error message
1484    // TOML errors often include "at line X column Y" or similar patterns
1485    if let Some(line_info) = extract_line_from_error(&err_str) {
1486        msg.push_str(&format!("\n\nError at line {}", line_info));
1487
1488        // Show the problematic line if we can parse the line number
1489        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    // Add the original error message
1497    msg.push_str(&format!("\n\nDetails: {}", err));
1498
1499    // Add hint for common issues
1500    msg.push_str(&format!("\n\nHint: {}", get_toml_hint(&err_str)));
1501
1502    msg
1503}
1504
1505/// Extract line number from error message
1506fn extract_line_from_error(err_str: &str) -> Option<String> {
1507    // Pattern: "at line X" or "line X"
1508    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
1523/// Format a YAML error with helpful context
1524fn 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
1541/// Format a JSON error with helpful context
1542fn 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
1557/// Extract the unknown field name from a TOML error message
1558fn extract_unknown_field(err_str: &str) -> Option<String> {
1559    // Pattern: "unknown field `fieldname`"
1560    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
1569/// Find a similar field name using Levenshtein distance
1570fn 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        // Only suggest if the distance is small (max 3 edits)
1578        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/// Calculate Levenshtein distance between two strings
1588#[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
1627/// Get a helpful hint based on the error type
1628fn 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        // Short aliases normalise to the canonical full name.
1651        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        // Full names are passthrough.
1655        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        // Case-sensitive: uppercase rejected so surprises don't silently
1659        // parse differently than the documented `s/d/f` + full forms.
1660        assert_eq!(expand_fix_commit_mode_alias("S"), None);
1661        assert_eq!(expand_fix_commit_mode_alias("Squash"), None);
1662        // Other garbage also rejected.
1663        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        // Test that old field names (exclude, plugin) still work via serde aliases
1702        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        // Test that new field names (includes, excludes, plugins) work
1724        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        // Test that simplified language syntax [rust] works (instead of [language_overrides.rust])
1750        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        // Check Rust overrides
1766        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        // Check Python overrides
1772        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    // ==================== LanguageOverrides tests ====================
1779
1780    #[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        // Rust should be overridden
1810        assert!(base.rust.is_some());
1811        assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(20));
1812
1813        // Python should be preserved (not in other)
1814        assert!(base.python.is_some());
1815        assert_eq!(base.python.as_ref().unwrap().max_complexity, Some(10));
1816
1817        // Go should be added from other
1818        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        // Rust should be preserved
1836        assert!(base.rust.is_some());
1837        assert_eq!(base.rust.as_ref().unwrap().max_complexity, Some(15));
1838    }
1839
1840    // ==================== Config new/default tests ====================
1841
1842    #[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    // ==================== generate_default_toml tests ====================
1860
1861    #[test]
1862    fn test_generate_default_toml_is_valid() {
1863        let toml_content = Config::generate_default_toml();
1864        // Should be parseable as TOML
1865        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    // ==================== project_config_path tests ====================
1878
1879    #[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    // ==================== Config merge edge cases ====================
1890
1891    #[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        // Languages should be replaced, not merged
1908        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        // Empty languages should not override
1929        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        // Includes should be extended, not replaced
1948        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    // ==================== PluginConfig tests ====================
1967
1968    #[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); // Should default to true
1987    }
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    // ==================== SourceConfig tests ====================
2010
2011    #[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    // ==================== CppLanguageConfig tests ====================
2036
2037    #[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        // Test that 'objectivec' alias works for 'oc'
2101        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    // ==================== get_plugin_sources tests ====================
2112
2113    #[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_length priority chain tests ====================
2143
2144    /// Helper: create a fake plugin cache dir containing a linthis.toml.
2145    /// Returns (TempDir, plugin_url) where the URL maps to a subdirectory of the cache.
2146    fn setup_fake_plugin_cache(linthis_toml_content: &str) -> (tempfile::TempDir, String) {
2147        let cache_root = tempfile::TempDir::new().unwrap();
2148        // url_to_cache_path strips scheme and .git suffix:
2149        // "https://example.com/test-plugin.git" → "{cache_root}/example.com/test-plugin"
2150        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    /// Helper: create a temp project dir with .linthis/config.toml referencing a plugin.
2158    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    /// Serialize tests that read or mutate `LINTHIS_TEST_PLUGIN_CACHE_DIR`.
2174    ///
2175    /// Cargo runs tests in parallel by default. `Config::load_merged`
2176    /// reads this env var, so any two tests that race on it can clobber
2177    /// each other: test A sets `/A/path`, test B sets `/B/path`, test A
2178    /// calls `load_merged` and sees B's path, asserts on data that
2179    /// isn't there. Holding this mutex for the full lifetime of the
2180    /// guard restores test isolation.
2181    static PLUGIN_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2182
2183    /// RAII guard: lock the test mutex, set `LINTHIS_TEST_PLUGIN_CACHE_DIR`
2184    /// to `path` (or remove it on `new_unset`), restore the original value
2185    /// on drop. Even tests that don't *set* the env var should hold a
2186    /// guard, because they still *read* it via `Config::load_merged` and
2187    /// could otherwise see a sibling test's value.
2188    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        /// Acquire the lock and ensure the env var is unset for this test.
2207        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        // Plugin linthis.toml sets fn_length=60; default is 80.
2232        // After merging, fn_length should be 60 (plugin overrides built-in default).
2233        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        // Override cache dir so load_plugin_linthis_toml finds our fake plugin.
2238        // Guard serializes against sibling tests that touch the same env var.
2239        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        // Plugin sets fn_length=60; project config.toml sets fn_length=40.
2263        // Project config must win (highest priority).
2264        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        // No plugin active, no project config override → fn_length is None (checker uses default 80).
2293        //
2294        // Isolate from real plugin cache: point LINTHIS_TEST_PLUGIN_CACHE_DIR to
2295        // an empty temp dir so globally-installed plugins don't leak into the test.
2296        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        // No plugin, no config → None (CppChecker::new() will unwrap_or(80))
2303        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        // Only project config.toml sets fn_length=50; no plugin.
2317        // Hold the guard with the env var unset so a sibling test that
2318        // pointed it at a fake cache can't bleed into this read.
2319        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        // Directly test Config::merge() priority: project config wins over plugin config.
2352        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}