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}