1#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10use pinyin::ToPinyin;
11
12#[cfg(feature = "server")]
13pub fn slugify(title: &str) -> String {
25 let mut out = String::with_capacity(title.len());
26 let mut prev_dash = true;
29 let mut len = 0usize;
30
31 fn push_char(out: &mut String, len: &mut usize, c: char) -> bool {
33 if *len >= 100 {
34 return false;
35 }
36 out.push(c);
37 *len += 1;
38 true
39 }
40
41 for c in title.chars() {
42 if let Some(py) = c.to_pinyin() {
44 for pc in py.plain().chars() {
46 if !push_char(&mut out, &mut len, pc) {
47 break;
48 }
49 }
50 if !push_char(&mut out, &mut len, '-') {
53 break;
54 }
55 prev_dash = true;
56 } else {
57 let lc = c.to_ascii_lowercase();
59 if lc.is_ascii_alphanumeric() {
60 if !push_char(&mut out, &mut len, lc) {
61 break;
62 }
63 prev_dash = false;
64 } else if lc == '_' {
65 if !push_char(&mut out, &mut len, '_') {
67 break;
68 }
69 prev_dash = false;
70 } else {
71 if !prev_dash {
74 if !push_char(&mut out, &mut len, '-') {
75 break;
76 }
77 prev_dash = true;
78 }
79 }
80 }
81 }
82
83 while out.ends_with('-') {
85 out.pop();
86 }
87
88 if out.is_empty() {
89 return chrono::Utc::now().timestamp().to_string();
90 }
91
92 out
93}
94
95#[cfg(feature = "server")]
96pub fn is_valid_slug(slug: &str) -> bool {
98 if slug.is_empty() || slug.len() > 200 {
99 return false;
100 }
101 slug.chars()
102 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
103}
104
105#[cfg(feature = "server")]
106pub async fn ensure_unique_slug(
114 tx: &deadpool_postgres::Transaction<'_>,
115 base: &str,
116 exclude_id: Option<i32>,
117) -> Result<String, crate::api::error::AppError> {
118 use crate::api::error::AppError;
119
120 let mut candidate = base.to_string();
121 let mut suffix = 2;
122
123 loop {
124 let exists = if let Some(exclude) = exclude_id {
126 tx.query_opt(
127 "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
128 &[&candidate, &exclude],
129 )
130 .await
131 .map_err(AppError::query)?
132 .is_some()
133 } else {
134 tx.query_opt(
135 "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
136 &[&candidate],
137 )
138 .await
139 .map_err(AppError::query)?
140 .is_some()
141 };
142
143 if !exists {
144 return Ok(candidate);
145 }
146
147 candidate = format!("{}-{}", base, suffix);
148 suffix += 1;
149
150 if candidate.len() > 200 {
152 return Err(AppError::Internal("无法生成唯一 slug"));
153 }
154 }
155}
156
157#[cfg(all(test, feature = "server"))]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn slugify_ascii_title() {
163 assert_eq!(slugify("Hello World"), "hello-world");
164 }
165
166 #[test]
167 fn slugify_special_characters() {
168 assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
169 }
170
171 #[test]
172 fn slugify_chinese_characters() {
173 assert_eq!(slugify("你好世界"), "ni-hao-shi-jie");
175 assert_eq!(slugify("你好世界 hello"), "ni-hao-shi-jie-hello");
176 }
177
178 #[test]
179 fn slugify_mixed_chinese_ascii() {
180 assert_eq!(slugify("Rust 入门指南"), "rust-ru-men-zhi-nan");
182 }
183
184 #[test]
185 fn slugify_chinese_with_punctuation() {
186 assert_eq!(slugify("你好,世界!"), "ni-hao-shi-jie");
188 }
189
190 #[test]
191 fn slugify_collapses_dashes() {
192 assert_eq!(slugify("a---b"), "a-b");
193 }
194
195 #[test]
196 fn slugify_empty_returns_timestamp() {
197 let slug = slugify("");
198 let _: i64 = slug.parse().expect("should be a valid timestamp");
199 }
200
201 #[test]
202 fn slugify_truncates_at_100_chars() {
203 let long_title = "a".repeat(200);
204 assert!(slugify(&long_title).len() <= 100);
205 }
206
207 #[test]
208 fn slugify_preserves_underscores() {
209 assert_eq!(slugify("hello_world"), "hello_world");
210 }
211
212 #[test]
213 fn is_valid_slug_normal() {
214 assert!(is_valid_slug("hello-world_123"));
215 }
216
217 #[test]
218 fn is_valid_slug_rejects_empty() {
219 assert!(!is_valid_slug(""));
220 }
221
222 #[test]
223 fn is_valid_slug_rejects_too_long() {
224 let long_slug = "a".repeat(201);
225 assert!(!is_valid_slug(&long_slug));
226 }
227
228 #[test]
229 fn is_valid_slug_accepts_max_length() {
230 let slug = "a".repeat(200);
231 assert!(is_valid_slug(&slug));
232 }
233
234 #[test]
235 fn is_valid_slug_rejects_special_chars() {
236 assert!(!is_valid_slug("hello world"));
237 assert!(!is_valid_slug("hello.world"));
238 assert!(!is_valid_slug("hello!world"));
239 }
240
241 #[test]
242 fn is_valid_slug_accepts_chinese() {
243 assert!(is_valid_slug("你好-world"));
244 }
245
246 #[test]
247 fn slugify_all_special_characters_returns_timestamp() {
248 let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~");
249 let _: i64 = slug.parse().expect("should be a valid timestamp");
250 }
251
252 #[test]
253 fn slugify_only_whitespace_returns_timestamp() {
254 let slug = slugify(" \t\n ");
255 let _: i64 = slug.parse().expect("should be a valid timestamp");
256 }
257
258 #[test]
259 fn slugify_leading_and_trailing_dashes() {
260 assert_eq!(slugify("-hello-world-"), "hello-world");
261 assert_eq!(slugify("---hello---world---"), "hello-world");
262 }
263
264 #[test]
265 fn is_valid_slug_mixed_chinese_and_digits() {
266 assert!(is_valid_slug("你好123"));
267 assert!(is_valid_slug("123你好456"));
268 }
269
270 #[test]
271 fn is_valid_slug_exact_200_char_boundary() {
272 let slug = "a".repeat(200);
273 assert!(is_valid_slug(&slug));
274 let slug = "a".repeat(201);
275 assert!(!is_valid_slug(&slug));
276 }
277}