Skip to main content

agentbin_core/
slug.rs

1/// Derive a URL-friendly slug from a filename.
2///
3/// Strips the file extension, lowercases, replaces non-alphanumeric characters
4/// with hyphens, collapses runs, and truncates to 48 characters. Returns `None`
5/// for empty, numeric-only, or single-character results.
6pub fn slugify_filename(filename: &str) -> Option<String> {
7    // Strip extension — if the only dot is at position 0 (e.g. ".md"), treat
8    // the entire input as extension-only (no usable stem).
9    let stem = match filename.rfind('.') {
10        Some(0) => return None,
11        Some(pos) => &filename[..pos],
12        None => filename,
13    };
14
15    let slug: String = stem
16        .chars()
17        .map(|c| {
18            if c.is_ascii_alphanumeric() {
19                c.to_ascii_lowercase()
20            } else {
21                '-'
22            }
23        })
24        .collect();
25
26    // Collapse runs of hyphens and trim leading/trailing hyphens
27    let mut collapsed = String::with_capacity(slug.len());
28    let mut prev_hyphen = true; // treat start as hyphen to trim leading
29    for ch in slug.chars() {
30        if ch == '-' {
31            if !prev_hyphen {
32                collapsed.push('-');
33            }
34            prev_hyphen = true;
35        } else {
36            collapsed.push(ch);
37            prev_hyphen = false;
38        }
39    }
40    // Trim trailing hyphen
41    if collapsed.ends_with('-') {
42        collapsed.pop();
43    }
44
45    // Truncate to 48 chars (on a hyphen boundary if possible)
46    if collapsed.len() > 48 {
47        collapsed.truncate(48);
48        if let Some(last_hyphen) = collapsed.rfind('-') {
49            collapsed.truncate(last_hyphen);
50        }
51    }
52
53    // Reject empty, single-char, or purely numeric slugs
54    if collapsed.len() <= 1 || collapsed.chars().all(|c| c.is_ascii_digit()) {
55        return None;
56    }
57
58    Some(collapsed)
59}
60
61/// Extract the 10-character UID prefix from a path segment that may contain a slug suffix.
62///
63/// Given `"1vjmeRjNdi-stdlib-fix-plan"`, returns `"1vjmeRjNdi"`.
64/// Short inputs pass through unchanged to fail at `validate_uid`.
65pub fn extract_uid(path_segment: &str) -> &str {
66    if path_segment.len() >= 10 {
67        &path_segment[..10]
68    } else {
69        path_segment
70    }
71}
72
73/// Combine a UID with an optional slug to form a URL path segment.
74///
75/// Returns `"{uid}-{slug}"` when a slug is present, or just `"{uid}"` otherwise.
76pub fn uid_with_slug(uid: &str, slug: Option<&str>) -> String {
77    match slug {
78        Some(s) if !s.is_empty() => format!("{uid}-{s}"),
79        _ => uid.to_string(),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn slugify_basic_filename() {
89        assert_eq!(
90            slugify_filename("stdlib-fix-plan.md"),
91            Some("stdlib-fix-plan".to_string())
92        );
93    }
94
95    #[test]
96    fn slugify_strips_extension() {
97        assert_eq!(
98            slugify_filename("My Report.html"),
99            Some("my-report".to_string())
100        );
101    }
102
103    #[test]
104    fn slugify_collapses_special_chars() {
105        assert_eq!(
106            slugify_filename("foo___bar--baz.txt"),
107            Some("foo-bar-baz".to_string())
108        );
109    }
110
111    #[test]
112    fn slugify_trims_leading_trailing_hyphens() {
113        assert_eq!(slugify_filename("--hello--.md"), Some("hello".to_string()));
114    }
115
116    #[test]
117    fn slugify_truncates_long_names() {
118        let long_name = "a".repeat(60) + ".md";
119        let result = slugify_filename(&long_name).unwrap();
120        assert!(result.len() <= 48);
121    }
122
123    #[test]
124    fn slugify_truncates_on_hyphen_boundary() {
125        // 45 chars of 'a', then '-bbb' = 49 chars total, should truncate at the hyphen
126        let name = format!("{}-bbb.md", "a".repeat(45));
127        let result = slugify_filename(&name).unwrap();
128        assert!(result.len() <= 48);
129        assert!(!result.ends_with('-'));
130    }
131
132    #[test]
133    fn slugify_returns_none_for_empty() {
134        assert_eq!(slugify_filename(".md"), None);
135    }
136
137    #[test]
138    fn slugify_returns_none_for_single_char() {
139        assert_eq!(slugify_filename("a.md"), None);
140    }
141
142    #[test]
143    fn slugify_returns_none_for_numeric_only() {
144        assert_eq!(slugify_filename("12345.txt"), None);
145    }
146
147    #[test]
148    fn slugify_returns_none_for_no_extension_single_char() {
149        assert_eq!(slugify_filename("x"), None);
150    }
151
152    #[test]
153    fn slugify_no_extension() {
154        assert_eq!(slugify_filename("readme"), Some("readme".to_string()));
155    }
156
157    #[test]
158    fn extract_uid_with_slug() {
159        assert_eq!(extract_uid("1vjmeRjNdi-stdlib-fix-plan"), "1vjmeRjNdi");
160    }
161
162    #[test]
163    fn extract_uid_plain() {
164        assert_eq!(extract_uid("1vjmeRjNdi"), "1vjmeRjNdi");
165    }
166
167    #[test]
168    fn extract_uid_short_input() {
169        assert_eq!(extract_uid("abc"), "abc");
170    }
171
172    #[test]
173    fn uid_with_slug_some() {
174        assert_eq!(
175            uid_with_slug("1vjmeRjNdi", Some("stdlib-fix-plan")),
176            "1vjmeRjNdi-stdlib-fix-plan"
177        );
178    }
179
180    #[test]
181    fn uid_with_slug_none() {
182        assert_eq!(uid_with_slug("1vjmeRjNdi", None), "1vjmeRjNdi");
183    }
184
185    #[test]
186    fn uid_with_slug_empty() {
187        assert_eq!(uid_with_slug("1vjmeRjNdi", Some("")), "1vjmeRjNdi");
188    }
189}