Skip to main content

agnix_core/config/
builder.rs

1use super::*;
2
3/// Builder for constructing a [`LintConfig`] with validation.
4///
5/// Uses the `&mut Self` return pattern (consistent with [`ValidatorRegistryBuilder`])
6/// for chaining setter calls, with a terminal `build()` that validates and returns
7/// `Result<LintConfig, ConfigError>`.
8///
9/// **Note:** `build()`, `build_lenient()`, and `build_unchecked()` drain the builder's state.
10/// A second call will produce a default config. Create a new builder if needed.
11///
12/// # Examples
13///
14/// ```rust
15/// use agnix_core::config::{LintConfig, SeverityLevel};
16///
17/// let config = LintConfig::builder()
18///     .severity(SeverityLevel::Error)
19///     .build()
20///     .expect("valid config");
21/// assert_eq!(config.severity(), SeverityLevel::Error);
22/// ```
23pub struct LintConfigBuilder {
24    severity: Option<SeverityLevel>,
25    rules: Option<RuleConfig>,
26    exclude: Option<Vec<String>>,
27    target: Option<TargetTool>,
28    tools: Option<Vec<String>>,
29    mcp_protocol_version: Option<Option<String>>,
30    tool_versions: Option<ToolVersions>,
31    spec_revisions: Option<SpecRevisions>,
32    files: Option<FilesConfig>,
33    locale: Option<Option<String>>,
34    max_files_to_validate: Option<Option<usize>>,
35    // Runtime
36    root_dir: Option<PathBuf>,
37    import_cache: Option<crate::parsers::ImportCache>,
38    fs: Option<Arc<dyn FileSystem>>,
39    disabled_rules: Vec<String>,
40    disabled_validators: Vec<String>,
41}
42
43impl LintConfigBuilder {
44    fn append_and_dedup(target: &mut Vec<String>, source: &mut Vec<String>) {
45        if source.is_empty() {
46            return;
47        }
48
49        target.append(source);
50        let mut seen = std::collections::HashSet::new();
51        target.retain(|item| seen.insert(item.clone()));
52    }
53
54    /// Create a new builder with all fields unset (defaults will be applied at build time).
55    ///
56    /// Prefer [`LintConfig::builder()`] over calling this directly.
57    fn new() -> Self {
58        Self {
59            severity: None,
60            rules: None,
61            exclude: None,
62            target: None,
63            tools: None,
64            mcp_protocol_version: None,
65            tool_versions: None,
66            spec_revisions: None,
67            files: None,
68            locale: None,
69            max_files_to_validate: None,
70            root_dir: None,
71            import_cache: None,
72            fs: None,
73            disabled_rules: Vec::new(),
74            disabled_validators: Vec::new(),
75        }
76    }
77
78    /// Set the severity level threshold.
79    pub fn severity(&mut self, severity: SeverityLevel) -> &mut Self {
80        self.severity = Some(severity);
81        self
82    }
83
84    /// Set the rules configuration.
85    pub fn rules(&mut self, rules: RuleConfig) -> &mut Self {
86        self.rules = Some(rules);
87        self
88    }
89
90    /// Set the exclude patterns.
91    pub fn exclude(&mut self, exclude: Vec<String>) -> &mut Self {
92        self.exclude = Some(exclude);
93        self
94    }
95
96    /// Set the target tool.
97    pub fn target(&mut self, target: TargetTool) -> &mut Self {
98        self.target = Some(target);
99        self
100    }
101
102    /// Set the tools list.
103    pub fn tools(&mut self, tools: Vec<String>) -> &mut Self {
104        self.tools = Some(tools);
105        self
106    }
107
108    /// Set the MCP protocol version (deprecated field).
109    pub fn mcp_protocol_version(&mut self, version: Option<String>) -> &mut Self {
110        self.mcp_protocol_version = Some(version);
111        self
112    }
113
114    /// Set the tool versions configuration.
115    pub fn tool_versions(&mut self, versions: ToolVersions) -> &mut Self {
116        self.tool_versions = Some(versions);
117        self
118    }
119
120    /// Set the spec revisions configuration.
121    pub fn spec_revisions(&mut self, revisions: SpecRevisions) -> &mut Self {
122        self.spec_revisions = Some(revisions);
123        self
124    }
125
126    /// Set the files configuration.
127    pub fn files(&mut self, files: FilesConfig) -> &mut Self {
128        self.files = Some(files);
129        self
130    }
131
132    /// Set the locale.
133    pub fn locale(&mut self, locale: Option<String>) -> &mut Self {
134        self.locale = Some(locale);
135        self
136    }
137
138    /// Set the maximum number of files to validate.
139    pub fn max_files_to_validate(&mut self, max: Option<usize>) -> &mut Self {
140        self.max_files_to_validate = Some(max);
141        self
142    }
143
144    /// Set the runtime validation root directory.
145    pub fn root_dir(&mut self, root_dir: PathBuf) -> &mut Self {
146        self.root_dir = Some(root_dir);
147        self
148    }
149
150    /// Set the shared import cache.
151    pub fn import_cache(&mut self, cache: crate::parsers::ImportCache) -> &mut Self {
152        self.import_cache = Some(cache);
153        self
154    }
155
156    /// Set the filesystem abstraction.
157    pub fn fs(&mut self, fs: Arc<dyn FileSystem>) -> &mut Self {
158        self.fs = Some(fs);
159        self
160    }
161
162    /// Add a rule ID to the disabled rules list.
163    pub fn disable_rule(&mut self, rule_id: impl Into<String>) -> &mut Self {
164        self.disabled_rules.push(rule_id.into());
165        self
166    }
167
168    /// Add a validator name to the disabled validators list.
169    pub fn disable_validator(&mut self, name: impl Into<String>) -> &mut Self {
170        self.disabled_validators.push(name.into());
171        self
172    }
173
174    /// Build the `LintConfig`, applying defaults for unset fields and
175    /// running validation.
176    ///
177    /// Returns `Err(ConfigError)` if:
178    /// - A glob pattern (in exclude or files config) has invalid syntax
179    /// - A glob pattern attempts path traversal (`../`)
180    /// - Configuration validation produces warnings (promoted to errors)
181    pub fn build(&mut self) -> Result<LintConfig, ConfigError> {
182        let config = self.build_inner();
183
184        Self::validate_patterns(&config)?;
185
186        // Run full config validation (unknown tools, deprecated fields, etc.)
187        let warnings = config.validate();
188        if !warnings.is_empty() {
189            return Err(ConfigError::ValidationFailed(warnings));
190        }
191
192        Ok(config)
193    }
194
195    /// Build the [`LintConfig`], running security-critical validation (glob
196    /// pattern syntax and path traversal checks) while skipping semantic
197    /// warnings such as unknown tool names, unknown rule ID prefixes, and
198    /// deprecated field warnings.
199    ///
200    /// Use this for embedders that need to accept future or unknown tool names
201    /// without rebuilding the library.
202    pub fn build_lenient(&mut self) -> Result<LintConfig, ConfigError> {
203        let config = self.build_inner();
204        Self::validate_patterns(&config)?;
205        Ok(config)
206    }
207
208    /// Build the `LintConfig` without running any validation.
209    ///
210    /// This is primarily intended for tests that need to construct configs
211    /// with intentionally invalid data. Only available in test builds or
212    /// when the `__internal_unchecked` feature is enabled.
213    #[cfg(any(test, feature = "__internal_unchecked"))]
214    #[doc(hidden)]
215    pub fn build_unchecked(&mut self) -> LintConfig {
216        self.build_inner()
217    }
218
219    /// Validate all glob pattern lists (exclude + files config) for syntax
220    /// and path traversal. This is the security-critical subset of validation
221    /// that `build_lenient()` and `build()` both enforce.
222    fn validate_patterns(config: &LintConfig) -> Result<(), ConfigError> {
223        let pattern_lists: &[(&str, &[String])] = &[
224            ("exclude", &config.data.exclude),
225            (
226                "files.include_as_memory",
227                &config.data.files.include_as_memory,
228            ),
229            (
230                "files.include_as_generic",
231                &config.data.files.include_as_generic,
232            ),
233            ("files.exclude", &config.data.files.exclude),
234        ];
235        for &(field, patterns) in pattern_lists {
236            for pattern in patterns {
237                let normalized = pattern.replace('\\', "/");
238                if let Err(e) = glob::Pattern::new(&normalized) {
239                    return Err(ConfigError::InvalidGlobPattern {
240                        pattern: pattern.clone(),
241                        error: format!("{} (in {})", e, field),
242                    });
243                }
244                if has_path_traversal(&normalized) {
245                    return Err(ConfigError::PathTraversal {
246                        pattern: pattern.clone(),
247                    });
248                }
249                if normalized.starts_with('/')
250                    || (normalized.len() >= 3
251                        && normalized.as_bytes()[0].is_ascii_alphabetic()
252                        && normalized.as_bytes().get(1..3) == Some(b":/"))
253                {
254                    return Err(ConfigError::AbsolutePathPattern {
255                        pattern: pattern.clone(),
256                    });
257                }
258            }
259        }
260        Ok(())
261    }
262
263    /// Internal: construct the LintConfig from builder state, applying defaults.
264    fn build_inner(&mut self) -> LintConfig {
265        let defaults = ConfigData::default();
266
267        let mut rules = self.rules.take().unwrap_or(defaults.rules);
268
269        // Apply convenience disabled_rules/disabled_validators.
270        Self::append_and_dedup(&mut rules.disabled_rules, &mut self.disabled_rules);
271        Self::append_and_dedup(
272            &mut rules.disabled_validators,
273            &mut self.disabled_validators,
274        );
275
276        let config_data = ConfigData {
277            severity: self.severity.take().unwrap_or(defaults.severity),
278            rules,
279            exclude: self.exclude.take().unwrap_or(defaults.exclude),
280            target: self.target.take().unwrap_or(defaults.target),
281            tools: self.tools.take().unwrap_or(defaults.tools),
282            mcp_protocol_version: self
283                .mcp_protocol_version
284                .take()
285                .unwrap_or(defaults.mcp_protocol_version),
286            tool_versions: self.tool_versions.take().unwrap_or(defaults.tool_versions),
287            spec_revisions: self
288                .spec_revisions
289                .take()
290                .unwrap_or(defaults.spec_revisions),
291            files: self.files.take().unwrap_or(defaults.files),
292            locale: self.locale.take().unwrap_or(defaults.locale),
293            max_files_to_validate: self
294                .max_files_to_validate
295                .take()
296                .unwrap_or(defaults.max_files_to_validate),
297        };
298
299        let mut config = LintConfig {
300            data: Arc::new(config_data),
301            runtime: RuntimeContext::default(),
302        };
303
304        // Apply runtime state
305        if let Some(root_dir) = self.root_dir.take() {
306            config.runtime.root_dir = Some(root_dir);
307        }
308        if let Some(cache) = self.import_cache.take() {
309            config.runtime.import_cache = Some(cache);
310        }
311        if let Some(fs) = self.fs.take() {
312            config.runtime.fs = fs;
313        }
314
315        config
316    }
317}
318
319impl Default for LintConfigBuilder {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325impl LintConfig {
326    /// Create a new [`LintConfigBuilder`] for constructing a `LintConfig`.
327    pub fn builder() -> LintConfigBuilder {
328        LintConfigBuilder::new()
329    }
330}