path_rs/text.rs
1//! Generic text/path-token normalization (no repository or VCS semantics).
2//!
3//! Callers that need Git-specific rules (e.g. stripping `.git` suffixes) must
4//! compose those rules themselves on top of [`normalize_path_token`].
5
6/// How to transform letter case in a comparison key or token.
7///
8/// # Platform notes
9///
10/// - Windows is normally case-insensitive for filesystem paths.
11/// - Linux is normally case-sensitive.
12/// - macOS may be either, depending on the volume.
13/// - Case folding is **not** proof of filesystem identity.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[non_exhaustive]
16pub enum CaseNormalization {
17 /// Leave case unchanged.
18 #[default]
19 Preserve,
20 /// ASCII A–Z → a–z only.
21 AsciiLowercase,
22 /// Unicode lowercase mapping (`str::to_lowercase`).
23 UnicodeLowercase,
24 /// Platform-oriented default: ASCII lowercase on Windows; preserve elsewhere.
25 PlatformDefault,
26}
27
28impl CaseNormalization {
29 /// Apply this case policy to `value`.
30 pub fn apply(self, value: &str) -> String {
31 match self {
32 Self::Preserve => value.to_owned(),
33 Self::AsciiLowercase => value.to_ascii_lowercase(),
34 Self::UnicodeLowercase => value.to_lowercase(),
35 Self::PlatformDefault => {
36 if cfg!(windows) {
37 value.to_ascii_lowercase()
38 } else {
39 value.to_owned()
40 }
41 }
42 }
43 }
44}
45
46/// Options for generic path/token string normalization.
47///
48/// This is intentionally free of repository, Git, or product-specific rules.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct TextNormalizationOptions {
51 /// Trim leading and trailing Unicode whitespace.
52 pub trim_whitespace: bool,
53 /// Strip trailing `/` and `\` characters.
54 pub trim_trailing_separators: bool,
55 /// Rewrite `\` to `/` for separator-normalized comparison.
56 pub normalize_separators: bool,
57 /// Case policy for the result.
58 pub case: CaseNormalization,
59}
60
61impl Default for TextNormalizationOptions {
62 fn default() -> Self {
63 Self {
64 trim_whitespace: true,
65 trim_trailing_separators: true,
66 normalize_separators: true,
67 case: CaseNormalization::Preserve,
68 }
69 }
70}
71
72impl TextNormalizationOptions {
73 /// Create default options (`Self::default()`).
74 pub fn new() -> Self {
75 Self::default()
76 }
77}
78
79/// Normalize a path-like text token for comparison or matching.
80///
81/// # Filesystem access
82///
83/// **No.**
84///
85/// # Returns
86///
87/// A `String` suitable for comparison only — not necessarily a valid filesystem path.
88///
89/// # Examples
90///
91/// ```
92/// use path_rs::{normalize_path_token, CaseNormalization, TextNormalizationOptions};
93///
94/// let key = normalize_path_token(
95/// r"C:\Users\Floris\Repo\",
96/// &TextNormalizationOptions {
97/// trim_whitespace: true,
98/// trim_trailing_separators: true,
99/// normalize_separators: true,
100/// case: CaseNormalization::AsciiLowercase,
101/// },
102/// );
103/// assert_eq!(key, "c:/users/floris/repo");
104/// ```
105pub fn normalize_path_token(value: &str, options: &TextNormalizationOptions) -> String {
106 let mut s = if options.trim_whitespace {
107 value.trim()
108 } else {
109 value
110 }
111 .to_owned();
112
113 if options.trim_trailing_separators {
114 while s.ends_with('/') || s.ends_with('\\') {
115 s.pop();
116 }
117 }
118
119 if options.normalize_separators {
120 s = s.replace('\\', "/");
121 // Collapse repeated separators for token comparison (not for FS paths).
122 while s.contains("//") {
123 s = s.replace("//", "/");
124 }
125 }
126
127 options.case.apply(&s)
128}