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