Skip to main content

agnix_core/
lib.rs

1// The if-let pattern `if config.is_rule_enabled("X") { if condition { ... } }`
2// is used intentionally throughout validators for readability.
3#![allow(clippy::collapsible_if)]
4
5//! # agnix-core
6//!
7//! Core validation engine for agent configurations.
8//!
9//! Validates:
10//! - Agent Skills (SKILL.md)
11//! - Agent definitions (.md files with frontmatter)
12//! - MCP tool configurations
13//! - Claude Code hooks
14//! - CLAUDE.md memory files
15//! - Plugin manifests
16//!
17//! This crate requires `std`. The `filesystem` Cargo feature (enabled by
18//! default) adds file I/O dependencies; disabling it does not enable `no_std`.
19//!
20//! ## Stability Tiers
21//!
22//! Public modules are classified into stability tiers:
23//!
24//! - **Stable** -- `config`, `diagnostics`, `fixes`, `fs`.
25//!   These modules follow semver: breaking changes require a major version bump.
26//! - **Unstable** -- `authoring`, `eval`, `i18n`, `validation`.
27//!   Interfaces may change on minor releases. Use with care in downstream crates.
28//! - **Internal** -- `parsers` (pub(crate)).
29//!   Not part of the public API. Some types are re-exported at the crate root
30//!   with `#[doc(hidden)]` for fuzz/bench/test use only.
31
32// Allow common test patterns that clippy flags but are intentional in tests
33#![cfg_attr(
34    test,
35    allow(
36        clippy::field_reassign_with_default,
37        clippy::len_zero,
38        clippy::useless_vec
39    )
40)]
41
42rust_i18n::i18n!("locales", fallback = "en");
43
44/// Skill authoring and scaffolding utilities.
45///
46/// **Stability: unstable** -- interface may change on minor releases.
47pub mod authoring;
48/// Lint configuration types and schema generation.
49///
50/// **Stability: stable** -- breaking changes require a major version bump.
51pub mod config;
52/// Diagnostic, severity, fix, and error types.
53///
54/// **Stability: stable** -- breaking changes require a major version bump.
55pub mod diagnostics;
56/// Rule efficacy evaluation (precision/recall/F1).
57///
58/// **Stability: unstable** -- interface may change on minor releases.
59#[cfg(feature = "filesystem")]
60pub mod eval;
61/// File type detection and extensible detector chain.
62///
63/// **Stability: unstable** -- interface may change on minor releases.
64pub mod file_types;
65mod file_utils;
66/// Auto-fix application engine.
67///
68/// **Stability: stable** -- breaking changes require a major version bump.
69pub mod fixes;
70/// Filesystem abstraction (real and mock).
71///
72/// **Stability: stable** -- breaking changes require a major version bump.
73pub mod fs;
74/// Internationalization helpers.
75///
76/// **Stability: unstable** -- interface may change on minor releases.
77pub mod i18n;
78/// Internal parsers (frontmatter, JSON, Markdown).
79///
80/// **Stability: internal** -- not part of the public API.
81pub(crate) mod parsers;
82mod pipeline;
83mod regex_util;
84mod registry;
85mod rules;
86mod schemas;
87pub(crate) mod span_utils;
88/// Validation registry and file-type detection.
89///
90/// **Stability: unstable** -- interface may change on minor releases.
91pub mod validation;
92
93pub use config::{ConfigWarning, FilesConfig, LintConfig, generate_schema};
94pub use diagnostics::{
95    ConfigError, CoreError, Diagnostic, DiagnosticLevel, FileError, Fix, FixConfidenceTier,
96    LintError, LintResult, RuleMetadata, ValidationError, ValidationOutcome,
97};
98pub use file_types::{FileType, detect_file_type};
99pub use file_types::{FileTypeDetector, FileTypeDetectorChain};
100pub use fixes::{
101    FixApplyMode, FixApplyOptions, FixResult, apply_fixes, apply_fixes_with_fs,
102    apply_fixes_with_fs_options, apply_fixes_with_options,
103};
104pub use fs::{FileSystem, MockFileSystem, RealFileSystem};
105pub use pipeline::{ValidationResult, resolve_file_type, validate_content};
106#[cfg(feature = "filesystem")]
107pub use pipeline::{
108    validate_file, validate_file_with_registry, validate_project, validate_project_rules,
109    validate_project_with_registry,
110};
111pub use registry::{
112    ValidatorFactory, ValidatorProvider, ValidatorRegistry, ValidatorRegistryBuilder,
113};
114pub use rules::{Validator, ValidatorMetadata};
115
116/// Normalize CRLF (`\r\n`) and lone CR (`\r`) line endings to LF (`\n`).
117///
118/// Returns `Cow::Borrowed` (zero allocation) when no `\r` is present.
119///
120/// **Stability: stable** - breaking changes require a major version bump.
121pub use parsers::frontmatter::normalize_line_endings;
122
123// Internal re-exports (not part of the stable API).
124// These types are needed by fuzz/bench/test targets or leak through LintConfig.
125// They are hidden from rustdoc and namespaced to discourage external use.
126// NOTE: normalize_line_endings is intentionally NOT re-exported here;
127// it is a stable crate-root export (see above).
128#[doc(hidden)]
129#[cfg(any(test, feature = "__internal"))]
130pub mod __internal {
131    pub use crate::parsers::ImportCache;
132    pub use crate::parsers::frontmatter::{FrontmatterParts, split_frontmatter};
133    pub use crate::parsers::json::parse_json_config;
134    pub use crate::parsers::markdown::Import;
135    pub use crate::parsers::markdown::{
136        MAX_REGEX_INPUT_SIZE, MarkdownLink, XmlTag, check_xml_balance,
137        check_xml_balance_with_content_end, extract_imports, extract_markdown_links,
138        extract_xml_tags, sanitize_for_pulldown_cmark,
139    };
140    pub use crate::schemas::cross_platform::is_instruction_file;
141}
142
143#[cfg(test)]
144mod i18n_tests {
145    use rust_i18n::t;
146    use std::sync::Mutex;
147
148    // Mutex to serialize i18n tests since set_locale is global state
149    static LOCALE_MUTEX: Mutex<()> = Mutex::new(());
150
151    /// Verify that English translations load correctly and are not raw keys.
152    #[test]
153    fn test_english_translations_load() {
154        let _lock = LOCALE_MUTEX.lock().unwrap();
155        rust_i18n::set_locale("en");
156
157        // Sample translations from each section
158        let xml_msg = t!("rules.xml_001.message", tag = "test");
159        assert!(
160            xml_msg.contains("Unclosed XML tag"),
161            "Expected English translation, got: {}",
162            xml_msg
163        );
164
165        let cli_validating = t!("cli.validating");
166        assert_eq!(cli_validating, "Validating:");
167
168        let lsp_label = t!("lsp.suggestion_label");
169        assert_eq!(lsp_label, "Suggestion:");
170    }
171
172    /// Verify that Spanish translations load correctly.
173    #[test]
174    fn test_spanish_translations_load() {
175        let _lock = LOCALE_MUTEX.lock().unwrap();
176        rust_i18n::set_locale("es");
177
178        let xml_msg = t!("rules.xml_001.message", tag = "test");
179        assert!(
180            xml_msg.contains("Etiqueta XML sin cerrar"),
181            "Expected Spanish translation, got: {}",
182            xml_msg
183        );
184
185        let cli_validating = t!("cli.validating");
186        assert_eq!(cli_validating, "Validando:");
187
188        rust_i18n::set_locale("en");
189    }
190
191    /// Verify that Chinese (Simplified) translations load correctly.
192    #[test]
193    fn test_chinese_translations_load() {
194        let _lock = LOCALE_MUTEX.lock().unwrap();
195        rust_i18n::set_locale("zh-CN");
196
197        let xml_msg = t!("rules.xml_001.message", tag = "test");
198        assert!(
199            xml_msg.contains("\u{672A}\u{5173}\u{95ED}"),
200            "Expected Chinese translation, got: {}",
201            xml_msg
202        );
203
204        let cli_validating = t!("cli.validating");
205        assert!(
206            cli_validating.contains("\u{6B63}\u{5728}\u{9A8C}\u{8BC1}"),
207            "Expected Chinese translation, got: {}",
208            cli_validating
209        );
210
211        rust_i18n::set_locale("en");
212    }
213
214    /// Verify that unsupported locale falls back to English.
215    #[test]
216    fn test_fallback_to_english() {
217        let _lock = LOCALE_MUTEX.lock().unwrap();
218        rust_i18n::set_locale("fr"); // French not supported
219
220        let msg = t!("cli.validating");
221        assert_eq!(
222            msg, "Validating:",
223            "Should fall back to English, got: {}",
224            msg
225        );
226
227        rust_i18n::set_locale("en");
228    }
229
230    /// Verify that translation keys with parameters resolve correctly.
231    #[test]
232    fn test_parameterized_translations() {
233        let _lock = LOCALE_MUTEX.lock().unwrap();
234        rust_i18n::set_locale("en");
235
236        let msg = t!("rules.as_004.message", name = "TestName");
237        assert!(
238            msg.contains("TestName"),
239            "Parameter should be interpolated, got: {}",
240            msg
241        );
242        assert!(
243            msg.contains("must be 1-64 characters"),
244            "Message template should be filled, got: {}",
245            msg
246        );
247    }
248
249    /// Verify that all supported locales have key sections.
250    #[test]
251    fn test_available_locales() {
252        let locales = rust_i18n::available_locales!();
253        assert!(
254            locales.contains(&"en"),
255            "English locale must be available, found: {:?}",
256            locales
257        );
258        assert!(
259            locales.contains(&"es"),
260            "Spanish locale must be available, found: {:?}",
261            locales
262        );
263        assert!(
264            locales.contains(&"zh-CN"),
265            "Chinese locale must be available, found: {:?}",
266            locales
267        );
268    }
269
270    /// Verify that rule IDs are never translated (they stay as-is in diagnostics).
271    #[test]
272    fn test_rule_ids_not_translated() {
273        use super::*;
274        use std::path::Path;
275
276        let _lock = LOCALE_MUTEX.lock().unwrap();
277        rust_i18n::set_locale("es"); // Spanish
278
279        let config = config::LintConfig::default();
280        let content = "---\nname: test\n---\nSome content";
281        let path = Path::new("test/.claude/skills/test/SKILL.md");
282
283        let validator = rules::skill::SkillValidator;
284        let diagnostics = validator.validate(path, content, &config);
285
286        // Rule IDs should always be in English format
287        for diag in &diagnostics {
288            assert!(
289                diag.rule.is_ascii(),
290                "Rule ID should be ASCII: {}",
291                diag.rule
292            );
293        }
294
295        rust_i18n::set_locale("en");
296    }
297
298    /// Verify that Spanish locale produces localized diagnostic messages.
299    #[test]
300    fn test_spanish_diagnostics() {
301        use super::*;
302        use std::path::Path;
303
304        let _lock = LOCALE_MUTEX.lock().unwrap();
305        rust_i18n::set_locale("es");
306
307        let config = config::LintConfig::default();
308        let content = "<unclosed>";
309        let path = Path::new("test/CLAUDE.md");
310
311        let validator = rules::xml::XmlValidator;
312        let diagnostics = validator.validate(path, content, &config);
313
314        assert!(!diagnostics.is_empty(), "Should produce diagnostics");
315        let xml_diag = diagnostics.iter().find(|d| d.rule == "XML-001").unwrap();
316        assert!(
317            xml_diag.message.contains("Etiqueta XML sin cerrar"),
318            "Message should be in Spanish, got: {}",
319            xml_diag.message
320        );
321
322        rust_i18n::set_locale("en");
323    }
324
325    /// Verify that new suggestion locale keys from #323 resolve to real text.
326    #[test]
327    fn test_new_suggestion_keys_resolve() {
328        let _lock = LOCALE_MUTEX.lock().unwrap();
329        rust_i18n::set_locale("en");
330
331        // Parse error suggestions added in #323
332        macro_rules! assert_key_resolves {
333            ($key:expr) => {
334                let value = t!($key);
335                assert!(
336                    !value.starts_with("rules."),
337                    "Locale key '{}' should resolve to text, not raw key path: {}",
338                    $key,
339                    value
340                );
341            };
342        }
343        assert_key_resolves!("rules.as_016.suggestion");
344        assert_key_resolves!("rules.cc_hk_012.suggestion");
345        assert_key_resolves!("rules.mcp_007.suggestion");
346        assert_key_resolves!("rules.cc_pl_006.suggestion");
347        assert_key_resolves!("rules.cc_ag_007.parse_error_suggestion");
348        assert_key_resolves!("rules.cdx_000.suggestion");
349        assert_key_resolves!("rules.file_read_error_suggestion");
350        assert_key_resolves!("rules.xp_004_read_error_suggestion");
351
352        // CDX-000 message (migrated from format!() to t!())
353        let cdx_msg = t!("rules.cdx_000.message", error = "test error");
354        assert!(
355            cdx_msg.contains("test error"),
356            "CDX-000 message should interpolate error param, got: {}",
357            cdx_msg
358        );
359
360        // file::read and XP-004 read error messages
361        let file_msg = t!("rules.file_read_error", error = "permission denied");
362        assert!(
363            file_msg.contains("permission denied"),
364            "file_read_error should interpolate error param, got: {}",
365            file_msg
366        );
367        let xp_msg = t!("rules.xp_004_read_error", error = "not found");
368        assert!(
369            xp_msg.contains("not found"),
370            "xp_004_read_error should interpolate error param, got: {}",
371            xp_msg
372        );
373    }
374
375    /// Verify that keys across all sections resolve to human-readable text,
376    /// not raw key paths. This catches the bug where i18n!() path resolution
377    /// fails and t!() silently returns the key itself.
378    #[test]
379    fn test_keys_resolve_to_text_not_raw_paths() {
380        let _lock = LOCALE_MUTEX.lock().unwrap();
381        rust_i18n::set_locale("en");
382
383        // Helper: assert a key resolves to real text (not the key path itself)
384        macro_rules! assert_not_raw_key {
385            ($key:expr) => {
386                let value = t!($key);
387                assert!(
388                    (!value.starts_with("rules."))
389                        && (!value.starts_with("cli."))
390                        && (!value.starts_with("lsp."))
391                        && (!value.starts_with("core.")),
392                    "Key '{}' returned raw path instead of translated text: {}",
393                    $key,
394                    value
395                );
396            };
397            ($key:expr, $($param:ident = $val:expr),+) => {
398                let value = t!($key, $($param = $val),+);
399                assert!(
400                    (!value.starts_with("rules."))
401                        && (!value.starts_with("cli."))
402                        && (!value.starts_with("lsp."))
403                        && (!value.starts_with("core.")),
404                    "Key '{}' returned raw path instead of translated text: {}",
405                    $key,
406                    value
407                );
408            };
409        }
410
411        // Rules section - sample messages from different validators
412        assert_not_raw_key!("rules.as_001.message");
413        assert_not_raw_key!("rules.as_004.message", name = "test");
414        assert_not_raw_key!("rules.cc_ag_009.message", tool = "x", known = "y");
415        assert_not_raw_key!("rules.xml_001.message", tag = "div");
416        assert_not_raw_key!("rules.cc_hk_001.message");
417        assert_not_raw_key!("rules.pe_003.message");
418        assert_not_raw_key!("rules.cc_mem_009.message");
419
420        // Rules section - suggestions
421        assert_not_raw_key!("rules.as_001.suggestion");
422        assert_not_raw_key!("rules.as_004.suggestion");
423        assert_not_raw_key!("rules.cc_ag_009.suggestion");
424
425        // Rules section - assumptions
426        assert_not_raw_key!("rules.cc_hk_010.assumption");
427        assert_not_raw_key!("rules.mcp_008.assumption");
428
429        // CLI section
430        assert_not_raw_key!("cli.validating");
431        assert_not_raw_key!("cli.no_issues_found");
432        assert_not_raw_key!(
433            "cli.found_errors_warnings",
434            errors = "1",
435            error_word = "error",
436            warnings = "0",
437            warning_word = "warnings"
438        );
439
440        // LSP section
441        assert_not_raw_key!("lsp.suggestion_label");
442
443        // Core section
444        assert_not_raw_key!("core.error.file_read", path = "/tmp/test");
445    }
446}