Skip to main content

ssg/
taxonomy.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Taxonomy generation plugin.
5//!
6//! Reads `tags` and `categories` from frontmatter sidecars and
7//! generates index pages for each taxonomy term.
8
9use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::{
12    collections::HashMap,
13    fs,
14    path::{Path, PathBuf},
15};
16
17/// A mapping from taxonomy term to a list of (title, URL) pairs.
18type TaxonomyMap = HashMap<String, Vec<(String, String)>>;
19
20/// A taxonomy term with its associated pages.
21#[derive(Debug, Clone)]
22pub struct TaxonomyTerm {
23    /// The term name (e.g. "rust", "web").
24    pub name: String,
25    /// The URL slug (e.g. "rust", "web").
26    pub slug: String,
27    /// Pages with this term: (title, url).
28    pub pages: Vec<(String, String)>,
29}
30
31/// Plugin that generates taxonomy index pages for tags and categories.
32///
33/// Runs in `after_compile`. Reads `.meta.json` sidecars to find
34/// `tags` and `categories` arrays, then generates:
35/// - `/tags/index.html` — list of all tags with page counts
36/// - `/tags/{slug}/index.html` — list of pages for each tag
37/// - `/categories/index.html` and `/categories/{slug}/index.html`
38#[derive(Debug, Clone, Copy)]
39pub struct TaxonomyPlugin;
40
41impl Plugin for TaxonomyPlugin {
42    fn name(&self) -> &'static str {
43        "taxonomy"
44    }
45
46    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
47        let sidecar_dir = ctx.build_dir.join(".meta");
48        if !sidecar_dir.exists() {
49            return Ok(());
50        }
51
52        let (tags, categories) = collect_taxonomy_entries(&sidecar_dir)?;
53
54        if !tags.is_empty() {
55            generate_taxonomy_pages(&ctx.site_dir, "tags", &tags)?;
56            log::info!("[taxonomy] Generated {} tag page(s)", tags.len());
57        }
58
59        if !categories.is_empty() {
60            generate_taxonomy_pages(&ctx.site_dir, "categories", &categories)?;
61            log::info!(
62                "[taxonomy] Generated {} category page(s)",
63                categories.len()
64            );
65        }
66
67        Ok(())
68    }
69}
70
71/// Extracts string terms from a JSON array value into the given map.
72fn extract_terms_from_array(
73    value: &serde_json::Value,
74    map: &mut HashMap<String, Vec<(String, String)>>,
75    title: &str,
76    url: &str,
77) {
78    if let Some(arr) = value.as_array() {
79        for item in arr {
80            if let Some(s) = item.as_str() {
81                map.entry(s.to_string())
82                    .or_default()
83                    .push((title.to_string(), url.to_string()));
84            }
85        }
86    }
87}
88
89/// Collects taxonomy entries (tags and categories) from sidecar JSON files.
90fn collect_taxonomy_entries(
91    sidecar_dir: &Path,
92) -> Result<(TaxonomyMap, TaxonomyMap)> {
93    let sidecars = collect_json_files(sidecar_dir)?;
94    let mut tags: TaxonomyMap = HashMap::new();
95    let mut categories: TaxonomyMap = HashMap::new();
96
97    for sidecar_path in &sidecars {
98        let content = fs::read_to_string(sidecar_path)?;
99        let meta: HashMap<String, serde_json::Value> =
100            match serde_json::from_str(&content) {
101                Ok(m) => m,
102                Err(_) => continue,
103            };
104
105        let title = meta
106            .get("title")
107            .and_then(|v| v.as_str())
108            .unwrap_or("Untitled")
109            .to_string();
110
111        let rel = sidecar_path
112            .strip_prefix(sidecar_dir)
113            .unwrap_or(sidecar_path)
114            .with_extension("")
115            .with_extension("html");
116        let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
117
118        if let Some(tag_arr) = meta.get("tags") {
119            extract_terms_from_array(tag_arr, &mut tags, &title, &url);
120        }
121        if let Some(cat_arr) = meta.get("categories") {
122            extract_terms_from_array(cat_arr, &mut categories, &title, &url);
123        }
124    }
125
126    Ok((tags, categories))
127}
128
129/// Generates index and term pages for a taxonomy.
130fn generate_taxonomy_pages(
131    site_dir: &Path,
132    taxonomy_name: &str,
133    terms: &HashMap<String, Vec<(String, String)>>,
134) -> Result<()> {
135    let tax_dir = site_dir.join(taxonomy_name);
136    fs::create_dir_all(&tax_dir)?;
137
138    // Generate index page listing all terms
139    let mut index_html = format!(
140        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
141         <meta charset=\"utf-8\">\
142         <title>{}</title></head>\n<body>\n<main>\n\
143         <h1>{}</h1>\n<ul>\n",
144        capitalize(taxonomy_name),
145        capitalize(taxonomy_name),
146    );
147
148    let mut sorted_terms: Vec<_> = terms.iter().collect();
149    sorted_terms.sort_by_key(|(name, _)| name.to_lowercase());
150
151    for (term, pages) in &sorted_terms {
152        let slug = slugify(term);
153        index_html.push_str(&format!(
154            "<li><a href=\"/{}/{}/\">{}</a> ({})</li>\n",
155            taxonomy_name,
156            slug,
157            term,
158            pages.len()
159        ));
160
161        // Generate individual term page
162        let term_dir = tax_dir.join(&slug);
163        fs::create_dir_all(&term_dir)?;
164
165        let mut term_html = format!(
166            "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
167             <meta charset=\"utf-8\">\
168             <title>{term}</title></head>\n<body>\n<main>\n\
169             <h1>{term}</h1>\n<ul>\n",
170        );
171        for (title, url) in *pages {
172            term_html
173                .push_str(&format!("<li><a href=\"{url}\">{title}</a></li>\n"));
174        }
175        term_html.push_str("</ul>\n</main>\n</body>\n</html>\n");
176
177        fs::write(term_dir.join("index.html"), term_html)?;
178    }
179
180    index_html.push_str("</ul>\n</main>\n</body>\n</html>\n");
181    fs::write(tax_dir.join("index.html"), index_html)?;
182
183    Ok(())
184}
185
186fn slugify(s: &str) -> String {
187    s.to_lowercase()
188        .chars()
189        .map(|c| if c.is_alphanumeric() { c } else { '-' })
190        .collect::<String>()
191        .split('-')
192        .filter(|s| !s.is_empty())
193        .collect::<Vec<_>>()
194        .join("-")
195}
196
197fn capitalize(s: &str) -> String {
198    let mut c = s.chars();
199    match c.next() {
200        None => String::new(),
201        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
202    }
203}
204
205fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>> {
206    crate::walk::walk_files(dir, "json")
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used, clippy::expect_used)]
211mod tests {
212    use super::*;
213    use crate::test_support::init_logger;
214    use std::path::PathBuf;
215    use tempfile::{tempdir, TempDir};
216
217    // -------------------------------------------------------------------
218    // Test fixtures
219    // -------------------------------------------------------------------
220
221    /// Builds a fresh temp dir layout: `<root>/site`, `<root>/build/.meta`
222    /// and a `PluginContext`.
223    fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
224        init_logger();
225        let dir = tempdir().expect("create tempdir");
226        let site = dir.path().join("site");
227        let build = dir.path().join("build");
228        let meta = build.join(".meta");
229        fs::create_dir_all(&site).expect("mkdir site");
230        fs::create_dir_all(&meta).expect("mkdir meta");
231        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
232        (dir, site, meta, ctx)
233    }
234
235    // -------------------------------------------------------------------
236    // slugify — table-driven coverage of the character classes
237    // -------------------------------------------------------------------
238
239    #[test]
240    fn slugify_table_driven_inputs_produce_expected_slugs() {
241        let cases: &[(&str, &str)] = &[
242            // basic — alphanumeric + space
243            ("Rust Programming", "rust-programming"),
244            // punctuation collapsing
245            ("C++", "c"),
246            ("hello world!", "hello-world"),
247            // multiple consecutive non-alphanumerics collapse to one dash
248            ("a !! b", "a-b"),
249            ("a___b", "a-b"),
250            // leading and trailing punctuation are stripped
251            ("---rust---", "rust"),
252            ("!!!hello!!!", "hello"),
253            // unicode letters survive (alphanumeric)
254            ("café", "café"),
255            // pure punctuation collapses to empty
256            ("!!!", ""),
257            // already-slug stays the same
258            ("rust-web", "rust-web"),
259            // mixed digits and letters
260            ("Rust 2024", "rust-2024"),
261            // empty input
262            ("", ""),
263        ];
264        for &(input, expected) in cases {
265            assert_eq!(
266                slugify(input),
267                expected,
268                "slugify({input:?}) should be {expected:?}"
269            );
270        }
271    }
272
273    #[test]
274    fn slugify_lowercases_uppercase_input() {
275        // Guards the `to_lowercase` step at line 180 — case
276        // normalization is the entire reason slug routing is stable.
277        assert_eq!(slugify("RUST"), "rust");
278        assert_eq!(slugify("CamelCase"), "camelcase");
279    }
280
281    // -------------------------------------------------------------------
282    // capitalize — table-driven (covers None/Some(_) match arms)
283    // -------------------------------------------------------------------
284
285    #[test]
286    fn capitalize_table_driven_inputs_produce_expected_output() {
287        let cases: &[(&str, &str)] = &[
288            // empty -> empty (None arm at line 193)
289            ("", ""),
290            // ASCII single char
291            ("a", "A"),
292            // word
293            ("tags", "Tags"),
294            ("categories", "Categories"),
295            // already capitalized
296            ("Tags", "Tags"),
297            // single non-letter passes through
298            ("1", "1"),
299        ];
300        for &(input, expected) in cases {
301            assert_eq!(
302                capitalize(input),
303                expected,
304                "capitalize({input:?}) should be {expected:?}"
305            );
306        }
307    }
308
309    // -------------------------------------------------------------------
310    // TaxonomyPlugin — derive surface
311    // -------------------------------------------------------------------
312
313    #[test]
314    fn taxonomy_plugin_is_copy_after_move() {
315        // Guards the `Copy` derive added in v0.0.34.
316        let plugin = TaxonomyPlugin;
317        let _copy = plugin;
318        assert_eq!(plugin.name(), "taxonomy");
319    }
320
321    #[test]
322    fn name_returns_static_taxonomy_identifier() {
323        assert_eq!(TaxonomyPlugin.name(), "taxonomy");
324    }
325
326    // -------------------------------------------------------------------
327    // after_compile — early-return paths
328    // -------------------------------------------------------------------
329
330    #[test]
331    fn after_compile_missing_meta_dir_returns_ok_without_writing() {
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        TaxonomyPlugin
340            .after_compile(&ctx)
341            .expect("missing meta is fine");
342        assert!(!site.join("tags").exists());
343        assert!(!site.join("categories").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        TaxonomyPlugin
350            .after_compile(&ctx)
351            .expect("empty meta is fine");
352        assert!(!site.join("tags").exists());
353        assert!(!site.join("categories").exists());
354    }
355
356    #[test]
357    fn after_compile_pages_without_taxonomies_emit_no_output() {
358        // A page with neither `tags` nor `categories` arrays must
359        // not trigger the `!tags.is_empty()` / `!categories.is_empty()`
360        // branches at lines 105 and 110.
361        let (_tmp, site, meta, ctx) = make_layout();
362        fs::write(meta.join("about.meta.json"), r#"{"title": "About"}"#)
363            .unwrap();
364
365        TaxonomyPlugin.after_compile(&ctx).unwrap();
366        assert!(!site.join("tags").exists());
367        assert!(!site.join("categories").exists());
368    }
369
370    // -------------------------------------------------------------------
371    // after_compile — sidecar parsing fallbacks
372    // -------------------------------------------------------------------
373
374    #[test]
375    fn after_compile_skips_invalid_json_sidecars() {
376        // The `Err(_) => continue` arm at line 59 must not poison
377        // the build. A valid sibling sidecar should still produce
378        // taxonomy pages.
379        let (_tmp, site, meta, ctx) = make_layout();
380        fs::write(meta.join("broken.meta.json"), "{not valid").unwrap();
381        fs::write(
382            meta.join("good.meta.json"),
383            r#"{"title": "Good", "tags": ["rust"]}"#,
384        )
385        .unwrap();
386
387        TaxonomyPlugin.after_compile(&ctx).unwrap();
388        assert!(site.join("tags/rust/index.html").exists());
389    }
390
391    #[test]
392    fn after_compile_missing_title_falls_back_to_untitled() {
393        let (_tmp, site, meta, ctx) = make_layout();
394        fs::write(meta.join("notitle.meta.json"), r#"{"tags": ["rust"]}"#)
395            .unwrap();
396
397        TaxonomyPlugin.after_compile(&ctx).unwrap();
398        let html =
399            fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
400        assert!(html.contains("Untitled"));
401    }
402
403    #[test]
404    fn after_compile_ignores_non_string_tag_values() {
405        // The `if let Some(t) = tag.as_str()` filter at line 80 must
406        // skip integers, objects, etc., without erroring.
407        let (_tmp, site, meta, ctx) = make_layout();
408        fs::write(
409            meta.join("mixed.meta.json"),
410            r#"{"title": "Mixed", "tags": ["rust", 42, null, "web", {"x":1}]}"#,
411        )
412        .unwrap();
413
414        TaxonomyPlugin.after_compile(&ctx).unwrap();
415        // Only the two string entries should produce term pages.
416        assert!(site.join("tags/rust/index.html").exists());
417        assert!(site.join("tags/web/index.html").exists());
418    }
419
420    #[test]
421    fn after_compile_ignores_non_array_categories_field() {
422        // Symmetric to `after_compile_ignores_non_array_tags_field`:
423        // a `categories: "tutorials"` (string, not array) must take
424        // the `as_array()` None branch without panicking. Closes
425        // line 100 (the closing brace of the inner if-let).
426        let (_tmp, site, meta, ctx) = make_layout();
427        fs::write(
428            meta.join("badcats.meta.json"),
429            r#"{"title": "BadCats", "categories": "not-an-array"}"#,
430        )
431        .unwrap();
432
433        TaxonomyPlugin.after_compile(&ctx).unwrap();
434        assert!(!site.join("categories").exists());
435    }
436
437    #[test]
438    fn after_compile_ignores_non_string_category_values() {
439        // The `if let Some(c) = cat.as_str()` filter at line 93
440        // must skip ints/objects/nulls.
441        let (_tmp, site, meta, ctx) = make_layout();
442        fs::write(
443            meta.join("mixed-cats.meta.json"),
444            r#"{"title": "Mixed", "categories": ["blog", 42, null, {"x":1}]}"#,
445        )
446        .unwrap();
447
448        TaxonomyPlugin.after_compile(&ctx).unwrap();
449        assert!(site.join("categories/blog/index.html").exists());
450    }
451
452    #[test]
453    fn after_compile_ignores_non_array_tags_field() {
454        // The `if let Some(arr) = tag_arr.as_array()` guard at line 78
455        // must reject string/object values silently.
456        let (_tmp, site, _meta_dir, ctx) = make_layout();
457        let meta_dir = ctx.build_dir.join(".meta");
458        fs::write(
459            meta_dir.join("badtype.meta.json"),
460            r#"{"title": "BadType", "tags": "not-an-array"}"#,
461        )
462        .unwrap();
463
464        TaxonomyPlugin.after_compile(&ctx).unwrap();
465        assert!(!site.join("tags").exists());
466    }
467
468    // -------------------------------------------------------------------
469    // after_compile — tags and categories generation
470    // -------------------------------------------------------------------
471
472    #[test]
473    fn after_compile_generates_index_and_term_pages_for_tags() {
474        let (_tmp, site, meta, ctx) = make_layout();
475        fs::write(
476            meta.join("p1.meta.json"),
477            r#"{"title": "P1", "tags": ["rust", "web"]}"#,
478        )
479        .unwrap();
480        fs::write(
481            meta.join("p2.meta.json"),
482            r#"{"title": "P2", "tags": ["rust"]}"#,
483        )
484        .unwrap();
485
486        TaxonomyPlugin.after_compile(&ctx).unwrap();
487
488        assert!(site.join("tags/index.html").exists());
489        assert!(site.join("tags/rust/index.html").exists());
490        assert!(site.join("tags/web/index.html").exists());
491
492        let rust =
493            fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
494        assert!(rust.contains("P1"));
495        assert!(rust.contains("P2"));
496
497        let web = fs::read_to_string(site.join("tags/web/index.html")).unwrap();
498        assert!(web.contains("P1"));
499        assert!(!web.contains("P2"));
500    }
501
502    #[test]
503    fn after_compile_generates_index_and_term_pages_for_categories() {
504        let (_tmp, site, meta, ctx) = make_layout();
505        fs::write(
506            meta.join("p1.meta.json"),
507            r#"{"title": "P1", "categories": ["tutorials"]}"#,
508        )
509        .unwrap();
510
511        TaxonomyPlugin.after_compile(&ctx).unwrap();
512        assert!(site.join("categories/index.html").exists());
513        assert!(site.join("categories/tutorials/index.html").exists());
514    }
515
516    #[test]
517    fn after_compile_index_shows_page_count_per_term() {
518        // The `({})` count rendering at line 151 must reflect the
519        // actual number of pages tagged with that term.
520        let (_tmp, site, meta, ctx) = make_layout();
521        fs::write(
522            meta.join("a.meta.json"),
523            r#"{"title": "A", "tags": ["rust"]}"#,
524        )
525        .unwrap();
526        fs::write(
527            meta.join("b.meta.json"),
528            r#"{"title": "B", "tags": ["rust"]}"#,
529        )
530        .unwrap();
531        fs::write(
532            meta.join("c.meta.json"),
533            r#"{"title": "C", "tags": ["rust", "web"]}"#,
534        )
535        .unwrap();
536
537        TaxonomyPlugin.after_compile(&ctx).unwrap();
538        let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
539        assert!(index.contains("(3)"), "rust should have 3 posts:\n{index}");
540        assert!(index.contains("(1)"), "web should have 1 post:\n{index}");
541    }
542
543    #[test]
544    fn after_compile_index_lists_terms_alphabetically_case_insensitive() {
545        // The sort key at line 142 is `name.to_lowercase()`, so
546        // `Apple` must precede `banana` despite uppercase coming
547        // earlier in ASCII.
548        let (_tmp, site, meta, ctx) = make_layout();
549        fs::write(
550            meta.join("p.meta.json"),
551            r#"{"title": "P", "tags": ["banana", "Apple", "cherry"]}"#,
552        )
553        .unwrap();
554
555        TaxonomyPlugin.after_compile(&ctx).unwrap();
556        let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
557        let apple = index.find("Apple").expect("Apple in index");
558        let banana = index.find("banana").expect("banana in index");
559        let cherry = index.find("cherry").expect("cherry in index");
560        assert!(apple < banana, "Apple should sort before banana");
561        assert!(banana < cherry, "banana should sort before cherry");
562    }
563
564    #[test]
565    fn after_compile_tags_and_categories_coexist_independently() {
566        let (_tmp, site, meta, ctx) = make_layout();
567        fs::write(
568            meta.join("p.meta.json"),
569            r#"{"title": "P", "tags": ["rust"], "categories": ["tutorials"]}"#,
570        )
571        .unwrap();
572
573        TaxonomyPlugin.after_compile(&ctx).unwrap();
574        assert!(site.join("tags/rust/index.html").exists());
575        assert!(site.join("categories/tutorials/index.html").exists());
576    }
577
578    #[test]
579    fn after_compile_idempotent_overwrites_existing_pages() {
580        let (_tmp, site, meta, ctx) = make_layout();
581        fs::write(
582            meta.join("p.meta.json"),
583            r#"{"title": "P", "tags": ["rust"]}"#,
584        )
585        .unwrap();
586
587        TaxonomyPlugin.after_compile(&ctx).expect("first run");
588        TaxonomyPlugin.after_compile(&ctx).expect("second run");
589        assert!(site.join("tags/rust/index.html").exists());
590    }
591
592    #[test]
593    fn after_compile_emits_doctype_lang_charset_in_index() {
594        let (_tmp, site, meta, ctx) = make_layout();
595        fs::write(
596            meta.join("p.meta.json"),
597            r#"{"title": "P", "tags": ["rust"]}"#,
598        )
599        .unwrap();
600
601        TaxonomyPlugin.after_compile(&ctx).unwrap();
602        let html = fs::read_to_string(site.join("tags/index.html")).unwrap();
603        assert!(html.starts_with("<!DOCTYPE html>"));
604        assert!(html.contains("<html lang=\"en\">"));
605        assert!(html.contains("<meta charset=\"utf-8\">"));
606        assert!(html.contains("<h1>Tags</h1>"));
607    }
608
609    #[test]
610    fn after_compile_term_page_links_back_to_source_url() {
611        // The url derived from the sidecar path (line 74) must be
612        // present in the term-page list item.
613        let (_tmp, site, meta, ctx) = make_layout();
614        fs::write(
615            meta.join("hello.meta.json"),
616            r#"{"title": "Hello", "tags": ["rust"]}"#,
617        )
618        .unwrap();
619
620        TaxonomyPlugin.after_compile(&ctx).unwrap();
621        let html =
622            fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
623        assert!(
624            html.contains(r#"href="/hello.html""#),
625            "term page should link back to /hello.html:\n{html}"
626        );
627    }
628
629    // -------------------------------------------------------------------
630    // collect_json_files — recursion + filtering
631    // -------------------------------------------------------------------
632
633    #[test]
634    fn collect_json_files_returns_empty_for_missing_directory() {
635        let dir = tempdir().expect("tempdir");
636        let result = collect_json_files(&dir.path().join("missing")).unwrap();
637        assert!(result.is_empty());
638    }
639
640    #[test]
641    fn collect_json_files_filters_non_json_extensions() {
642        let dir = tempdir().expect("tempdir");
643        fs::write(dir.path().join("a.json"), "{}").unwrap();
644        fs::write(dir.path().join("b.txt"), "x").unwrap();
645        fs::write(dir.path().join("c"), "x").unwrap();
646
647        let result = collect_json_files(dir.path()).unwrap();
648        assert_eq!(result.len(), 1);
649    }
650
651    #[test]
652    fn collect_json_files_recurses_into_nested_subdirectories() {
653        let dir = tempdir().expect("tempdir");
654        let nested = dir.path().join("a").join("b");
655        fs::create_dir_all(&nested).unwrap();
656        fs::write(dir.path().join("top.json"), "{}").unwrap();
657        fs::write(nested.join("deep.json"), "{}").unwrap();
658
659        let result = collect_json_files(dir.path()).unwrap();
660        assert_eq!(result.len(), 2);
661    }
662
663    #[test]
664    fn collect_json_files_returns_results_sorted() {
665        let dir = tempdir().expect("tempdir");
666        for name in ["zebra.json", "apple.json", "mango.json"] {
667            fs::write(dir.path().join(name), "{}").unwrap();
668        }
669        let result = collect_json_files(dir.path()).unwrap();
670        let names: Vec<_> = result
671            .iter()
672            .map(|p| p.file_name().unwrap().to_str().unwrap())
673            .collect();
674        assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
675    }
676
677    // -------------------------------------------------------------------
678    // TaxonomyTerm — public type smoke test
679    // -------------------------------------------------------------------
680
681    #[test]
682    fn taxonomy_term_can_be_constructed_and_cloned() {
683        let term = TaxonomyTerm {
684            name: "Rust".to_string(),
685            slug: "rust".to_string(),
686            pages: vec![("Hello".to_string(), "/hello.html".to_string())],
687        };
688        let copy = term;
689        assert_eq!(copy.name, "Rust");
690        assert_eq!(copy.slug, "rust");
691        assert_eq!(copy.pages.len(), 1);
692    }
693}
694
695#[cfg(test)]
696#[allow(clippy::unwrap_used, clippy::expect_used)]
697mod proptests {
698    use super::*;
699    use proptest::prelude::*;
700
701    proptest! {
702        #![proptest_config(ProptestConfig::with_cases(1000))]
703
704        /// `slugify` output must contain only (Unicode) alphanumerics and
705        /// hyphens, with no leading/trailing/consecutive hyphens.
706        ///
707        /// NOTE: proptest discovered that `slugify` preserves Unicode
708        /// alphanumeric characters (e.g. `𐞀`). This is intentional —
709        /// the existing test suite asserts `"café"` -> `"café"`.
710        #[test]
711        fn slugify_valid_chars(input in "\\PC*") {
712            let slug = slugify(&input);
713            for ch in slug.chars() {
714                prop_assert!(
715                    ch.is_alphanumeric() || ch == '-',
716                    "unexpected char {:?} in slug {:?}", ch, slug,
717                );
718            }
719            prop_assert!(
720                !slug.starts_with('-'),
721                "slug must not start with hyphen: {:?}", slug,
722            );
723            prop_assert!(
724                !slug.ends_with('-'),
725                "slug must not end with hyphen: {:?}", slug,
726            );
727            prop_assert!(
728                !slug.contains("--"),
729                "slug must not contain consecutive hyphens: {:?}", slug,
730            );
731        }
732    }
733}