skill-veil-core 0.2.0

Core library for skill-veil behavioral analysis
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
//! Rule engine for detecting security signals in skills
//!
//! Provides declarative rule definitions and evaluation logic for analyzing
//! skill documents. Rules are defined declaratively in YAML and can detect
//! patterns using regex, section content matching, or code block language detection.
//!
//! # Example
//!
//! ```
//! use skill_veil_core::rules::{default_external_rule_dirs, RuleEngine};
//! use skill_veil_core::analyzer::SkillDocument;
//! use skill_veil_core::adapters::{
//!     PulldownMarkdownParser, RegexPatternMatcher, StdFileSystemProvider,
//! };
//! use std::path::PathBuf;
//! use std::sync::Arc;
//!
//! // Compose adapters at the application boundary, then hand them to the
//! // domain layer through the injected ports.
//! let fs = StdFileSystemProvider::new();
//! let runtime_dirs = default_external_rule_dirs();
//! let engine = RuleEngine::with_defaults_and_matcher(
//!     Arc::new(RegexPatternMatcher::new()),
//!     &fs,
//!     &runtime_dirs,
//! )
//! .unwrap();
//! assert!(engine.rule_count() > 0);
//!
//! // Parse a skill document
//! let parser = PulldownMarkdownParser::new();
//! let doc = SkillDocument::parse_with_parser(
//!     PathBuf::from("test.md"),
//!     "# My Skill\n\n## Setup\n```bash\necho hello\n```".to_string(),
//!     &parser,
//! ).unwrap();
//!
//! // Evaluate rules against the document
//! let findings = engine.evaluate(&doc);
//! ```

mod builtin;
mod compiled;
mod condition;
mod ioc;
mod parser;
mod schema;

use crate::ports::{FileSystemError, FileSystemProvider, MarkdownParser, PatternMatcher};
use sha2::{Digest, Sha256};
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
use tracing::warn;

pub use compiled::CompiledRule;
pub use condition::RuleCondition;
pub use parser::{default_external_rule_dirs, is_supported_rule_pack_schema, parse_rules_file};
pub use schema::{IocFeedFile, Rule, RulePackFile, RulePackKind, RulePackMetadata, ShieldHint};

/// Versioned schema string for external rule packs.
pub const RULE_PACK_SCHEMA_VERSION: &str = "skill-veil.dev/rules/v1alpha1";

/// Default confidence score for rules (0.0 - 1.0)
pub const DEFAULT_RULE_CONFIDENCE: f32 = 0.9;

/// Error type for rule operations
///
/// Encapsulates errors that can occur during rule loading, compilation,
/// and evaluation.
#[derive(Error, Debug)]
pub enum RuleError {
    /// Failed to load rules from a file or directory
    #[error("Failed to load rules: {0}")]
    LoadError(String),
    /// Rule configuration is invalid
    #[error("Invalid rule configuration: {0}")]
    InvalidRule(String),
    /// Failed to compile a pattern through the matcher port
    #[error("Pattern compilation failed: {0}")]
    PatternError(#[from] crate::ports::PatternError),
    /// Failed to parse YAML rule file
    #[error("YAML parsing error: {0}")]
    YamlError(#[from] serde_yaml::Error),
    /// I/O error during file operations
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),
    /// Two embedded built-in rule packs define the same rule id with
    /// divergent content. This is always a developer bug in the source YAML
    /// and must not be silently deduplicated at runtime.
    #[error(
        "Duplicate built-in rule id `{id}` in `{first}` and `{second}` — \
         remove or rename one of the definitions"
    )]
    DuplicateBuiltinRule {
        id: String,
        first: String,
        second: String,
    },
    /// A user-supplied rule pack declared a rule id that collides with an
    /// already-loaded rule. Only surfaced when strict mode is enabled.
    #[error(
        "Duplicate external rule id `{id}` in `{path}` — \
         already loaded; rename or remove the duplicate (strict mode)"
    )]
    DuplicateUserRule { id: String, path: String },
    /// External rule pack body's SHA-256 digest does not match the value
    /// recorded in the `<pack>.sha256` sidecar. The pack is rejected to
    /// prevent silently loading tampered rules.
    #[error(
        "Rule pack `{path}` failed integrity check: \
         expected sha256 `{expected}`, computed `{actual}` — \
         the pack body changed since the sidecar was issued; \
         re-issue the sidecar or revert the body"
    )]
    ChecksumMismatch {
        path: String,
        expected: String,
        actual: String,
    },
    /// External rule pack has no `<pack>.sha256` sidecar and the engine is
    /// running with `ChecksumPolicy::Required`. Operators who want to load
    /// unsigned packs (development, ad-hoc tooling) can opt out via
    /// `set_checksum_policy(ChecksumPolicy::Lenient)` or
    /// `ChecksumPolicy::WarnOnMissing`.
    #[error(
        "Rule pack `{path}` has no sha256 sidecar and ChecksumPolicy::Required \
         is in effect — generate `{path}.sha256` containing the hex digest \
         of the pack body"
    )]
    MissingChecksum { path: String },
}

/// Suffix appended to a rule pack path to locate its SHA-256 sidecar.
/// `<pack>.yaml` therefore resolves to `<pack>.yaml.sha256`. Mirrors the
/// `sha256sum` convention so operators can issue and verify sidecars
/// with stock tooling: `sha256sum pack.yaml > pack.yaml.sha256`.
const RULE_PACK_CHECKSUM_SUFFIX: &str = ".sha256";

/// Compute the SHA-256 hex digest of `bytes`. Used for both the
/// integrity verification and the regression tests that pin the sidecar
/// format. Pure; no allocation beyond the returned string.
fn sha256_hex_of(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

/// Parse the body of a `.sha256` sidecar. Accepts both the bare-digest
/// form (`<hex>\n`) and the canonical `sha256sum` form (`<hex>  <name>\n`)
/// — the latter is what stock `sha256sum > pack.yaml.sha256` produces.
/// Returns `None` if no plausible 64-char hex digest is found.
fn parse_checksum_sidecar(body: &str) -> Option<String> {
    let first_token = body.split_whitespace().next()?;
    if first_token.len() == 64 && first_token.chars().all(|c| c.is_ascii_hexdigit()) {
        Some(first_token.to_ascii_lowercase())
    } else {
        None
    }
}

/// Verify a rule pack body against its sidecar according to `policy`.
///
/// - [`ChecksumPolicy::Lenient`]: never reads the sidecar, never fails.
/// - [`ChecksumPolicy::WarnOnMissing`]: if the sidecar exists, verify;
///   if it is missing, emit a `tracing::warn!` and continue.
/// - [`ChecksumPolicy::Required`]: the sidecar MUST exist and match;
///   any other state surfaces as `RuleError::MissingChecksum` or
///   `RuleError::ChecksumMismatch`.
fn verify_pack_checksum<F: FileSystemProvider>(
    fs: &F,
    pack_path: &Path,
    body: &[u8],
    policy: ChecksumPolicy,
) -> Result<(), RuleError> {
    if matches!(policy, ChecksumPolicy::Lenient) {
        return Ok(());
    }
    let sidecar_path = {
        let mut buf = pack_path.as_os_str().to_os_string();
        buf.push(RULE_PACK_CHECKSUM_SUFFIX);
        std::path::PathBuf::from(buf)
    };
    let sidecar_bytes = match fs.read_file_bytes(&sidecar_path) {
        Ok(bytes) => bytes,
        Err(FileSystemError::PathNotFound(_)) => match policy {
            ChecksumPolicy::Required => {
                return Err(RuleError::MissingChecksum {
                    path: pack_path.display().to_string(),
                });
            }
            ChecksumPolicy::WarnOnMissing => {
                warn!(
                    pack = %pack_path.display(),
                    sidecar = %sidecar_path.display(),
                    "rule pack loaded without integrity verification — \
                     issue a `<pack>.sha256` sidecar to silence this warning"
                );
                return Ok(());
            }
            ChecksumPolicy::Lenient => unreachable!("handled above"),
        },
        Err(FileSystemError::IoError(io)) => return Err(RuleError::IoError(io)),
    };
    let sidecar_text = String::from_utf8(sidecar_bytes.as_bytes().to_vec()).map_err(|err| {
        RuleError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, err))
    })?;
    let expected = parse_checksum_sidecar(&sidecar_text).ok_or_else(|| {
        RuleError::IoError(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
                "rule pack sidecar `{}` does not contain a 64-char hex SHA-256 digest",
                sidecar_path.display()
            ),
        ))
    })?;
    let actual = sha256_hex_of(body);
    if expected != actual {
        return Err(RuleError::ChecksumMismatch {
            path: pack_path.display().to_string(),
            expected,
            actual,
        });
    }
    Ok(())
}

/// Verification policy applied to external rule pack bodies during
/// `load_rules_file`. The default — [`ChecksumPolicy::WarnOnMissing`] —
/// emits a `tracing::warn!` when a pack ships without a `<path>.sha256`
/// sidecar but does not block the load. Operators running production
/// scans against untrusted rule directories should flip to
/// [`ChecksumPolicy::Required`] to enforce integrity verification at the
/// boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChecksumPolicy {
    /// Skip integrity verification entirely; do not warn on missing sidecars.
    /// Use only for built-in / embedded packs that the binary itself ships.
    Lenient,
    /// Verify the sidecar when present; emit `tracing::warn!` when absent.
    /// Default for runtime overlays so operators can incrementally adopt
    /// signed packs without breaking existing deployments.
    WarnOnMissing,
    /// Verify the sidecar when present; reject the pack if the sidecar is
    /// missing. Recommended for production scans against rule directories
    /// that any user can write to.
    Required,
}

/// Rule engine for loading and evaluating rules
///
/// The engine is generic over the pattern matcher implementation, allowing
/// different matching strategies to be used (regex, literal, etc.).
///
/// # Example
///
/// ```
/// use skill_veil_core::rules::{default_external_rule_dirs, RuleEngine};
/// use skill_veil_core::adapters::{RegexPatternMatcher, StdFileSystemProvider};
/// use std::sync::Arc;
///
/// // Compose adapters at the application boundary; the engine receives
/// // them through the injected ports.
/// let fs = StdFileSystemProvider::new();
/// let runtime_dirs = default_external_rule_dirs();
/// let engine = RuleEngine::with_defaults_and_matcher(
///     Arc::new(RegexPatternMatcher::new()),
///     &fs,
///     &runtime_dirs,
/// )
/// .unwrap();
/// assert!(engine.rule_count() > 0);
/// ```
pub struct RuleEngine<M: PatternMatcher> {
    rules: Vec<CompiledRule>,
    rules_dir: Option<std::path::PathBuf>,
    matcher: Arc<M>,
    /// When true, `load_rules_file` / `add_rule` return
    /// `RuleError::DuplicateUserRule` on an id collision instead of logging
    /// a `warn!()` and skipping. Default: **true** as of round-5 hardening.
    ///
    /// # Why strict by default
    ///
    /// The previous lenient default meant that an external pack with an ID
    /// colliding with a built-in (or with another loaded pack) was silently
    /// dropped with only a `tracing::warn!()` line. Maintainers writing
    /// override packs in `rules/official/` would have no visible signal
    /// that their rule was discarded — they had to grep logs at runtime.
    /// Strict-by-default surfaces the collision at load time as a hard
    /// error with file path context, matching how `cargo` treats duplicate
    /// crate names and how `eslint` treats duplicate rule definitions.
    ///
    /// Pre-flight: `comm` of `rules/official/*.yaml` IDs against
    /// `builtin_rules.yaml` IDs at the time of the flip showed 0
    /// collisions, so flipping the default does not break the canonical
    /// distribution.
    ///
    /// # Opt-out
    ///
    /// Callers who *intentionally* want the silent-skip behaviour (e.g.
    /// experimental tooling that loads many overlapping packs) must call
    /// `set_strict_mode(false)` explicitly. The opt-out is preserved so
    /// no consumer is forced to rename rules unilaterally.
    strict_mode: bool,
    /// Integrity verification policy for external rule pack bodies. See
    /// [`ChecksumPolicy`] for the three modes. Default is
    /// `ChecksumPolicy::WarnOnMissing` so operators are informed about
    /// unverified packs without breaking existing deployments that have
    /// not yet shipped sidecars.
    checksum_policy: ChecksumPolicy,
}

impl<M: PatternMatcher> RuleEngine<M> {
    /// Create a new rule engine with a custom pattern matcher.
    #[must_use]
    pub fn with_matcher(matcher: Arc<M>) -> Self {
        Self {
            rules: Vec::new(),
            rules_dir: None,
            matcher,
            strict_mode: true,
            checksum_policy: ChecksumPolicy::WarnOnMissing,
        }
    }

    /// Override the integrity verification policy for external rule
    /// pack bodies. See [`ChecksumPolicy`] for the three modes. Default
    /// is `WarnOnMissing`.
    pub fn set_checksum_policy(&mut self, policy: ChecksumPolicy) {
        self.checksum_policy = policy;
    }

    /// Toggle strict mode. When enabled, loading an external pack with a
    /// duplicate rule id returns `RuleError::DuplicateUserRule` instead of
    /// emitting a `tracing::warn!()` and skipping.
    pub fn set_strict_mode(&mut self, strict: bool) {
        self.strict_mode = strict;
    }

    /// Create a rule engine with built-in rules plus an optional runtime
    /// overlay loaded through the injected `FileSystemProvider`.
    ///
    /// # Load order contract
    ///
    /// Built-in rules are loaded first, runtime overrides second. The
    /// non-strict duplicate-skip means inverting the order would silently
    /// discard canonical detections.
    ///
    /// # Hexagonal boundary
    ///
    /// `runtime_overlay_fs` and `runtime_overlay_dirs` are injected so the
    /// domain layer never instantiates a concrete adapter. Production
    /// callers compose them in the application layer (typically
    /// `Scanner::with_std_adapters`) by pairing `StdFileSystemProvider`
    /// with `default_external_rule_dirs()`.
    #[must_use = "RuleEngine::with_defaults_and_matcher() returns a Result that should be used"]
    pub fn with_defaults_and_matcher<F: FileSystemProvider>(
        matcher: Arc<M>,
        runtime_overlay_fs: &F,
        runtime_overlay_dirs: &[std::path::PathBuf],
    ) -> Result<Self, RuleError> {
        let mut engine = Self::with_matcher(matcher);
        engine.load_builtin_rules()?;
        engine.load_runtime_default_rules(runtime_overlay_fs, runtime_overlay_dirs)?;
        Ok(engine)
    }

    fn load_builtin_rules(&mut self) -> Result<(), RuleError> {
        for rule in builtin::get_builtin_rules()? {
            self.add_rule(rule)?;
        }
        Ok(())
    }

    /// Load rules from a directory through a `FileSystemProvider`. Going
    /// through the port preserves the hexagonal contract: this loader
    /// reads YAML rule packs from disk, but the domain layer never
    /// reaches `std::fs` directly.
    pub fn load_from_dir<F: FileSystemProvider>(
        &mut self,
        fs: &F,
        dir: impl AsRef<Path>,
    ) -> Result<(), RuleError> {
        let dir = dir.as_ref();
        self.rules_dir = Some(dir.to_path_buf());

        for pattern in &["*.yaml", "*.yml"] {
            let paths = fs.list_files(dir, pattern, true).map_err(|err| match err {
                FileSystemError::IoError(io) => RuleError::IoError(io),
                FileSystemError::PathNotFound(missing) => RuleError::IoError(std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    format!("path not found: {}", missing.display()),
                )),
            })?;
            for path in paths {
                self.load_rules_file(fs, &path)?;
            }
        }

        Ok(())
    }

    /// Load rules from a YAML file.
    ///
    /// In **strict mode** (default — see `RuleEngine.strict_mode` doc-comment
    /// for rationale), an ID that collides with an already-loaded rule
    /// (built-in or earlier-loaded external) returns
    /// `RuleError::DuplicateUserRule { id, path }`. The pre-flight at the
    /// time of the round-5 strict-mode flip showed 0 collisions between
    /// the embedded `builtin_rules.yaml` and the `rules/official/` packs.
    ///
    /// Callers that intentionally want the legacy "warn-and-skip" behaviour
    /// (e.g. tooling that loads many overlapping experimental packs) must
    /// opt out via `set_strict_mode(false)`.
    pub fn load_rules_file<F: FileSystemProvider>(
        &mut self,
        fs: &F,
        path: impl AsRef<Path>,
    ) -> Result<(), RuleError> {
        let bytes = fs.read_file_bytes(path.as_ref()).map_err(|err| match err {
            FileSystemError::IoError(io) => RuleError::IoError(io),
            FileSystemError::PathNotFound(missing) => RuleError::IoError(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("path not found: {}", missing.display()),
            )),
        })?;
        verify_pack_checksum(fs, path.as_ref(), bytes.as_bytes(), self.checksum_policy)?;
        let content = String::from_utf8(bytes.as_bytes().to_vec()).map_err(|err| {
            RuleError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, err))
        })?;
        for rule in parse_rules_file(&content)? {
            let compiled = CompiledRule::compile(rule)?;
            if self
                .rules
                .iter()
                .any(|existing| existing.rule.id == compiled.rule.id)
            {
                if self.strict_mode {
                    return Err(RuleError::DuplicateUserRule {
                        id: compiled.rule.id.clone(),
                        path: path.as_ref().display().to_string(),
                    });
                }
                warn!(
                    rule_id = %compiled.rule.id,
                    path = %path.as_ref().display(),
                    "skipping duplicate rule ID (existing rule takes priority)"
                );
            } else {
                self.rules.push(compiled);
            }
        }

        Ok(())
    }

    /// Add a single rule.
    ///
    /// Skips the rule if one with the same ID already exists.
    pub fn add_rule(&mut self, rule: Rule) -> Result<(), RuleError> {
        let compiled = CompiledRule::compile(rule)?;
        if self
            .rules
            .iter()
            .any(|existing| existing.rule.id == compiled.rule.id)
        {
            if self.strict_mode {
                return Err(RuleError::DuplicateUserRule {
                    id: compiled.rule.id.clone(),
                    path: "<programmatic add_rule>".to_string(),
                });
            }
            warn!(
                rule_id = %compiled.rule.id,
                "skipping duplicate rule ID (existing rule takes priority)"
            );
        } else {
            self.rules.push(compiled);
        }
        Ok(())
    }

    /// Get all loaded rules.
    pub fn rules(&self) -> Vec<&Rule> {
        self.rules.iter().map(|cr| &cr.rule).collect()
    }

    /// Evaluate all rules against a document.
    pub fn evaluate(&self, doc: &crate::analyzer::SkillDocument) -> Vec<crate::findings::Finding> {
        let mut all_findings = Vec::new();

        for compiled_rule in &self.rules {
            let findings = compiled_rule.matches(doc, self.matcher.as_ref());
            all_findings.extend(findings);
        }

        all_findings
    }

    /// Get rule count.
    pub fn rule_count(&self) -> usize {
        self.rules.len()
    }

    /// Test a rule against sample content.
    ///
    /// The caller injects the `MarkdownParser` adapter so the domain layer
    /// stays free of concrete adapter dependencies. Production callers in
    /// the CLI pass `&PulldownMarkdownParser::new()`; tests pass whichever
    /// parser their fixture exercises.
    pub fn test_rule(
        &self,
        rule_id: &str,
        content: &str,
        parser: &dyn MarkdownParser,
    ) -> Result<Vec<crate::findings::Finding>, RuleError> {
        let doc = crate::analyzer::SkillDocument::parse_with_parser(
            std::path::PathBuf::from("test.md"),
            content.to_string(),
            parser,
        )
        .map_err(|e| RuleError::InvalidRule(e.to_string()))?;

        let findings = self
            .rules
            .iter()
            .filter(|cr| cr.rule.id == rule_id)
            .flat_map(|cr| cr.matches(&doc, self.matcher.as_ref()))
            .collect();

        Ok(findings)
    }

    /// Load runtime overlay rule directories through the injected
    /// `FileSystemProvider`. Each directory is loaded only if it exists;
    /// non-existent paths are skipped silently so callers can pass a
    /// canonical list (`default_external_rule_dirs()`) regardless of
    /// whether the overlay is present in the current working directory.
    ///
    /// # Why strict mode is forced off
    ///
    /// The runtime overlay is a *development* copy of the embedded packs
    /// at `crates/skill-veil-core/resources/official/`. When the binary
    /// runs from the repo root (CI, `cargo run`, local dev) the overlay
    /// paths happen to resolve and re-introduce IDs already loaded from
    /// the embedded packs. Strict mode would surface those overlaps as
    /// `DuplicateUserRule` and abort startup. The intent of the overlay
    /// is "skip duplicates; the embedded canonical version wins", so we
    /// run this stage with strict mode forced off and restore the
    /// caller's preference afterwards. Callers passing `--rules-dir` go
    /// through `load_from_dir` directly and keep whatever strict setting
    /// `set_strict_mode` last applied.
    fn load_runtime_default_rules<F: FileSystemProvider>(
        &mut self,
        fs: &F,
        dirs: &[std::path::PathBuf],
    ) -> Result<bool, RuleError> {
        self.with_strict_mode(false, |engine| {
            let mut loaded = false;
            for dir in dirs {
                if fs.exists(dir) {
                    engine.load_from_dir(fs, dir)?;
                    loaded = true;
                }
            }
            Ok(loaded)
        })
    }

    /// Run `f` with `self.strict_mode` temporarily set to `temporary`,
    /// restoring the previous value before returning. The closure receives
    /// `&mut self` so it can call existing `&mut self` methods that consult
    /// `strict_mode` (e.g. `load_from_dir` → `add_rule`) and observe the
    /// override.
    ///
    /// # Why a helper instead of inline mutation
    ///
    /// The previous implementation inlined `std::mem::replace` plus a
    /// post-loop restore in the caller. Co-locating the override window
    /// here makes the contract a named operation ("run this block with
    /// `strict=false`") instead of an open-coded mutation pattern, in
    /// keeping with the CLAUDE.md guidance to prefer explicit inputs
    /// over hidden state. The restore happens on both success and error
    /// paths, mirroring the previous behaviour.
    fn with_strict_mode<R>(
        &mut self,
        temporary: bool,
        f: impl FnOnce(&mut Self) -> Result<R, RuleError>,
    ) -> Result<R, RuleError> {
        let previous = std::mem::replace(&mut self.strict_mode, temporary);
        let result = f(self);
        self.strict_mode = previous;
        result
    }
}

#[cfg(test)]
mod tests;