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 {
149 reason: &'static str,
151 },
152 AmbiguousReplacementDelimiter {
159 delimiter: char,
161 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
205pub fn slugify(input: &str, config: &SlugConfig) -> Result<SlugResult, SlugError> {
239 validate_replacement_delimiter(config)?;
240 let transliterated = apply_transliteration(input, config.transliteration_policy)?;
241 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
283pub 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
328fn 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 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
430fn 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(slug("hello world", &config), Ok("hello".to_string()));
893
894 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 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 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 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 assert_eq!(
959 slug("!!!", &ascii_capped("untitled")),
960 Ok("untitled".to_string())
961 );
962
963 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}