link_bridge/redirector.rs
1//! URL redirection system for generating short links and HTML redirect pages.
2//!
3//! This module provides the core functionality for creating URL redirects by:
4//! - Validating and normalizing URL paths
5//! - Generating unique short file names using base62 encoding
6//! - Creating HTML redirect pages with meta refresh and JavaScript fallbacks
7//! - Writing redirect files to the filesystem
8//!
9//! # Example Usage
10//!
11//! ```rust
12//! use link_bridge::Redirector;
13//! use std::fs;
14//!
15//! // Create a redirector for a URL path
16//! let mut redirector = Redirector::new("api/v1/users").unwrap();
17//!
18//! // Optionally set a custom output directory
19//! redirector.set_path("doc_test_output");
20//!
21//! // Write the redirect HTML file
22//! redirector.write_redirects().unwrap();
23//!
24//! // Clean up test files
25//! fs::remove_dir_all("doc_test_output").ok();
26//! ```
27
28mod url_path;
29
30use std::ffi::OsString;
31use std::fs::File;
32use std::io::Write;
33use std::path::{Path, PathBuf};
34use std::{fmt, fs};
35use thiserror::Error;
36
37use chrono::Utc;
38
39use crate::redirector::url_path::UrlPath;
40
41/// Errors that can occur during redirect operations.
42#[derive(Debug, Error)]
43pub enum RedirectorError {
44 /// An I/O error occurred while creating or writing redirect files.
45 ///
46 /// This includes errors like permission denied, disk full, or invalid file paths.
47 #[error("Failed to create redirect file")]
48 FileCreationError(#[from] std::io::Error),
49
50 /// The short link has not been generated (should not occur in normal usage).
51 ///
52 /// This error is included for completeness but should not happen since
53 /// short links are automatically generated during `Redirector::new()`.
54 #[error("Short link not found")]
55 ShortLinkNotFound,
56
57 /// The provided URL path is invalid.
58 ///
59 /// This occurs when the path contains invalid characters like query parameters (?),
60 /// semicolons (;), or other forbidden characters.
61 #[error("Invalid URL path: {0}")]
62 InvalidUrlPath(#[from] url_path::UrlPathError),
63}
64
65/// Manages URL redirection by generating short links and HTML redirect pages.
66///
67/// The `Redirector` creates HTML files that automatically redirect users to longer URLs
68/// on your website. It handles the entire process from URL validation to file generation.
69///
70/// # Key Features
71///
72/// - **URL Validation**: Ensures paths contain only valid characters
73/// - **Unique Naming**: Generates unique file names using base62 encoding and timestamps
74/// - **HTML Generation**: Creates complete HTML pages with meta refresh and JavaScript fallbacks
75/// - **File Management**: Handles directory creation and file writing operations
76///
77/// # Short Link Generation
78///
79/// Short file names are generated using:
80/// - Current timestamp in milliseconds
81/// - Sum of UTF-16 code units from the URL path
82/// - Base62 encoding for compact, URL-safe names
83/// - `.html` extension for web server compatibility
84///
85/// # HTML Output
86///
87/// Generated HTML files include:
88/// - Meta refresh tag for immediate redirection
89/// - JavaScript fallback for better compatibility
90/// - User-friendly link for manual navigation
91/// - Proper HTML5 structure and encoding
92#[derive(Debug, Clone, PartialEq, Default)]
93pub struct Redirector {
94 /// The validated and normalized URL path to redirect to.
95 long_path: UrlPath,
96 /// The generated short file name (including .html extension).
97 short_file_name: OsString,
98 /// The directory path where redirect HTML files will be stored.
99 path: PathBuf,
100}
101
102impl Redirector {
103 /// Creates a new `Redirector` instance for the specified URL path.
104 ///
105 /// Validates the provided path and automatically generates a unique short file name.
106 /// The redirector is initialized with a default output directory of "s".
107 ///
108 /// # Arguments
109 ///
110 /// * `long_path` - The URL path to create a redirect for (e.g., "api/v1/users")
111 ///
112 /// # Returns
113 ///
114 /// * `Ok(Redirector)` - A configured redirector ready to generate redirect files
115 /// * `Err(RedirectorError::InvalidUrlPath)` - If the path contains invalid characters
116 ///
117 /// # Examples
118 ///
119 /// ```rust
120 /// use link_bridge::Redirector;
121 ///
122 /// // Valid paths
123 /// let redirector1 = Redirector::new("api/v1").unwrap();
124 /// let redirector2 = Redirector::new("/docs/getting-started/").unwrap();
125 /// let redirector3 = Redirector::new("user-profile").unwrap();
126 ///
127 /// // Invalid paths (will return errors)
128 /// assert!(Redirector::new("api?param=value").is_err()); // Query parameters
129 /// assert!(Redirector::new("api;session=123").is_err()); // Semicolons
130 /// assert!(Redirector::new("").is_err()); // Empty string
131 /// ```
132 pub fn new<S: ToString>(long_path: S) -> Result<Self, RedirectorError> {
133 let long_path = UrlPath::new(long_path.to_string())?;
134
135 let short_file_name = Redirector::generate_short_file_name(&long_path);
136
137 Ok(Redirector {
138 long_path,
139 short_file_name,
140 path: PathBuf::from("s"),
141 })
142 }
143
144 /// Generates a unique short file name based on timestamp and URL path content.
145 ///
146 /// Creates a unique identifier by combining the current timestamp with the URL path's
147 /// UTF-16 character values, then encoding the result using base62 for a compact,
148 /// URL-safe file name.
149 ///
150 /// # Algorithm
151 ///
152 /// 1. Get current timestamp in milliseconds
153 /// 2. Sum all UTF-16 code units from the URL path
154 /// 3. Add timestamp and UTF-16 sum together
155 /// 4. Encode the result using base62 (0-9, A-Z, a-z)
156 /// 5. Append ".html" extension
157 ///
158 /// # Returns
159 ///
160 /// An `OsString` containing the generated file name with `.html` extension.
161 fn generate_short_file_name(long_path: &UrlPath) -> OsString {
162 let name = base62::encode(
163 Utc::now().timestamp_millis() as u64
164 + long_path.encode_utf16().iter().sum::<u16>() as u64,
165 );
166 OsString::from(format!("{name}.html"))
167 }
168
169 /// Sets the output directory where redirect HTML files will be stored.
170 ///
171 /// By default, redirector uses "s" as the output directory. Use this method
172 /// to specify a custom directory path. The directory will be created automatically
173 /// when `write_redirects()` is called if it doesn't exist.
174 ///
175 /// # Arguments
176 ///
177 /// * `path` - A path-like value (String, &str, PathBuf, etc.) specifying the directory
178 ///
179 /// # Examples
180 ///
181 /// ```rust
182 /// use link_bridge::Redirector;
183 ///
184 /// let mut redirector = Redirector::new("api/v1").unwrap();
185 ///
186 /// // Set various types of paths
187 /// redirector.set_path("redirects"); // &str
188 /// redirector.set_path("output/html".to_string()); // String
189 /// redirector.set_path(std::path::PathBuf::from("custom/path")); // PathBuf
190 /// ```
191 pub fn set_path<P: Into<PathBuf>>(&mut self, path: P) {
192 self.path = path.into();
193 }
194
195 /// Writes the redirect HTML file to the filesystem.
196 ///
197 /// Creates the output directory (if it doesn't exist) and generates a complete
198 /// HTML redirect page that automatically redirects users to the target URL.
199 /// The file name is the automatically generated short name with `.html` extension.
200 ///
201 /// # File Structure
202 ///
203 /// The generated HTML includes:
204 /// - DOCTYPE and proper HTML5 structure
205 /// - Meta charset and refresh tags for immediate redirection
206 /// - JavaScript fallback for better browser compatibility
207 /// - User-friendly fallback link for manual navigation
208 ///
209 /// # Returns
210 ///
211 /// * `Ok(())` - If the file was successfully created and written
212 /// * `Err(RedirectorError::FileCreationError)` - If file operations fail
213 ///
214 /// # Errors
215 ///
216 /// Common causes of `FileCreationError`:
217 /// - Permission denied (insufficient write permissions)
218 /// - Disk full or insufficient space
219 /// - Invalid characters in the file path
220 /// - Parent directory cannot be created
221 ///
222 /// # Examples
223 ///
224 /// ```rust
225 /// use link_bridge::Redirector;
226 /// use std::fs;
227 ///
228 /// let mut redirector = Redirector::new("api/v1/users").unwrap();
229 /// redirector.set_path("doc_test_redirects");
230 ///
231 /// // This creates: doc_test_redirects/{unique_name}.html
232 /// redirector.write_redirects().unwrap();
233 ///
234 /// // Clean up after the test
235 /// fs::remove_dir_all("doc_test_redirects").ok();
236 /// ```
237 pub fn write_redirects(&self) -> Result<(), RedirectorError> {
238 // create store directory if it doesn't exist
239 if !Path::new(&self.path).exists() {
240 fs::create_dir_all(&self.path)?;
241 }
242
243 let file_path = self.path.join(&self.short_file_name);
244 let file_name = file_path.to_string_lossy();
245 let mut file = File::create(file_name.as_ref())?;
246
247 file.write_all(self.to_string().as_bytes())?;
248 file.sync_all()?;
249
250 Ok(())
251 }
252}
253
254impl fmt::Display for Redirector {
255 /// Generates the complete HTML redirect page content.
256 ///
257 /// Creates a standard HTML5 page that redirects to the target URL using
258 /// multiple methods for maximum compatibility:
259 /// - Meta refresh tag (works in all browsers)
260 /// - JavaScript redirect (faster, works when JS is enabled)
261 /// - Fallback link (for manual navigation if automatic redirect fails)
262 ///
263 /// The HTML follows web standards and includes proper accessibility features.
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 let target = self.long_path.to_string();
266 write!(
267 f,
268 r#"
269 <!DOCTYPE HTML>
270 <html lang="en-US">
271
272 <head>
273 <meta charset="UTF-8">
274 <meta http-equiv="refresh" content="0; url={target}">
275 <script type="text/javascript">
276 window.location.href = "{target}";
277 </script>
278 <title>Page Redirection</title>
279 </head>
280
281 <body>
282 <!-- Note: don't tell people to `click` the link, just tell them that it is a link. -->
283 If you are not redirected automatically, follow this <a href='{target}'>link to example</a>.
284 </body>
285
286 </html>
287 "#
288 )
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::fs;
296 use std::thread;
297 use std::time::Duration;
298
299 #[test]
300 fn test_new_redirector() {
301 let long_link = "/some/path";
302 let redirector = Redirector::new(long_link).unwrap();
303
304 assert_eq!(
305 redirector.long_path,
306 UrlPath::new(long_link.to_string()).unwrap()
307 );
308 assert!(!redirector.short_file_name.is_empty());
309 assert_eq!(redirector.path, PathBuf::from("s"));
310 }
311
312 #[test]
313 fn test_generate_short_link_unique() {
314 let redirector1 = Redirector::new("/some/path").unwrap();
315 thread::sleep(Duration::from_millis(1));
316 let redirector2 = Redirector::new("/some/path").unwrap();
317
318 assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
319 }
320
321 #[test]
322 fn test_set_path() {
323 let mut redirector = Redirector::new("/some/path/").unwrap();
324
325 redirector.set_path("custom_path");
326 assert_eq!(redirector.path, PathBuf::from("custom_path"));
327
328 redirector.set_path("another/path".to_string());
329 assert_eq!(redirector.path, PathBuf::from("another/path"));
330 }
331
332 #[test]
333 fn test_display_renders_html() {
334 let redirector = Redirector::new("some/path").unwrap();
335 let output = format!("{redirector}");
336
337 assert!(output.contains("<!DOCTYPE HTML>"));
338 assert!(output.contains("/some/path/"));
339 assert!(output.contains("meta http-equiv=\"refresh\""));
340 assert!(output.contains("window.location.href"));
341 }
342
343 #[test]
344 fn test_display_with_complex_path() {
345 let redirector = Redirector::new("api/v2/users").unwrap();
346
347 let output = format!("{redirector}");
348
349 assert!(output.contains("<!DOCTYPE HTML>"));
350 assert!(output.contains("/api/v2/users/"));
351 assert!(output.contains("meta http-equiv=\"refresh\""));
352 assert!(output.contains("window.location.href"));
353 }
354
355 #[test]
356 fn test_write_redirects_with_valid_path() {
357 let redirector = Redirector::new("some/path").unwrap();
358
359 let result = redirector.write_redirects();
360
361 // Should succeed since short link is generated in new()
362 assert!(result.is_ok());
363
364 // Clean up
365 let file_name = redirector.short_file_name.into_string().unwrap();
366 let file_path = format!("s/{file_name}");
367 if Path::new(&file_path).exists() {
368 fs::remove_file(&file_path).ok();
369 fs::remove_dir("s").ok();
370 }
371 }
372
373 #[test]
374 fn test_write_redirects_success() {
375 let mut redirector = Redirector::new("some/path").unwrap();
376 redirector.set_path("test_output");
377
378 let result = redirector.write_redirects();
379 assert!(result.is_ok());
380
381 let file_path = redirector.path.join(&redirector.short_file_name);
382
383 assert!(Path::new(&file_path).exists());
384
385 let content = fs::read_to_string(&file_path).unwrap();
386 assert!(content.contains("<!DOCTYPE HTML>"));
387 assert!(content.contains("meta http-equiv=\"refresh\""));
388 assert!(content.contains("window.location.href"));
389 assert!(content.contains("If you are not redirected automatically"));
390 assert!(content.contains("/some/path/"));
391
392 fs::remove_file(&file_path).unwrap();
393 fs::remove_dir("test_output").unwrap();
394 }
395
396 #[test]
397 fn test_write_redirects_creates_directory() {
398 let mut redirector = Redirector::new("some/path").unwrap();
399 redirector.set_path("test_dir/subdir");
400
401 assert!(!Path::new("test_dir").exists());
402
403 let result = redirector.write_redirects();
404 assert!(result.is_ok());
405
406 assert!(Path::new("test_dir/subdir").exists());
407
408 let file_path = redirector.path.join(&redirector.short_file_name);
409 assert!(Path::new(&file_path).exists());
410
411 fs::remove_file(&file_path).unwrap();
412 fs::remove_dir_all("test_dir").unwrap();
413 }
414
415 #[test]
416 fn test_redirector_clone() {
417 let mut redirector = Redirector::new("some/path").unwrap();
418 redirector.set_path("custom");
419
420 let cloned = redirector.clone();
421
422 assert_eq!(redirector, cloned);
423 assert_eq!(redirector.long_path, cloned.long_path);
424 assert_eq!(redirector.short_file_name, cloned.short_file_name);
425 assert_eq!(redirector.path, cloned.path);
426 }
427
428 #[test]
429 fn test_redirector_default() {
430 let redirector = Redirector::default();
431
432 assert_eq!(redirector.long_path, UrlPath::default());
433 assert_eq!(redirector.path, PathBuf::new());
434 assert!(redirector.short_file_name.is_empty());
435 }
436
437 #[test]
438 fn test_new_redirector_error_handling() {
439 // Test invalid path - single segment should be okay now
440 let result = Redirector::new("api");
441 assert!(result.is_ok());
442
443 // Test empty path
444 let result = Redirector::new("");
445 assert!(result.is_err());
446
447 // Test invalid characters
448 let result = Redirector::new("api?param=value");
449 assert!(result.is_err());
450 }
451
452 #[test]
453 fn test_generate_short_link_different_paths() {
454 let redirector1 = Redirector::new("api/v1").unwrap();
455 let redirector2 = Redirector::new("api/v2").unwrap();
456
457 // Different paths should generate different short links
458 assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
459 }
460
461 #[test]
462 fn test_short_file_name_format() {
463 let redirector = Redirector::new("some/path").unwrap();
464 let file_name = redirector.short_file_name.to_string_lossy();
465
466 // Should end with .html
467 assert!(file_name.ends_with(".html"));
468 // Should not be empty
469 assert!(!file_name.is_empty());
470 }
471
472 #[test]
473 fn test_debug_and_partialeq_traits() {
474 let redirector1 = Redirector::new("some/path").unwrap();
475 let redirector2 = redirector1.clone();
476
477 // Test PartialEq
478 assert_eq!(redirector1, redirector2);
479
480 // Test Debug
481 let debug_output = format!("{redirector1:?}");
482 assert!(debug_output.contains("Redirector"));
483 }
484}