Skip to main content

agent_first_slug/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::borrow::Cow;
5use std::fmt;
6
7use unicode_general_category::{GeneralCategory, get_general_category};
8
9/// Rules used by [`slugify`].
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SlugConfig {
12    /// Character inserted for each run of filtered input characters.
13    pub replacement_delimiter: char,
14    /// Lowercase the generated slug after delimiter trimming.
15    pub lowercase_enabled: bool,
16    /// Maximum number of Unicode scalar values to keep after lowercasing.
17    pub max_slug_chars: Option<usize>,
18    /// Character set kept from the input after transliteration.
19    pub allowed_character_set: AllowedCharacterSet,
20    /// How dots are handled before other filtered characters become delimiters.
21    pub dot_handling_policy: DotHandlingPolicy,
22    /// Optional transliteration applied before character filtering.
23    pub transliteration_policy: TransliterationPolicy,
24    /// Optional validation applied after empty-output handling.
25    pub validation_policy: SlugValidationPolicy,
26    /// Behavior when the generated slug is empty.
27    pub empty_output_policy: EmptyOutputPolicy,
28}
29
30impl Default for SlugConfig {
31    fn default() -> Self {
32        Self {
33            replacement_delimiter: '-',
34            lowercase_enabled: true,
35            max_slug_chars: None,
36            allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
37            dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
38            transliteration_policy: TransliterationPolicy::None,
39            validation_policy: SlugValidationPolicy::None,
40            empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
41        }
42    }
43}
44
45/// Character sets that can pass through the slug filter.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AllowedCharacterSet {
48    /// Rust's Unicode alphanumeric predicate.
49    UnicodeAlphanumericCharacters,
50    /// ASCII letters and digits only.
51    AsciiAlphanumericCharacters,
52    /// Unicode letter categories plus Unicode decimal digits.
53    UnicodeLettersAndDecimalDigits,
54}
55
56/// Dot handling before all other filtered characters become delimiters.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DotHandlingPolicy {
59    /// Treat every dot as a delimiter.
60    ReplaceAllDots,
61    /// Preserve every dot.
62    PreserveAllDots,
63    /// Preserve a dot only when the previous and next characters are decimal digits.
64    PreserveDotsBetweenDecimalDigits,
65}
66
67/// Transliteration applied before character filtering.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TransliterationPolicy {
70    /// Do not transliterate.
71    None,
72    /// Replace static string patterns with static replacement strings. At each
73    /// position the longest matching pattern wins, so pattern order in the slice
74    /// does not matter.
75    StaticReplacementMap(&'static [(&'static str, &'static str)]),
76}
77
78/// Optional validation applied after slug generation and empty-output handling.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum SlugValidationPolicy {
81    /// Do not validate the resulting slug.
82    None,
83    /// Validate as one local filesystem path segment.
84    LocalPathSegment,
85    /// Validate as one URL path segment before percent-encoding.
86    UrlPathSegment,
87}
88
89/// Behavior when the generated slug is empty.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum EmptyOutputPolicy {
92    /// Return the empty slug.
93    KeepEmptySlug,
94    /// Replace the empty slug with a fallback held to the same rules.
95    ///
96    /// The fallback is checked against the configuration that produced the
97    /// slug it stands in for — character set, delimiter, dot policy, case and
98    /// `max_slug_chars` — not only against the target surface. Without that,
99    /// an ASCII-only, 80-character configuration could still return an
100    /// arbitrary-length mixed-case Unicode string and call it validated, and a
101    /// caller reading "validated" as "matches my `SlugConfig`" would be wrong
102    /// in exactly the case they reached for a fallback to avoid.
103    UseFallbackSlug(String),
104    /// Replace the empty slug with a fallback inserted exactly as written.
105    ///
106    /// Only the target surface is checked. This exists for a value that must
107    /// match something already stored — a legacy identifier a caller cannot
108    /// regenerate — and it means the result may not satisfy the configuration
109    /// that produced it.
110    UseVerbatimFallbackSlug(String),
111}
112
113/// Slug generation result.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SlugResult {
116    /// Generated slug.
117    pub slug: String,
118    /// Whether the final slug differs from the input.
119    pub changed_from_input: bool,
120}
121
122/// Errors returned by slug generation or validation.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum SlugError {
125    /// A static transliteration map contains an empty pattern.
126    EmptyTransliterationPattern,
127    /// A path segment cannot be empty.
128    EmptyPathSegment,
129    /// A path segment cannot contain `/` or `\`.
130    PathSegmentSeparator { character: char },
131    /// A path segment cannot contain Unicode whitespace.
132    PathSegmentWhitespace { character: char },
133    /// A path segment cannot contain control characters.
134    PathSegmentControlCharacter { character: char },
135    /// A path segment cannot be `.` or `..`.
136    PathSegmentDotValue,
137    /// A URL path segment cannot contain URL delimiter or raw percent characters.
138    UrlPathSegmentReservedCharacter { character: char },
139    /// A [`EmptyOutputPolicy::UseFallbackSlug`] value does not satisfy the
140    /// configuration that produced the slug it replaces.
141    FallbackViolatesConfig {
142        /// Which rule it broke.
143        reason: &'static str,
144    },
145    /// The replacement delimiter is a character this configuration would also
146    /// keep from the input, so the two could not be told apart.
147    AmbiguousReplacementDelimiter {
148        /// The configured delimiter.
149        delimiter: char,
150        /// Which part of the configuration also claims it.
151        reason: &'static str,
152    },
153}
154
155impl fmt::Display for SlugError {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            Self::EmptyTransliterationPattern => {
159                write!(f, "transliteration patterns must not be empty")
160            }
161            Self::EmptyPathSegment => write!(f, "path segment must not be empty"),
162            Self::PathSegmentSeparator { character } => {
163                write!(f, "path segment must not contain separator `{character}`")
164            }
165            Self::PathSegmentWhitespace { character } => {
166                write!(f, "path segment must not contain whitespace `{character}`")
167            }
168            Self::FallbackViolatesConfig { reason } => write!(
169                f,
170                "fallback slug does not satisfy this configuration: {reason}; fix it, or use UseVerbatimFallbackSlug to insert it as written"
171            ),
172            Self::AmbiguousReplacementDelimiter { delimiter, reason } => write!(
173                f,
174                "replacement delimiter `{delimiter}` is ambiguous: {reason}; pick one this configuration filters out, such as `-` or `_`"
175            ),
176            Self::PathSegmentControlCharacter { character } => {
177                write!(
178                    f,
179                    "path segment must not contain control character U+{:04X}",
180                    *character as u32
181                )
182            }
183            Self::PathSegmentDotValue => write!(f, "path segment must not be `.` or `..`"),
184            Self::UrlPathSegmentReservedCharacter { character } => write!(
185                f,
186                "URL path segment must not contain reserved character `{character}`"
187            ),
188        }
189    }
190}
191
192impl std::error::Error for SlugError {}
193
194/// Generate a slug from `input` using explicit caller-provided rules.
195///
196/// Processing is deterministic:
197///
198/// 1. Reject a `replacement_delimiter` this configuration could not tell apart
199///    from an input character.
200/// 2. Apply [`TransliterationPolicy`].
201/// 3. Lowercase if `lowercase_enabled` is `true`.
202/// 4. Walk characters left-to-right.
203/// 5. Keep characters allowed by [`AllowedCharacterSet`].
204/// 6. Apply [`DotHandlingPolicy`].
205/// 7. Convert all other character runs to one `replacement_delimiter`.
206/// 8. Trim leading and trailing `replacement_delimiter` characters.
207/// 9. Apply `max_slug_chars` if present, then strip any trailing
208///    `replacement_delimiter` the cut exposed.
209/// 10. Apply [`EmptyOutputPolicy`] if the slug is empty.
210/// 11. Validate according to [`SlugValidationPolicy`].
211///
212/// Case mapping runs at step 3, before filtering, so every scalar in the result
213/// is one the character set admits. It used to run after, and a case mapping
214/// that expands — `İ` becomes `i` plus a combining dot — put characters in the
215/// output that the character set would have rejected.
216///
217/// See the crate-level documentation (the README) for worked examples of each
218/// target surface: default Unicode slugs, local path segments, URL path
219/// segments, dot handling, and transliteration.
220///
221/// An [`EmptyOutputPolicy::UseFallbackSlug`] value is inserted as written
222/// rather than run through the pipeline — steps 3 and 9 already ran on the
223/// empty slug it replaces — but it is required to satisfy the same grammar
224/// those steps would have produced.
225/// [`EmptyOutputPolicy::UseVerbatimFallbackSlug`] waives that and checks only
226/// the target surface.
227pub fn slugify(input: &str, config: &SlugConfig) -> Result<SlugResult, SlugError> {
228    validate_replacement_delimiter(config)?;
229    let transliterated = apply_transliteration(input, config.transliteration_policy)?;
230    // Case mapping runs before the filter, not after it.
231    //
232    // Unicode case mapping is not one scalar for one scalar: lowercasing
233    // `U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE` yields `i` followed by a
234    // combining dot. Lowercasing after the filter therefore put characters into
235    // the slug that no character set here would have let through — so
236    // `allowed_character_set` stopped describing the output alphabet, and a slug
237    // could pass validation while breaking the configuration that generated it.
238    // Filtering afterwards means whatever case mapping produces is judged by the
239    // same rule as everything else.
240    let cased = if config.lowercase_enabled {
241        Cow::Owned(transliterated.to_lowercase())
242    } else {
243        transliterated
244    };
245    let filtered = filter_chars(&cased, config);
246    let trimmed = filtered.trim_matches(config.replacement_delimiter);
247    let truncated = match config.max_slug_chars {
248        Some(max_slug_chars) => truncate_chars(
249            trimmed.to_string(),
250            max_slug_chars,
251            config.replacement_delimiter,
252        ),
253        None => trimmed.to_string(),
254    };
255    let slug = match (&config.empty_output_policy, truncated.is_empty()) {
256        (EmptyOutputPolicy::UseFallbackSlug(fallback), true) => {
257            validate_generated_grammar(fallback, config)?;
258            fallback.clone()
259        }
260        (EmptyOutputPolicy::UseVerbatimFallbackSlug(fallback), true) => fallback.clone(),
261        _ => truncated,
262    };
263
264    validate_slug(&slug, config.validation_policy)?;
265
266    Ok(SlugResult {
267        changed_from_input: slug != input,
268        slug,
269    })
270}
271
272/// Validate `value` according to a standalone validation policy.
273pub fn validate_slug(value: &str, policy: SlugValidationPolicy) -> Result<(), SlugError> {
274    match policy {
275        SlugValidationPolicy::None => Ok(()),
276        SlugValidationPolicy::LocalPathSegment => validate_local_path_segment(value),
277        SlugValidationPolicy::UrlPathSegment => validate_url_path_segment(value),
278    }
279}
280
281fn apply_transliteration(
282    input: &str,
283    policy: TransliterationPolicy,
284) -> Result<Cow<'_, str>, SlugError> {
285    let map = match policy {
286        TransliterationPolicy::None => return Ok(Cow::Borrowed(input)),
287        TransliterationPolicy::StaticReplacementMap(map) => map,
288    };
289
290    if map.iter().any(|(pattern, _)| pattern.is_empty()) {
291        return Err(SlugError::EmptyTransliterationPattern);
292    }
293
294    let mut output = String::with_capacity(input.len());
295    let mut remaining = input;
296    while !remaining.is_empty() {
297        if let Some((pattern, replacement)) = map
298            .iter()
299            .filter(|(pattern, _)| remaining.starts_with(*pattern))
300            .max_by_key(|(pattern, _)| pattern.len())
301        {
302            output.push_str(replacement);
303            remaining = &remaining[pattern.len()..];
304            continue;
305        }
306
307        let Some(ch) = remaining.chars().next() else {
308            break;
309        };
310        output.push(ch);
311        remaining = &remaining[ch.len_utf8()..];
312    }
313
314    Ok(Cow::Owned(output))
315}
316
317/// Refuse a delimiter this configuration would also keep from the input.
318///
319/// The delimiter plays three parts at once: a character a caller may type, the
320/// marker for a run of filtered characters, and the sentinel trimmed off both
321/// ends. Those only stay distinct while the delimiter is one the filter would
322/// never keep. With `a` as the delimiter, `alpha beta` and `lpha beta` both
323/// come out `lphabet` — the boundary is not inserted because the output already
324/// ends in `a`, and the trim then eats real letters off real words. Two
325/// different inputs, one slug, and characters the caller wrote silently gone.
326///
327/// So the invariant is checked before any input is read, rather than left to a
328/// path validation that happens to catch some cases later.
329/// Hold a fallback to the grammar the pipeline would have produced.
330fn validate_generated_grammar(value: &str, config: &SlugConfig) -> Result<(), SlugError> {
331    if let Some(max_slug_chars) = config.max_slug_chars
332        && value.chars().count() > max_slug_chars
333    {
334        return Err(SlugError::FallbackViolatesConfig {
335            reason: "it is longer than max_slug_chars",
336        });
337    }
338    if value.starts_with(config.replacement_delimiter)
339        || value.ends_with(config.replacement_delimiter)
340    {
341        return Err(SlugError::FallbackViolatesConfig {
342            reason: "a generated slug never begins or ends with the replacement delimiter",
343        });
344    }
345    let mut previous: Option<char> = None;
346    let mut chars = value.chars().peekable();
347    while let Some(scalar) = chars.next() {
348        let ok = is_allowed(scalar, config.allowed_character_set)
349            || scalar == config.replacement_delimiter
350            || (scalar == '.'
351                && should_preserve_dot(
352                    previous,
353                    chars.peek().copied(),
354                    config.dot_handling_policy,
355                ));
356        if !ok {
357            return Err(SlugError::FallbackViolatesConfig {
358                reason: "it contains a character this configuration would have filtered out",
359            });
360        }
361        if config.lowercase_enabled && scalar.to_lowercase().next() != Some(scalar) {
362            return Err(SlugError::FallbackViolatesConfig {
363                reason: "lowercasing is enabled and it is not lowercase",
364            });
365        }
366        previous = Some(scalar);
367    }
368    Ok(())
369}
370
371fn validate_replacement_delimiter(config: &SlugConfig) -> Result<(), SlugError> {
372    let delimiter = config.replacement_delimiter;
373    let reason = if is_allowed(delimiter, config.allowed_character_set) {
374        Some("the allowed character set keeps it from the input")
375    } else if delimiter == '.' && config.dot_handling_policy != DotHandlingPolicy::ReplaceAllDots {
376        Some("the dot handling policy preserves it from the input")
377    } else if config.lowercase_enabled && delimiter.to_lowercase().next() != Some(delimiter) {
378        // Lowercasing now runs before the delimiter is inserted, so an
379        // uppercase delimiter would be the one uppercase character in an
380        // otherwise lowercased slug.
381        Some("lowercasing changes it, so it would be the only uncased character in the slug")
382    } else {
383        None
384    };
385    match reason {
386        Some(reason) => Err(SlugError::AmbiguousReplacementDelimiter { delimiter, reason }),
387        None => Ok(()),
388    }
389}
390
391fn filter_chars(input: &str, config: &SlugConfig) -> String {
392    let mut output = String::with_capacity(input.len());
393    let mut previous: Option<char> = None;
394    let mut chars = input.chars().peekable();
395
396    while let Some(ch) = chars.next() {
397        if is_allowed(ch, config.allowed_character_set) {
398            output.push(ch);
399        } else if ch == '.'
400            && should_preserve_dot(previous, chars.peek().copied(), config.dot_handling_policy)
401        {
402            output.push('.');
403        } else {
404            push_replacement_delimiter(&mut output, config.replacement_delimiter);
405        }
406        previous = Some(ch);
407    }
408
409    output
410}
411
412fn push_replacement_delimiter(output: &mut String, replacement_delimiter: char) {
413    if output.is_empty() || output.ends_with(replacement_delimiter) {
414        return;
415    }
416    output.push(replacement_delimiter);
417}
418
419/// Keep at most `max_chars` Unicode scalar values, then drop a trailing
420/// `replacement_delimiter` the cut may have exposed. Filtering collapses interior
421/// runs to one delimiter, so cutting mid-run can leave the slug ending in the
422/// delimiter; trimming it keeps the result clean and never above `max_chars`.
423fn truncate_chars(mut value: String, max_chars: usize, replacement_delimiter: char) -> String {
424    if let Some((byte_index, _)) = value.char_indices().nth(max_chars) {
425        value.truncate(byte_index);
426    }
427    while value.ends_with(replacement_delimiter) {
428        value.pop();
429    }
430    value
431}
432
433fn should_preserve_dot(
434    previous: Option<char>,
435    next: Option<char>,
436    policy: DotHandlingPolicy,
437) -> bool {
438    match policy {
439        DotHandlingPolicy::ReplaceAllDots => false,
440        DotHandlingPolicy::PreserveAllDots => true,
441        DotHandlingPolicy::PreserveDotsBetweenDecimalDigits => {
442            matches!(
443                (previous, next),
444                (Some(previous), Some(next))
445                    if is_unicode_decimal_digit(previous) && is_unicode_decimal_digit(next)
446            )
447        }
448    }
449}
450
451fn is_allowed(ch: char, allowed_character_set: AllowedCharacterSet) -> bool {
452    match allowed_character_set {
453        AllowedCharacterSet::UnicodeAlphanumericCharacters => ch.is_alphanumeric(),
454        AllowedCharacterSet::AsciiAlphanumericCharacters => ch.is_ascii_alphanumeric(),
455        AllowedCharacterSet::UnicodeLettersAndDecimalDigits => {
456            is_unicode_letter(ch) || is_unicode_decimal_digit(ch)
457        }
458    }
459}
460
461fn is_unicode_letter(ch: char) -> bool {
462    if ch.is_ascii() {
463        return ch.is_ascii_alphabetic();
464    }
465    matches!(
466        get_general_category(ch),
467        GeneralCategory::UppercaseLetter
468            | GeneralCategory::LowercaseLetter
469            | GeneralCategory::TitlecaseLetter
470            | GeneralCategory::ModifierLetter
471            | GeneralCategory::OtherLetter
472    )
473}
474
475fn is_unicode_decimal_digit(ch: char) -> bool {
476    if ch.is_ascii() {
477        return ch.is_ascii_digit();
478    }
479    get_general_category(ch) == GeneralCategory::DecimalNumber
480}
481
482fn validate_local_path_segment(value: &str) -> Result<(), SlugError> {
483    if value.is_empty() {
484        return Err(SlugError::EmptyPathSegment);
485    }
486    if value == "." || value == ".." {
487        return Err(SlugError::PathSegmentDotValue);
488    }
489
490    for ch in value.chars() {
491        match ch {
492            '/' | '\\' => return Err(SlugError::PathSegmentSeparator { character: ch }),
493            _ if ch.is_whitespace() => {
494                return Err(SlugError::PathSegmentWhitespace { character: ch });
495            }
496            _ if ch.is_control() => {
497                return Err(SlugError::PathSegmentControlCharacter { character: ch });
498            }
499            _ => {}
500        }
501    }
502
503    Ok(())
504}
505
506fn validate_url_path_segment(value: &str) -> Result<(), SlugError> {
507    validate_local_path_segment(value)?;
508
509    for ch in value.chars() {
510        if matches!(ch, '?' | '#' | '%') {
511            return Err(SlugError::UrlPathSegmentReservedCharacter { character: ch });
512        }
513    }
514
515    Ok(())
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn slug(input: &str, config: &SlugConfig) -> Result<String, SlugError> {
523        slugify(input, config).map(|result| result.slug)
524    }
525
526    fn unicode_local_path_config() -> SlugConfig {
527        SlugConfig {
528            replacement_delimiter: '-',
529            lowercase_enabled: true,
530            max_slug_chars: None,
531            allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
532            dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
533            transliteration_policy: TransliterationPolicy::None,
534            validation_policy: SlugValidationPolicy::LocalPathSegment,
535            empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
536        }
537    }
538
539    fn url_path_segment_config() -> SlugConfig {
540        SlugConfig {
541            replacement_delimiter: '-',
542            lowercase_enabled: true,
543            max_slug_chars: None,
544            allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
545            dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
546            transliteration_policy: TransliterationPolicy::None,
547            validation_policy: SlugValidationPolicy::UrlPathSegment,
548            empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
549        }
550    }
551
552    fn ascii_local_path_config_with_fallback() -> SlugConfig {
553        SlugConfig {
554            replacement_delimiter: '-',
555            lowercase_enabled: true,
556            max_slug_chars: None,
557            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
558            dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
559            transliteration_policy: TransliterationPolicy::None,
560            validation_policy: SlugValidationPolicy::LocalPathSegment,
561            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
562        }
563    }
564
565    #[test]
566    fn default_config_is_minimal_and_keeps_empty() {
567        let config = SlugConfig::default();
568
569        assert_eq!(
570            slugify("", &config),
571            Ok(SlugResult {
572                slug: String::new(),
573                changed_from_input: false,
574            })
575        );
576        assert_eq!(slug("!!!", &config), Ok(String::new()));
577    }
578
579    #[test]
580    fn unicode_local_path_segment_examples_pass_when_non_empty() {
581        let config = unicode_local_path_config();
582
583        assert_eq!(
584            slug("現在的Nobody,未來的Somebody!", &config),
585            Ok("現在的nobody-未來的somebody".to_string())
586        );
587        assert_eq!(
588            slug("牙好,胃口就好,身体倍儿棒,吃嘛嘛香。", &config),
589            Ok("牙好-胃口就好-身体倍儿棒-吃嘛嘛香".to_string())
590        );
591        assert_eq!(
592            slug("お元気ですか?", &config),
593            Ok("お元気ですか".to_string())
594        );
595        assert_eq!(
596            slug("Ubuntu 16.04", &config),
597            Ok("ubuntu-16-04".to_string())
598        );
599    }
600
601    #[test]
602    fn local_path_segment_validation_rejects_empty_slug_after_keep_empty() {
603        assert_eq!(
604            slug("!!!", &unicode_local_path_config()),
605            Err(SlugError::EmptyPathSegment)
606        );
607    }
608
609    #[test]
610    fn url_path_segment_examples_pass() {
611        let config = url_path_segment_config();
612
613        assert_eq!(
614            slug("Ubuntu 16.04", &config),
615            Ok("ubuntu-16.04".to_string())
616        );
617        assert_eq!(
618            slug("T.U.S.F.G.E.3.0.8", &config),
619            Ok("t-u-s-f-g-e-3.0.8".to_string())
620        );
621        assert_eq!(
622            slug(".18 increased ! ", &config),
623            Ok("18-increased".to_string())
624        );
625        assert_eq!(
626            slug("お元気ですか?", &config),
627            Ok("お元気ですか".to_string())
628        );
629    }
630
631    #[test]
632    fn ascii_local_path_segment_examples_pass_with_configured_fallback() {
633        let config = ascii_local_path_config_with_fallback();
634
635        assert_eq!(slug("Hello 世界", &config), Ok("hello".to_string()));
636        assert_eq!(
637            slug("Ubuntu 16.04", &config),
638            Ok("ubuntu-16-04".to_string())
639        );
640        assert_eq!(slug("你好,世界", &config), Ok("fallback".to_string()));
641    }
642
643    #[test]
644    fn preserve_all_dots_keeps_every_dot() {
645        let config = SlugConfig {
646            dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
647            ..SlugConfig::default()
648        };
649
650        assert_eq!(slug("A.B..C", &config), Ok("a.b..c".to_string()));
651    }
652
653    #[test]
654    fn ascii_character_set_removes_non_ascii_letters() {
655        let config = SlugConfig {
656            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
657            ..SlugConfig::default()
658        };
659
660        assert_eq!(slug("Cafe 世界 42", &config), Ok("cafe-42".to_string()));
661    }
662
663    #[test]
664    fn unicode_letters_decimal_digits_excludes_letter_numbers() {
665        let config = SlugConfig {
666            allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
667            ..SlugConfig::default()
668        };
669
670        assert_eq!(slug("Chapter \u{2163}", &config), Ok("chapter".to_string()));
671    }
672
673    #[test]
674    fn unicode_alphanumeric_keeps_letter_numbers() {
675        let config = SlugConfig {
676            allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
677            ..SlugConfig::default()
678        };
679
680        assert_eq!(
681            slug("Chapter \u{2163}", &config),
682            Ok("chapter-\u{2173}".to_string())
683        );
684    }
685
686    #[test]
687    fn static_transliteration_runs_before_filtering() {
688        static MAP: &[(&str, &str)] = &[("Æ", "AE"), ("東京", "Tokyo")];
689        let config = SlugConfig {
690            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
691            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
692            ..SlugConfig::default()
693        };
694
695        assert_eq!(slug("Æther 東京", &config), Ok("aether-tokyo".to_string()));
696    }
697
698    #[test]
699    fn transliteration_prefers_the_longest_match() {
700        static MAP: &[(&str, &str)] = &[("a", "1"), ("abc", "9")];
701        let config = SlugConfig {
702            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
703            ..SlugConfig::default()
704        };
705
706        // "abc" matches both "a" and "abc" at index 0; the longer pattern wins
707        // regardless of slice order.
708        assert_eq!(slug("abc", &config), Ok("9".to_string()));
709        assert_eq!(slug("ax", &config), Ok("1x".to_string()));
710    }
711
712    #[test]
713    fn empty_transliteration_pattern_is_rejected() {
714        static MAP: &[(&str, &str)] = &[("", "x")];
715        let config = SlugConfig {
716            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
717            ..SlugConfig::default()
718        };
719
720        assert_eq!(
721            slugify("anything", &config),
722            Err(SlugError::EmptyTransliterationPattern)
723        );
724    }
725
726    #[test]
727    fn max_slug_chars_runs_after_lowercase_and_can_trigger_fallback() {
728        let lower_before_max = SlugConfig {
729            max_slug_chars: Some(1),
730            ..SlugConfig::default()
731        };
732        assert_eq!(slug("\u{0130}", &lower_before_max), Ok("i".to_string()));
733
734        // A zero-character budget cannot hold a fallback either: the budget is
735        // the configuration's, and the fallback stands in for what it would
736        // have produced.
737        let fallback_after_max = SlugConfig {
738            max_slug_chars: Some(0),
739            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
740            ..SlugConfig::default()
741        };
742        assert_eq!(
743            slug("abc", &fallback_after_max),
744            Err(SlugError::FallbackViolatesConfig {
745                reason: "it is longer than max_slug_chars"
746            })
747        );
748        let verbatim_after_max = SlugConfig {
749            empty_output_policy: EmptyOutputPolicy::UseVerbatimFallbackSlug("fallback".to_string()),
750            ..fallback_after_max
751        };
752        assert_eq!(slug("abc", &verbatim_after_max), Ok("fallback".to_string()));
753    }
754
755    #[test]
756    fn every_scalar_in_a_slug_is_one_the_character_set_admits() {
757        // `İ` lowercases to `i` plus a combining dot. Lowercasing after the
758        // filter let that dot into the slug even though no character set here
759        // would have kept it, so `allowed_character_set` stopped describing the
760        // output. Now the mapping happens first and the dot is a filtered run
761        // like any other.
762        let config = SlugConfig::default();
763        let slugged = slug("\u{0130}stanbul", &config).expect("a slug");
764        assert_eq!(slugged, "i-stanbul");
765        for scalar in slugged.chars() {
766            assert!(
767                is_allowed(scalar, config.allowed_character_set)
768                    || scalar == config.replacement_delimiter,
769                "U+{:04X} is in the slug but not in its alphabet",
770                scalar as u32
771            );
772        }
773
774        // ASCII-only sees the same expansion and keeps neither half of it.
775        let ascii = SlugConfig {
776            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
777            ..SlugConfig::default()
778        };
779        for scalar in slug("\u{0130}stanbul", &ascii).expect("a slug").chars() {
780            assert!(scalar.is_ascii_alphanumeric() || scalar == '-');
781        }
782    }
783
784    #[test]
785    fn a_delimiter_the_filter_would_keep_is_refused_before_any_input_is_read() {
786        // With `a` as the delimiter these two inputs both used to produce
787        // `lphabet`: the run boundary was skipped because the output already
788        // ended in `a`, and the trim then ate real letters. Different inputs,
789        // one slug.
790        let ambiguous = SlugConfig {
791            replacement_delimiter: 'a',
792            ..SlugConfig::default()
793        };
794        for input in ["alpha beta", "lpha beta"] {
795            assert!(matches!(
796                slugify(input, &ambiguous),
797                Err(SlugError::AmbiguousReplacementDelimiter { delimiter: 'a', .. })
798            ));
799        }
800
801        // A dot policy that preserves dots claims `.` the same way.
802        assert!(matches!(
803            slugify(
804                "a.b c",
805                &SlugConfig {
806                    replacement_delimiter: '.',
807                    dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
808                    ..SlugConfig::default()
809                }
810            ),
811            Err(SlugError::AmbiguousReplacementDelimiter { delimiter: '.', .. })
812        ));
813        // ...and is fine when the policy replaces them.
814        assert_eq!(
815            slug(
816                "a.b c",
817                &SlugConfig {
818                    replacement_delimiter: '.',
819                    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
820                    ..SlugConfig::default()
821                }
822            ),
823            Ok("a.b.c".to_string())
824        );
825
826        // A delimiter lowercasing would change cannot be the one uncased
827        // character in a lowercased slug. Under ASCII-only, `Ä` gets past the
828        // character-set rule and is caught by this one.
829        assert_eq!(
830            slugify(
831                "a b",
832                &SlugConfig {
833                    replacement_delimiter: 'Ä',
834                    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
835                    lowercase_enabled: true,
836                    ..SlugConfig::default()
837                }
838            ),
839            Err(SlugError::AmbiguousReplacementDelimiter {
840                delimiter: 'Ä',
841                reason: "lowercasing changes it, so it would be the only uncased character in the slug",
842            })
843        );
844        // The same delimiter is fine when nothing is being lowercased.
845        assert_eq!(
846            slug(
847                "a b",
848                &SlugConfig {
849                    replacement_delimiter: 'Ä',
850                    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
851                    lowercase_enabled: false,
852                    ..SlugConfig::default()
853                }
854            ),
855            Ok("aÄb".to_string())
856        );
857
858        // The ordinary delimiters stay ordinary.
859        for delimiter in ['-', '_', '~'] {
860            assert!(
861                slugify(
862                    "hello world",
863                    &SlugConfig {
864                        replacement_delimiter: delimiter,
865                        ..SlugConfig::default()
866                    }
867                )
868                .is_ok(),
869                "`{delimiter}` must remain usable"
870            );
871        }
872    }
873
874    #[test]
875    fn truncation_strips_trailing_delimiter_the_cut_exposes() {
876        let config = SlugConfig {
877            max_slug_chars: Some(6),
878            ..SlugConfig::default()
879        };
880        // "hello-world" cut to 6 chars is "hello-"; the exposed delimiter is dropped.
881        assert_eq!(slug("hello world", &config), Ok("hello".to_string()));
882
883        // A cut that lands mid-word keeps the partial word unchanged.
884        let mid_word = SlugConfig {
885            max_slug_chars: Some(4),
886            ..SlugConfig::default()
887        };
888        assert_eq!(slug("hello world", &mid_word), Ok("hell".to_string()));
889
890        // A cut landing right after a delimiter drops it, leaving the leading token.
891        let after_delimiter = SlugConfig {
892            max_slug_chars: Some(2),
893            ..SlugConfig::default()
894        };
895        assert_eq!(slug("a bb cc", &after_delimiter), Ok("a".to_string()));
896    }
897
898    #[test]
899    fn validation_runs_after_fallback() {
900        let valid_fallback = ascii_local_path_config_with_fallback();
901        assert_eq!(slug("你好", &valid_fallback), Ok("fallback".to_string()));
902
903        // A verbatim fallback is judged only by the target surface, so this is
904        // where surface validation is the thing that catches it.
905        let invalid_fallback = SlugConfig {
906            empty_output_policy: EmptyOutputPolicy::UseVerbatimFallbackSlug(
907                "bad/fallback".to_string(),
908            ),
909            validation_policy: SlugValidationPolicy::LocalPathSegment,
910            ..SlugConfig::default()
911        };
912        assert_eq!(
913            slug("!!!", &invalid_fallback),
914            Err(SlugError::PathSegmentSeparator { character: '/' })
915        );
916    }
917
918    #[test]
919    fn a_fallback_must_satisfy_the_configuration_it_stands_in_for() {
920        // The case the report names: an ASCII-only, length-capped configuration
921        // used to accept any fallback at all and still report it as validated.
922        let ascii_capped = |fallback: &str| SlugConfig {
923            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
924            max_slug_chars: Some(8),
925            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug(fallback.to_string()),
926            ..SlugConfig::default()
927        };
928        for (fallback, expected) in [
929            (
930                "Ünïcode",
931                "it contains a character this configuration would have filtered out",
932            ),
933            ("MixedCase", "it is longer than max_slug_chars"),
934            ("waytoolongfallback", "it is longer than max_slug_chars"),
935            (
936                "-leading",
937                "a generated slug never begins or ends with the replacement delimiter",
938            ),
939        ] {
940            assert_eq!(
941                slug("!!!", &ascii_capped(fallback)),
942                Err(SlugError::FallbackViolatesConfig { reason: expected }),
943                "fallback {fallback:?}"
944            );
945        }
946        // One the configuration could itself have produced is fine.
947        assert_eq!(
948            slug("!!!", &ascii_capped("untitled")),
949            Ok("untitled".to_string())
950        );
951
952        // And a caller who must match something already stored says so.
953        assert_eq!(
954            slug(
955                "!!!",
956                &SlugConfig {
957                    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
958                    max_slug_chars: Some(8),
959                    empty_output_policy: EmptyOutputPolicy::UseVerbatimFallbackSlug(
960                        "Legacy Ünïcode Name".to_string()
961                    ),
962                    ..SlugConfig::default()
963                }
964            ),
965            Ok("Legacy Ünïcode Name".to_string())
966        );
967    }
968
969    #[test]
970    fn no_validation_accepts_raw_unsafe_value() {
971        assert_eq!(validate_slug("", SlugValidationPolicy::None), Ok(()));
972        assert_eq!(validate_slug("../x", SlugValidationPolicy::None), Ok(()));
973    }
974
975    #[test]
976    fn local_path_segment_validation_rejects_unsafe_values() {
977        assert_eq!(
978            validate_slug("", SlugValidationPolicy::LocalPathSegment),
979            Err(SlugError::EmptyPathSegment)
980        );
981        assert_eq!(
982            validate_slug("a/b", SlugValidationPolicy::LocalPathSegment),
983            Err(SlugError::PathSegmentSeparator { character: '/' })
984        );
985        assert_eq!(
986            validate_slug("a\\b", SlugValidationPolicy::LocalPathSegment),
987            Err(SlugError::PathSegmentSeparator { character: '\\' })
988        );
989        assert_eq!(
990            validate_slug("a b", SlugValidationPolicy::LocalPathSegment),
991            Err(SlugError::PathSegmentWhitespace { character: ' ' })
992        );
993        assert_eq!(
994            validate_slug("a\u{0007}b", SlugValidationPolicy::LocalPathSegment),
995            Err(SlugError::PathSegmentControlCharacter {
996                character: '\u{0007}'
997            })
998        );
999        assert_eq!(
1000            validate_slug(".", SlugValidationPolicy::LocalPathSegment),
1001            Err(SlugError::PathSegmentDotValue)
1002        );
1003        assert_eq!(
1004            validate_slug("..", SlugValidationPolicy::LocalPathSegment),
1005            Err(SlugError::PathSegmentDotValue)
1006        );
1007    }
1008
1009    #[test]
1010    fn url_path_segment_validation_rejects_url_delimiters_and_raw_percent() {
1011        assert_eq!(
1012            validate_slug("a?b", SlugValidationPolicy::UrlPathSegment),
1013            Err(SlugError::UrlPathSegmentReservedCharacter { character: '?' })
1014        );
1015        assert_eq!(
1016            validate_slug("a#b", SlugValidationPolicy::UrlPathSegment),
1017            Err(SlugError::UrlPathSegmentReservedCharacter { character: '#' })
1018        );
1019        assert_eq!(
1020            validate_slug("a%b", SlugValidationPolicy::UrlPathSegment),
1021            Err(SlugError::UrlPathSegmentReservedCharacter { character: '%' })
1022        );
1023        assert_eq!(
1024            validate_slug("a/b", SlugValidationPolicy::UrlPathSegment),
1025            Err(SlugError::PathSegmentSeparator { character: '/' })
1026        );
1027        assert_eq!(
1028            validate_slug("safe-現在-16.04", SlugValidationPolicy::UrlPathSegment),
1029            Ok(())
1030        );
1031    }
1032}