Skip to main content

lanekeep_testkit/
lib.rs

1//! Fixture-based rule testing harness for lanekeep.
2//!
3//! `RuleTester`: the harness every rule is tested through.
4//!
5//! This is not optional infrastructure. Without it, community rule contributions are
6//! unreviewable — a reviewer cannot tell a rule that works from one that happens not to
7//! have been tried on the case it gets wrong.
8//!
9//! # It runs the real path
10//!
11//! A tester builds a throwaway project on disk and runs the actual engine over it: real
12//! config loading, real gates, real sandbox, real query matching. Nothing is stubbed.
13//!
14//! That costs more than calling a handler directly, and buys the thing that matters. A rule
15//! can be correct in isolation and still never fire because its gate excludes the file, its
16//! query does not compile against the language it named, or its card fails validation. A
17//! harness that skipped those would pass rules that do nothing.
18//!
19//! # Usage
20//!
21//! ```no_run
22//! use lanekeep_testkit::RuleTester;
23//!
24//! let tester = RuleTester::new("no-debugger", RULE_SOURCE).expect("builds");
25//! tester.accepts("const a = 1;").expect("clean code passes");
26//! tester.reports_at("debugger;", &[(1, 1)]).expect("violations are found");
27//! # const RULE_SOURCE: &str = "";
28//! ```
29
30use std::fmt::Write as _;
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33use std::sync::atomic::{AtomicU64, Ordering};
34
35use lanekeep_core::Violation;
36use lanekeep_engine::Engine;
37use lanekeep_js::{BuiltinComponent, BuiltinComponentMap, BuiltinSource, RuleRoot};
38use lanekeep_lang_js::{JavaScript, TypeScript};
39use thiserror::Error;
40
41/// Why a rule test could not run, or did not hold.
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum TestError {
44    /// The harness could not set up its temporary project.
45    #[error("could not set up the rule test: {0}")]
46    Setup(String),
47
48    /// The rule or its config failed to load.
49    ///
50    /// Distinct from a failed assertion: the rule never ran, so nothing was proven either
51    /// way, and reporting it as "no violations found" would be actively misleading.
52    #[error("rule failed to load:\n{0}")]
53    Load(String),
54
55    /// The run aborted — a rule threw, or breached a budget.
56    #[error("rule failed while running:\n{0}")]
57    Run(String),
58
59    /// The rule reported something other than what was expected.
60    #[error("{0}")]
61    Mismatch(String),
62}
63
64/// Distinguishes testers built in the same process.
65static NEXT_ID: AtomicU64 = AtomicU64::new(0);
66
67/// A rule under test, with a throwaway project to run it in.
68///
69/// The project is removed when the tester is dropped.
70#[derive(Debug)]
71pub struct RuleTester {
72    dir: PathBuf,
73    extension: String,
74    /// The config file the project was written with, relative to [`RuleTester::dir`].
75    ///
76    /// A field rather than a constant because a component tester writes a `lanekeep.json` and a
77    /// source tester writes a `lanekeep.config.ts` — see [`RuleTester::for_component`] for why
78    /// that is forced rather than chosen.
79    config: &'static str,
80    /// How `lanekeep/<name>` resolves to a component, for a tester built by
81    /// [`RuleTester::for_built_in`]. [`no_components`] for every other constructor, which is
82    /// what [`RuleRoot::new`] starts with anyway.
83    components: BuiltinComponent,
84    /// The matching source-map lookup, on the same terms.
85    component_maps: BuiltinComponentMap,
86    /// How `lanekeep/<name>` resolves to a built-in module's source — `lanekeep/patterns`
87    /// and friends — for a rule that imports one. [`no_builtins`] for every constructor,
88    /// which is what [`RuleRoot::new`] starts with anyway.
89    builtins: BuiltinSource,
90}
91
92/// A build with no built-in components, which is what every tester but a built-in one wants.
93///
94/// Written here rather than reached for in `lanekeep-js` because that crate keeps its own
95/// equivalents private, and a `fn` item is cheaper than widening its API for one caller.
96const fn no_components(_: &str) -> Option<(&'static [u8], u32)> {
97    None
98}
99
100/// The source-map half of [`no_components`].
101const fn no_component_maps(_: &str) -> Option<&'static [u8]> {
102    None
103}
104
105/// A build with no built-in modules, which is what every tester but a module-rule one wants.
106///
107/// On the same terms as [`no_components`]: `lanekeep-js` keeps its own equivalent private,
108/// and a `fn` item is cheaper than widening its API for one caller.
109const fn no_builtins(_: &str) -> Option<&'static str> {
110    None
111}
112
113/// What a source rule's project is configured by.
114const TS_CONFIG: &str = "lanekeep.config.ts";
115
116/// What a component rule's project is configured by.
117const JSON_CONFIG: &str = "lanekeep.json";
118
119/// Where a component tester puts the rule, relative to the project root.
120///
121/// Inside the rules root, which is the project root here, because `lanekeep-config` confines a
122/// component reference to it — a `.wasm` outside is refused before it is read.
123const COMPONENT_PATH: &str = "rules/rule.wasm";
124
125impl RuleTester {
126    /// Build a tester for a rule's source.
127    ///
128    /// `name` labels the temporary directory and need not be unique — every tester gets its
129    /// own directory regardless, so a test file with a `fn tester()` helper shared across
130    /// cases works. It has to: two testers sharing a directory would delete each other's
131    /// project mid-run, and the resulting error would point at the config rather than at
132    /// the collision.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`TestError::Setup`] if the temporary project cannot be written.
137    pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
138        Self::with_extension(name, rule_source, "ts")
139    }
140
141    /// Build a tester whose subject files use a given extension.
142    ///
143    /// Needed for a rule targeting `tsx`, since which grammar parses a file is decided by
144    /// its extension — a TSX rule tested against a `.ts` file would never match.
145    ///
146    /// # Errors
147    ///
148    /// As [`RuleTester::new`].
149    pub fn with_extension(
150        name: &str,
151        rule_source: &str,
152        extension: &str,
153    ) -> Result<Self, TestError> {
154        Self::build(name, rule_source, extension, "rule")
155    }
156
157    /// Build a tester for a *factory* rule — one whose default export returns a rule when
158    /// called with options — using the given options expression.
159    ///
160    /// `options` is JavaScript, spliced into the generated config as `rule(<options>)`.
161    /// Passing the options as source rather than as a serialized value is deliberate: a
162    /// factory takes whatever its author designed, and a harness that only accepted JSON
163    /// could not test one taking a function or a regular expression.
164    ///
165    /// ```no_run
166    /// # use lanekeep_testkit::RuleTester;
167    /// let tester = RuleTester::configured(
168    ///     "restricted",
169    ///     RULE_SOURCE,
170    ///     "{ restrictions: [{ module: 'lodash' }] }",
171    /// )
172    /// .expect("builds");
173    /// # const RULE_SOURCE: &str = "";
174    /// ```
175    ///
176    /// # Errors
177    ///
178    /// As [`RuleTester::new`].
179    pub fn configured(name: &str, rule_source: &str, options: &str) -> Result<Self, TestError> {
180        Self::configured_with_extension(name, rule_source, "ts", options)
181    }
182
183    /// Build a tester for a factory rule whose subject files use a given extension.
184    ///
185    /// [`RuleTester::configured`] and [`RuleTester::with_extension`] each vary one axis, and
186    /// a rule that is both parameterized and non-TypeScript needs both — every built-in
187    /// targeting Rust, Go or Python is in that position the moment it takes an option. The
188    /// absence was not a decision: `configured` predates any parameterized rule outside
189    /// TypeScript, and the two Rust-targeting built-ins that document an `allow` option had
190    /// no test reaching them here at all.
191    ///
192    /// # Errors
193    ///
194    /// As [`RuleTester::new`].
195    pub fn configured_with_extension(
196        name: &str,
197        rule_source: &str,
198        extension: &str,
199        options: &str,
200    ) -> Result<Self, TestError> {
201        Self::build(name, rule_source, extension, &format!("rule({options})"))
202    }
203
204    /// Build a tester for a rule compiled to a WebAssembly component.
205    ///
206    /// `bytes` are the component itself — `lanekeep_rules::component("no-unwrap")`, or whatever
207    /// a project's own build produced. They are written to `rules/rule.wasm` inside the
208    /// throwaway project, because a component reference is confined to the rules root.
209    ///
210    /// # It generates a `lanekeep.json`, and it has to
211    ///
212    /// Every other constructor writes a `lanekeep.config.ts` that imports the rule. A `.wasm` is
213    /// not a value a TypeScript module can import: a component is resolved in Rust, by path, and
214    /// answers `metadata` for itself. So the component path is the JSON config path, and that is
215    /// forced by what a component is rather than chosen for convenience.
216    ///
217    /// # `extension` is required rather than defaulted
218    ///
219    /// [`RuleTester::new`] defaults to `ts` and [`RuleTester::with_extension`] varies it, which
220    /// is right when TypeScript is the overwhelmingly common case. It is not the common case
221    /// here — a rule authored as a component is one written in the language it inspects, and the
222    /// two that exist target Rust — so a default would be wrong more often than not, and the
223    /// pair of `*_with_extension` variants it would need doubles the constructor count for
224    /// nothing.
225    ///
226    /// # Errors
227    ///
228    /// As [`RuleTester::new`].
229    pub fn for_component(name: &str, bytes: &[u8], extension: &str) -> Result<Self, TestError> {
230        Self::build_component(name, bytes, extension, None)
231    }
232
233    /// Build a tester for a component rule configured with options.
234    ///
235    /// `options` is **JSON**, and the difference from [`RuleTester::configured`] is the whole
236    /// point rather than an inconvenience. That one splices JavaScript source into a config, so
237    /// a factory rule can be handed a function or a regular expression. A component cannot close
238    /// over a host-supplied value at all: its options cross the boundary as data, through the
239    /// world's `configure(options-json)`. Accepting source here would suggest otherwise, and
240    /// would test a shape no real config can produce.
241    ///
242    /// # Errors
243    ///
244    /// As [`RuleTester::new`], plus [`TestError::Setup`] if `options` is not valid JSON — which
245    /// is caught here rather than left to surface as a config parse error naming a generated
246    /// file the caller never wrote.
247    pub fn for_component_configured(
248        name: &str,
249        bytes: &[u8],
250        extension: &str,
251        options: &str,
252    ) -> Result<Self, TestError> {
253        let options: serde_json::Value = serde_json::from_str(options)
254            .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
255        Self::build_component(name, bytes, extension, Some(options))
256    }
257
258    /// Build a tester for a built-in rule, named the way a real config names it.
259    ///
260    /// `name` is the bare rule name — `"no-default-export"` — and the generated `lanekeep.json`
261    /// carries `"lanekeep/no-default-export"`. `components` is the lookup that answers it, which
262    /// for lanekeep's own rules is `lanekeep_rules::component`; it is a parameter rather than a
263    /// dependency because `lanekeep-rules` dev-depends on this crate, and an edge the other way
264    /// would put each crate ahead of the other in the publication order.
265    ///
266    /// # What this exists for, and what [`RuleTester::for_component`] cannot do
267    ///
268    /// `for_component` writes an artifact to a path, and **a path reference contributes every
269    /// rule the artifact hosts**. That is right for a component built from one `rust-rules/`
270    /// crate and wrong for a shared one: the four TypeScript built-ins live in a single
271    /// `typescript-builtins.wasm`, so pointing a tester at its bytes runs all four and there is
272    /// no way to say which one is under test. Naming the *specifier* is how a config says it —
273    /// resolution goes through the embedded table, which carries the rule's index, and the
274    /// engine is handed one rule. Until this constructor existed, a rule of a shared component
275    /// could not be tested through `RuleTester` at all.
276    ///
277    /// # It is slower than every other constructor, by a lot
278    ///
279    /// A built-in that ships as a component is compiled at load, and the shared TypeScript one
280    /// is 12.4 MiB. The first `run` on a tester pays that — seconds, not milliseconds — and
281    /// later runs on the same tester map what it wrote into the throwaway project. Prefer one
282    /// tester over a table of cases to a tester per case.
283    ///
284    /// # Errors
285    ///
286    /// As [`RuleTester::new`].
287    pub fn for_built_in(
288        name: &str,
289        extension: &str,
290        components: BuiltinComponent,
291    ) -> Result<Self, TestError> {
292        Self::build_built_in(name, extension, components, None)
293    }
294
295    /// Build a tester for a built-in rule configured with options.
296    ///
297    /// `options` is **JSON**, for the reason [`RuleTester::for_component_configured`] gives: a
298    /// built-in that ships as a component takes its options as data through `configure`, and one
299    /// that ships as a module is reached from a `lanekeep.json` here too, where JSON is all a
300    /// config can write. Neither form can close over a host-supplied value.
301    ///
302    /// # Errors
303    ///
304    /// As [`RuleTester::for_component_configured`].
305    pub fn for_built_in_configured(
306        name: &str,
307        extension: &str,
308        components: BuiltinComponent,
309        options: &str,
310    ) -> Result<Self, TestError> {
311        let options: serde_json::Value = serde_json::from_str(options)
312            .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
313        Self::build_built_in(name, extension, components, Some(options))
314    }
315
316    /// Serve source maps for built-in components too.
317    ///
318    /// Diagnostics only, and separate from the constructor because of it: a map decides where a
319    /// *thrown* rule is reported and nothing about what a rule finds, so a test asserting
320    /// behavior needs none. `lanekeep_rules::component_source_map` is the lookup for lanekeep's
321    /// own rules.
322    #[must_use]
323    pub const fn with_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
324        self.component_maps = maps;
325        self
326    }
327
328    /// Serve the built-in modules a rule imports — `lanekeep/patterns` and friends.
329    ///
330    /// The lookup for lanekeep's own rules is `lanekeep_rules::source`; it is a parameter
331    /// rather than a dependency for the same reason [`RuleTester::for_built_in`]'s
332    /// `components` is — `lanekeep-rules` dev-depends on this crate, and an edge the other
333    /// way would put each crate ahead of the other in the publication order.
334    #[must_use]
335    pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
336        self.builtins = builtins;
337        self
338    }
339
340    /// Write the throwaway project for a built-in rule named by its specifier.
341    fn build_built_in(
342        name: &str,
343        extension: &str,
344        components: BuiltinComponent,
345        options: Option<serde_json::Value>,
346    ) -> Result<Self, TestError> {
347        let mut tester = Self::empty(name, extension, JSON_CONFIG);
348        tester.components = components;
349
350        let reference = format!("lanekeep/{name}");
351        let rule = match options {
352            None => serde_json::Value::String(reference),
353            Some(options) => serde_json::json!({ "rule": reference, "options": options }),
354        };
355        let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
356        tester.write(JSON_CONFIG, &config.to_string())?;
357        Ok(tester)
358    }
359
360    /// Write the throwaway project.
361    ///
362    /// `rule_expr` is what goes in the config's `rules` array — the imported module for a
363    /// plain rule, a call for a factory.
364    fn build(
365        name: &str,
366        rule_source: &str,
367        extension: &str,
368        rule_expr: &str,
369    ) -> Result<Self, TestError> {
370        let tester = Self::empty(name, extension, TS_CONFIG);
371        // Nested one level rather than sitting at the fixture's own top, so a rule that
372        // imports a sibling module the way this repository's local rules do — `../modules/x`
373        // from `lanekeep/rules/some-rule.ts` — resolves inside the fixture instead of
374        // escaping it. `mirror_modules` is what makes `../modules/x` resolve to something
375        // real rather than merely legal.
376        tester.write("rules/rule.ts", rule_source)?;
377        tester.mirror_modules()?;
378        tester.write(
379            TS_CONFIG,
380            &format!(
381                "import {{ defineConfig }} from 'lanekeep';\n\
382                 import rule from './rules/rule';\n\
383                 export default defineConfig({{ include: ['subject/**'], rules: [{rule_expr}] }});\n"
384            ),
385        )?;
386        Ok(tester)
387    }
388
389    /// Copy this repository's own `lanekeep/modules/` into the fixture, as `modules/` — a
390    /// sibling of `rules/rule.ts`, one level up from it, exactly as `lanekeep/modules/` sits
391    /// relative to `lanekeep/rules/` in the real repository.
392    ///
393    /// A rule tested here is given as a source string, not a path, so there is no file on
394    /// disk this crate could otherwise learn the rule's real location from — and no way to
395    /// thread one through without changing the shape every existing caller of `new`,
396    /// `with_extension` and `configured` already depends on. Locating the source directory
397    /// from this crate's own manifest directory keeps that shape unchanged.
398    ///
399    /// A no-op when the source directory does not exist, which is true for everything other
400    /// than this workspace's own tests: a rule with no relative import never reads the copy,
401    /// and a project outside this repository that depends on this crate to test its own
402    /// rules has no such directory to find.
403    fn mirror_modules(&self) -> Result<(), TestError> {
404        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lanekeep/modules");
405        let Ok(read_dir) = std::fs::read_dir(&source) else {
406            return Ok(());
407        };
408
409        // Read-dir order is not guaranteed; fixed order keeps a failure in here reproducible.
410        let mut paths = read_dir
411            .map(|entry| {
412                entry
413                    .map(|e| e.path())
414                    .map_err(|e| TestError::Setup(e.to_string()))
415            })
416            .collect::<Result<Vec<_>, _>>()?;
417        paths.sort();
418
419        for path in paths {
420            if !path.is_file() {
421                continue;
422            }
423            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
424                continue;
425            };
426            let contents =
427                std::fs::read_to_string(&path).map_err(|e| TestError::Setup(e.to_string()))?;
428            self.write(&format!("modules/{name}"), &contents)?;
429        }
430        Ok(())
431    }
432
433    /// Write the throwaway project for a component rule.
434    ///
435    /// `options` is `None` for the bare form and `Some` for the object form, and the two are not
436    /// the same config: `lanekeep-config` reads a bare string as a rule used as it comes and
437    /// `{ "rule", "options" }` as one configured, which is the distinction a rule author already
438    /// makes between a rule and a rule factory. `Some(Value::Null)` is therefore a third thing
439    /// again — a rule explicitly configured with nothing — and is reachable from here on
440    /// purpose.
441    fn build_component(
442        name: &str,
443        bytes: &[u8],
444        extension: &str,
445        options: Option<serde_json::Value>,
446    ) -> Result<Self, TestError> {
447        let tester = Self::empty(name, extension, JSON_CONFIG);
448        tester.write_bytes(COMPONENT_PATH, bytes)?;
449
450        let reference = format!("./{COMPONENT_PATH}");
451        let rule = match options {
452            None => serde_json::Value::String(reference),
453            Some(options) => serde_json::json!({ "rule": reference, "options": options }),
454        };
455        let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
456
457        // `to_string` rather than `to_string_pretty`: nothing reads this by eye except when a
458        // test fails, and a failure prints the error rather than the config.
459        tester.write(JSON_CONFIG, &config.to_string())?;
460        Ok(tester)
461    }
462
463    /// A tester with its own directory and nothing in it yet.
464    fn empty(name: &str, extension: &str, config: &'static str) -> Self {
465        // Unique per tester: the counter separates testers in one process, the process id
466        // separates the processes nextest spawns per test.
467        let seq = NEXT_ID.fetch_add(1, Ordering::Relaxed);
468        let dir = std::env::temp_dir().join(format!(
469            "lanekeep-ruletest-{name}-{}-{seq}",
470            std::process::id()
471        ));
472        let _ = std::fs::remove_dir_all(&dir);
473
474        Self {
475            dir,
476            extension: extension.to_owned(),
477            config,
478            components: no_components,
479            component_maps: no_component_maps,
480            builtins: no_builtins,
481        }
482    }
483
484    /// Write a fixture file into the tester's project, at a path relative to it.
485    ///
486    /// A cross-file rule — one whose `reduce` reads files other than the subject — needs a
487    /// corpus to read, and this is how a test builds it. The subject itself is still written
488    /// by [`RuleTester::run`]; this is for the *other* files the rule reaches through
489    /// `ctx.files()`.
490    ///
491    /// # Errors
492    ///
493    /// Returns [`TestError::Setup`] if the file cannot be written.
494    pub fn write_fixture(&self, path: &str, contents: &str) -> Result<(), TestError> {
495        self.write(path, contents)
496    }
497
498    fn write(&self, path: &str, contents: &str) -> Result<(), TestError> {
499        self.write_bytes(path, contents.as_bytes())
500    }
501
502    fn write_bytes(&self, path: &str, contents: &[u8]) -> Result<(), TestError> {
503        let full = self.dir.join(path);
504        if let Some(parent) = full.parent() {
505            std::fs::create_dir_all(parent).map_err(|e| TestError::Setup(e.to_string()))?;
506        }
507        std::fs::write(full, contents).map_err(|e| TestError::Setup(e.to_string()))
508    }
509
510    /// Run the rule over a single source file and return what it reported.
511    ///
512    /// # Errors
513    ///
514    /// Returns [`TestError::Load`] if the rule does not load, or [`TestError::Run`] if it
515    /// throws or breaches a budget.
516    pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
517        // A fresh subject each time, so one case cannot see another's file.
518        let _ = std::fs::remove_dir_all(self.dir.join("subject"));
519        self.write(&format!("subject/input.{}", self.extension), source)?;
520
521        let root = RuleRoot::new(&self.dir)
522            .map_err(|e| TestError::Setup(e.to_string()))?
523            .with_builtins(self.builtins)
524            .with_builtin_components(self.components)
525            .with_builtin_component_maps(self.component_maps);
526        let config_path = self.dir.join(self.config);
527
528        let sandbox =
529            lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
530                .map_err(|e| TestError::Load(e.to_string()))?;
531        // **The throwaway project is named as the artifact root, and this crate is the one
532        // caller entitled to do it.** `LoadOptions::artifacts` is `None` by default because a
533        // rules root is not generally a project root, and guessing one would make loading a
534        // config write into a directory nobody asked for. Here the two are the same directory,
535        // this crate created it, and `Drop` removes it. Without it every `run` compiles each
536        // component twice from scratch — tolerable for a 26 KB Rust rule and seconds per case
537        // for the 12.4 MiB shared TypeScript one, which is what `for_built_in` reaches.
538        let config = lanekeep_config::load_with(
539            &sandbox,
540            &root,
541            &config_path,
542            lanekeep_config::LoadOptions {
543                artifacts: Some(&self.dir),
544                ..lanekeep_config::LoadOptions::default()
545            },
546        )
547        .map_err(|e| TestError::Load(e.to_string()))?;
548
549        let engine = Engine::prepare(
550            &config,
551            &self.dir,
552            root,
553            &config_path,
554            &lanekeep_languages::registry(),
555            Arc::new(TypeScript),
556            Arc::new(JavaScript),
557        )
558        .map_err(|e| TestError::Load(e.to_string()))?;
559
560        engine
561            .run()
562            .map(|outcome| outcome.violations)
563            .map_err(|e| TestError::Run(e.to_string()))
564    }
565
566    /// Assert the rule reports nothing for this source.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`TestError::Mismatch`] listing what was reported, since "expected none,
571    /// got some" is only actionable if you can see which.
572    pub fn accepts(&self, source: &str) -> Result<(), TestError> {
573        let violations = self.run(source)?;
574        if violations.is_empty() {
575            return Ok(());
576        }
577
578        let mut message = format!(
579            "expected no violations, but the rule reported {}:\n",
580            violations.len()
581        );
582        for violation in &violations {
583            let _ = writeln!(
584                message,
585                "  {}:{} {}",
586                violation.location.position.line,
587                violation.location.position.column,
588                violation.message
589            );
590        }
591        let _ = write!(message, "\nsource:\n{}", indent(source));
592        Err(TestError::Mismatch(message))
593    }
594
595    /// Assert the rule reports at exactly these one-based positions, in order.
596    ///
597    /// Positions rather than a count, because a rule reporting the right number of
598    /// violations in the wrong places is a rule that is wrong — and a count-only assertion
599    /// is exactly what lets that through.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`TestError::Mismatch`] showing expected and actual side by side.
604    pub fn reports_at(&self, source: &str, expected: &[(u32, u32)]) -> Result<(), TestError> {
605        let violations = self.run(source)?;
606        let actual: Vec<(u32, u32)> = violations
607            .iter()
608            .map(|v| (v.location.position.line, v.location.position.column))
609            .collect();
610
611        if actual == expected {
612            return Ok(());
613        }
614
615        Err(TestError::Mismatch(format!(
616            "reported positions did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
617            indent(source)
618        )))
619    }
620
621    /// Assert the rule reports exactly these messages, in order.
622    ///
623    /// For a rule that substitutes its own message per match — the position alone would not
624    /// show whether the right one was chosen.
625    ///
626    /// # Errors
627    ///
628    /// Returns [`TestError::Mismatch`] showing both lists.
629    pub fn reports_messages(&self, source: &str, expected: &[&str]) -> Result<(), TestError> {
630        let violations = self.run(source)?;
631        let actual: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
632
633        if actual == expected {
634            return Ok(());
635        }
636
637        Err(TestError::Mismatch(format!(
638            "reported messages did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
639            indent(source)
640        )))
641    }
642}
643
644impl Drop for RuleTester {
645    fn drop(&mut self) {
646        let _ = std::fs::remove_dir_all(&self.dir);
647    }
648}
649
650/// Indent source for inclusion in a failure message, so it is visibly quoted rather than
651/// running together with the assertion text.
652fn indent(source: &str) -> String {
653    source.lines().fold(String::new(), |mut out, line| {
654        // Writing into a String cannot fail; swallowing the Result keeps this a fold
655        // rather than a loop with an unreachable error arm.
656        let _ = writeln!(out, "  | {line}");
657        out
658    })
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    const DEBUGGER: &str = "import { defineRule } from 'lanekeep';\n\
666        export default defineRule({\n\
667          id: 'local/no-debugger',\n\
668          query: '(debugger_statement) @stmt',\n\
669          card: {\n\
670            message: 'debugger statement',\n\
671            remediation: 'remove it',\n\
672            examples: { bad: 'debugger;', good: 'log();' },\n\
673          },\n\
674          check(ctx, m) { ctx.report(m.stmt); },\n\
675        });\n";
676
677    fn tester(name: &str) -> RuleTester {
678        RuleTester::new(name, DEBUGGER).expect("builds")
679    }
680
681    #[test]
682    fn accepts_clean_source() {
683        tester("accepts")
684            .accepts("const a = 1;\n")
685            .expect("should accept");
686    }
687
688    #[test]
689    fn reports_at_the_expected_positions() {
690        tester("positions")
691            .reports_at("const a = 1;\ndebugger;\n", &[(2, 1)])
692            .expect("should report");
693    }
694
695    #[test]
696    fn reports_several_in_order() {
697        tester("several")
698            .reports_at("debugger;\nconst a = 1;\ndebugger;\n", &[(1, 1), (3, 1)])
699            .expect("should report both");
700    }
701
702    #[test]
703    fn accepts_fails_loudly_and_shows_what_was_found() {
704        // A harness that said only "expected none, got some" would leave the author
705        // guessing which case tripped.
706        let err = tester("accepts-fail")
707            .accepts("debugger;\n")
708            .expect_err("should not accept");
709
710        let rendered = err.to_string();
711        assert!(rendered.contains("expected no violations"), "{rendered}");
712        assert!(
713            rendered.contains("debugger statement"),
714            "should show the message: {rendered}"
715        );
716        assert!(rendered.contains("1:1"), "should show where: {rendered}");
717    }
718
719    #[test]
720    fn a_position_mismatch_shows_both_sides() {
721        let err = tester("position-fail")
722            .reports_at("debugger;\n", &[(5, 5)])
723            .expect_err("should not match");
724
725        let rendered = err.to_string();
726        assert!(rendered.contains("expected: [(5, 5)]"), "{rendered}");
727        assert!(rendered.contains("actual:   [(1, 1)]"), "{rendered}");
728    }
729
730    #[test]
731    fn checks_messages_when_a_rule_substitutes_its_own() {
732        let rule = "import { defineRule } from 'lanekeep';\n\
733            export default defineRule({\n\
734              id: 'local/named',\n\
735              query: '(variable_declarator name: (identifier) @name)',\n\
736              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
737              check(ctx, m) { ctx.report(m.name, `saw ${ctx.text(m.name)}`); },\n\
738            });\n";
739
740        RuleTester::new("messages", rule)
741            .expect("builds")
742            .reports_messages(
743                "const alpha = 1;\nconst beta = 2;\n",
744                &["saw alpha", "saw beta"],
745            )
746            .expect("should match");
747    }
748
749    #[test]
750    fn a_rule_that_does_not_load_is_distinguished_from_one_that_found_nothing() {
751        // The distinction that matters most. Reporting a load failure as "no violations"
752        // would make a broken rule look like a passing one — which is the same failure
753        // mode the config's has_check test exists to prevent, one layer up.
754        let broken = "import { defineRule } from 'lanekeep';\n\
755            export default defineRule({\n\
756              id: 'local/broken',\n\
757              query: '(no_such_node) @x',\n\
758              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
759              check() {},\n\
760            });\n";
761
762        let err = RuleTester::new("broken", broken)
763            .expect("builds")
764            .accepts("const a = 1;\n")
765            .expect_err("must not pass");
766
767        assert!(matches!(err, TestError::Load(_)), "{err:?}");
768        assert!(err.to_string().contains("no_such_node"), "{err}");
769    }
770
771    #[test]
772    fn a_throwing_rule_is_reported_as_a_run_failure() {
773        let throwing = "import { defineRule } from 'lanekeep';\n\
774            export default defineRule({\n\
775              id: 'local/throws',\n\
776              query: '(debugger_statement) @s',\n\
777              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
778              check() { throw new Error('boom'); },\n\
779            });\n";
780
781        let err = RuleTester::new("throwing", throwing)
782            .expect("builds")
783            .accepts("debugger;\n")
784            .expect_err("must not pass");
785
786        assert!(matches!(err, TestError::Run(_)), "{err:?}");
787        assert!(err.to_string().contains("boom"), "{err}");
788    }
789
790    #[test]
791    fn cases_do_not_leak_into_each_other() {
792        // Each case rewrites the subject directory. Without that, a violation from an
793        // earlier case would still be on disk and show up in the next one.
794        let tester = tester("isolation");
795        tester
796            .reports_at("debugger;\n", &[(1, 1)])
797            .expect("first case");
798        tester
799            .accepts("const a = 1;\n")
800            .expect("second case must not see the first");
801    }
802
803    #[test]
804    fn a_tsx_rule_can_be_tested_against_tsx() {
805        // Which grammar parses a file is decided by its extension, so a TSX rule tested
806        // against a `.ts` subject would silently never match.
807        let rule = "import { defineRule } from 'lanekeep';\n\
808            export default defineRule({\n\
809              id: 'local/no-jsx',\n\
810              language: 'tsx',\n\
811              query: '(jsx_element) @el',\n\
812              card: { message: 'jsx', remediation: 'do not', examples: { bad: '<a/>', good: 'a()' } },\n\
813              check(ctx, m) { ctx.report(m.el); },\n\
814            });\n";
815
816        RuleTester::with_extension("tsx", rule, "tsx")
817            .expect("builds")
818            .reports_at("const a = <div>hi</div>;\n", &[(1, 11)])
819            .expect("should report the element");
820    }
821
822    /// Not a component. These cases assert what the harness *writes*, which is the whole of what
823    /// the component constructors do; running one is `crates/lanekeep-rules/tests/no_unwrap.rs`'s
824    /// job, and it has real components to do it with. Four bytes of magic keep the fixture from
825    /// looking like something that could be loaded.
826    const NOT_A_COMPONENT: &[u8] = b"\0asm not really";
827
828    /// The generated config, parsed back.
829    fn written_config(tester: &RuleTester) -> serde_json::Value {
830        let text = std::fs::read_to_string(tester.dir.join(JSON_CONFIG)).expect("config written");
831        serde_json::from_str(&text).expect("the generated config is JSON")
832    }
833
834    #[test]
835    fn a_component_tester_writes_the_bytes_and_points_a_json_config_at_them() {
836        let tester = RuleTester::for_component("component", NOT_A_COMPONENT, "rs").expect("builds");
837
838        assert_eq!(
839            std::fs::read(tester.dir.join(COMPONENT_PATH)).expect("component written"),
840            NOT_A_COMPONENT,
841            "the bytes must reach disk unchanged — a component is identified by them"
842        );
843        assert_eq!(
844            written_config(&tester)["rules"][0],
845            serde_json::json!("./rules/rule.wasm"),
846            "the bare form is a string, which is what `lanekeep-config` reads as a rule used \
847             as it comes"
848        );
849        assert!(
850            !tester.dir.join(TS_CONFIG).exists(),
851            "a component project must not also carry a TypeScript config"
852        );
853    }
854
855    #[test]
856    fn a_configured_component_tester_embeds_its_options_as_data() {
857        // The distinction `configured` cannot make: those options are spliced into JavaScript
858        // source, and these have to survive as a value, because `configure(options-json)` is
859        // where they arrive. A harness that stringified them would hand the rule `"{...}"`.
860        let tester = RuleTester::for_component_configured(
861            "configured",
862            NOT_A_COMPONENT,
863            "rs",
864            r#"{"allow": ["subject/input.rs"]}"#,
865        )
866        .expect("builds");
867
868        assert_eq!(
869            written_config(&tester)["rules"][0],
870            serde_json::json!({
871                "rule": "./rules/rule.wasm",
872                "options": { "allow": ["subject/input.rs"] },
873            })
874        );
875    }
876
877    #[test]
878    fn explicit_null_options_are_a_different_config_from_the_bare_form() {
879        // Three states, not two. `lanekeep-config` reads a bare string as a rule used as it
880        // comes and the object form as one configured, so a rule named with `null` options is
881        // not the same config as a rule named with none — and a harness collapsing them would
882        // make the bare case untestable.
883        let configured =
884            RuleTester::for_component_configured("null-options", NOT_A_COMPONENT, "rs", "null")
885                .expect("builds");
886        let bare = RuleTester::for_component("bare", NOT_A_COMPONENT, "rs").expect("builds");
887
888        assert_eq!(
889            written_config(&configured)["rules"][0],
890            serde_json::json!({ "rule": "./rules/rule.wasm", "options": null })
891        );
892        assert_ne!(
893            written_config(&configured)["rules"][0],
894            written_config(&bare)["rules"][0]
895        );
896    }
897
898    #[test]
899    fn options_that_are_not_json_are_refused_naming_the_options() {
900        // The failure mode this branch exists to prevent: spliced into the config unchecked, a
901        // malformed value becomes a parse error against a generated file the caller never wrote,
902        // pointing at a line number that means nothing to them.
903        let err = RuleTester::for_component_configured(
904            "bad-options",
905            NOT_A_COMPONENT,
906            "rs",
907            "{ allow: ['subject/input.rs'] }",
908        )
909        .expect_err("JavaScript object syntax is not JSON");
910
911        assert!(matches!(err, TestError::Setup(_)), "{err:?}");
912        assert!(
913            err.to_string().contains("`options` is not valid JSON"),
914            "{err}"
915        );
916    }
917
918    #[test]
919    fn the_temporary_project_is_cleaned_up() {
920        let path = {
921            let tester = tester("cleanup");
922            tester.accepts("const a = 1;\n").expect("runs");
923            tester.dir.clone()
924        };
925        assert!(
926            !path.exists(),
927            "the tester should remove its project on drop"
928        );
929    }
930}