Skip to main content

slug_preserve/
lib.rs

1//! `slug-preserve` - case-preserving slugifier.
2//!
3//! Internal crate for `fren`. Public API is intentionally narrow: a single
4//! [`slugify`] entry point plus a [`SlugOpts`] struct controlling separator
5//! and case behavior.
6//!
7//! Unlike the popular `slug` crate (and most other Rust slug crates) which
8//! always lowercase, `slug-preserve` exposes a [`CaseMode::Preserve`] mode
9//! that keeps the original character case intact, plus `Lower`, `Upper`,
10//! `Title`, and `Capitalize` modes for explicit case control.
11
12#![deny(missing_docs)]
13#![deny(rustdoc::broken_intra_doc_links)]
14
15mod case;
16mod normalize;
17mod separator;
18
19pub use case::CaseMode;
20
21/// Options controlling how a string is slugified.
22#[derive(Debug, Clone, Copy)]
23pub struct SlugOpts {
24    /// Output separator character (e.g. `-` or `_`).
25    pub separator: char,
26    /// How to handle character case.
27    pub case: CaseMode,
28    /// Whether to inject a separator at CamelCase / PascalCase boundaries
29    /// before slugifying (e.g. `WhatsApp` -> `Whats_App`).
30    ///
31    /// Default: `false`. When `false`, `WhatsApp` is preserved as-is.
32    /// When `true`, the boundary `[a-z][A-Z]+` gets a separator inserted
33    /// between the lowercase letter and the run of uppercase letters that
34    /// follows.
35    ///
36    /// `slug-preserve` itself does not split CamelCase; it just carries
37    /// the option so consumers (like `fren`) can act on it before calling
38    /// `slugify`.
39    pub split_camel: bool,
40}
41
42impl Default for SlugOpts {
43    fn default() -> Self {
44        Self {
45            separator: '-',
46            case: CaseMode::Preserve,
47            split_camel: false,
48        }
49    }
50}
51
52/// Slugify a string using the given options.
53#[must_use]
54pub fn slugify(input: &str, opts: &SlugOpts) -> String {
55    slugify_with_sentinel(input, opts.separator, opts)
56}
57
58/// Slugify a string but keep the chosen `sentinel` character as the internal
59/// separator throughout, only substituting it for `opts.separator` at the end.
60///
61/// This entry point is the one `fren` uses: the date-detection pipeline runs
62/// over the sentinel-separated form (the date-format table is keyed off the
63/// sentinel), and the final pass substitutes sentinel → `opts.separator`.
64///
65/// Most callers want [`slugify`] instead.
66#[must_use]
67pub fn slugify_with_sentinel(input: &str, sentinel: char, opts: &SlugOpts) -> String {
68    let normalized = normalize::nfkc(input);
69    let folded = normalize::fold_to_ascii_keep(&normalized, sentinel);
70    let with_sentinels = separator::replace_non_alnum(&folded, sentinel);
71    let collapsed = separator::collapse_runs(&with_sentinels, sentinel);
72    let cased = case::apply(&collapsed, opts.case);
73    let trimmed = cased.trim_matches(sentinel).to_string();
74    if sentinel == opts.separator {
75        trimmed
76    } else {
77        trimmed.replace(sentinel, &opts.separator.to_string())
78    }
79}