lanekeep-testkit 0.8.1

Fixture-based rule testing harness for lanekeep.
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
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
//! Fixture-based rule testing harness for lanekeep.
//!
//! `RuleTester`: the harness every rule is tested through.
//!
//! This is not optional infrastructure. Without it, community rule contributions are
//! unreviewable — a reviewer cannot tell a rule that works from one that happens not to
//! have been tried on the case it gets wrong.
//!
//! # It runs the real path
//!
//! A tester builds a throwaway project on disk and runs the actual engine over it: real
//! config loading, real gates, real sandbox, real query matching. Nothing is stubbed.
//!
//! That costs more than calling a handler directly, and buys the thing that matters. A rule
//! can be correct in isolation and still never fire because its gate excludes the file, its
//! query does not compile against the language it named, or its card fails validation. A
//! harness that skipped those would pass rules that do nothing.
//!
//! # Usage
//!
//! ```no_run
//! use lanekeep_testkit::RuleTester;
//!
//! let tester = RuleTester::new("no-debugger", RULE_SOURCE).expect("builds");
//! tester.accepts("const a = 1;").expect("clean code passes");
//! tester.reports_at("debugger;", &[(1, 1)]).expect("violations are found");
//! # const RULE_SOURCE: &str = "";
//! ```

use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use lanekeep_core::Violation;
use lanekeep_engine::Engine;
use lanekeep_js::{BuiltinComponent, BuiltinComponentMap, BuiltinSource, RuleRoot};
use lanekeep_lang_js::{JavaScript, TypeScript};
use thiserror::Error;

/// Why a rule test could not run, or did not hold.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TestError {
    /// The harness could not set up its temporary project.
    #[error("could not set up the rule test: {0}")]
    Setup(String),

    /// The rule or its config failed to load.
    ///
    /// Distinct from a failed assertion: the rule never ran, so nothing was proven either
    /// way, and reporting it as "no violations found" would be actively misleading.
    #[error("rule failed to load:\n{0}")]
    Load(String),

    /// The run aborted — a rule threw, or breached a budget.
    #[error("rule failed while running:\n{0}")]
    Run(String),

    /// The rule reported something other than what was expected.
    #[error("{0}")]
    Mismatch(String),
}

/// Distinguishes testers built in the same process.
static NEXT_ID: AtomicU64 = AtomicU64::new(0);

/// A rule under test, with a throwaway project to run it in.
///
/// The project is removed when the tester is dropped.
#[derive(Debug)]
pub struct RuleTester {
    dir: PathBuf,
    extension: String,
    /// The config file the project was written with, relative to [`RuleTester::dir`].
    ///
    /// A field rather than a constant because a component tester writes a `lanekeep.json` and a
    /// source tester writes a `lanekeep.config.ts` — see [`RuleTester::for_component`] for why
    /// that is forced rather than chosen.
    config: &'static str,
    /// How `lanekeep/<name>` resolves to a component, for a tester built by
    /// [`RuleTester::for_built_in`]. [`no_components`] for every other constructor, which is
    /// what [`RuleRoot::new`] starts with anyway.
    components: BuiltinComponent,
    /// The matching source-map lookup, on the same terms.
    component_maps: BuiltinComponentMap,
    /// How `lanekeep/<name>` resolves to a built-in module's source — `lanekeep/patterns`
    /// and friends — for a rule that imports one. [`no_builtins`] for every constructor,
    /// which is what [`RuleRoot::new`] starts with anyway.
    builtins: BuiltinSource,
}

/// A build with no built-in components, which is what every tester but a built-in one wants.
///
/// Written here rather than reached for in `lanekeep-js` because that crate keeps its own
/// equivalents private, and a `fn` item is cheaper than widening its API for one caller.
const fn no_components(_: &str) -> Option<(&'static [u8], u32)> {
    None
}

/// The source-map half of [`no_components`].
const fn no_component_maps(_: &str) -> Option<&'static [u8]> {
    None
}

/// A build with no built-in modules, which is what every tester but a module-rule one wants.
///
/// On the same terms as [`no_components`]: `lanekeep-js` keeps its own equivalent private,
/// and a `fn` item is cheaper than widening its API for one caller.
const fn no_builtins(_: &str) -> Option<&'static str> {
    None
}

/// What a source rule's project is configured by.
const TS_CONFIG: &str = "lanekeep.config.ts";

/// What a component rule's project is configured by.
const JSON_CONFIG: &str = "lanekeep.json";

/// Where a component tester puts the rule, relative to the project root.
///
/// Inside the rules root, which is the project root here, because `lanekeep-config` confines a
/// component reference to it — a `.wasm` outside is refused before it is read.
const COMPONENT_PATH: &str = "rules/rule.wasm";

impl RuleTester {
    /// Build a tester for a rule's source.
    ///
    /// `name` labels the temporary directory and need not be unique — every tester gets its
    /// own directory regardless, so a test file with a `fn tester()` helper shared across
    /// cases works. It has to: two testers sharing a directory would delete each other's
    /// project mid-run, and the resulting error would point at the config rather than at
    /// the collision.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Setup`] if the temporary project cannot be written.
    pub fn new(name: &str, rule_source: &str) -> Result<Self, TestError> {
        Self::with_extension(name, rule_source, "ts")
    }

    /// Build a tester whose subject files use a given extension.
    ///
    /// Needed for a rule targeting `tsx`, since which grammar parses a file is decided by
    /// its extension — a TSX rule tested against a `.ts` file would never match.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`].
    pub fn with_extension(
        name: &str,
        rule_source: &str,
        extension: &str,
    ) -> Result<Self, TestError> {
        Self::build(name, rule_source, extension, "rule")
    }

    /// Build a tester for a *factory* rule — one whose default export returns a rule when
    /// called with options — using the given options expression.
    ///
    /// `options` is JavaScript, spliced into the generated config as `rule(<options>)`.
    /// Passing the options as source rather than as a serialized value is deliberate: a
    /// factory takes whatever its author designed, and a harness that only accepted JSON
    /// could not test one taking a function or a regular expression.
    ///
    /// ```no_run
    /// # use lanekeep_testkit::RuleTester;
    /// let tester = RuleTester::configured(
    ///     "restricted",
    ///     RULE_SOURCE,
    ///     "{ restrictions: [{ module: 'lodash' }] }",
    /// )
    /// .expect("builds");
    /// # const RULE_SOURCE: &str = "";
    /// ```
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`].
    pub fn configured(name: &str, rule_source: &str, options: &str) -> Result<Self, TestError> {
        Self::configured_with_extension(name, rule_source, "ts", options)
    }

    /// Build a tester for a factory rule whose subject files use a given extension.
    ///
    /// [`RuleTester::configured`] and [`RuleTester::with_extension`] each vary one axis, and
    /// a rule that is both parameterized and non-TypeScript needs both — every built-in
    /// targeting Rust, Go or Python is in that position the moment it takes an option. The
    /// absence was not a decision: `configured` predates any parameterized rule outside
    /// TypeScript, and the two Rust-targeting built-ins that document an `allow` option had
    /// no test reaching them here at all.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`].
    pub fn configured_with_extension(
        name: &str,
        rule_source: &str,
        extension: &str,
        options: &str,
    ) -> Result<Self, TestError> {
        Self::build(name, rule_source, extension, &format!("rule({options})"))
    }

    /// Build a tester for a rule compiled to a WebAssembly component.
    ///
    /// `bytes` are the component itself — `lanekeep_rules::component("no-unwrap")`, or whatever
    /// a project's own build produced. They are written to `rules/rule.wasm` inside the
    /// throwaway project, because a component reference is confined to the rules root.
    ///
    /// # It generates a `lanekeep.json`, and it has to
    ///
    /// Every other constructor writes a `lanekeep.config.ts` that imports the rule. A `.wasm` is
    /// not a value a TypeScript module can import: a component is resolved in Rust, by path, and
    /// answers `metadata` for itself. So the component path is the JSON config path, and that is
    /// forced by what a component is rather than chosen for convenience.
    ///
    /// # `extension` is required rather than defaulted
    ///
    /// [`RuleTester::new`] defaults to `ts` and [`RuleTester::with_extension`] varies it, which
    /// is right when TypeScript is the overwhelmingly common case. It is not the common case
    /// here — a rule authored as a component is one written in the language it inspects, and the
    /// two that exist target Rust — so a default would be wrong more often than not, and the
    /// pair of `*_with_extension` variants it would need doubles the constructor count for
    /// nothing.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`].
    pub fn for_component(name: &str, bytes: &[u8], extension: &str) -> Result<Self, TestError> {
        Self::build_component(name, bytes, extension, None)
    }

    /// Build a tester for a component rule configured with options.
    ///
    /// `options` is **JSON**, and the difference from [`RuleTester::configured`] is the whole
    /// point rather than an inconvenience. That one splices JavaScript source into a config, so
    /// a factory rule can be handed a function or a regular expression. A component cannot close
    /// over a host-supplied value at all: its options cross the boundary as data, through the
    /// world's `configure(options-json)`. Accepting source here would suggest otherwise, and
    /// would test a shape no real config can produce.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`], plus [`TestError::Setup`] if `options` is not valid JSON — which
    /// is caught here rather than left to surface as a config parse error naming a generated
    /// file the caller never wrote.
    pub fn for_component_configured(
        name: &str,
        bytes: &[u8],
        extension: &str,
        options: &str,
    ) -> Result<Self, TestError> {
        let options: serde_json::Value = serde_json::from_str(options)
            .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
        Self::build_component(name, bytes, extension, Some(options))
    }

    /// Build a tester for a built-in rule, named the way a real config names it.
    ///
    /// `name` is the bare rule name — `"no-default-export"` — and the generated `lanekeep.json`
    /// carries `"lanekeep/no-default-export"`. `components` is the lookup that answers it, which
    /// for lanekeep's own rules is `lanekeep_rules::component`; it is a parameter rather than a
    /// dependency because `lanekeep-rules` dev-depends on this crate, and an edge the other way
    /// would put each crate ahead of the other in the publication order.
    ///
    /// # What this exists for, and what [`RuleTester::for_component`] cannot do
    ///
    /// `for_component` writes an artifact to a path, and **a path reference contributes every
    /// rule the artifact hosts**. That is right for a component built from one `rust-rules/`
    /// crate and wrong for a shared one: the four TypeScript built-ins live in a single
    /// `typescript-builtins.wasm`, so pointing a tester at its bytes runs all four and there is
    /// no way to say which one is under test. Naming the *specifier* is how a config says it —
    /// resolution goes through the embedded table, which carries the rule's index, and the
    /// engine is handed one rule. Until this constructor existed, a rule of a shared component
    /// could not be tested through `RuleTester` at all.
    ///
    /// # It is slower than every other constructor, by a lot
    ///
    /// A built-in that ships as a component is compiled at load, and the shared TypeScript one
    /// is 12.4 MiB. The first `run` on a tester pays that — seconds, not milliseconds — and
    /// later runs on the same tester map what it wrote into the throwaway project. Prefer one
    /// tester over a table of cases to a tester per case.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::new`].
    pub fn for_built_in(
        name: &str,
        extension: &str,
        components: BuiltinComponent,
    ) -> Result<Self, TestError> {
        Self::build_built_in(name, extension, components, None)
    }

    /// Build a tester for a built-in rule configured with options.
    ///
    /// `options` is **JSON**, for the reason [`RuleTester::for_component_configured`] gives: a
    /// built-in that ships as a component takes its options as data through `configure`, and one
    /// that ships as a module is reached from a `lanekeep.json` here too, where JSON is all a
    /// config can write. Neither form can close over a host-supplied value.
    ///
    /// # Errors
    ///
    /// As [`RuleTester::for_component_configured`].
    pub fn for_built_in_configured(
        name: &str,
        extension: &str,
        components: BuiltinComponent,
        options: &str,
    ) -> Result<Self, TestError> {
        let options: serde_json::Value = serde_json::from_str(options)
            .map_err(|e| TestError::Setup(format!("`options` is not valid JSON: {e}")))?;
        Self::build_built_in(name, extension, components, Some(options))
    }

    /// Serve source maps for built-in components too.
    ///
    /// Diagnostics only, and separate from the constructor because of it: a map decides where a
    /// *thrown* rule is reported and nothing about what a rule finds, so a test asserting
    /// behavior needs none. `lanekeep_rules::component_source_map` is the lookup for lanekeep's
    /// own rules.
    #[must_use]
    pub const fn with_component_maps(mut self, maps: BuiltinComponentMap) -> Self {
        self.component_maps = maps;
        self
    }

    /// Serve the built-in modules a rule imports — `lanekeep/patterns` and friends.
    ///
    /// The lookup for lanekeep's own rules is `lanekeep_rules::source`; it is a parameter
    /// rather than a dependency for the same reason [`RuleTester::for_built_in`]'s
    /// `components` is — `lanekeep-rules` dev-depends on this crate, and an edge the other
    /// way would put each crate ahead of the other in the publication order.
    #[must_use]
    pub const fn with_builtins(mut self, builtins: BuiltinSource) -> Self {
        self.builtins = builtins;
        self
    }

    /// Write the throwaway project for a built-in rule named by its specifier.
    fn build_built_in(
        name: &str,
        extension: &str,
        components: BuiltinComponent,
        options: Option<serde_json::Value>,
    ) -> Result<Self, TestError> {
        let mut tester = Self::empty(name, extension, JSON_CONFIG);
        tester.components = components;

        let reference = format!("lanekeep/{name}");
        let rule = match options {
            None => serde_json::Value::String(reference),
            Some(options) => serde_json::json!({ "rule": reference, "options": options }),
        };
        let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });
        tester.write(JSON_CONFIG, &config.to_string())?;
        Ok(tester)
    }

    /// Write the throwaway project.
    ///
    /// `rule_expr` is what goes in the config's `rules` array — the imported module for a
    /// plain rule, a call for a factory.
    fn build(
        name: &str,
        rule_source: &str,
        extension: &str,
        rule_expr: &str,
    ) -> Result<Self, TestError> {
        let tester = Self::empty(name, extension, TS_CONFIG);
        // Nested one level rather than sitting at the fixture's own top, so a rule that
        // imports a sibling module the way this repository's local rules do — `../modules/x`
        // from `lanekeep/rules/some-rule.ts` — resolves inside the fixture instead of
        // escaping it. `mirror_modules` is what makes `../modules/x` resolve to something
        // real rather than merely legal.
        tester.write("rules/rule.ts", rule_source)?;
        tester.mirror_modules()?;
        tester.write(
            TS_CONFIG,
            &format!(
                "import {{ defineConfig }} from 'lanekeep';\n\
                 import rule from './rules/rule';\n\
                 export default defineConfig({{ include: ['subject/**'], rules: [{rule_expr}] }});\n"
            ),
        )?;
        Ok(tester)
    }

    /// Copy this repository's own `lanekeep/modules/` into the fixture, as `modules/` — a
    /// sibling of `rules/rule.ts`, one level up from it, exactly as `lanekeep/modules/` sits
    /// relative to `lanekeep/rules/` in the real repository.
    ///
    /// A rule tested here is given as a source string, not a path, so there is no file on
    /// disk this crate could otherwise learn the rule's real location from — and no way to
    /// thread one through without changing the shape every existing caller of `new`,
    /// `with_extension` and `configured` already depends on. Locating the source directory
    /// from this crate's own manifest directory keeps that shape unchanged.
    ///
    /// A no-op when the source directory does not exist, which is true for everything other
    /// than this workspace's own tests: a rule with no relative import never reads the copy,
    /// and a project outside this repository that depends on this crate to test its own
    /// rules has no such directory to find.
    fn mirror_modules(&self) -> Result<(), TestError> {
        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lanekeep/modules");
        let Ok(read_dir) = std::fs::read_dir(&source) else {
            return Ok(());
        };

        // Read-dir order is not guaranteed; fixed order keeps a failure in here reproducible.
        let mut paths = read_dir
            .map(|entry| {
                entry
                    .map(|e| e.path())
                    .map_err(|e| TestError::Setup(e.to_string()))
            })
            .collect::<Result<Vec<_>, _>>()?;
        paths.sort();

        for path in paths {
            if !path.is_file() {
                continue;
            }
            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
                continue;
            };
            let contents =
                std::fs::read_to_string(&path).map_err(|e| TestError::Setup(e.to_string()))?;
            self.write(&format!("modules/{name}"), &contents)?;
        }
        Ok(())
    }

    /// Write the throwaway project for a component rule.
    ///
    /// `options` is `None` for the bare form and `Some` for the object form, and the two are not
    /// the same config: `lanekeep-config` reads a bare string as a rule used as it comes and
    /// `{ "rule", "options" }` as one configured, which is the distinction a rule author already
    /// makes between a rule and a rule factory. `Some(Value::Null)` is therefore a third thing
    /// again — a rule explicitly configured with nothing — and is reachable from here on
    /// purpose.
    fn build_component(
        name: &str,
        bytes: &[u8],
        extension: &str,
        options: Option<serde_json::Value>,
    ) -> Result<Self, TestError> {
        let tester = Self::empty(name, extension, JSON_CONFIG);
        tester.write_bytes(COMPONENT_PATH, bytes)?;

        let reference = format!("./{COMPONENT_PATH}");
        let rule = match options {
            None => serde_json::Value::String(reference),
            Some(options) => serde_json::json!({ "rule": reference, "options": options }),
        };
        let config = serde_json::json!({ "include": ["subject/**"], "rules": [rule] });

        // `to_string` rather than `to_string_pretty`: nothing reads this by eye except when a
        // test fails, and a failure prints the error rather than the config.
        tester.write(JSON_CONFIG, &config.to_string())?;
        Ok(tester)
    }

    /// A tester with its own directory and nothing in it yet.
    fn empty(name: &str, extension: &str, config: &'static str) -> Self {
        // Unique per tester: the counter separates testers in one process, the process id
        // separates the processes nextest spawns per test.
        let seq = NEXT_ID.fetch_add(1, Ordering::Relaxed);
        let dir = std::env::temp_dir().join(format!(
            "lanekeep-ruletest-{name}-{}-{seq}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);

        Self {
            dir,
            extension: extension.to_owned(),
            config,
            components: no_components,
            component_maps: no_component_maps,
            builtins: no_builtins,
        }
    }

    /// Write a fixture file into the tester's project, at a path relative to it.
    ///
    /// A cross-file rule — one whose `reduce` reads files other than the subject — needs a
    /// corpus to read, and this is how a test builds it. The subject itself is still written
    /// by [`RuleTester::run`]; this is for the *other* files the rule reaches through
    /// `ctx.files()`.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Setup`] if the file cannot be written.
    pub fn write_fixture(&self, path: &str, contents: &str) -> Result<(), TestError> {
        self.write(path, contents)
    }

    fn write(&self, path: &str, contents: &str) -> Result<(), TestError> {
        self.write_bytes(path, contents.as_bytes())
    }

    fn write_bytes(&self, path: &str, contents: &[u8]) -> Result<(), TestError> {
        let full = self.dir.join(path);
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TestError::Setup(e.to_string()))?;
        }
        std::fs::write(full, contents).map_err(|e| TestError::Setup(e.to_string()))
    }

    /// Run the rule over a single source file and return what it reported.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Load`] if the rule does not load, or [`TestError::Run`] if it
    /// throws or breaches a budget.
    pub fn run(&self, source: &str) -> Result<Vec<Violation>, TestError> {
        // A fresh subject each time, so one case cannot see another's file.
        let _ = std::fs::remove_dir_all(self.dir.join("subject"));
        self.write(&format!("subject/input.{}", self.extension), source)?;

        let root = RuleRoot::new(&self.dir)
            .map_err(|e| TestError::Setup(e.to_string()))?
            .with_builtins(self.builtins)
            .with_builtin_components(self.components)
            .with_builtin_component_maps(self.component_maps);
        let config_path = self.dir.join(self.config);

        let sandbox =
            lanekeep_config::sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript))
                .map_err(|e| TestError::Load(e.to_string()))?;
        // **The throwaway project is named as the artifact root, and this crate is the one
        // caller entitled to do it.** `LoadOptions::artifacts` is `None` by default because a
        // rules root is not generally a project root, and guessing one would make loading a
        // config write into a directory nobody asked for. Here the two are the same directory,
        // this crate created it, and `Drop` removes it. Without it every `run` compiles each
        // component twice from scratch — tolerable for a 26 KB Rust rule and seconds per case
        // for the 12.4 MiB shared TypeScript one, which is what `for_built_in` reaches.
        let config = lanekeep_config::load_with(
            &sandbox,
            &root,
            &config_path,
            lanekeep_config::LoadOptions {
                artifacts: Some(&self.dir),
                ..lanekeep_config::LoadOptions::default()
            },
        )
        .map_err(|e| TestError::Load(e.to_string()))?;

        let engine = Engine::prepare(
            &config,
            &self.dir,
            root,
            &config_path,
            &lanekeep_languages::registry(),
            Arc::new(TypeScript),
            Arc::new(JavaScript),
        )
        .map_err(|e| TestError::Load(e.to_string()))?;

        engine
            .run()
            .map(|outcome| outcome.violations)
            .map_err(|e| TestError::Run(e.to_string()))
    }

    /// Assert the rule reports nothing for this source.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Mismatch`] listing what was reported, since "expected none,
    /// got some" is only actionable if you can see which.
    pub fn accepts(&self, source: &str) -> Result<(), TestError> {
        let violations = self.run(source)?;
        if violations.is_empty() {
            return Ok(());
        }

        let mut message = format!(
            "expected no violations, but the rule reported {}:\n",
            violations.len()
        );
        for violation in &violations {
            let _ = writeln!(
                message,
                "  {}:{} {}",
                violation.location.position.line,
                violation.location.position.column,
                violation.message
            );
        }
        let _ = write!(message, "\nsource:\n{}", indent(source));
        Err(TestError::Mismatch(message))
    }

    /// Assert the rule reports at exactly these one-based positions, in order.
    ///
    /// Positions rather than a count, because a rule reporting the right number of
    /// violations in the wrong places is a rule that is wrong — and a count-only assertion
    /// is exactly what lets that through.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Mismatch`] showing expected and actual side by side.
    pub fn reports_at(&self, source: &str, expected: &[(u32, u32)]) -> Result<(), TestError> {
        let violations = self.run(source)?;
        let actual: Vec<(u32, u32)> = violations
            .iter()
            .map(|v| (v.location.position.line, v.location.position.column))
            .collect();

        if actual == expected {
            return Ok(());
        }

        Err(TestError::Mismatch(format!(
            "reported positions did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
            indent(source)
        )))
    }

    /// Assert the rule reports exactly these messages, in order.
    ///
    /// For a rule that substitutes its own message per match — the position alone would not
    /// show whether the right one was chosen.
    ///
    /// # Errors
    ///
    /// Returns [`TestError::Mismatch`] showing both lists.
    pub fn reports_messages(&self, source: &str, expected: &[&str]) -> Result<(), TestError> {
        let violations = self.run(source)?;
        let actual: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();

        if actual == expected {
            return Ok(());
        }

        Err(TestError::Mismatch(format!(
            "reported messages did not match\n  expected: {expected:?}\n  actual:   {actual:?}\n\nsource:\n{}",
            indent(source)
        )))
    }
}

impl Drop for RuleTester {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

/// Indent source for inclusion in a failure message, so it is visibly quoted rather than
/// running together with the assertion text.
fn indent(source: &str) -> String {
    source.lines().fold(String::new(), |mut out, line| {
        // Writing into a String cannot fail; swallowing the Result keeps this a fold
        // rather than a loop with an unreachable error arm.
        let _ = writeln!(out, "  | {line}");
        out
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const DEBUGGER: &str = "import { defineRule } from 'lanekeep';\n\
        export default defineRule({\n\
          id: 'local/no-debugger',\n\
          query: '(debugger_statement) @stmt',\n\
          card: {\n\
            message: 'debugger statement',\n\
            remediation: 'remove it',\n\
            examples: { bad: 'debugger;', good: 'log();' },\n\
          },\n\
          check(ctx, m) { ctx.report(m.stmt); },\n\
        });\n";

    fn tester(name: &str) -> RuleTester {
        RuleTester::new(name, DEBUGGER).expect("builds")
    }

    #[test]
    fn accepts_clean_source() {
        tester("accepts")
            .accepts("const a = 1;\n")
            .expect("should accept");
    }

    #[test]
    fn reports_at_the_expected_positions() {
        tester("positions")
            .reports_at("const a = 1;\ndebugger;\n", &[(2, 1)])
            .expect("should report");
    }

    #[test]
    fn reports_several_in_order() {
        tester("several")
            .reports_at("debugger;\nconst a = 1;\ndebugger;\n", &[(1, 1), (3, 1)])
            .expect("should report both");
    }

    #[test]
    fn accepts_fails_loudly_and_shows_what_was_found() {
        // A harness that said only "expected none, got some" would leave the author
        // guessing which case tripped.
        let err = tester("accepts-fail")
            .accepts("debugger;\n")
            .expect_err("should not accept");

        let rendered = err.to_string();
        assert!(rendered.contains("expected no violations"), "{rendered}");
        assert!(
            rendered.contains("debugger statement"),
            "should show the message: {rendered}"
        );
        assert!(rendered.contains("1:1"), "should show where: {rendered}");
    }

    #[test]
    fn a_position_mismatch_shows_both_sides() {
        let err = tester("position-fail")
            .reports_at("debugger;\n", &[(5, 5)])
            .expect_err("should not match");

        let rendered = err.to_string();
        assert!(rendered.contains("expected: [(5, 5)]"), "{rendered}");
        assert!(rendered.contains("actual:   [(1, 1)]"), "{rendered}");
    }

    #[test]
    fn checks_messages_when_a_rule_substitutes_its_own() {
        let rule = "import { defineRule } from 'lanekeep';\n\
            export default defineRule({\n\
              id: 'local/named',\n\
              query: '(variable_declarator name: (identifier) @name)',\n\
              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
              check(ctx, m) { ctx.report(m.name, `saw ${ctx.text(m.name)}`); },\n\
            });\n";

        RuleTester::new("messages", rule)
            .expect("builds")
            .reports_messages(
                "const alpha = 1;\nconst beta = 2;\n",
                &["saw alpha", "saw beta"],
            )
            .expect("should match");
    }

    #[test]
    fn a_rule_that_does_not_load_is_distinguished_from_one_that_found_nothing() {
        // The distinction that matters most. Reporting a load failure as "no violations"
        // would make a broken rule look like a passing one — which is the same failure
        // mode the config's has_check test exists to prevent, one layer up.
        let broken = "import { defineRule } from 'lanekeep';\n\
            export default defineRule({\n\
              id: 'local/broken',\n\
              query: '(no_such_node) @x',\n\
              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
              check() {},\n\
            });\n";

        let err = RuleTester::new("broken", broken)
            .expect("builds")
            .accepts("const a = 1;\n")
            .expect_err("must not pass");

        assert!(matches!(err, TestError::Load(_)), "{err:?}");
        assert!(err.to_string().contains("no_such_node"), "{err}");
    }

    #[test]
    fn a_throwing_rule_is_reported_as_a_run_failure() {
        let throwing = "import { defineRule } from 'lanekeep';\n\
            export default defineRule({\n\
              id: 'local/throws',\n\
              query: '(debugger_statement) @s',\n\
              card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
              check() { throw new Error('boom'); },\n\
            });\n";

        let err = RuleTester::new("throwing", throwing)
            .expect("builds")
            .accepts("debugger;\n")
            .expect_err("must not pass");

        assert!(matches!(err, TestError::Run(_)), "{err:?}");
        assert!(err.to_string().contains("boom"), "{err}");
    }

    #[test]
    fn cases_do_not_leak_into_each_other() {
        // Each case rewrites the subject directory. Without that, a violation from an
        // earlier case would still be on disk and show up in the next one.
        let tester = tester("isolation");
        tester
            .reports_at("debugger;\n", &[(1, 1)])
            .expect("first case");
        tester
            .accepts("const a = 1;\n")
            .expect("second case must not see the first");
    }

    #[test]
    fn a_tsx_rule_can_be_tested_against_tsx() {
        // Which grammar parses a file is decided by its extension, so a TSX rule tested
        // against a `.ts` subject would silently never match.
        let rule = "import { defineRule } from 'lanekeep';\n\
            export default defineRule({\n\
              id: 'local/no-jsx',\n\
              language: 'tsx',\n\
              query: '(jsx_element) @el',\n\
              card: { message: 'jsx', remediation: 'do not', examples: { bad: '<a/>', good: 'a()' } },\n\
              check(ctx, m) { ctx.report(m.el); },\n\
            });\n";

        RuleTester::with_extension("tsx", rule, "tsx")
            .expect("builds")
            .reports_at("const a = <div>hi</div>;\n", &[(1, 11)])
            .expect("should report the element");
    }

    /// Not a component. These cases assert what the harness *writes*, which is the whole of what
    /// the component constructors do; running one is `crates/lanekeep-rules/tests/no_unwrap.rs`'s
    /// job, and it has real components to do it with. Four bytes of magic keep the fixture from
    /// looking like something that could be loaded.
    const NOT_A_COMPONENT: &[u8] = b"\0asm not really";

    /// The generated config, parsed back.
    fn written_config(tester: &RuleTester) -> serde_json::Value {
        let text = std::fs::read_to_string(tester.dir.join(JSON_CONFIG)).expect("config written");
        serde_json::from_str(&text).expect("the generated config is JSON")
    }

    #[test]
    fn a_component_tester_writes_the_bytes_and_points_a_json_config_at_them() {
        let tester = RuleTester::for_component("component", NOT_A_COMPONENT, "rs").expect("builds");

        assert_eq!(
            std::fs::read(tester.dir.join(COMPONENT_PATH)).expect("component written"),
            NOT_A_COMPONENT,
            "the bytes must reach disk unchanged — a component is identified by them"
        );
        assert_eq!(
            written_config(&tester)["rules"][0],
            serde_json::json!("./rules/rule.wasm"),
            "the bare form is a string, which is what `lanekeep-config` reads as a rule used \
             as it comes"
        );
        assert!(
            !tester.dir.join(TS_CONFIG).exists(),
            "a component project must not also carry a TypeScript config"
        );
    }

    #[test]
    fn a_configured_component_tester_embeds_its_options_as_data() {
        // The distinction `configured` cannot make: those options are spliced into JavaScript
        // source, and these have to survive as a value, because `configure(options-json)` is
        // where they arrive. A harness that stringified them would hand the rule `"{...}"`.
        let tester = RuleTester::for_component_configured(
            "configured",
            NOT_A_COMPONENT,
            "rs",
            r#"{"allow": ["subject/input.rs"]}"#,
        )
        .expect("builds");

        assert_eq!(
            written_config(&tester)["rules"][0],
            serde_json::json!({
                "rule": "./rules/rule.wasm",
                "options": { "allow": ["subject/input.rs"] },
            })
        );
    }

    #[test]
    fn explicit_null_options_are_a_different_config_from_the_bare_form() {
        // Three states, not two. `lanekeep-config` reads a bare string as a rule used as it
        // comes and the object form as one configured, so a rule named with `null` options is
        // not the same config as a rule named with none — and a harness collapsing them would
        // make the bare case untestable.
        let configured =
            RuleTester::for_component_configured("null-options", NOT_A_COMPONENT, "rs", "null")
                .expect("builds");
        let bare = RuleTester::for_component("bare", NOT_A_COMPONENT, "rs").expect("builds");

        assert_eq!(
            written_config(&configured)["rules"][0],
            serde_json::json!({ "rule": "./rules/rule.wasm", "options": null })
        );
        assert_ne!(
            written_config(&configured)["rules"][0],
            written_config(&bare)["rules"][0]
        );
    }

    #[test]
    fn options_that_are_not_json_are_refused_naming_the_options() {
        // The failure mode this branch exists to prevent: spliced into the config unchecked, a
        // malformed value becomes a parse error against a generated file the caller never wrote,
        // pointing at a line number that means nothing to them.
        let err = RuleTester::for_component_configured(
            "bad-options",
            NOT_A_COMPONENT,
            "rs",
            "{ allow: ['subject/input.rs'] }",
        )
        .expect_err("JavaScript object syntax is not JSON");

        assert!(matches!(err, TestError::Setup(_)), "{err:?}");
        assert!(
            err.to_string().contains("`options` is not valid JSON"),
            "{err}"
        );
    }

    #[test]
    fn the_temporary_project_is_cleaned_up() {
        let path = {
            let tester = tester("cleanup");
            tester.accepts("const a = 1;\n").expect("runs");
            tester.dir.clone()
        };
        assert!(
            !path.exists(),
            "the tester should remove its project on drop"
        );
    }
}