web_modules 0.5.1

Pure-Rust, buildless toolchain for ES modules and Web Components
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
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
//! Preflight-capable pipeline steps: every build stage states what it WOULD emit for a
//! given source file, before anything is written.
//!
//! The driver walks the source roots exactly once, offers every file to every enabled
//! step, and collects the claims — which output-relative path each step would produce.
//! The build resolves the claims up front: duplicate output paths are detected before
//! the first write, and each output path is then written by exactly one winner, so
//! precedence is an explicit policy instead of an emergent write order.
//!
//! Feature and toggle variance lives in [`enabled_steps`] alone. The same step list
//! drives the preflight and the emission, and the dev server builds the identical list
//! for its startup duplicate warnings — so what the preflight predicts is, by
//! construction, what the pipeline does. Each step's `claim` logic lives next to the
//! processor it describes, so classification cannot drift from emission.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::module_graph::ModuleImport;
use crate::Result;

/// Within-root precedence, lowest wins: a `*.tera` beats a literal file, which beats a
/// transformed sibling (a copied `app.js` over the output of `app.ts`) — the order the
/// dev server probes candidates in, so `dev` and `build` resolve a shadowed path alike.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Rank {
    Tera = 0,
    Static = 1,
    Transform = 2,
}

/// What one step would emit for one source file.
#[derive(Clone, Debug)]
pub(crate) struct Claim {
    /// The output-relative path the emission would write.
    pub out_rel: PathBuf,
    /// Orders same-rank claims on one target (TypeScript: `ts` = 0, `tsx` = 1,
    /// `mts` = 2 — dev's probe order). Zero for every other step.
    pub tiebreak: u8,
}

/// A stage that can say what it would emit without emitting.
pub(crate) trait Preflight {
    /// Stable name for conflict reports (e.g. "static copy", "TypeScript transform").
    fn name(&self) -> &'static str;
    fn rank(&self) -> Rank;
    /// The claim this step makes on the source file at root-relative `rel`, or `None`
    /// when the file is not its input (wrong extension, `_` partial, `.d.ts`, …).
    /// Steps never consult the reject list: the driver drops every claim whose
    /// *target* is rejected, centrally, and records the drop for reporting. (The
    /// standalone [`copy_static`](crate::static_files::copy_static) walk applies the
    /// list itself — it runs without a driver.)
    fn claim(&self, rel: &Path) -> Option<Claim>;
}

/// A preflight-capable stage that can also emit one file for real.
pub(crate) trait Step: Preflight {
    /// Emit the source at `src` (absolute; `rel` names it in messages) to `dest`,
    /// returning what the module graph should record. The caller creates `dest`'s
    /// parent directories. `cx` carries the generated import map — final only by the
    /// time the Tera step runs, which is the only step reading it.
    fn emit(&self, cx: &EmitCx<'_>, src: &Path, rel: &Path, dest: &Path) -> Result<Emitted>;
}

/// Late-bound emission context: what is not known when the step list is built.
pub(crate) struct EmitCx<'a> {
    pub importmap: &'a crate::importmap::Importmap,
}

/// The module-graph contribution of one emission: `Some` for JavaScript-producing
/// emissions (a transform output, a copied `.js`/`.mjs`), `None` otherwise.
#[derive(Debug, Default)]
pub(crate) struct Emitted {
    pub imports: Option<Vec<ModuleImport>>,
}

/// Per-build tuning the steps need, threaded through [`enabled_steps`]. Fields exist
/// only for the features that use them; [`Default`] is the claims-only configuration
/// the dev server uses for its duplicate warnings.
#[derive(Default)]
pub(crate) struct StepConfig {
    #[cfg(feature = "typescript")]
    pub transpile: crate::typescript::TranspileOptions,
    #[cfg(feature = "scss")]
    pub scss_load_paths: Vec<PathBuf>,
}

/// The enabled steps: compiled-in features ∩ `Processors` toggles — the one place that
/// variance lives. Build and dev both construct their step list here, so the
/// preflighted pipeline is the pipeline that runs.
pub(crate) fn enabled_steps(
    processors: &crate::build::Processors,
    config: StepConfig,
) -> Vec<Box<dyn Step>> {
    let mut steps: Vec<Box<dyn Step>> = Vec::new();
    #[cfg(feature = "tera")]
    if processors.tera {
        steps.push(Box::new(TeraStep));
    }
    steps.push(Box::new(crate::static_files::StaticStep::new(
        processors.reject.clone(),
    )));
    #[cfg(feature = "typescript")]
    if processors.typescript {
        steps.push(Box::new(crate::typescript::TypeScriptStep::new(
            config.transpile,
        )));
    }
    #[cfg(feature = "scss")]
    if processors.scss {
        steps.push(Box::new(crate::scss::ScssStep::new(config.scss_load_paths)));
    }
    steps
}

/// Renders `x.y.tera` → `x.y` with the generated import map exposed as the
/// `importmap` template variable — the static counterpart of the dev server's
/// on-the-fly rendering. `tera::one_off` (via [`crate::templates`]) has no template
/// registry, so each file renders independently; the `_`-partial skip is a
/// convention, not an inheritance system.
#[cfg(feature = "tera")]
pub(crate) struct TeraStep;

#[cfg(feature = "tera")]
impl Preflight for TeraStep {
    fn name(&self) -> &'static str {
        "Tera template"
    }

    fn rank(&self) -> Rank {
        Rank::Tera
    }

    fn claim(&self, rel: &Path) -> Option<Claim> {
        let name = rel.file_name()?.to_str()?;
        let ext = rel.extension()?.to_str()?;
        if !ext.eq_ignore_ascii_case("tera") || name.starts_with('_') {
            return None;
        }
        // Drop the final `.tera`: `index.html.tera` → `index.html`, `page.tera` → `page`.
        Some(Claim {
            out_rel: rel.with_extension(""),
            tiebreak: 0,
        })
    }
}

#[cfg(feature = "tera")]
impl Step for TeraStep {
    fn emit(&self, cx: &EmitCx<'_>, src: &Path, rel: &Path, dest: &Path) -> Result<Emitted> {
        let ctx = crate::templates::importmap_context(cx.importmap);
        let rendered = crate::templates::render_file(src, &ctx)?;

        // A template can render JavaScript (`app.js.tera`); the result joins the
        // module graph like any other emitted JS, read from the rendered text before
        // the write through the same shared helper the copy step uses.
        let ext = dest.extension().and_then(|x| x.to_str()).unwrap_or("");
        let imports = crate::module_graph::imports_for_emitted_js(&rendered, ext, rel)?;
        std::fs::write(dest, rendered)?;
        Ok(Emitted { imports })
    }
}

/// One recorded claim: which step, from which root, on which source file, targeting
/// which output path.
#[derive(Debug)]
pub(crate) struct ClaimRecord {
    pub root: usize,
    pub rel: PathBuf,
    pub step: usize,
    pub rank: Rank,
    pub tiebreak: u8,
    pub out_rel: PathBuf,
}

impl ClaimRecord {
    /// The precedence key, lowest wins: earlier root, then rank (tera < static <
    /// transform), then the step's own tiebreak, then the source path for stability.
    fn precedence(&self) -> (usize, Rank, u8, &Path) {
        (self.root, self.rank, self.tiebreak, &self.rel)
    }
}

/// Every claim from one walk of the source roots, plus every visited path (the
/// `cargo:rerun-if-changed` set), the files that resolve outside their root, the
/// symlinks the mode told the walk not to follow, and the walk problems that would
/// make the preflight incomplete.
pub(crate) struct PreflightReport {
    claims: Vec<ClaimRecord>,
    walked: Vec<PathBuf>,
    escaping_sources: Vec<EscapingSource>,
    walk_errors: Vec<String>,
    skipped_symlinks: Vec<SkippedSymlink>,
    rejected: Vec<RejectedClaim>,
    npm_assets: Vec<NpmAsset>,
}

/// The walk-level policies [`preflight`] enforces, bundled so the signature does not
/// grow one parameter per policy: the reject list (drops claims by target) and the
/// symlink mode (what a link in the tree means).
pub(crate) struct WalkPolicy<'a> {
    pub reject: &'a crate::reject::Reject,
    pub symlinks: crate::SymlinkMode,
}

/// A symlink the walk did not follow — [`Redirect`](crate::SymlinkMode::Redirect) /
/// [`Move`](crate::SymlinkMode::Move) modes only, where serving answers with a
/// redirect and a static build has nothing to emit. A directory link is recorded as
/// its single top entry; nothing beneath it is walked.
#[derive(Debug)]
pub(crate) struct SkippedSymlink {
    pub root: usize,
    pub rel: PathBuf,
}

/// A symlink whose target is an `npm://<package>/<subpath>` URL — a reference into
/// `node_modules`, not a filesystem link. The build resolves it through
/// [`crate::npm_link`] and writes the file(s) at `rel`, in every symlink mode.
#[derive(Debug)]
pub(crate) struct NpmAsset {
    pub root: usize,
    pub rel: PathBuf,
    pub target: String,
}

/// A source file whose canonical location is not under its canonical root — a
/// symlink pointing outside the tree. No step gets to claim such a file: the build
/// refuses to publish it, matching the dev server's canonical containment
/// (`contained_file`), which refuses to serve it.
#[derive(Debug)]
pub(crate) struct EscapingSource {
    pub root: usize,
    pub rel: PathBuf,
    /// Where the path actually resolves.
    pub target: PathBuf,
}

/// A claim dropped because its output path is on the reject list — recorded so
/// `build` and `dev` can say aloud what they excluded.
#[derive(Debug)]
pub(crate) struct RejectedClaim {
    pub root: usize,
    pub rel: PathBuf,
    pub out_rel: PathBuf,
}

impl RejectedClaim {
    /// The warning line for this drop: the source path, plus the target when it
    /// differs (a template or compiled source materializing a rejected path).
    pub(crate) fn describe(&self, roots: &[PathBuf]) -> String {
        let source = roots[self.root].join(&self.rel);
        if self.rel == self.out_rel {
            format!("{} dropped by the reject list", source.display())
        } else {
            format!(
                "{} would emit {} - dropped by the reject list",
                source.display(),
                self.out_rel.display()
            )
        }
    }
}

/// Walk each root once and offer every file to every step, under the walk policy.
///
/// Symlinks follow the mode: under [`Follow`](crate::SymlinkMode::Follow) (default)
/// containment is canonical — each root resolves once, every file must resolve under
/// it, and one that does not is recorded as an [`EscapingSource`] instead of being
/// offered, so in-root links work and a link out of the tree never publishes. Under
/// [`FollowUnsafe`](crate::SymlinkMode::FollowUnsafe) every link is followed and
/// nothing escapes by definition. Under [`Redirect`](crate::SymlinkMode::Redirect) /
/// [`Move`](crate::SymlinkMode::Move) links are not followed at all: each one is
/// recorded as a [`SkippedSymlink`] (serving answers a redirect; a build has nothing
/// to emit). Unreadable entries and unresolvable links land in `walk_errors` rather
/// than being silently dropped — a partial preflight would otherwise pass for a
/// complete one.
///
/// The `reject` list guards emission centrally, by **target**: a claim on a rejected
/// output path is dropped (with a warning) no matter which step makes it, so a
/// template or a compiled source cannot materialize `.env` or `private.key` — the
/// same decision the dev server takes on the request path.
pub(crate) fn preflight(
    roots: &[PathBuf],
    steps: &[&dyn Preflight],
    policy: WalkPolicy<'_>,
) -> PreflightReport {
    let mut report = PreflightReport {
        claims: Vec::new(),
        walked: Vec::new(),
        escaping_sources: Vec::new(),
        walk_errors: Vec::new(),
        skipped_symlinks: Vec::new(),
        rejected: Vec::new(),
        npm_assets: Vec::new(),
    };
    let follow = matches!(
        policy.symlinks,
        crate::SymlinkMode::Follow | crate::SymlinkMode::FollowUnsafe
    );
    for (root_index, root) in roots.iter().enumerate() {
        // Canonical containment applies under `Follow` only: `FollowUnsafe` permits
        // escapes, and the redirect modes never follow a link in the first place.
        let canonical_root = match policy.symlinks {
            crate::SymlinkMode::Follow => match std::fs::canonicalize(root) {
                Ok(canonical) => Some(canonical),
                Err(e) => {
                    report.walk_errors.push(format!("{}: {e}", root.display()));
                    continue;
                }
            },
            _ => None,
        };
        for entry in walkdir::WalkDir::new(root).follow_links(follow) {
            let entry = match entry {
                Ok(entry) => entry,
                Err(e) => {
                    // A dangling link whose target is an `npm://` reference is not a walk
                    // error: under a following mode walkdir fails to stat the (virtual)
                    // target, so intercept it here before it is reported as one.
                    if let Some(path) = e.path() {
                        if let Some(target) = crate::npm_link::link_target(path) {
                            if let Ok(rel) = path.strip_prefix(root) {
                                report.npm_assets.push(NpmAsset {
                                    root: root_index,
                                    rel: rel.to_path_buf(),
                                    target,
                                });
                            }
                            continue;
                        }
                    }
                    report.walk_errors.push(e.to_string());
                    continue;
                }
            };
            let path = entry.path();
            report.walked.push(path.to_path_buf());
            // Redirect/Move: a symlink (file or directory alike) is a redirect when
            // served and nothing when built — record it and move on. Checked before
            // `is_file`, which would stat *through* the link.
            if !follow && entry.path_is_symlink() {
                // An `npm://` link is a package reference, resolved in every mode; a
                // filesystem link here is a redirect a static build cannot express.
                if let Some(target) = crate::npm_link::link_target(path) {
                    if let Ok(rel) = path.strip_prefix(root) {
                        report.npm_assets.push(NpmAsset {
                            root: root_index,
                            rel: rel.to_path_buf(),
                            target,
                        });
                    }
                    continue;
                }
                if let Ok(rel) = path.strip_prefix(root) {
                    report.skipped_symlinks.push(SkippedSymlink {
                        root: root_index,
                        rel: rel.to_path_buf(),
                    });
                }
                continue;
            }
            if !path.is_file() {
                continue;
            }
            let Ok(rel) = path.strip_prefix(root) else {
                continue;
            };
            if let Some(canonical_root) = &canonical_root {
                let canonical = match std::fs::canonicalize(path) {
                    Ok(canonical) => canonical,
                    Err(e) => {
                        report.walk_errors.push(format!("{}: {e}", path.display()));
                        continue;
                    }
                };
                if !canonical.starts_with(canonical_root) {
                    report.escaping_sources.push(EscapingSource {
                        root: root_index,
                        rel: rel.to_path_buf(),
                        target: canonical,
                    });
                    continue;
                }
            }
            for (step_index, step) in steps.iter().enumerate() {
                if let Some(claim) = step.claim(rel) {
                    if policy.reject.rejects_path(&claim.out_rel) {
                        crate::reject::warn_rejected(&claim.out_rel.display().to_string());
                        report.rejected.push(RejectedClaim {
                            root: root_index,
                            rel: rel.to_path_buf(),
                            out_rel: claim.out_rel,
                        });
                        continue;
                    }
                    report.claims.push(ClaimRecord {
                        root: root_index,
                        rel: rel.to_path_buf(),
                        step: step_index,
                        rank: step.rank(),
                        tiebreak: claim.tiebreak,
                        out_rel: claim.out_rel,
                    });
                }
            }
        }
    }
    report
}

/// Two or more sources claiming one output path.
pub(crate) struct Conflict<'a> {
    pub out_rel: &'a Path,
    /// All claimants in precedence order — `[0]` is what `--skip-duplicates` ships.
    pub claimants: Vec<&'a ClaimRecord>,
}

impl PreflightReport {
    /// Claims grouped by output path (deterministic order), each group sorted by
    /// precedence, winner first.
    fn grouped(&self) -> BTreeMap<&Path, Vec<&ClaimRecord>> {
        let mut groups: BTreeMap<&Path, Vec<&ClaimRecord>> = BTreeMap::new();
        for claim in &self.claims {
            groups.entry(&claim.out_rel).or_default().push(claim);
        }
        for group in groups.values_mut() {
            group.sort_by_key(|c| c.precedence());
        }
        groups
    }

    /// Every output path claimed more than once, with all claimants (winner first).
    pub(crate) fn conflicts(&self) -> Vec<Conflict<'_>> {
        self.grouped()
            .into_iter()
            .filter(|(_, claimants)| claimants.len() > 1)
            .map(|(out_rel, claimants)| Conflict { out_rel, claimants })
            .collect()
    }

    /// Claims whose output path is not a purely normal relative path — a root, prefix,
    /// `.` or `..` component would let a write land outside the output directory. The
    /// bundled steps cannot produce one (their targets derive from walk-relative source
    /// paths, and a file name cannot contain a separator), but the invariant is
    /// enforced by the build rather than assumed of every step.
    pub(crate) fn escaping(&self) -> Vec<&ClaimRecord> {
        self.claims
            .iter()
            .filter(|claim| {
                !claim
                    .out_rel
                    .components()
                    .all(|c| matches!(c, std::path::Component::Normal(_)))
                    || claim.out_rel.as_os_str().is_empty()
            })
            .collect()
    }

    /// One claim per output path — the winner under the unified precedence — in
    /// output-path order.
    pub(crate) fn winners(&self) -> Vec<&ClaimRecord> {
        self.grouped().into_values().map(|group| group[0]).collect()
    }

    /// Whether any source claims `out_rel` — the fallback-index gate.
    pub(crate) fn claims_target(&self, out_rel: impl AsRef<Path>) -> bool {
        let out_rel = out_rel.as_ref();
        self.claims.iter().any(|c| c.out_rel == out_rel)
    }

    /// Every path the scan visited, directories included — the
    /// `cargo:rerun-if-changed` set.
    pub(crate) fn walked_paths(&self) -> &[PathBuf] {
        &self.walked
    }

    /// Files whose canonical location is outside their canonical source root.
    pub(crate) fn escaping_sources(&self) -> &[EscapingSource] {
        &self.escaping_sources
    }

    /// Walk problems — unreadable directories, unresolvable links. The preflight
    /// describes the complete output only when this is empty.
    pub(crate) fn walk_errors(&self) -> &[String] {
        &self.walk_errors
    }

    /// Symlinks the mode told the walk not to follow (Redirect/Move modes only).
    pub(crate) fn skipped_symlinks(&self) -> &[SkippedSymlink] {
        &self.skipped_symlinks
    }

    /// Claims dropped because their output path is on the reject list.
    pub(crate) fn rejected_claims(&self) -> &[RejectedClaim] {
        &self.rejected
    }

    /// `npm://` symlink references found in the tree — resolved into `node_modules` and
    /// emitted after the step winners, each at its own output path.
    pub(crate) fn npm_assets(&self) -> &[NpmAsset] {
        &self.npm_assets
    }
}

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

    /// A copy-like step: claims every file except `.src` sources, target = same path.
    struct CopyLike;

    impl Preflight for CopyLike {
        fn name(&self) -> &'static str {
            "copy"
        }
        fn rank(&self) -> Rank {
            Rank::Static
        }
        fn claim(&self, rel: &Path) -> Option<Claim> {
            let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
            (ext != "src").then(|| Claim {
                out_rel: rel.to_path_buf(),
                tiebreak: 0,
            })
        }
    }

    /// A transform-like step: claims `.src` files, target = `.txt` sibling.
    struct TransformLike;

    impl Preflight for TransformLike {
        fn name(&self) -> &'static str {
            "transform"
        }
        fn rank(&self) -> Rank {
            Rank::Transform
        }
        fn claim(&self, rel: &Path) -> Option<Claim> {
            let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
            (ext == "src").then(|| Claim {
                out_rel: rel.with_extension("txt"),
                tiebreak: 0,
            })
        }
    }

    fn scan(roots: &[PathBuf]) -> PreflightReport {
        scan_with(roots, crate::SymlinkMode::Follow)
    }

    fn scan_with(roots: &[PathBuf], symlinks: crate::SymlinkMode) -> PreflightReport {
        let reject = crate::reject::Reject::none();
        preflight(
            roots,
            &[&CopyLike, &TransformLike],
            WalkPolicy {
                reject: &reject,
                symlinks,
            },
        )
    }

    #[cfg(unix)]
    #[test]
    fn preflight_records_an_npm_link_as_an_asset_not_an_error() {
        use std::os::unix::fs::symlink;
        // A dangling `npm://` link would otherwise surface as a walk error under the
        // following mode; it must be captured as an asset instead, and the sibling
        // real file still claimed.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("web");
        std::fs::create_dir_all(root.join("icons/bi")).unwrap();
        symlink(
            "npm://bootstrap-icons/icons/eye.svg",
            root.join("icons/bi/eye.svg"),
        )
        .unwrap();
        std::fs::write(root.join("app.js"), "export {};").unwrap();

        let report = scan(std::slice::from_ref(&root));
        assert!(
            report.walk_errors().is_empty(),
            "an npm:// link is not a walk error"
        );
        assert!(report.escaping_sources().is_empty());
        let assets = report.npm_assets();
        assert_eq!(assets.len(), 1);
        assert_eq!(assets[0].rel, Path::new("icons/bi/eye.svg"));
        assert_eq!(assets[0].target, "npm://bootstrap-icons/icons/eye.svg");
        // The real sibling is still claimed for emission.
        assert!(report
            .winners()
            .iter()
            .any(|w| w.out_rel == Path::new("app.js")));
    }

    #[test]
    fn preflight_detects_a_within_root_conflict_and_ranks_the_winner() {
        // `a.txt` (copy) and `a.src` (transform → a.txt) claim one target; the
        // literal copy outranks the transform, exactly like `app.js` over `app.ts`.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("a.txt"), "literal").unwrap();
        std::fs::write(root.join("a.src"), "source").unwrap();
        std::fs::write(root.join("b.md"), "fine").unwrap();

        let report = scan(std::slice::from_ref(&root));
        let conflicts = report.conflicts();
        assert_eq!(conflicts.len(), 1, "one contested target");
        assert_eq!(conflicts[0].out_rel, Path::new("a.txt"));
        assert_eq!(conflicts[0].claimants.len(), 2);
        assert_eq!(
            conflicts[0].claimants[0].rel,
            Path::new("a.txt"),
            "the literal file wins"
        );

        let winners = report.winners();
        assert_eq!(winners.len(), 2, "one winner per target: a.txt and b.md");
        assert!(winners.iter().any(|w| w.out_rel == Path::new("b.md")));
    }

    #[test]
    fn preflight_detects_a_cross_root_conflict_and_the_first_root_wins() {
        let dir = tempfile::tempdir().unwrap();
        let first = dir.path().join("first");
        let second = dir.path().join("second");
        std::fs::create_dir_all(&first).unwrap();
        std::fs::create_dir_all(&second).unwrap();
        std::fs::write(first.join("app.txt"), "first").unwrap();
        std::fs::write(second.join("app.txt"), "second").unwrap();

        let roots = vec![first, second];
        let report = scan(&roots);
        let conflicts = report.conflicts();
        assert_eq!(conflicts.len(), 1);
        assert_eq!(
            conflicts[0].claimants[0].root, 0,
            "the earlier root outranks the later one"
        );
        assert_eq!(report.winners().len(), 1);
    }

    #[test]
    fn preflight_records_a_rejected_claim_instead_of_claiming() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join(".env"), "S=1").unwrap();
        std::fs::write(root.join("ok.md"), "fine").unwrap();

        let reject = crate::reject::Reject::default();
        let report = preflight(
            std::slice::from_ref(&root),
            &[&CopyLike, &TransformLike],
            WalkPolicy {
                reject: &reject,
                symlinks: crate::SymlinkMode::Follow,
            },
        );
        assert!(
            report.claims.iter().all(|c| c.out_rel != Path::new(".env")),
            "a rejected target never claims"
        );
        let rejected = report.rejected_claims();
        assert_eq!(rejected.len(), 1, "got {rejected:?}");
        assert_eq!(rejected[0].rel, Path::new(".env"));
        assert_eq!(rejected[0].out_rel, Path::new(".env"));
        assert_eq!(
            rejected[0].describe(std::slice::from_ref(&root)),
            format!("{} dropped by the reject list", root.join(".env").display())
        );
    }

    #[test]
    fn a_rejected_transform_target_names_both_paths() {
        // `.env.tera` → `.env`: the drop line names the source and the target it
        // would have materialized.
        let rejected = RejectedClaim {
            root: 0,
            rel: PathBuf::from(".env.tera"),
            out_rel: PathBuf::from(".env"),
        };
        let line = rejected.describe(&[PathBuf::from("web")]);
        assert!(
            line.contains(".env.tera")
                && line.contains("would emit .env - dropped by the reject list"),
            "got: {line}"
        );
    }

    #[test]
    fn tiebreak_orders_same_rank_claims() {
        // Two same-rank claims on one target differ only by tiebreak — the probe
        // order dev uses for ts/tsx/mts.
        struct Multi;
        impl Preflight for Multi {
            fn name(&self) -> &'static str {
                "multi"
            }
            fn rank(&self) -> Rank {
                Rank::Transform
            }
            fn claim(&self, rel: &Path) -> Option<Claim> {
                let ext = rel.extension()?.to_str()?;
                let tiebreak = ["one", "two"].iter().position(|e| *e == ext)? as u8;
                Some(Claim {
                    out_rel: rel.with_extension("out"),
                    tiebreak,
                })
            }
        }
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("x.two"), "later").unwrap();
        std::fs::write(root.join("x.one"), "earlier").unwrap();

        let reject = crate::reject::Reject::none();
        let report = preflight(
            std::slice::from_ref(&root),
            &[&Multi],
            WalkPolicy {
                reject: &reject,
                symlinks: crate::SymlinkMode::Follow,
            },
        );
        let conflicts = report.conflicts();
        assert_eq!(conflicts.len(), 1);
        assert_eq!(
            conflicts[0].claimants[0].rel,
            Path::new("x.one"),
            "the lower tiebreak wins"
        );
    }

    #[test]
    fn escaping_claims_are_flagged_and_honest_ones_are_not() {
        // A step that (wrongly) derives a traversing or absolute target: the report
        // flags every such claim, so the build can refuse it before any write. The
        // real steps never produce one — their targets come from walk-relative paths.
        struct Hostile;
        impl Preflight for Hostile {
            fn name(&self) -> &'static str {
                "hostile"
            }
            fn rank(&self) -> Rank {
                Rank::Static
            }
            fn claim(&self, rel: &Path) -> Option<Claim> {
                let ext = rel.extension().and_then(|x| x.to_str()).unwrap_or("");
                match ext {
                    "up" => Some(Claim {
                        out_rel: PathBuf::from("../evil.txt"),
                        tiebreak: 0,
                    }),
                    "abs" => Some(Claim {
                        out_rel: PathBuf::from("/etc/evil.txt"),
                        tiebreak: 0,
                    }),
                    _ => None,
                }
            }
        }
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("a.up"), "x").unwrap();
        std::fs::write(root.join("b.abs"), "x").unwrap();
        std::fs::write(root.join("fine.txt"), "x").unwrap();

        let reject = crate::reject::Reject::none();
        let report = preflight(
            std::slice::from_ref(&root),
            &[&Hostile, &CopyLike],
            WalkPolicy {
                reject: &reject,
                symlinks: crate::SymlinkMode::Follow,
            },
        );
        let escaping = report.escaping();
        assert_eq!(escaping.len(), 2, "got {escaping:?}");
        assert!(escaping
            .iter()
            .all(|claim| claim.out_rel.starts_with("..") || claim.out_rel.is_absolute()));

        // The honest steps' walk-derived claims never escape.
        let honest = scan(std::slice::from_ref(&root));
        assert!(honest.escaping().is_empty());
    }

    #[test]
    fn claims_target_gates_on_any_claim_and_walk_records_every_entry() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(root.join("sub")).unwrap();
        std::fs::write(root.join("sub/page.src"), "src").unwrap();

        let report = scan(std::slice::from_ref(&root));
        assert!(
            report.claims_target("sub/page.txt"),
            "the transform's target counts as claimed"
        );
        assert!(!report.claims_target("index.html"));
        // The walk records the root, the subdirectory and the file — directory mtimes
        // catch add/remove for rerun-if-changed.
        assert!(report.walked_paths().len() >= 3);
    }

    #[test]
    fn preflight_drops_claims_with_rejected_targets() {
        // Rejection is by target, centrally: the transform's `.txt` output dies even
        // though its `.src` source matches no pattern, and it dies for every step.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("a.src"), "source").unwrap();
        std::fs::write(root.join("b.md"), "fine").unwrap();

        let reject = crate::reject::Reject::from_list(["*.txt"]);
        let report = preflight(
            std::slice::from_ref(&root),
            &[&CopyLike, &TransformLike],
            WalkPolicy {
                reject: &reject,
                symlinks: crate::SymlinkMode::Follow,
            },
        );
        assert!(
            !report.claims_target("a.txt"),
            "the rejected target is not claimed"
        );
        assert!(report.claims_target("b.md"), "unrejected claims survive");
    }

    #[cfg(unix)]
    #[test]
    fn preflight_flags_files_resolving_outside_the_root() {
        // `root/exposed -> ../private`: reachable through the root lexically, but
        // canonically outside it — recorded as escaping, never offered to a step.
        let dir = tempfile::tempdir().unwrap();
        let private = dir.path().join("private");
        std::fs::create_dir_all(&private).unwrap();
        std::fs::write(private.join("credentials.txt"), "secret").unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();

        let report = scan(std::slice::from_ref(&root));
        let escaping = report.escaping_sources();
        assert_eq!(escaping.len(), 1, "one file resolves outside the root");
        assert_eq!(escaping[0].rel, Path::new("exposed/credentials.txt"));
        assert!(escaping[0].target.ends_with("private/credentials.txt"));
        assert!(
            !report.claims_target("exposed/credentials.txt"),
            "no step may claim an escaping file"
        );
    }

    #[cfg(unix)]
    #[test]
    fn preflight_allows_symlinks_resolving_inside_the_root() {
        // In-root links are legitimate tree layout: the linked file and the file
        // inside the linked dir both claim normally.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(root.join("real")).unwrap();
        std::fs::write(root.join("real/a.txt"), "data").unwrap();
        std::os::unix::fs::symlink(root.join("real/a.txt"), root.join("alias.txt")).unwrap();
        std::os::unix::fs::symlink(root.join("real"), root.join("linked")).unwrap();

        let report = scan(std::slice::from_ref(&root));
        assert!(report.escaping_sources().is_empty(), "nothing escapes");
        assert!(report.walk_errors().is_empty(), "no walk problems");
        assert!(report.claims_target("alias.txt"), "linked file claims");
        assert!(
            report.claims_target("linked/a.txt"),
            "file in a linked dir claims"
        );
    }

    #[cfg(unix)]
    #[test]
    fn preflight_follow_unsafe_claims_escaping_files() {
        // The mode's contract: every link is followed, wherever it points — the
        // escape is neither recorded nor refused.
        let dir = tempfile::tempdir().unwrap();
        let private = dir.path().join("private");
        std::fs::create_dir_all(&private).unwrap();
        std::fs::write(private.join("credentials.txt"), "secret").unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::os::unix::fs::symlink(&private, root.join("exposed")).unwrap();

        let report = scan_with(
            std::slice::from_ref(&root),
            crate::SymlinkMode::FollowUnsafe,
        );
        assert!(
            report.escaping_sources().is_empty(),
            "escapes are permitted"
        );
        assert!(
            report.claims_target("exposed/credentials.txt"),
            "the escaping file claims like any other"
        );
    }

    #[cfg(all(unix, feature = "symlink-move"))]
    #[test]
    fn preflight_redirect_skips_symlinks_and_claims_plain_files() {
        // Redirect/Move: links are never followed — a file link and a directory
        // link are each recorded once, nothing beneath a dir link is walked, and
        // plain files claim as usual. A dangling link is a skip too, not an error:
        // serving redirects to its content whether or not the target exists.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(root.join("real")).unwrap();
        std::fs::write(root.join("real/a.txt"), "data").unwrap();
        std::fs::write(root.join("plain.txt"), "plain").unwrap();
        std::os::unix::fs::symlink(root.join("plain.txt"), root.join("link.txt")).unwrap();
        std::os::unix::fs::symlink(root.join("real"), root.join("linked")).unwrap();
        std::os::unix::fs::symlink(root.join("missing"), root.join("dangling")).unwrap();

        let report = scan_with(std::slice::from_ref(&root), crate::SymlinkMode::Redirect);
        let mut skipped: Vec<_> = report
            .skipped_symlinks()
            .iter()
            .map(|s| s.rel.display().to_string())
            .collect();
        skipped.sort();
        assert_eq!(skipped, ["dangling", "link.txt", "linked"]);
        assert!(report.walk_errors().is_empty(), "a skip is not an error");
        assert!(report.claims_target("plain.txt"), "plain files still claim");
        assert!(report.claims_target("real/a.txt"));
        assert!(
            !report.claims_target("linked/a.txt"),
            "nothing beneath a directory link is walked"
        );
    }

    #[cfg(unix)]
    #[test]
    fn preflight_surfaces_a_dangling_link_as_a_walk_error() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("root");
        std::fs::create_dir_all(&root).unwrap();
        std::os::unix::fs::symlink(root.join("missing"), root.join("dangling")).unwrap();

        let report = scan(std::slice::from_ref(&root));
        assert_eq!(
            report.walk_errors().len(),
            1,
            "got {:?}",
            report.walk_errors()
        );
        assert!(report.escaping_sources().is_empty());
        assert!(report.conflicts().is_empty(), "the tree still preflights");
    }
}