Skip to main content

perl_module_name/
lib.rs

1//! Perl module-name separator normalization and variant helpers.
2//!
3//! This crate has a single responsibility: normalize and project Perl module
4//! names across canonical (`::`) and legacy (`'`) package separator forms.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use std::borrow::Cow;
12
13/// Normalize legacy package separator `'` to canonical `::`.
14///
15/// # Examples
16///
17/// ```
18/// use perl_module_name::normalize_package_separator;
19///
20/// assert_eq!(normalize_package_separator("Foo'Bar"), "Foo::Bar");
21/// assert_eq!(normalize_package_separator("Foo::Bar"), "Foo::Bar");
22/// ```
23#[must_use]
24pub fn normalize_package_separator(module_name: &str) -> Cow<'_, str> {
25    if module_name.contains('\'') {
26        Cow::Owned(module_name.replace('\'', "::"))
27    } else {
28        Cow::Borrowed(module_name)
29    }
30}
31
32/// Project canonical package separator `::` to legacy `'`.
33///
34/// # Examples
35///
36/// ```
37/// use perl_module_name::legacy_package_separator;
38///
39/// assert_eq!(legacy_package_separator("Foo::Bar"), "Foo'Bar");
40/// assert_eq!(legacy_package_separator("Foo'Bar"), "Foo'Bar");
41/// ```
42#[must_use]
43pub fn legacy_package_separator(module_name: &str) -> Cow<'_, str> {
44    if module_name.contains("::") {
45        Cow::Owned(module_name.replace("::", "'"))
46    } else {
47        Cow::Borrowed(module_name)
48    }
49}
50
51/// Build canonical + legacy module rename pairs.
52///
53/// The returned vector always includes the canonical `::` pair. It also
54/// includes the legacy `'` pair when it differs.
55///
56/// # Examples
57///
58/// ```
59/// use perl_module_name::module_variant_pairs;
60///
61/// let variants = module_variant_pairs("Foo::Bar", "New::Path");
62/// assert_eq!(
63///     variants,
64///     vec![
65///         ("Foo::Bar".to_string(), "New::Path".to_string()),
66///         ("Foo'Bar".to_string(), "New'Path".to_string()),
67///     ]
68/// );
69/// ```
70#[must_use]
71pub fn module_variant_pairs(old_module: &str, new_module: &str) -> Vec<(String, String)> {
72    let canonical_old = normalize_package_separator(old_module).into_owned();
73    let canonical_new = normalize_package_separator(new_module).into_owned();
74
75    let canonical = (canonical_old.clone(), canonical_new.clone());
76    let legacy = (
77        legacy_package_separator(&canonical_old).into_owned(),
78        legacy_package_separator(&canonical_new).into_owned(),
79    );
80
81    if legacy == canonical { vec![canonical] } else { vec![canonical, legacy] }
82}
83
84#[cfg(test)]
85mod tests {
86    use std::borrow::Cow;
87
88    use super::{legacy_package_separator, module_variant_pairs, normalize_package_separator};
89
90    #[test]
91    fn normalizes_legacy_separator() {
92        assert_eq!(normalize_package_separator("Foo'Bar"), "Foo::Bar");
93        assert_eq!(normalize_package_separator("Foo::Bar"), "Foo::Bar");
94    }
95
96    #[test]
97    fn projects_canonical_separator_to_legacy() {
98        assert_eq!(legacy_package_separator("Foo::Bar"), "Foo'Bar");
99        assert_eq!(legacy_package_separator("Foo'Bar"), "Foo'Bar");
100    }
101
102    #[test]
103    fn builds_canonical_and_legacy_variant_pairs() {
104        assert_eq!(
105            module_variant_pairs("Foo::Bar", "New::Path"),
106            vec![
107                ("Foo::Bar".to_string(), "New::Path".to_string()),
108                ("Foo'Bar".to_string(), "New'Path".to_string()),
109            ]
110        );
111    }
112
113    #[test]
114    fn deduplicates_pair_when_no_separator_variants_exist() {
115        assert_eq!(module_variant_pairs("strict", "warnings").len(), 1);
116    }
117
118    // ── Simple module names ──────────────────────────────────────
119
120    #[test]
121    fn normalize_simple_bare_name_foo() {
122        let result = normalize_package_separator("Foo");
123        assert_eq!(result, "Foo");
124        assert!(matches!(result, Cow::Borrowed(_)));
125    }
126
127    #[test]
128    fn normalize_simple_two_segment_foo_bar() {
129        assert_eq!(normalize_package_separator("Foo'Bar"), "Foo::Bar");
130    }
131
132    #[test]
133    fn legacy_simple_two_segment_foo_bar() {
134        assert_eq!(legacy_package_separator("Foo::Bar"), "Foo'Bar");
135    }
136
137    #[test]
138    fn legacy_simple_bare_name_foo() {
139        let result = legacy_package_separator("Foo");
140        assert_eq!(result, "Foo");
141        assert!(matches!(result, Cow::Borrowed(_)));
142    }
143
144    // ── Deeply nested: Foo::Bar::Baz::Qux ───────────────────────
145
146    #[test]
147    fn normalize_four_segments_deeply_nested() {
148        assert_eq!(normalize_package_separator("Foo'Bar'Baz'Qux"), "Foo::Bar::Baz::Qux");
149    }
150
151    #[test]
152    fn legacy_four_segments_deeply_nested() {
153        assert_eq!(legacy_package_separator("Foo::Bar::Baz::Qux"), "Foo'Bar'Baz'Qux");
154    }
155
156    #[test]
157    fn variant_pairs_four_segments_produces_both_forms() {
158        let pairs = module_variant_pairs("Foo::Bar::Baz::Qux", "One::Two::Three::Four");
159        assert_eq!(pairs.len(), 2);
160        assert_eq!(
161            pairs[0],
162            ("Foo::Bar::Baz::Qux".to_string(), "One::Two::Three::Four".to_string())
163        );
164        assert_eq!(pairs[1], ("Foo'Bar'Baz'Qux".to_string(), "One'Two'Three'Four".to_string()));
165    }
166
167    #[test]
168    fn roundtrip_four_segment_normalize_then_legacy() {
169        let input = "Foo'Bar'Baz'Qux";
170        let canonical = normalize_package_separator(input);
171        assert_eq!(canonical, "Foo::Bar::Baz::Qux");
172        let back = legacy_package_separator(&canonical);
173        assert_eq!(back, input);
174    }
175
176    #[test]
177    fn roundtrip_four_segment_legacy_then_normalize() {
178        let input = "Foo::Bar::Baz::Qux";
179        let legacy = legacy_package_separator(input);
180        assert_eq!(legacy, "Foo'Bar'Baz'Qux");
181        let back = normalize_package_separator(&legacy);
182        assert_eq!(back, input);
183    }
184
185    // ── Module name validation behavior ──────────────────────────
186
187    #[test]
188    fn normalize_preserves_content_with_no_separators() {
189        // Names without separators pass through unchanged
190        for name in &["DBI", "strict", "warnings", "utf8", "POSIX", "Carp"] {
191            let result = normalize_package_separator(name);
192            assert_eq!(result.as_ref(), *name);
193            assert!(matches!(result, Cow::Borrowed(_)));
194        }
195    }
196
197    #[test]
198    fn legacy_preserves_content_with_no_separators() {
199        for name in &["DBI", "strict", "warnings", "utf8", "POSIX", "Carp"] {
200            let result = legacy_package_separator(name);
201            assert_eq!(result.as_ref(), *name);
202            assert!(matches!(result, Cow::Borrowed(_)));
203        }
204    }
205
206    #[test]
207    fn normalize_handles_only_identifier_chars() {
208        // Underscores and digits are valid Perl identifier chars
209        assert_eq!(normalize_package_separator("_Private'_Internal"), "_Private::_Internal");
210    }
211
212    #[test]
213    fn normalize_handles_numeric_only_segments() {
214        assert_eq!(normalize_package_separator("V5'V6"), "V5::V6");
215    }
216
217    // ── Module name components extraction ────────────────────────
218    // These tests verify that separator normalization correctly
219    // enables component extraction via split.
220
221    #[test]
222    fn components_after_normalize_two_segments() {
223        let canonical = normalize_package_separator("Foo'Bar");
224        let components: Vec<&str> = canonical.split("::").collect();
225        assert_eq!(components, vec!["Foo", "Bar"]);
226    }
227
228    #[test]
229    fn components_after_normalize_four_segments() {
230        let canonical = normalize_package_separator("Foo'Bar'Baz'Qux");
231        let components: Vec<&str> = canonical.split("::").collect();
232        assert_eq!(components, vec!["Foo", "Bar", "Baz", "Qux"]);
233    }
234
235    #[test]
236    fn components_after_normalize_single_segment() {
237        let canonical = normalize_package_separator("strict");
238        let components: Vec<&str> = canonical.split("::").collect();
239        assert_eq!(components, vec!["strict"]);
240    }
241
242    #[test]
243    fn components_after_normalize_already_canonical() {
244        let canonical = normalize_package_separator("File::Spec::Functions");
245        let components: Vec<&str> = canonical.split("::").collect();
246        assert_eq!(components, vec!["File", "Spec", "Functions"]);
247    }
248
249    #[test]
250    fn components_after_normalize_mixed_separators() {
251        let canonical = normalize_package_separator("A'B::C'D");
252        let components: Vec<&str> = canonical.split("::").collect();
253        assert_eq!(components, vec!["A", "B", "C", "D"]);
254    }
255
256    // ── Module name to file path conversion ──────────────────────
257    // These tests verify the separator-to-path relationship:
258    // Foo::Bar::Baz -> Foo/Bar/Baz.pm
259
260    #[test]
261    fn normalized_name_converts_to_file_path_two_segments() {
262        let canonical = normalize_package_separator("Foo'Bar");
263        let path = canonical.replace("::", "/") + ".pm";
264        assert_eq!(path, "Foo/Bar.pm");
265    }
266
267    #[test]
268    fn normalized_name_converts_to_file_path_four_segments() {
269        let canonical = normalize_package_separator("Foo'Bar'Baz'Qux");
270        let path = canonical.replace("::", "/") + ".pm";
271        assert_eq!(path, "Foo/Bar/Baz/Qux.pm");
272    }
273
274    #[test]
275    fn normalized_name_converts_to_file_path_single_segment() {
276        let canonical = normalize_package_separator("strict");
277        let path = canonical.replace("::", "/") + ".pm";
278        assert_eq!(path, "strict.pm");
279    }
280
281    #[test]
282    fn normalized_name_converts_to_file_path_cpan_style() {
283        let canonical = normalize_package_separator("File'Spec'Functions");
284        let path = canonical.replace("::", "/") + ".pm";
285        assert_eq!(path, "File/Spec/Functions.pm");
286    }
287
288    #[test]
289    fn file_path_roundtrip_through_normalize() {
290        // Given a file path, derive module name, then convert back
291        let module = "DateTime::Format::Strptime";
292        let path = module.replace("::", "/") + ".pm";
293        assert_eq!(path, "DateTime/Format/Strptime.pm");
294
295        // Reverse: path components to module name
296        let recovered = path.trim_end_matches(".pm").replace('/', "::");
297        assert_eq!(recovered, module);
298    }
299
300    // ── Edge cases: single word ──────────────────────────────────
301
302    #[test]
303    fn single_word_normalize_is_identity() {
304        let result = normalize_package_separator("Moose");
305        assert_eq!(result, "Moose");
306        assert!(matches!(result, Cow::Borrowed(_)));
307    }
308
309    #[test]
310    fn single_word_legacy_is_identity() {
311        let result = legacy_package_separator("Moose");
312        assert_eq!(result, "Moose");
313        assert!(matches!(result, Cow::Borrowed(_)));
314    }
315
316    #[test]
317    fn single_word_variant_pairs_produces_one_pair() {
318        let pairs = module_variant_pairs("Moose", "Moo");
319        assert_eq!(pairs.len(), 1);
320        assert_eq!(pairs[0], ("Moose".to_string(), "Moo".to_string()));
321    }
322
323    // ── Edge cases: trailing :: ──────────────────────────────────
324
325    #[test]
326    fn normalize_trailing_double_colon_passthrough() {
327        // Trailing :: has no legacy quote to replace, so borrowed
328        let result = normalize_package_separator("Foo::Bar::");
329        assert_eq!(result, "Foo::Bar::");
330        assert!(matches!(result, Cow::Borrowed(_)));
331    }
332
333    #[test]
334    fn legacy_trailing_double_colon_converts() {
335        assert_eq!(legacy_package_separator("Foo::Bar::"), "Foo'Bar'");
336    }
337
338    #[test]
339    fn variant_pairs_trailing_separator() {
340        let pairs = module_variant_pairs("Foo::Bar::", "Baz::Qux::");
341        assert_eq!(pairs.len(), 2);
342        assert_eq!(pairs[0], ("Foo::Bar::".to_string(), "Baz::Qux::".to_string()));
343        assert_eq!(pairs[1], ("Foo'Bar'".to_string(), "Baz'Qux'".to_string()));
344    }
345
346    // ── Edge cases: leading :: ───────────────────────────────────
347
348    #[test]
349    fn normalize_leading_double_colon_passthrough() {
350        let result = normalize_package_separator("::Foo::Bar");
351        assert_eq!(result, "::Foo::Bar");
352        assert!(matches!(result, Cow::Borrowed(_)));
353    }
354
355    #[test]
356    fn legacy_leading_double_colon_converts() {
357        assert_eq!(legacy_package_separator("::Foo::Bar"), "'Foo'Bar");
358    }
359
360    // ── CPAN real-world deeply nested modules ────────────────────
361
362    #[test]
363    fn normalize_cpan_deeply_nested_catalyst() {
364        assert_eq!(
365            normalize_package_separator("Catalyst'Plugin'Authentication'Store"),
366            "Catalyst::Plugin::Authentication::Store"
367        );
368    }
369
370    #[test]
371    fn variant_pairs_cpan_moosex_types_rename() {
372        let pairs = module_variant_pairs("MooseX::Types::Structured", "Type::Tiny::Structured");
373        assert_eq!(pairs.len(), 2);
374        assert_eq!(
375            pairs[0],
376            ("MooseX::Types::Structured".to_string(), "Type::Tiny::Structured".to_string())
377        );
378    }
379
380    #[test]
381    fn variant_pairs_cpan_deeply_nested_six_segments() {
382        let pairs = module_variant_pairs(
383            "App::Prove::State::Result::Test",
384            "TAP::Harness::Result::Test::V2",
385        );
386        assert_eq!(pairs.len(), 2);
387        // Canonical form
388        assert!(!pairs[0].0.contains('\''));
389        assert!(!pairs[0].1.contains('\''));
390        // Legacy form
391        assert!(!pairs[1].0.contains("::"));
392        assert!(!pairs[1].1.contains("::"));
393    }
394}