Skip to main content

ssg/
pagination.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Pagination plugin.
5//!
6//! Generates paginated index pages (`/page/2/`, `/page/3/`, etc.)
7//! from frontmatter sidecars when `paginate` is specified.
8
9use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::{
12    collections::HashMap,
13    fs,
14    path::{Path, PathBuf},
15};
16
17/// Default number of items per page.
18const DEFAULT_PER_PAGE: usize = 10;
19
20/// Page metadata for pagination.
21#[derive(Debug, Clone)]
22struct PageEntry {
23    title: String,
24    url: String,
25    date: String,
26}
27
28/// Plugin that generates paginated listing pages.
29///
30/// Runs in `after_compile`. Reads `.meta.json` sidecars, collects
31/// pages with dates, sorts by date descending, and generates
32/// `/page/N/index.html` files.
33#[derive(Debug, Clone, Copy)]
34pub struct PaginationPlugin {
35    per_page: usize,
36}
37
38impl Default for PaginationPlugin {
39    fn default() -> Self {
40        Self {
41            per_page: DEFAULT_PER_PAGE,
42        }
43    }
44}
45
46impl PaginationPlugin {
47    /// Creates a pagination plugin with a custom page size.
48    #[must_use]
49    pub fn with_per_page(per_page: usize) -> Self {
50        Self {
51            per_page: per_page.max(1),
52        }
53    }
54}
55
56impl Plugin for PaginationPlugin {
57    fn name(&self) -> &'static str {
58        "pagination"
59    }
60
61    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
62        let sidecar_dir = ctx.build_dir.join(".meta");
63        if !sidecar_dir.exists() {
64            return Ok(());
65        }
66
67        let mut entries = collect_page_entries(&sidecar_dir)?;
68        if entries.is_empty() {
69            return Ok(());
70        }
71
72        entries.sort_by(|a, b| b.date.cmp(&a.date));
73
74        let total_pages = entries.len().div_ceil(self.per_page);
75        if total_pages <= 1 {
76            return Ok(());
77        }
78
79        let page_dir = ctx.site_dir.join("page");
80        for page_num in 2..=total_pages {
81            let start = (page_num - 1) * self.per_page;
82            let end = (start + self.per_page).min(entries.len());
83            let page_entries = &entries[start..end];
84
85            write_pagination_page(
86                &page_dir,
87                page_num,
88                total_pages,
89                page_entries,
90            )?;
91        }
92
93        log::info!(
94            "[pagination] Generated {} page(s) ({} entries, {} per page)",
95            total_pages - 1,
96            entries.len(),
97            self.per_page
98        );
99        Ok(())
100    }
101}
102
103/// Collects page entries with dates from sidecar JSON files.
104fn collect_page_entries(sidecar_dir: &Path) -> Result<Vec<PageEntry>> {
105    let sidecars = collect_json_files(sidecar_dir)?;
106    let mut entries = Vec::new();
107
108    for sidecar_path in &sidecars {
109        if let Some(entry) = parse_page_entry(sidecar_path, sidecar_dir) {
110            entries.push(entry);
111        }
112    }
113
114    Ok(entries)
115}
116
117/// Parses a single sidecar JSON file into a `PageEntry`, if it has a date.
118fn parse_page_entry(
119    sidecar_path: &Path,
120    sidecar_dir: &Path,
121) -> Option<PageEntry> {
122    let content = fs::read_to_string(sidecar_path).ok()?;
123    let meta: HashMap<String, serde_json::Value> =
124        serde_json::from_str(&content).ok()?;
125
126    let title = meta
127        .get("title")
128        .and_then(|v| v.as_str())
129        .unwrap_or("Untitled")
130        .to_string();
131    let date = meta
132        .get("date")
133        .and_then(|v| v.as_str())
134        .unwrap_or("")
135        .to_string();
136
137    if date.is_empty() {
138        return None;
139    }
140
141    let rel = sidecar_path
142        .strip_prefix(sidecar_dir)
143        .unwrap_or(sidecar_path)
144        .with_extension("")
145        .with_extension("html");
146    let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
147
148    Some(PageEntry { title, url, date })
149}
150
151/// Writes a single pagination page to disk.
152fn write_pagination_page(
153    page_dir: &Path,
154    page_num: usize,
155    total_pages: usize,
156    page_entries: &[PageEntry],
157) -> Result<()> {
158    let dir = page_dir.join(page_num.to_string());
159    fs::create_dir_all(&dir)?;
160
161    let prev_url = if page_num == 2 {
162        "/".to_string()
163    } else {
164        format!("/page/{}/", page_num - 1)
165    };
166    let next_url = if page_num < total_pages {
167        Some(format!("/page/{}/", page_num + 1))
168    } else {
169        None
170    };
171
172    let mut html = format!(
173        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
174         <meta charset=\"utf-8\">\
175         <title>Page {page_num} of {total_pages}</title></head>\n\
176         <body>\n<main>\n\
177         <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
178    );
179
180    for entry in page_entries {
181        html.push_str(&format!(
182            "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
183            entry.url, entry.title, entry.date
184        ));
185    }
186
187    html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
188    html.push_str(&format!(
189        "<a href=\"{prev_url}\" rel=\"prev\">&larr; Previous</a>\n"
190    ));
191    if let Some(next) = &next_url {
192        html.push_str(&format!(
193            "<a href=\"{next}\" rel=\"next\">Next &rarr;</a>\n"
194        ));
195    }
196    html.push_str("</nav>\n</main>\n</body>\n</html>\n");
197
198    fs::write(dir.join("index.html"), html)?;
199    Ok(())
200}
201
202fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>> {
203    crate::walk::walk_files(dir, "json")
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210    use crate::test_support::init_logger;
211    use std::path::PathBuf;
212    use tempfile::{tempdir, TempDir};
213
214    // -------------------------------------------------------------------
215    // Test fixtures
216    // -------------------------------------------------------------------
217
218    /// Builds a fresh temp dir layout: `<root>/site`, `<root>/build/.meta`,
219    /// and a `PluginContext` pointing at it. Returns the temp dir guard
220    /// (must outlive the test), the site path, the meta sidecar path,
221    /// and the context.
222    fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
223        init_logger();
224        let dir = tempdir().expect("create tempdir");
225        let site = dir.path().join("site");
226        let build = dir.path().join("build");
227        let meta = build.join(".meta");
228        fs::create_dir_all(&site).expect("mkdir site");
229        fs::create_dir_all(&meta).expect("mkdir meta");
230        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
231        (dir, site, meta, ctx)
232    }
233
234    /// Writes a sidecar JSON file shaped `{"title": ..., "date": ...}`.
235    fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
236        let json = if date.is_empty() {
237            format!(r#"{{"title": "{title}"}}"#)
238        } else {
239            format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
240        };
241        fs::write(meta.join(format!("{name}.meta.json")), json)
242            .expect("write sidecar");
243    }
244
245    /// Writes `n` dated posts numbered 1..=n with monotonically
246    /// increasing dates so sort order is well-defined.
247    fn write_n_dated_posts(meta: &Path, n: usize) {
248        for i in 1..=n {
249            write_sidecar(
250                meta,
251                &format!("post{i:03}"),
252                &format!("Post {i}"),
253                &format!("2026-01-{i:02}"),
254            );
255        }
256    }
257
258    // -------------------------------------------------------------------
259    // Constructor + derive surface
260    // -------------------------------------------------------------------
261
262    #[test]
263    fn default_uses_default_per_page_constant() {
264        // The Default impl is the public ergonomic — assert it matches
265        // the documented constant rather than a magic number, so the
266        // test stays correct if DEFAULT_PER_PAGE is ever retuned.
267        let plugin = PaginationPlugin::default();
268        assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
269    }
270
271    #[test]
272    fn with_per_page_stores_supplied_value() {
273        let plugin = PaginationPlugin::with_per_page(7);
274        assert_eq!(plugin.per_page, 7);
275    }
276
277    #[test]
278    fn with_per_page_zero_clamps_to_one() {
279        // Zero would cause a divide-by-zero in `div_ceil`. The
280        // constructor must clamp it.
281        let plugin = PaginationPlugin::with_per_page(0);
282        assert_eq!(plugin.per_page, 1);
283    }
284
285    #[test]
286    fn with_per_page_one_is_valid_lower_bound() {
287        let plugin = PaginationPlugin::with_per_page(1);
288        assert_eq!(plugin.per_page, 1);
289    }
290
291    #[test]
292    fn with_per_page_table_driven_values() {
293        // Table-driven sanity check across a spread of valid sizes.
294        let cases: &[(usize, usize)] = &[
295            (1, 1),
296            (5, 5),
297            (10, 10),
298            (100, 100),
299            (usize::MAX, usize::MAX),
300        ];
301        for &(input, expected) in cases {
302            let plugin = PaginationPlugin::with_per_page(input);
303            assert_eq!(
304                plugin.per_page, expected,
305                "with_per_page({input}) should store {expected}"
306            );
307        }
308    }
309
310    #[test]
311    fn pagination_plugin_is_copy_after_move() {
312        // Guards the `Copy` derive added in v0.0.34.
313        let plugin = PaginationPlugin::with_per_page(3);
314        let _copy = plugin;
315        assert_eq!(plugin.per_page, 3);
316    }
317
318    #[test]
319    fn name_returns_static_pagination_identifier() {
320        let plugin = PaginationPlugin::default();
321        assert_eq!(plugin.name(), "pagination");
322    }
323
324    // -------------------------------------------------------------------
325    // after_compile — early-return paths
326    // -------------------------------------------------------------------
327
328    #[test]
329    fn after_compile_missing_meta_dir_returns_ok_without_writing() {
330        // No `.meta` directory under build/ — must short-circuit, not
331        // error, and must not create the page/ directory.
332        let dir = tempdir().expect("tempdir");
333        let site = dir.path().join("site");
334        let build = dir.path().join("build");
335        fs::create_dir_all(&site).expect("mkdir site");
336        fs::create_dir_all(&build).expect("mkdir build");
337        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
338
339        PaginationPlugin::default()
340            .after_compile(&ctx)
341            .expect("missing meta dir is not an error");
342
343        assert!(!site.join("page").exists());
344    }
345
346    #[test]
347    fn after_compile_empty_meta_dir_returns_ok_without_writing() {
348        let (_tmp, site, _meta, ctx) = make_layout();
349        PaginationPlugin::default()
350            .after_compile(&ctx)
351            .expect("empty meta is fine");
352        assert!(!site.join("page").exists());
353    }
354
355    #[test]
356    fn after_compile_only_undated_pages_returns_ok_without_writing() {
357        // Pages without `date` are skipped — see line 91. Only undated
358        // entries means `entries.is_empty()` short-circuit at line 105.
359        let (_tmp, site, meta, ctx) = make_layout();
360        write_sidecar(&meta, "about", "About", "");
361        write_sidecar(&meta, "contact", "Contact", "");
362
363        PaginationPlugin::default().after_compile(&ctx).unwrap();
364        assert!(!site.join("page").exists());
365    }
366
367    #[test]
368    fn after_compile_single_page_skips_pagination() {
369        // 5 dated posts at default per_page=10 → 1 page total → no
370        // /page/N/ directories produced (line 114 `total_pages <= 1`).
371        let (_tmp, site, meta, ctx) = make_layout();
372        write_n_dated_posts(&meta, 5);
373
374        PaginationPlugin::default().after_compile(&ctx).unwrap();
375        assert!(!site.join("page").exists());
376    }
377
378    // -------------------------------------------------------------------
379    // after_compile — sidecar parsing fallbacks
380    // -------------------------------------------------------------------
381
382    #[test]
383    fn after_compile_skips_invalid_json_sidecars() {
384        // The JSON parser error branch at line 76 must not propagate —
385        // bad sidecars are silently skipped so a single corrupt file
386        // can't poison the whole build.
387        let (_tmp, site, meta, ctx) = make_layout();
388        fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
389        // Add 11 valid posts so we still cross the pagination threshold
390        // (default per_page=10 → 2 pages).
391        write_n_dated_posts(&meta, 11);
392
393        PaginationPlugin::default()
394            .after_compile(&ctx)
395            .expect("broken sidecar must not error");
396        assert!(site.join("page/2/index.html").exists());
397    }
398
399    #[test]
400    fn after_compile_missing_title_defaults_to_untitled() {
401        // Pages without a `title` field but with a `date` are still
402        // paginated; the title falls back to "Untitled" (line 82).
403        let (_tmp, site, meta, ctx) = make_layout();
404        // 11 entries with NO title field → "Untitled" fallback used.
405        for i in 1..=11 {
406            fs::write(
407                meta.join(format!("post{i}.meta.json")),
408                format!(r#"{{"date": "2026-01-{i:02}"}}"#),
409            )
410            .unwrap();
411        }
412
413        PaginationPlugin::default().after_compile(&ctx).unwrap();
414        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
415        assert!(
416            page2.contains("Untitled"),
417            "missing title must fall back to \"Untitled\":\n{page2}"
418        );
419    }
420
421    #[test]
422    fn after_compile_skips_pages_with_empty_date_string() {
423        // A `date` field present but empty must be treated the same
424        // as a missing date (line 91-93).
425        let (_tmp, site, meta, ctx) = make_layout();
426        write_sidecar(&meta, "draft", "Draft", ""); // empty date branch
427        write_n_dated_posts(&meta, 11);
428
429        PaginationPlugin::default().after_compile(&ctx).unwrap();
430        // Only the 11 dated posts paginate; the empty-date entry is
431        // ignored, so we get exactly one /page/2/ (11 → 2 pages).
432        assert!(site.join("page/2/index.html").exists());
433        assert!(!site.join("page/3/index.html").exists());
434    }
435
436    // -------------------------------------------------------------------
437    // after_compile — page slicing arithmetic
438    // -------------------------------------------------------------------
439
440    #[test]
441    fn after_compile_exact_multiple_yields_full_pages() {
442        // 10 posts at per_page=5 → exactly 2 full pages, no remainder.
443        let (_tmp, site, meta, ctx) = make_layout();
444        write_n_dated_posts(&meta, 10);
445
446        PaginationPlugin::with_per_page(5)
447            .after_compile(&ctx)
448            .unwrap();
449
450        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
451        // Page 2 of 2: should contain exactly 5 list items.
452        let li_count = page2.matches("<li>").count();
453        assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
454        assert!(!site.join("page/3/index.html").exists());
455    }
456
457    #[test]
458    fn after_compile_non_multiple_yields_partial_last_page() {
459        // 11 posts at per_page=5 → 3 pages: 5 + 5 + 1.
460        let (_tmp, site, meta, ctx) = make_layout();
461        write_n_dated_posts(&meta, 11);
462
463        PaginationPlugin::with_per_page(5)
464            .after_compile(&ctx)
465            .unwrap();
466
467        assert!(site.join("page/2/index.html").exists());
468        assert!(site.join("page/3/index.html").exists());
469        assert!(!site.join("page/4/index.html").exists());
470
471        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
472        let li_count = page3.matches("<li>").count();
473        assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
474    }
475
476    #[test]
477    fn after_compile_per_page_one_yields_one_page_per_post() {
478        // per_page=1 boundary: 5 posts → 5 pages → /page/2 .. /page/5.
479        let (_tmp, site, meta, ctx) = make_layout();
480        write_n_dated_posts(&meta, 5);
481
482        PaginationPlugin::with_per_page(1)
483            .after_compile(&ctx)
484            .unwrap();
485
486        for n in 2..=5 {
487            assert!(
488                site.join(format!("page/{n}/index.html")).exists(),
489                "page/{n}/ should exist"
490            );
491        }
492        assert!(!site.join("page/6/index.html").exists());
493    }
494
495    // -------------------------------------------------------------------
496    // after_compile — sort order
497    // -------------------------------------------------------------------
498
499    #[test]
500    fn after_compile_sorts_entries_by_date_descending() {
501        // Posts written out of order — newest must appear first on
502        // page 1 (which is unwritten by this plugin), so the remainder
503        // on page 2 must be the *oldest* entries.
504        let (_tmp, site, meta, ctx) = make_layout();
505        // Write posts with dates that are NOT in filename order:
506        // file `a` → 2026-01-01 (oldest)
507        // file `z` → 2026-01-11 (newest)
508        let dates = [
509            ("a", "2026-01-01"),
510            ("m", "2026-01-05"),
511            ("z", "2026-01-11"),
512            ("b", "2026-01-02"),
513            ("y", "2026-01-10"),
514            ("c", "2026-01-03"),
515            ("x", "2026-01-09"),
516            ("d", "2026-01-04"),
517            ("w", "2026-01-08"),
518            ("e", "2026-01-06"),
519            ("f", "2026-01-07"),
520        ];
521        for (name, date) in dates {
522            write_sidecar(&meta, name, &format!("Post {name}"), date);
523        }
524
525        PaginationPlugin::with_per_page(10)
526            .after_compile(&ctx)
527            .unwrap();
528
529        // 11 entries / 10 per page → page 2 has the single OLDEST entry.
530        let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
531        assert!(
532            page2.contains("2026-01-01"),
533            "page 2 should contain the oldest entry:\n{page2}"
534        );
535        assert!(
536            !page2.contains("2026-01-11"),
537            "page 2 should NOT contain the newest entry:\n{page2}"
538        );
539    }
540
541    // -------------------------------------------------------------------
542    // after_compile — HTML structure & navigation
543    // -------------------------------------------------------------------
544
545    #[test]
546    fn after_compile_emits_doctype_lang_and_charset() {
547        let (_tmp, site, meta, ctx) = make_layout();
548        write_n_dated_posts(&meta, 11);
549        PaginationPlugin::default().after_compile(&ctx).unwrap();
550
551        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
552        assert!(html.starts_with("<!DOCTYPE html>"));
553        assert!(html.contains("<html lang=\"en\">"));
554        assert!(html.contains("<meta charset=\"utf-8\">"));
555    }
556
557    #[test]
558    fn after_compile_emits_pagination_nav_landmark() {
559        let (_tmp, site, meta, ctx) = make_layout();
560        write_n_dated_posts(&meta, 11);
561        PaginationPlugin::default().after_compile(&ctx).unwrap();
562
563        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
564        assert!(html.contains("<nav aria-label=\"Pagination\">"));
565    }
566
567    #[test]
568    fn after_compile_page_two_prev_link_points_at_root() {
569        // The "previous" link from page 2 must point to "/" — page 1
570        // is the home/root, not /page/1/. Guards line 129-130.
571        let (_tmp, site, meta, ctx) = make_layout();
572        write_n_dated_posts(&meta, 11);
573        PaginationPlugin::default().after_compile(&ctx).unwrap();
574
575        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
576        assert!(
577            html.contains(r#"<a href="/" rel="prev">"#),
578            "page 2's prev should point to root:\n{html}"
579        );
580    }
581
582    #[test]
583    fn after_compile_page_three_prev_link_points_at_page_two() {
584        // Beyond page 2 the prev link uses /page/N-1/ form (line 132).
585        let (_tmp, site, meta, ctx) = make_layout();
586        write_n_dated_posts(&meta, 11);
587        PaginationPlugin::with_per_page(5)
588            .after_compile(&ctx)
589            .unwrap();
590
591        let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
592        assert!(
593            html.contains(r#"<a href="/page/2/" rel="prev">"#),
594            "page 3's prev should point to /page/2/:\n{html}"
595        );
596    }
597
598    #[test]
599    fn after_compile_last_page_has_no_next_link() {
600        // The Next link is omitted on the final page — guards
601        // the `if let Some(next)` branch at line 159.
602        let (_tmp, site, meta, ctx) = make_layout();
603        write_n_dated_posts(&meta, 11);
604        PaginationPlugin::with_per_page(5)
605            .after_compile(&ctx)
606            .unwrap();
607
608        let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
609        assert!(
610            !last.contains(r#"rel="next""#),
611            "last page must not emit a Next link:\n{last}"
612        );
613    }
614
615    #[test]
616    fn after_compile_middle_page_has_both_prev_and_next() {
617        // 16 posts / per_page=5 → 4 pages. Page 2 and page 3 are both
618        // "middle" — assert they have BOTH prev and next.
619        let (_tmp, site, meta, ctx) = make_layout();
620        write_n_dated_posts(&meta, 16);
621        PaginationPlugin::with_per_page(5)
622            .after_compile(&ctx)
623            .unwrap();
624
625        let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
626        assert!(page3.contains(r#"rel="prev""#));
627        assert!(page3.contains(r#"rel="next""#));
628    }
629
630    #[test]
631    fn after_compile_renders_time_element_per_entry() {
632        let (_tmp, site, meta, ctx) = make_layout();
633        write_n_dated_posts(&meta, 11);
634        PaginationPlugin::default().after_compile(&ctx).unwrap();
635
636        let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
637        assert!(
638            html.contains("<time>2026-01-01</time>"),
639            "page 2 should render a <time> element:\n{html}"
640        );
641    }
642
643    #[test]
644    fn after_compile_idempotent_overwrites_existing_pages() {
645        // Re-running must not error and must leave the page directories
646        // intact. Guards against any future use of `create_new`.
647        let (_tmp, site, meta, ctx) = make_layout();
648        write_n_dated_posts(&meta, 11);
649        let plugin = PaginationPlugin::default();
650        plugin.after_compile(&ctx).expect("first run");
651        plugin.after_compile(&ctx).expect("second run");
652        assert!(site.join("page/2/index.html").exists());
653    }
654
655    // -------------------------------------------------------------------
656    // collect_json_files — recursion + filtering
657    // -------------------------------------------------------------------
658
659    #[test]
660    fn collect_json_files_returns_empty_for_missing_directory() {
661        // Non-existent path: the inner `is_dir()` check at line 183
662        // means we just `continue`, ending with an empty Vec — no Err.
663        let dir = tempdir().expect("tempdir");
664        let result =
665            collect_json_files(&dir.path().join("does-not-exist")).unwrap();
666        assert!(result.is_empty());
667    }
668
669    #[test]
670    fn collect_json_files_returns_empty_for_empty_directory() {
671        let dir = tempdir().expect("tempdir");
672        let result = collect_json_files(dir.path()).unwrap();
673        assert!(result.is_empty());
674    }
675
676    #[test]
677    fn collect_json_files_filters_non_json_extensions() {
678        // Only `.json` files are returned. The `is_some_and` filter at
679        // line 191 must reject `.txt`, `.md`, extensionless files, etc.
680        let dir = tempdir().expect("tempdir");
681        fs::write(dir.path().join("a.json"), "{}").unwrap();
682        fs::write(dir.path().join("b.txt"), "x").unwrap();
683        fs::write(dir.path().join("c.md"), "x").unwrap();
684        fs::write(dir.path().join("noext"), "x").unwrap();
685
686        let result = collect_json_files(dir.path()).unwrap();
687        assert_eq!(result.len(), 1);
688        assert!(result[0].file_name().unwrap() == "a.json");
689    }
690
691    #[test]
692    fn collect_json_files_recurses_into_subdirectories() {
693        // Walks nested directories — guards line 189-190 (push subdir
694        // onto stack).
695        let dir = tempdir().expect("tempdir");
696        let nested = dir.path().join("a").join("b").join("c");
697        fs::create_dir_all(&nested).unwrap();
698        fs::write(dir.path().join("top.json"), "{}").unwrap();
699        fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
700        fs::write(nested.join("deep.json"), "{}").unwrap();
701
702        let result = collect_json_files(dir.path()).unwrap();
703        assert_eq!(result.len(), 3);
704    }
705
706    #[test]
707    fn collect_json_files_returns_results_sorted() {
708        // The `files.sort()` at line 196 must yield deterministic output.
709        let dir = tempdir().expect("tempdir");
710        for name in ["zebra.json", "apple.json", "mango.json"] {
711            fs::write(dir.path().join(name), "{}").unwrap();
712        }
713        let result = collect_json_files(dir.path()).unwrap();
714        let names: Vec<&str> = result
715            .iter()
716            .map(|p| p.file_name().unwrap().to_str().unwrap())
717            .collect();
718        assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
719    }
720}