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#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SlugConfig {
12 pub replacement_delimiter: char,
14 pub lowercase_enabled: bool,
16 pub max_slug_chars: Option<usize>,
18 pub allowed_character_set: AllowedCharacterSet,
20 pub dot_handling_policy: DotHandlingPolicy,
22 pub transliteration_policy: TransliterationPolicy,
24 pub validation_policy: SlugValidationPolicy,
26 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AllowedCharacterSet {
48 UnicodeAlphanumericCharacters,
50 AsciiAlphanumericCharacters,
52 UnicodeLettersAndDecimalDigits,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DotHandlingPolicy {
59 ReplaceAllDots,
61 PreserveAllDots,
63 PreserveDotsBetweenDecimalDigits,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TransliterationPolicy {
70 None,
72 StaticReplacementMap(&'static [(&'static str, &'static str)]),
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum SlugValidationPolicy {
81 None,
83 LocalPathSegment,
85 UrlPathSegment,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum EmptyOutputPolicy {
92 KeepEmptySlug,
94 UseFallbackSlug(String),
104 UseVerbatimFallbackSlug(String),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SlugResult {
116 pub slug: String,
118 pub changed_from_input: bool,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum SlugError {
125 EmptyTransliterationPattern,
127 EmptyPathSegment,
129 PathSegmentSeparator { character: char },
131 PathSegmentWhitespace { character: char },
133 PathSegmentControlCharacter { character: char },
135 PathSegmentDotValue,
137 UrlPathSegmentReservedCharacter { character: char },
139 FallbackViolatesConfig {
142 reason: &'static str,
144 },
145 AmbiguousReplacementDelimiter {
148 delimiter: char,
150 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
194pub fn slugify(input: &str, config: &SlugConfig) -> Result<SlugResult, SlugError> {
228 validate_replacement_delimiter(config)?;
229 let transliterated = apply_transliteration(input, config.transliteration_policy)?;
230 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
272pub 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
317fn 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 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
419fn 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(slug("hello world", &config), Ok("hello".to_string()));
882
883 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 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 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 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 assert_eq!(
948 slug("!!!", &ascii_capped("untitled")),
949 Ok("untitled".to_string())
950 );
951
952 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}