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 caller-provided fallback.
95    UseFallbackSlug(String),
96}
97
98/// Slug generation result.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct SlugResult {
101    /// Generated slug.
102    pub slug: String,
103    /// Whether the final slug differs from the input.
104    pub changed_from_input: bool,
105}
106
107/// Errors returned by slug generation or validation.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum SlugError {
110    /// A static transliteration map contains an empty pattern.
111    EmptyTransliterationPattern,
112    /// A path segment cannot be empty.
113    EmptyPathSegment,
114    /// A path segment cannot contain `/` or `\`.
115    PathSegmentSeparator { character: char },
116    /// A path segment cannot contain Unicode whitespace.
117    PathSegmentWhitespace { character: char },
118    /// A path segment cannot contain control characters.
119    PathSegmentControlCharacter { character: char },
120    /// A path segment cannot be `.` or `..`.
121    PathSegmentDotValue,
122    /// A URL path segment cannot contain URL delimiter or raw percent characters.
123    UrlPathSegmentReservedCharacter { character: char },
124}
125
126impl fmt::Display for SlugError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::EmptyTransliterationPattern => {
130                write!(f, "transliteration patterns must not be empty")
131            }
132            Self::EmptyPathSegment => write!(f, "path segment must not be empty"),
133            Self::PathSegmentSeparator { character } => {
134                write!(f, "path segment must not contain separator `{character}`")
135            }
136            Self::PathSegmentWhitespace { character } => {
137                write!(f, "path segment must not contain whitespace `{character}`")
138            }
139            Self::PathSegmentControlCharacter { character } => {
140                write!(
141                    f,
142                    "path segment must not contain control character U+{:04X}",
143                    *character as u32
144                )
145            }
146            Self::PathSegmentDotValue => write!(f, "path segment must not be `.` or `..`"),
147            Self::UrlPathSegmentReservedCharacter { character } => write!(
148                f,
149                "URL path segment must not contain reserved character `{character}`"
150            ),
151        }
152    }
153}
154
155impl std::error::Error for SlugError {}
156
157/// Generate a slug from `input` using explicit caller-provided rules.
158///
159/// Processing is deterministic:
160///
161/// 1. Apply [`TransliterationPolicy`].
162/// 2. Walk characters left-to-right.
163/// 3. Keep characters allowed by [`AllowedCharacterSet`].
164/// 4. Apply [`DotHandlingPolicy`].
165/// 5. Convert all other character runs to one `replacement_delimiter`.
166/// 6. Trim leading and trailing `replacement_delimiter` characters.
167/// 7. Lowercase if `lowercase_enabled` is `true`.
168/// 8. Apply `max_slug_chars` if present, then strip any trailing
169///    `replacement_delimiter` the cut exposed.
170/// 9. Apply [`EmptyOutputPolicy`] if the slug is empty.
171/// 10. Validate according to [`SlugValidationPolicy`].
172///
173/// See the crate-level documentation (the README) for worked examples of each
174/// target surface: default Unicode slugs, local path segments, URL path
175/// segments, dot handling, and transliteration.
176///
177/// A caller-provided [`EmptyOutputPolicy::UseFallbackSlug`] value is inserted
178/// verbatim — it is validated (step 10) but is not lowercased or truncated,
179/// because steps 7 and 8 already ran on the empty slug it replaces.
180pub fn slugify(input: &str, config: &SlugConfig) -> Result<SlugResult, SlugError> {
181    let transliterated = apply_transliteration(input, config.transliteration_policy)?;
182    let filtered = filter_chars(&transliterated, config);
183    let trimmed = filtered.trim_matches(config.replacement_delimiter);
184    let lowered = if config.lowercase_enabled {
185        trimmed.to_lowercase()
186    } else {
187        trimmed.to_string()
188    };
189    let truncated = match config.max_slug_chars {
190        Some(max_slug_chars) => {
191            truncate_chars(lowered, max_slug_chars, config.replacement_delimiter)
192        }
193        None => lowered,
194    };
195    let slug = match (&config.empty_output_policy, truncated.is_empty()) {
196        (EmptyOutputPolicy::UseFallbackSlug(fallback), true) => fallback.clone(),
197        _ => truncated,
198    };
199
200    validate_slug(&slug, config.validation_policy)?;
201
202    Ok(SlugResult {
203        changed_from_input: slug != input,
204        slug,
205    })
206}
207
208/// Validate `value` according to a standalone validation policy.
209pub fn validate_slug(value: &str, policy: SlugValidationPolicy) -> Result<(), SlugError> {
210    match policy {
211        SlugValidationPolicy::None => Ok(()),
212        SlugValidationPolicy::LocalPathSegment => validate_local_path_segment(value),
213        SlugValidationPolicy::UrlPathSegment => validate_url_path_segment(value),
214    }
215}
216
217fn apply_transliteration(
218    input: &str,
219    policy: TransliterationPolicy,
220) -> Result<Cow<'_, str>, SlugError> {
221    let map = match policy {
222        TransliterationPolicy::None => return Ok(Cow::Borrowed(input)),
223        TransliterationPolicy::StaticReplacementMap(map) => map,
224    };
225
226    if map.iter().any(|(pattern, _)| pattern.is_empty()) {
227        return Err(SlugError::EmptyTransliterationPattern);
228    }
229
230    let mut output = String::with_capacity(input.len());
231    let mut remaining = input;
232    while !remaining.is_empty() {
233        if let Some((pattern, replacement)) = map
234            .iter()
235            .filter(|(pattern, _)| remaining.starts_with(*pattern))
236            .max_by_key(|(pattern, _)| pattern.len())
237        {
238            output.push_str(replacement);
239            remaining = &remaining[pattern.len()..];
240            continue;
241        }
242
243        let Some(ch) = remaining.chars().next() else {
244            break;
245        };
246        output.push(ch);
247        remaining = &remaining[ch.len_utf8()..];
248    }
249
250    Ok(Cow::Owned(output))
251}
252
253fn filter_chars(input: &str, config: &SlugConfig) -> String {
254    let mut output = String::with_capacity(input.len());
255    let mut previous: Option<char> = None;
256    let mut chars = input.chars().peekable();
257
258    while let Some(ch) = chars.next() {
259        if is_allowed(ch, config.allowed_character_set) {
260            output.push(ch);
261        } else if ch == '.'
262            && should_preserve_dot(previous, chars.peek().copied(), config.dot_handling_policy)
263        {
264            output.push('.');
265        } else {
266            push_replacement_delimiter(&mut output, config.replacement_delimiter);
267        }
268        previous = Some(ch);
269    }
270
271    output
272}
273
274fn push_replacement_delimiter(output: &mut String, replacement_delimiter: char) {
275    if output.is_empty() || output.ends_with(replacement_delimiter) {
276        return;
277    }
278    output.push(replacement_delimiter);
279}
280
281/// Keep at most `max_chars` Unicode scalar values, then drop a trailing
282/// `replacement_delimiter` the cut may have exposed. Filtering collapses interior
283/// runs to one delimiter, so cutting mid-run can leave the slug ending in the
284/// delimiter; trimming it keeps the result clean and never above `max_chars`.
285fn truncate_chars(mut value: String, max_chars: usize, replacement_delimiter: char) -> String {
286    if let Some((byte_index, _)) = value.char_indices().nth(max_chars) {
287        value.truncate(byte_index);
288    }
289    while value.ends_with(replacement_delimiter) {
290        value.pop();
291    }
292    value
293}
294
295fn should_preserve_dot(
296    previous: Option<char>,
297    next: Option<char>,
298    policy: DotHandlingPolicy,
299) -> bool {
300    match policy {
301        DotHandlingPolicy::ReplaceAllDots => false,
302        DotHandlingPolicy::PreserveAllDots => true,
303        DotHandlingPolicy::PreserveDotsBetweenDecimalDigits => {
304            matches!(
305                (previous, next),
306                (Some(previous), Some(next))
307                    if is_unicode_decimal_digit(previous) && is_unicode_decimal_digit(next)
308            )
309        }
310    }
311}
312
313fn is_allowed(ch: char, allowed_character_set: AllowedCharacterSet) -> bool {
314    match allowed_character_set {
315        AllowedCharacterSet::UnicodeAlphanumericCharacters => ch.is_alphanumeric(),
316        AllowedCharacterSet::AsciiAlphanumericCharacters => ch.is_ascii_alphanumeric(),
317        AllowedCharacterSet::UnicodeLettersAndDecimalDigits => {
318            is_unicode_letter(ch) || is_unicode_decimal_digit(ch)
319        }
320    }
321}
322
323fn is_unicode_letter(ch: char) -> bool {
324    if ch.is_ascii() {
325        return ch.is_ascii_alphabetic();
326    }
327    matches!(
328        get_general_category(ch),
329        GeneralCategory::UppercaseLetter
330            | GeneralCategory::LowercaseLetter
331            | GeneralCategory::TitlecaseLetter
332            | GeneralCategory::ModifierLetter
333            | GeneralCategory::OtherLetter
334    )
335}
336
337fn is_unicode_decimal_digit(ch: char) -> bool {
338    if ch.is_ascii() {
339        return ch.is_ascii_digit();
340    }
341    get_general_category(ch) == GeneralCategory::DecimalNumber
342}
343
344fn validate_local_path_segment(value: &str) -> Result<(), SlugError> {
345    if value.is_empty() {
346        return Err(SlugError::EmptyPathSegment);
347    }
348    if value == "." || value == ".." {
349        return Err(SlugError::PathSegmentDotValue);
350    }
351
352    for ch in value.chars() {
353        match ch {
354            '/' | '\\' => return Err(SlugError::PathSegmentSeparator { character: ch }),
355            _ if ch.is_whitespace() => {
356                return Err(SlugError::PathSegmentWhitespace { character: ch });
357            }
358            _ if ch.is_control() => {
359                return Err(SlugError::PathSegmentControlCharacter { character: ch });
360            }
361            _ => {}
362        }
363    }
364
365    Ok(())
366}
367
368fn validate_url_path_segment(value: &str) -> Result<(), SlugError> {
369    validate_local_path_segment(value)?;
370
371    for ch in value.chars() {
372        if matches!(ch, '?' | '#' | '%') {
373            return Err(SlugError::UrlPathSegmentReservedCharacter { character: ch });
374        }
375    }
376
377    Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn slug(input: &str, config: &SlugConfig) -> Result<String, SlugError> {
385        slugify(input, config).map(|result| result.slug)
386    }
387
388    fn unicode_local_path_config() -> SlugConfig {
389        SlugConfig {
390            replacement_delimiter: '-',
391            lowercase_enabled: true,
392            max_slug_chars: None,
393            allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
394            dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
395            transliteration_policy: TransliterationPolicy::None,
396            validation_policy: SlugValidationPolicy::LocalPathSegment,
397            empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
398        }
399    }
400
401    fn url_path_segment_config() -> SlugConfig {
402        SlugConfig {
403            replacement_delimiter: '-',
404            lowercase_enabled: true,
405            max_slug_chars: None,
406            allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
407            dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
408            transliteration_policy: TransliterationPolicy::None,
409            validation_policy: SlugValidationPolicy::UrlPathSegment,
410            empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
411        }
412    }
413
414    fn ascii_local_path_config_with_fallback() -> SlugConfig {
415        SlugConfig {
416            replacement_delimiter: '-',
417            lowercase_enabled: true,
418            max_slug_chars: None,
419            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
420            dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
421            transliteration_policy: TransliterationPolicy::None,
422            validation_policy: SlugValidationPolicy::LocalPathSegment,
423            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
424        }
425    }
426
427    #[test]
428    fn default_config_is_minimal_and_keeps_empty() {
429        let config = SlugConfig::default();
430
431        assert_eq!(
432            slugify("", &config),
433            Ok(SlugResult {
434                slug: String::new(),
435                changed_from_input: false,
436            })
437        );
438        assert_eq!(slug("!!!", &config), Ok(String::new()));
439    }
440
441    #[test]
442    fn unicode_local_path_segment_examples_pass_when_non_empty() {
443        let config = unicode_local_path_config();
444
445        assert_eq!(
446            slug("現在的Nobody,未來的Somebody!", &config),
447            Ok("現在的nobody-未來的somebody".to_string())
448        );
449        assert_eq!(
450            slug("牙好,胃口就好,身体倍儿棒,吃嘛嘛香。", &config),
451            Ok("牙好-胃口就好-身体倍儿棒-吃嘛嘛香".to_string())
452        );
453        assert_eq!(
454            slug("お元気ですか?", &config),
455            Ok("お元気ですか".to_string())
456        );
457        assert_eq!(
458            slug("Ubuntu 16.04", &config),
459            Ok("ubuntu-16-04".to_string())
460        );
461    }
462
463    #[test]
464    fn local_path_segment_validation_rejects_empty_slug_after_keep_empty() {
465        assert_eq!(
466            slug("!!!", &unicode_local_path_config()),
467            Err(SlugError::EmptyPathSegment)
468        );
469    }
470
471    #[test]
472    fn url_path_segment_examples_pass() {
473        let config = url_path_segment_config();
474
475        assert_eq!(
476            slug("Ubuntu 16.04", &config),
477            Ok("ubuntu-16.04".to_string())
478        );
479        assert_eq!(
480            slug("T.U.S.F.G.E.3.0.8", &config),
481            Ok("t-u-s-f-g-e-3.0.8".to_string())
482        );
483        assert_eq!(
484            slug(".18 increased ! ", &config),
485            Ok("18-increased".to_string())
486        );
487        assert_eq!(
488            slug("お元気ですか?", &config),
489            Ok("お元気ですか".to_string())
490        );
491    }
492
493    #[test]
494    fn ascii_local_path_segment_examples_pass_with_configured_fallback() {
495        let config = ascii_local_path_config_with_fallback();
496
497        assert_eq!(slug("Hello 世界", &config), Ok("hello".to_string()));
498        assert_eq!(
499            slug("Ubuntu 16.04", &config),
500            Ok("ubuntu-16-04".to_string())
501        );
502        assert_eq!(slug("你好,世界", &config), Ok("fallback".to_string()));
503    }
504
505    #[test]
506    fn preserve_all_dots_keeps_every_dot() {
507        let config = SlugConfig {
508            dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
509            ..SlugConfig::default()
510        };
511
512        assert_eq!(slug("A.B..C", &config), Ok("a.b..c".to_string()));
513    }
514
515    #[test]
516    fn ascii_character_set_removes_non_ascii_letters() {
517        let config = SlugConfig {
518            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
519            ..SlugConfig::default()
520        };
521
522        assert_eq!(slug("Cafe 世界 42", &config), Ok("cafe-42".to_string()));
523    }
524
525    #[test]
526    fn unicode_letters_decimal_digits_excludes_letter_numbers() {
527        let config = SlugConfig {
528            allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
529            ..SlugConfig::default()
530        };
531
532        assert_eq!(slug("Chapter \u{2163}", &config), Ok("chapter".to_string()));
533    }
534
535    #[test]
536    fn unicode_alphanumeric_keeps_letter_numbers() {
537        let config = SlugConfig {
538            allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
539            ..SlugConfig::default()
540        };
541
542        assert_eq!(
543            slug("Chapter \u{2163}", &config),
544            Ok("chapter-\u{2173}".to_string())
545        );
546    }
547
548    #[test]
549    fn static_transliteration_runs_before_filtering() {
550        static MAP: &[(&str, &str)] = &[("Æ", "AE"), ("東京", "Tokyo")];
551        let config = SlugConfig {
552            allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
553            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
554            ..SlugConfig::default()
555        };
556
557        assert_eq!(slug("Æther 東京", &config), Ok("aether-tokyo".to_string()));
558    }
559
560    #[test]
561    fn transliteration_prefers_the_longest_match() {
562        static MAP: &[(&str, &str)] = &[("a", "1"), ("abc", "9")];
563        let config = SlugConfig {
564            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
565            ..SlugConfig::default()
566        };
567
568        // "abc" matches both "a" and "abc" at index 0; the longer pattern wins
569        // regardless of slice order.
570        assert_eq!(slug("abc", &config), Ok("9".to_string()));
571        assert_eq!(slug("ax", &config), Ok("1x".to_string()));
572    }
573
574    #[test]
575    fn empty_transliteration_pattern_is_rejected() {
576        static MAP: &[(&str, &str)] = &[("", "x")];
577        let config = SlugConfig {
578            transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
579            ..SlugConfig::default()
580        };
581
582        assert_eq!(
583            slugify("anything", &config),
584            Err(SlugError::EmptyTransliterationPattern)
585        );
586    }
587
588    #[test]
589    fn max_slug_chars_runs_after_lowercase_and_can_trigger_fallback() {
590        let lower_before_max = SlugConfig {
591            max_slug_chars: Some(1),
592            ..SlugConfig::default()
593        };
594        assert_eq!(slug("\u{0130}", &lower_before_max), Ok("i".to_string()));
595
596        let fallback_after_max = SlugConfig {
597            max_slug_chars: Some(0),
598            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
599            ..SlugConfig::default()
600        };
601        assert_eq!(slug("abc", &fallback_after_max), Ok("fallback".to_string()));
602    }
603
604    #[test]
605    fn truncation_strips_trailing_delimiter_the_cut_exposes() {
606        let config = SlugConfig {
607            max_slug_chars: Some(6),
608            ..SlugConfig::default()
609        };
610        // "hello-world" cut to 6 chars is "hello-"; the exposed delimiter is dropped.
611        assert_eq!(slug("hello world", &config), Ok("hello".to_string()));
612
613        // A cut that lands mid-word keeps the partial word unchanged.
614        let mid_word = SlugConfig {
615            max_slug_chars: Some(4),
616            ..SlugConfig::default()
617        };
618        assert_eq!(slug("hello world", &mid_word), Ok("hell".to_string()));
619
620        // A cut landing right after a delimiter drops it, leaving the leading token.
621        let after_delimiter = SlugConfig {
622            max_slug_chars: Some(2),
623            ..SlugConfig::default()
624        };
625        assert_eq!(slug("a bb cc", &after_delimiter), Ok("a".to_string()));
626    }
627
628    #[test]
629    fn validation_runs_after_fallback() {
630        let valid_fallback = ascii_local_path_config_with_fallback();
631        assert_eq!(slug("你好", &valid_fallback), Ok("fallback".to_string()));
632
633        let invalid_fallback = SlugConfig {
634            empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("bad/fallback".to_string()),
635            validation_policy: SlugValidationPolicy::LocalPathSegment,
636            ..SlugConfig::default()
637        };
638        assert_eq!(
639            slug("!!!", &invalid_fallback),
640            Err(SlugError::PathSegmentSeparator { character: '/' })
641        );
642    }
643
644    #[test]
645    fn no_validation_accepts_raw_unsafe_value() {
646        assert_eq!(validate_slug("", SlugValidationPolicy::None), Ok(()));
647        assert_eq!(validate_slug("../x", SlugValidationPolicy::None), Ok(()));
648    }
649
650    #[test]
651    fn local_path_segment_validation_rejects_unsafe_values() {
652        assert_eq!(
653            validate_slug("", SlugValidationPolicy::LocalPathSegment),
654            Err(SlugError::EmptyPathSegment)
655        );
656        assert_eq!(
657            validate_slug("a/b", SlugValidationPolicy::LocalPathSegment),
658            Err(SlugError::PathSegmentSeparator { character: '/' })
659        );
660        assert_eq!(
661            validate_slug("a\\b", SlugValidationPolicy::LocalPathSegment),
662            Err(SlugError::PathSegmentSeparator { character: '\\' })
663        );
664        assert_eq!(
665            validate_slug("a b", SlugValidationPolicy::LocalPathSegment),
666            Err(SlugError::PathSegmentWhitespace { character: ' ' })
667        );
668        assert_eq!(
669            validate_slug("a\u{0007}b", SlugValidationPolicy::LocalPathSegment),
670            Err(SlugError::PathSegmentControlCharacter {
671                character: '\u{0007}'
672            })
673        );
674        assert_eq!(
675            validate_slug(".", SlugValidationPolicy::LocalPathSegment),
676            Err(SlugError::PathSegmentDotValue)
677        );
678        assert_eq!(
679            validate_slug("..", SlugValidationPolicy::LocalPathSegment),
680            Err(SlugError::PathSegmentDotValue)
681        );
682    }
683
684    #[test]
685    fn url_path_segment_validation_rejects_url_delimiters_and_raw_percent() {
686        assert_eq!(
687            validate_slug("a?b", SlugValidationPolicy::UrlPathSegment),
688            Err(SlugError::UrlPathSegmentReservedCharacter { character: '?' })
689        );
690        assert_eq!(
691            validate_slug("a#b", SlugValidationPolicy::UrlPathSegment),
692            Err(SlugError::UrlPathSegmentReservedCharacter { character: '#' })
693        );
694        assert_eq!(
695            validate_slug("a%b", SlugValidationPolicy::UrlPathSegment),
696            Err(SlugError::UrlPathSegmentReservedCharacter { character: '%' })
697        );
698        assert_eq!(
699            validate_slug("a/b", SlugValidationPolicy::UrlPathSegment),
700            Err(SlugError::PathSegmentSeparator { character: '/' })
701        );
702        assert_eq!(
703            validate_slug("safe-現在-16.04", SlugValidationPolicy::UrlPathSegment),
704            Ok(())
705        );
706    }
707}