moss-core 0.11.0

Pure-Rust content engine for moss: AST, render, resolve, validate, frontmatter, schema.
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
//! Pure URL/path transform helpers — no filesystem access, no env lookups.
//! Used by moss-core's render functions (see crate::render::*) and by
//! upstream src-tauri call sites.
//!
//! # Design Intent
//!
//! Every component that derives an output path from a video or image source
//! path (e.g., .mov → .mp4, .mov → .thumb.jpg, .jpg → .webp) MUST use these
//! functions. This ensures consistency between:
//! - HTML attributes set by the synthesizers (src, data-placeholder-src, poster)
//! - AssetReady event paths emitted by the build pipeline
//! - AssetRegistry keys used by the preview server
//! - iframe-bridge.ts path comparisons
//!
//! If naming conventions change, they change here only.
//!
//! These are pure string functions — no I/O, no filesystem access.
//! They work on both root-relative paths ("videos/clip.mov") and
//! document-relative paths ("../clip.mov").

/// Known video extensions that get transcoded to .mp4.
const MOV_SUFFIXES: &[&str] = &[".mov", ".MOV", ".Mp4", ".MP4"];

/// All video extensions (including those that pass through unchanged for `to_mp4`
/// but still need thumbnail derivation).
const ALL_VIDEO_SUFFIXES: &[&str] = &[
    ".mov", ".MOV", ".mp4", ".MP4", ".Mp4", ".webm", ".WEBM",
];

/// Known image extensions that get re-encoded to .webp.
/// Order matters: longer suffixes (jpeg) before shorter (jpg) is not required
/// because we strip exact suffixes, but keep the list grouped by family for readability.
const IMAGE_SUFFIXES: &[&str] = &[
    ".jpg", ".JPG", ".jpeg", ".JPEG",
    ".png", ".PNG",
    ".webp", ".WEBP",
];

/// Converts a video source path to its .mp4 output path.
///
/// - `.mov` / `.MOV` → `.mp4`
/// - `.Mp4` / `.MP4` → `.mp4` (case normalization)
/// - `.mp4` → `.mp4` (unchanged)
/// - `.webm` → `.webm` (pass-through, not transcoded)
///
/// Any directory prefix is preserved.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::to_mp4;
/// assert_eq!(to_mp4("clip.mov"), "clip.mp4");
/// assert_eq!(to_mp4("../clip.MOV"), "../clip.mp4");
/// assert_eq!(to_mp4("clip.webm"), "clip.webm");
/// ```
pub fn to_mp4(source: &str) -> String {
    for suffix in MOV_SUFFIXES {
        if let Some(stem) = source.strip_suffix(suffix) {
            return format!("{stem}.mp4");
        }
    }
    // .mp4 (lowercase) and .webm pass through unchanged
    source.to_string()
}

/// Converts a video source path to its thumbnail path (.thumb.jpg).
///
/// All recognized video extensions are stripped and replaced with `.thumb.jpg`.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::to_thumb;
/// assert_eq!(to_thumb("clip.mov"), "clip.thumb.jpg");
/// assert_eq!(to_thumb("../clip.mp4"), "../clip.thumb.jpg");
/// ```
pub fn to_thumb(source: &str) -> String {
    for suffix in ALL_VIDEO_SUFFIXES {
        if let Some(stem) = source.strip_suffix(suffix) {
            return format!("{stem}.thumb.jpg");
        }
    }
    // Fallback: append .thumb.jpg (shouldn't happen with known video files)
    format!("{source}.thumb.jpg")
}

/// Like `to_thumb`, but returns `None` for non-video paths instead of
/// blindly appending `.thumb.jpg`.
///
/// Use this when the caller needs to *decide* whether a path is a video
/// (e.g. cover-color resolution), as opposed to forcing the conversion.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::to_thumb_if_video;
/// assert_eq!(to_thumb_if_video("clip.mov").as_deref(), Some("clip.thumb.jpg"));
/// assert_eq!(to_thumb_if_video("clip.MP4").as_deref(), Some("clip.thumb.jpg"));
/// assert_eq!(to_thumb_if_video("photo.jpg"), None);
/// ```
pub fn to_thumb_if_video(source: &str) -> Option<String> {
    for suffix in ALL_VIDEO_SUFFIXES {
        if let Some(stem) = source.strip_suffix(suffix) {
            return Some(format!("{stem}.thumb.jpg"));
        }
    }
    None
}

/// Converts an image source path to its WebP output path.
///
/// - `.jpg` / `.JPG` / `.jpeg` / `.JPEG` → `.webp`
/// - `.png` / `.PNG` → `.webp`
/// - `.webp` / `.WEBP` → `.webp` (case normalization only)
///
/// Any directory prefix is preserved. Unknown extensions fall back to
/// appending `.webp` (same defensive style as `to_thumb`).
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::to_webp;
/// assert_eq!(to_webp("photo.jpg"), "photo.webp");
/// assert_eq!(to_webp("../photo.PNG"), "../photo.webp");
/// assert_eq!(to_webp("photo.webp"), "photo.webp");
/// ```
pub fn to_webp(source: &str) -> String {
    for suffix in IMAGE_SUFFIXES {
        if let Some(stem) = source.strip_suffix(suffix) {
            return format!("{stem}.webp");
        }
    }
    // Fallback: append .webp (shouldn't happen with known image files)
    format!("{source}.webp")
}

/// The raster source extensions that participate in the responsive ladder
/// (png/jpg/jpeg/webp). Single source of truth for the ladder-membership
/// **GATE** — the "does this extension take part at all?" question, distinct
/// from the "which rung WIDTHS exist?" question [`ladder_rungs`] answers (whose
/// own doc enumerates the ladder-DERIVATION sites). Phase B (Task 12) lifted
/// webp's participation by editing ONLY this predicate, so every gate site
/// picked up webp in lockstep.
///
/// GATE SITES — the six `is_ladder_source_ext(` production callers
/// (grep-verified 2026-07-23), each deciding png/jpg/jpeg/webp participation:
/// 1. **emission** — `is_raster_original` (`render/image.rs`);
/// 2. **registration** — the rung loop in `generate_blocking_content`
///    (`build/render/blocking.rs`);
/// 3. **encode dispatch** — `rungs_gated` in `convert_single_image`
///    (`build/media/image.rs`);
/// 4. **success/failure rung sweep** — `registered_rungs` in
///    `run_image_conversion` (`build/media/image.rs`);
/// 5. **fingerprint-skip heal** — `dispatch_image_conversions`
///    (`build/media/image.rs`);
/// 6. **`should_skip`'s AlreadySmall carve-out** — `raster_with_picture`
///    (`build/media/image.rs`).
///
/// These six are the "six census sites" named in the design doc and
/// MIGRATION-STATE. NOTE: `encode_rungs` is NOT a gate site — it is a
/// [`ladder_rungs`] DERIVATION consumer (it computes the rung SET, it does not
/// gate on extension). A maintainer adding a new participating format updates
/// THIS list; one adding a rung-set derivation updates [`ladder_rungs`]' census.
///
/// NOTE ON `<picture>` vs `<img srcset>`: ladder membership is NOT the same as
/// "emits a `<picture><source>` webp CONVERSION". png/jpg/jpeg are re-encoded to
/// a differently-named `.webp` and emit `<picture><source srcset=X.webp>`; a
/// webp SOURCE is already webp, so [`to_webp`]`(src) == src` and it emits the
/// ladder directly on `<img srcset>` (no `<picture>` — a `<source>` identical to
/// the `<img>` is pointless). Callers that need the CONVERSION-only subset
/// (e.g. `should_skip`'s AlreadySmall carve-out, which must keep small webp
/// eligible to skip re-encode) combine this with [`is_webp_source_ext`]:
/// `is_ladder_source_ext(ext) && !is_webp_source_ext(ext)`.
///
/// Case-insensitive; `ext` is the extension WITHOUT the leading dot
/// ("png", "JPG", "jpeg", "webp"). Emission derives the gate from a full path
/// via `render/image.rs::is_raster_original` / `is_webp_source`; the pipeline
/// sites pass the scan-derived `item.ext` / a `source_file.extension()` string.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::is_ladder_source_ext;
/// assert!(is_ladder_source_ext("png"));
/// assert!(is_ladder_source_ext("JPG"));
/// assert!(is_ladder_source_ext("webp")); // joined the ladder in Phase B (Task 12)
/// ```
pub fn is_ladder_source_ext(ext: &str) -> bool {
    matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp")
}

/// True when `ext` is a WebP source extension (`webp`, case-insensitive).
///
/// A webp SOURCE is already webp: it does NOT get a differently-named
/// `<picture><source>` webp conversion — instead it carries the responsive
/// ladder directly on `<img srcset>` (Phase B, Task 12). Two callers need this
/// distinction that [`is_ladder_source_ext`] (which now unions webp in) can no
/// longer make alone:
/// - emission (`render/image.rs::synthesize_inner`) routes webp to the
///   `<img srcset>` branch instead of the `<picture>` branch;
/// - `should_skip`'s AlreadySmall carve-out excludes webp from the
///   "must-convert, never-skip" set so a small rung-free webp can still skip
///   the wasteful webp→webp re-encode.
///
/// `ext` is the extension WITHOUT the leading dot.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::is_webp_source_ext;
/// assert!(is_webp_source_ext("webp"));
/// assert!(is_webp_source_ext("WEBP"));
/// assert!(!is_webp_source_ext("png"));
/// assert!(!is_webp_source_ext("jpg"));
/// ```
pub fn is_webp_source_ext(ext: &str) -> bool {
    ext.eq_ignore_ascii_case("webp")
}

/// Max edge (px) of any deployed raster. Single source of truth — the encode
/// pipeline's `ImageCompressionConfig::default()` reads this constant, and the
/// srcset base-width descriptor caps at it. 2400 covers retina displays.
pub const DEPLOY_MAX_EDGE: u32 = 2400;

/// The responsive ladder: rung widths generated below the deployed base.
/// Must be strictly ascending — the `take_while` in [`ladder_rungs`] depends on it.
/// This is the canonical ladder definition and part of the public `asset_paths`
/// vocabulary (named in the moss-core CHANGELOG); consumers should prefer
/// [`ladder_rungs`] / [`deployed_width`] over indexing `LADDER` directly, so
/// ladder policy stays in one place.
/// See docs/archive/2026-07-22-responsive-image-variants-design.md.
pub const LADDER: [u32; 2] = [800, 1600];

/// Width the deployed base variant actually has after the encoder's
/// aspect-preserving longest-EDGE resize (`img.resize(max_edge, max_edge,
/// Lanczos3)` in build/media/image.rs). When the longest edge exceeds
/// [`DEPLOY_MAX_EDGE`], BOTH dimensions shrink by the same ratio — for
/// portraits the deployed width is therefore SMALLER than `min(w, 2400)`:
/// a 3024×4032 portrait deploys at 1800×2400, so its base width is 1800.
///
/// Integer math (u64 multiply, truncating divide, floor at 1) mirrors the
/// image crate's `resize_dimensions` as closely as practical. srcset width
/// descriptors are browser HINTS: ±1px rounding drift vs the encoder's
/// float `.round()` is acceptable — a Task-5 cross-check test against real
/// encode output pins gross agreement.
pub fn deployed_width(natural_w: u32, natural_h: u32) -> u32 {
    let long_edge = natural_w.max(natural_h);
    if long_edge <= DEPLOY_MAX_EDGE {
        return natural_w;
    }
    ((natural_w as u64 * DEPLOY_MAX_EDGE as u64 / long_edge as u64) as u32).max(1)
}

/// Which ladder rungs exist for a source of `natural_w`×`natural_h` px.
///
/// DETERMINISTIC-AGREEMENT CONTRACT — the ladder-DERIVATION census: the
/// `ladder_rungs(` production callers (grep-verified 2026-07-23). Keep this
/// list current; every set-agreement site derives ladder membership from the
/// same scan-derived inputs. These answer "which rung WIDTHS exist?"; the
/// separate participation GATE ("does this extension take part at all?") is the
/// six-site [`is_ladder_source_ext`] census — a site usually gates there first,
/// then derives here, so most appear on both lists. The five set-agreement
/// sites (all five must produce the identical rung SET):
///
/// 1. **emission** — the synthesizer's `resolve_ladder`
///    (`render/image.rs::synthesize_inner`) decides which rung srcset
///    candidates appear in HTML;
/// 2. **registration** — blocking.rs's rung loop promises each rung URL via
///    `set_source_passthrough` + `set_pending`;
/// 3. **encode** — `encode_rungs` (build/media/rungs.rs, extracted from image.rs
///    at Task 10.5) derives the same ladder from oriented dims on the
///    full-encode and warm-cache paths; `convert_single_image`'s `ladder_len`
///    (build/media/image.rs) reads the same call to keep the oriented original
///    alive for the rung re-encode;
/// 4. **dispatch/sweep/Err-arm retraction** — the worker success/failure
///    arms' `registered_rungs` reconstruction (build/media/image.rs)
///    resolves or retracts every promise registration made;
/// 5. **fingerprint-skip heal** — the unchanged-fingerprint pass
///    (`dispatch_image_conversions`, build/media/image.rs) rematerializes each
///    rung it recomputes here.
///
/// One further `ladder_rungs(` caller is NOT a set-agreement member:
/// `should_skip`'s AlreadySmall carve-out (build/media/image.rs) consults only
/// `ladder_rungs(..).is_empty()` — a "carries any rung?" boolean that keeps a
/// small rung-bearing webp from skipping re-encode — so it never emits,
/// registers, encodes, or sweeps a rung SET and cannot disagree on membership.
///
/// A rung is emitted in HTML iff it is registered iff it is encoded. Never
/// add an input here that one of these sites cannot supply (e.g. encode
/// outcomes, cache state) — that is the parallel-oracle bug class deleted
/// 2026-05-20 (see build/media/image.rs:297-310).
///
/// EXIF-ORIENTATION AGREEMENT (canonical; the pipeline sites back-reference
/// here). The "same scan-derived dims on every site" premise holds
/// UNCONDITIONALLY — including an EXIF-oriented (orientation 5-8, i.e. a 90°/270°
/// rotation) png/webp/jpeg. Scan's `extract_image_dimensions` (build/scan/
/// scan.rs) swaps `w`×`h` for orientation 5-8 on EVERY ladder source, reading
/// orientation through the SAME `read_exif_orientation` the encode side's
/// `decode_oriented` uses — so the dims scan feeds emission/registration/sweep/
/// heal equal encode's oriented dims byte-for-byte and the two ladders cannot
/// diverge. (Before the 2026-07-23 scan-swap fix — design follow-up #1 — scan's
/// orientation read was JPEG-GATED, so an EXIF-rotated png/webp stored the
/// UNswapped header dims and could strand an emitted-but-never-encoded rung →
/// publish-404, preview degrading to the placeholder via the unresolved-promise
/// sweep.) Do NOT re-gate scan's swap to jpeg-only, and do NOT "fix" any future
/// divergence by narrowing the encode side: the encoder strips EXIF, so a base
/// that isn't oriented at encode time ships stored sideways (visibly rotated).
///
/// Rungs are strictly below the deployed base WIDTH (post-resize, see
/// [`deployed_width`] — portrait sources have a smaller base width than
/// `min(w, DEPLOY_MAX_EDGE)`) so the base descriptor never duplicates a
/// rung and no rung is ever wider than the base. Animated sources get no
/// ladder (animation-preserving multi-size re-encode is out of scope).
///
/// APNG: an animated PNG passes `is_raster_original` like any png, and the
/// BASE webp encode already FLATTENS it to a still today (`should_skip` in
/// build/media/image.rs sniffs animation only for gif/webp). Rungs run
/// through the same encode path and inherit the same flattening —
/// consistent by construction, no 404 risk.
///
/// ANIMATED-FLAG AGREEMENT (Phase B, Task 12). Only ONE of the five sites
/// passes a non-`false` flag: **emission** passes the scan-derived
/// `assets.is_animated(src)` for webp sources (an animated webp → empty
/// ladder → bare `<img>`, no srcset). The four pipeline sites (registration,
/// `rungs_gated`/`ladder_len`, `registered_rungs`, fingerprint-heal, plus
/// `encode_rungs`) keep the `false` literal, and that literal is PROVABLY
/// correct — not an assumption: an animated webp returns
/// `SkipReason::AnimatedWebp` in `should_skip`, so `collect_images_for_
/// conversion` drops it BEFORE it can become an `ImageConversionItem`. Every
/// webp that reaches the pipeline is therefore non-animated, and `false`
/// equals its real flag. Because scan's `sniff_is_animated` and `should_skip`
/// both call the SAME `is_animated_webp`, the emission verdict and the
/// pipeline's inclusion verdict never disagree: an animated webp is
/// simultaneously flagged in the snapshot (emission → no rungs) AND filtered
/// from the item list (pipeline → no rungs). Both sides produce zero rungs.
/// png/jpg/jpeg are never animated through this path (animated gif/webp are
/// not in the conversion set) and keep `false` everywhere too.
///
/// WARNING: any future pipeline-side skip that is NOT expressible as an
/// input to this function — e.g. copying the Y1 sized-raster APNG
/// verbatim-keep guard (build/media/image.rs ~line 830) onto rung encodes —
/// would create emitted-but-never-encoded rungs, i.e. the non-recoverable
/// chosen-`<source>` 404 (ADR-013). Task 5 must NOT copy that guard.
pub fn ladder_rungs(natural_w: u32, natural_h: u32, is_animated: bool) -> &'static [u32] {
    if is_animated {
        return &[];
    }
    let base = deployed_width(natural_w, natural_h);
    let n = LADDER.iter().take_while(|&&w| w < base).count();
    &LADDER[..n]
}

/// Rung variant URL: `photo.jpg` + 800 → `photo.w800.webp`.
/// Same derivation style as [`to_webp`]; preserves directory prefix.
///
/// # Examples
/// ```
/// # use moss_core::asset_paths::to_webp_rung;
/// assert_eq!(to_webp_rung("photo.jpg", 800), "photo.w800.webp");
/// assert_eq!(to_webp_rung("../photo.PNG", 1600), "../photo.w1600.webp");
/// ```
pub fn to_webp_rung(source: &str, width: u32) -> String {
    let webp = to_webp(source);
    let stem = webp.strip_suffix(".webp").unwrap_or(&webp);
    format!("{stem}.w{width}.webp")
}

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

    // ── to_mp4 tests ───────────────────────────────────────────────

    #[test]
    fn test_to_mp4_mov() {
        assert_eq!(to_mp4("clip.mov"), "clip.mp4");
    }

    #[test]
    fn test_to_mp4_mov_uppercase() {
        assert_eq!(to_mp4("clip.MOV"), "clip.mp4");
    }

    #[test]
    fn test_to_mp4_mp4_mixed_case() {
        assert_eq!(to_mp4("clip.Mp4"), "clip.mp4");
    }

    #[test]
    fn test_to_mp4_mp4_uppercase() {
        assert_eq!(to_mp4("clip.MP4"), "clip.mp4");
    }

    #[test]
    fn test_to_mp4_already_mp4() {
        assert_eq!(to_mp4("clip.mp4"), "clip.mp4");
    }

    #[test]
    fn test_to_mp4_webm_passthrough() {
        assert_eq!(to_mp4("clip.webm"), "clip.webm");
    }

    #[test]
    fn test_to_mp4_with_relative_prefix() {
        assert_eq!(to_mp4("../clip.mov"), "../clip.mp4");
    }

    #[test]
    fn test_to_mp4_with_dot_prefix() {
        assert_eq!(to_mp4("./clip.MOV"), "./clip.mp4");
    }

    #[test]
    fn test_to_mp4_with_directory() {
        assert_eq!(to_mp4("videos/clip.mov"), "videos/clip.mp4");
    }

    #[test]
    fn test_to_mp4_unicode_filename() {
        assert_eq!(to_mp4("videos/冬日之歌.mov"), "videos/冬日之歌.mp4");
    }

    #[test]
    fn test_to_mp4_space_in_name() {
        assert_eq!(to_mp4("./Morning Mist.mov"), "./Morning Mist.mp4");
    }

    // ── to_thumb tests ─────────────────────────────────────────────

    #[test]
    fn test_to_thumb_mov() {
        assert_eq!(to_thumb("clip.mov"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_mp4() {
        assert_eq!(to_thumb("clip.mp4"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_mov_uppercase() {
        assert_eq!(to_thumb("clip.MOV"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_mp4_uppercase() {
        assert_eq!(to_thumb("clip.MP4"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_mp4_mixed() {
        assert_eq!(to_thumb("clip.Mp4"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_webm() {
        assert_eq!(to_thumb("clip.webm"), "clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_with_directory() {
        assert_eq!(to_thumb("videos/clip.mov"), "videos/clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_with_relative_prefix() {
        assert_eq!(to_thumb("../clip.mp4"), "../clip.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_unicode() {
        assert_eq!(to_thumb("videos/冬日之歌.mov"), "videos/冬日之歌.thumb.jpg");
    }

    #[test]
    fn test_to_thumb_space() {
        assert_eq!(to_thumb("./Morning Mist.MOV"), "./Morning Mist.thumb.jpg");
    }

    // ── to_thumb_if_video tests ────────────────────────────────────

    #[test]
    fn test_to_thumb_if_video_recognizes_lowercase_extensions() {
        assert_eq!(to_thumb_if_video("clip.mov").as_deref(), Some("clip.thumb.jpg"));
        assert_eq!(to_thumb_if_video("clip.mp4").as_deref(), Some("clip.thumb.jpg"));
        assert_eq!(to_thumb_if_video("clip.webm").as_deref(), Some("clip.thumb.jpg"));
    }

    #[test]
    fn test_to_thumb_if_video_recognizes_uppercase_extensions() {
        // Regression for the original bug: uppercase video extensions
        // (.MOV from iPhone, .MP4 from some cameras) must be detected.
        assert_eq!(to_thumb_if_video("clip.MOV").as_deref(), Some("clip.thumb.jpg"));
        assert_eq!(to_thumb_if_video("clip.MP4").as_deref(), Some("clip.thumb.jpg"));
        assert_eq!(to_thumb_if_video("clip.Mp4").as_deref(), Some("clip.thumb.jpg"));
        assert_eq!(to_thumb_if_video("clip.WEBM").as_deref(), Some("clip.thumb.jpg"));
    }

    #[test]
    fn test_to_thumb_if_video_with_directory() {
        assert_eq!(to_thumb_if_video("音乐/cover.mp4").as_deref(), Some("音乐/cover.thumb.jpg"));
        assert_eq!(to_thumb_if_video("../clip.MOV").as_deref(), Some("../clip.thumb.jpg"));
    }

    #[test]
    fn test_to_thumb_if_video_returns_none_for_images() {
        assert_eq!(to_thumb_if_video("photo.jpg"), None);
        assert_eq!(to_thumb_if_video("photo.png"), None);
        assert_eq!(to_thumb_if_video("photo.webp"), None);
        assert_eq!(to_thumb_if_video("photo.JPEG"), None);
    }

    #[test]
    fn test_to_thumb_if_video_returns_none_for_unknown() {
        // Unlike `to_thumb`, no `.thumb.jpg` fallback is appended.
        // Callers using this helper want a definitive yes/no.
        assert_eq!(to_thumb_if_video("file.gif"), None);
        assert_eq!(to_thumb_if_video("file"), None);
        assert_eq!(to_thumb_if_video(""), None);
    }

    // ── to_webp tests ──────────────────────────────────────────────

    #[test]
    fn test_to_webp_jpg() {
        assert_eq!(to_webp("photo.jpg"), "photo.webp");
    }

    #[test]
    fn test_to_webp_jpg_uppercase() {
        assert_eq!(to_webp("photo.JPG"), "photo.webp");
    }

    #[test]
    fn test_to_webp_jpeg() {
        assert_eq!(to_webp("photo.jpeg"), "photo.webp");
    }

    #[test]
    fn test_to_webp_jpeg_uppercase() {
        assert_eq!(to_webp("photo.JPEG"), "photo.webp");
    }

    #[test]
    fn test_to_webp_png() {
        assert_eq!(to_webp("photo.png"), "photo.webp");
    }

    #[test]
    fn test_to_webp_png_uppercase() {
        assert_eq!(to_webp("photo.PNG"), "photo.webp");
    }

    #[test]
    fn test_to_webp_already_webp() {
        assert_eq!(to_webp("photo.webp"), "photo.webp");
    }

    #[test]
    fn test_to_webp_webp_uppercase() {
        assert_eq!(to_webp("photo.WEBP"), "photo.webp");
    }

    #[test]
    fn test_to_webp_with_directory() {
        assert_eq!(to_webp("images/photo.jpg"), "images/photo.webp");
    }

    #[test]
    fn test_to_webp_with_relative_prefix() {
        assert_eq!(to_webp("../photo.png"), "../photo.webp");
    }

    #[test]
    fn test_to_webp_with_dot_prefix() {
        assert_eq!(to_webp("./photo.JPG"), "./photo.webp");
    }

    #[test]
    fn test_to_webp_unicode_filename() {
        assert_eq!(to_webp("images/冬日之歌.jpg"), "images/冬日之歌.webp");
    }

    #[test]
    fn test_to_webp_space_in_name() {
        assert_eq!(to_webp("./Morning Mist.png"), "./Morning Mist.webp");
    }

    #[test]
    fn test_to_webp_unknown_extension_fallback() {
        // Unknown extensions fall back to appending .webp (matches to_thumb's defensive style).
        assert_eq!(to_webp("file.gif"), "file.gif.webp");
    }

    // ── is_ladder_source_ext tests ─────────────────────────────────

    #[test]
    fn is_ladder_source_ext_accepts_png_jpg_jpeg_webp_case_insensitively() {
        // Phase B (Task 12) added webp/WEBP: a webp SOURCE now participates in
        // the responsive ladder (emitted on `<img srcset>`, not `<picture>`).
        for ext in [
            "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "Jpg", "jPeG", "webp", "WEBP", "WebP",
        ] {
            assert!(is_ladder_source_ext(ext), "{ext} must be a ladder source");
        }
    }

    #[test]
    fn is_ladder_source_ext_rejects_non_ladder_formats() {
        // webp joined in Phase B (asserted above); these never join.
        for ext in ["gif", "svg", "avif", "heic", "bmp", "tiff", ""] {
            assert!(!is_ladder_source_ext(ext), "{ext} must NOT be a ladder source");
        }
    }

    #[test]
    fn is_webp_source_ext_matches_only_webp() {
        // The conversion-vs-webp split: webp is a ladder source that is NOT a
        // `<picture>` conversion (it emits `<img srcset>` and can skip a small
        // re-encode). png/jpg/jpeg are ladder sources that ARE conversions.
        for ext in ["webp", "WEBP", "WebP"] {
            assert!(is_webp_source_ext(ext), "{ext} must be a webp source");
        }
        for ext in ["png", "PNG", "jpg", "jpeg", "gif", "svg", ""] {
            assert!(!is_webp_source_ext(ext), "{ext} must NOT be a webp source");
        }
    }

    // ── ladder tests ───────────────────────────────────────────────

    #[test]
    fn ladder_is_strictly_ascending() {
        assert!(LADDER.windows(2).all(|w| w[0] < w[1]));
    }

    #[test]
    fn ladder_rungs_below_deployed_base_only() {
        assert_eq!(ladder_rungs(2000, 1200, false), &[800, 1600][..]);
        assert_eq!(ladder_rungs(1601, 900, false), &[800, 1600][..]);
        // strict: no upscale, no dup of base
        assert_eq!(ladder_rungs(1600, 900, false), &[800][..]);
        assert_eq!(ladder_rungs(801, 600, false), &[800][..]);
        assert_eq!(ladder_rungs(800, 600, false), &[] as &[u32]);
        assert_eq!(ladder_rungs(0, 0, false), &[] as &[u32]);
    }

    #[test]
    fn ladder_rungs_capped_width_never_duplicates_base() {
        // 4000px-wide landscape deploys at DEPLOY_MAX_EDGE (2400) wide;
        // rungs must stay below the cap.
        assert_eq!(ladder_rungs(4000, 3000, false), &[800, 1600][..]);
        assert_eq!(ladder_rungs(2400, 1600, false), &[800, 1600][..]);
        // Square at exactly the cap: base 2400, both rungs below it.
        assert_eq!(ladder_rungs(2400, 2400, false), &[800, 1600][..]);
    }

    #[test]
    fn ladder_rungs_portrait_uses_post_resize_width() {
        // 3024×4032 portrait: the encoder shrinks the longest EDGE to 2400,
        // so the deployed base is 1800 wide — both rungs still below it.
        assert_eq!(ladder_rungs(3024, 4032, false), &[800, 1600][..]);
        // Extreme portrait 1179×8000: base width 353 — NO rung is below it,
        // so the ladder must be empty (a w800 rung would be WIDER than the
        // base: ladder inversion).
        assert_eq!(ladder_rungs(1179, 8000, false), &[] as &[u32]);
        // 1200×3600: base width exactly 800 — strict `<` excludes the 800 rung.
        assert_eq!(ladder_rungs(1200, 3600, false), &[] as &[u32]);
    }

    #[test]
    fn ladder_rungs_animated_is_empty() {
        assert_eq!(ladder_rungs(2000, 1200, true), &[] as &[u32]);
    }

    #[test]
    fn deployed_width_caps_longest_edge() {
        // Landscape: width IS the longest edge — capped directly.
        assert_eq!(deployed_width(4000, 3000), 2400);
        assert_eq!(deployed_width(2000, 1200), 2000);
        // Portrait: HEIGHT is the longest edge; width shrinks by the same
        // aspect-preserving ratio the encoder applies.
        assert_eq!(deployed_width(3024, 4032), 1800);
        assert_eq!(deployed_width(1179, 8000), 353);
        assert_eq!(deployed_width(1200, 3600), 800);
        // Square at the cap: untouched.
        assert_eq!(deployed_width(2400, 2400), 2400);
        // Degenerate sliver never collapses to 0.
        assert_eq!(deployed_width(1, 100_000), 1);
    }

    #[test]
    fn to_webp_rung_inserts_width_suffix() {
        assert_eq!(to_webp_rung("photo.jpg", 800), "photo.w800.webp");
        assert_eq!(to_webp_rung("a/b/photo.PNG", 1600), "a/b/photo.w1600.webp");
        assert_eq!(to_webp_rung("photo.webp", 800), "photo.w800.webp");
    }

    #[test]
    fn to_webp_rung_unknown_extension_fallback() {
        // Unknown extensions inherit to_webp's defensive-append behavior.
        assert_eq!(to_webp_rung("file.gif", 800), "file.gif.w800.webp");
    }
}