Skip to main content

simple_gal/
naming.rs

1//! Centralized filename parsing for the NNN-name convention.
2//!
3//! All entry types (albums, groups, images, pages) follow the same naming pattern:
4//! an optional numeric prefix (`NNN-`) followed by a name. This module provides
5//! a single parsing function that extracts both parts consistently.
6//!
7//! ## Display Titles
8//!
9//! Dashes in the name portion are converted to spaces for display (preserving
10//! original case). The `name` (slug) field is lowercased with underscores
11//! converted to hyphens for URL-friendly paths:
12//! - `020-My-Best-Photos/` → slug "my-best-photos", title "My Best Photos"
13//! - `001-My-Museum.jpg` → slug "my-museum", title "My Museum"
14//! - `040-who-am-i.md` → slug "who-am-i", title "who am i"
15
16/// Result of parsing a numbered entry name like `020-My-Best-Photos`.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ParsedName {
19    /// Number prefix if present (e.g., `20` from `020-My-Best-Photos`)
20    pub number: Option<u32>,
21    /// URL-friendly slug: lowercased, underscores replaced with hyphens.
22    /// For unnumbered entries, derived from the full input.
23    pub name: String,
24    /// Display title: name with dashes converted to spaces (preserves original case).
25    pub display_title: String,
26}
27
28/// Normalize a name part into a URL-friendly slug: lowercase, spaces and underscores → hyphens.
29fn slugify(s: &str) -> String {
30    s.to_lowercase().replace([' ', '_'], "-")
31}
32
33/// Parse an entry name following the `NNN-name` convention.
34///
35/// Handles these patterns:
36/// - `"020-My-Best-Photos"` → number=Some(20), name="my-best-photos", display_title="My Best Photos"
37/// - `"010-Landscapes"` → number=Some(10), name="landscapes", display_title="Landscapes"
38/// - `"001"` → number=Some(1), name="", display_title=""
39/// - `"001-"` → number=Some(1), name="", display_title=""
40/// - `"Museum"` → number=None, name="museum", display_title="Museum"
41/// - `"wip-drafts"` → number=None, name="wip-drafts", display_title="wip drafts"
42pub fn parse_entry_name(name: &str) -> ParsedName {
43    // Try splitting on first dash
44    if let Some(dash_pos) = name.find('-') {
45        let prefix = &name[..dash_pos];
46        if let Ok(num) = prefix.parse::<u32>() {
47            let raw = &name[dash_pos + 1..];
48            return ParsedName {
49                number: Some(num),
50                name: slugify(raw),
51                display_title: raw.replace('-', " "),
52            };
53        }
54    }
55    // Check if the entire string is a pure number (no dash)
56    if let Ok(num) = name.parse::<u32>() {
57        return ParsedName {
58            number: Some(num),
59            name: String::new(),
60            display_title: String::new(),
61        };
62    }
63    // No number prefix
64    ParsedName {
65        number: None,
66        name: slugify(name),
67        display_title: name.replace('-', " "),
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn numbered_with_multi_word_name() {
77        let p = parse_entry_name("020-My-Best-Photos");
78        assert_eq!(p.number, Some(20));
79        assert_eq!(p.name, "my-best-photos");
80        assert_eq!(p.display_title, "My Best Photos");
81    }
82
83    #[test]
84    fn numbered_single_word() {
85        let p = parse_entry_name("010-Landscapes");
86        assert_eq!(p.number, Some(10));
87        assert_eq!(p.name, "landscapes");
88        assert_eq!(p.display_title, "Landscapes");
89    }
90
91    #[test]
92    fn number_only_no_dash() {
93        let p = parse_entry_name("001");
94        assert_eq!(p.number, Some(1));
95        assert_eq!(p.name, "");
96        assert_eq!(p.display_title, "");
97    }
98
99    #[test]
100    fn number_with_trailing_dash() {
101        let p = parse_entry_name("001-");
102        assert_eq!(p.number, Some(1));
103        assert_eq!(p.name, "");
104        assert_eq!(p.display_title, "");
105    }
106
107    #[test]
108    fn unnumbered_single_word() {
109        let p = parse_entry_name("Museum");
110        assert_eq!(p.number, None);
111        assert_eq!(p.name, "museum");
112        assert_eq!(p.display_title, "Museum");
113    }
114
115    #[test]
116    fn unnumbered_with_dashes() {
117        let p = parse_entry_name("wip-drafts");
118        assert_eq!(p.number, None);
119        assert_eq!(p.name, "wip-drafts");
120        assert_eq!(p.display_title, "wip drafts");
121    }
122
123    #[test]
124    fn image_stem_numbered_with_title() {
125        let p = parse_entry_name("001-Museum");
126        assert_eq!(p.number, Some(1));
127        assert_eq!(p.name, "museum");
128        assert_eq!(p.display_title, "Museum");
129    }
130
131    #[test]
132    fn image_stem_dashes_become_spaces() {
133        let p = parse_entry_name("001-My-Museum");
134        assert_eq!(p.number, Some(1));
135        assert_eq!(p.name, "my-museum");
136        assert_eq!(p.display_title, "My Museum");
137    }
138
139    #[test]
140    fn page_name_dashes_become_spaces() {
141        let p = parse_entry_name("040-who-am-i");
142        assert_eq!(p.number, Some(40));
143        assert_eq!(p.name, "who-am-i");
144        assert_eq!(p.display_title, "who am i");
145    }
146
147    #[test]
148    fn large_number_prefix() {
149        let p = parse_entry_name("999-Last");
150        assert_eq!(p.number, Some(999));
151        assert_eq!(p.name, "last");
152        assert_eq!(p.display_title, "Last");
153    }
154
155    #[test]
156    fn zero_prefix() {
157        let p = parse_entry_name("000-First");
158        assert_eq!(p.number, Some(0));
159        assert_eq!(p.name, "first");
160        assert_eq!(p.display_title, "First");
161    }
162
163    #[test]
164    fn underscores_become_hyphens_in_slug() {
165        let p = parse_entry_name("010-My_Photos");
166        assert_eq!(p.number, Some(10));
167        assert_eq!(p.name, "my-photos");
168        assert_eq!(p.display_title, "My_Photos");
169    }
170
171    #[test]
172    fn spaces_become_hyphens_in_slug() {
173        let p = parse_entry_name("001-Magna Graecia With Theo");
174        assert_eq!(p.number, Some(1));
175        assert_eq!(p.name, "magna-graecia-with-theo");
176        assert_eq!(p.display_title, "Magna Graecia With Theo");
177    }
178}