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}