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//! - Managing a registry system to prevent duplicate redirects
9//!
10//! # Example Usage
11//!
12//! ```rust
13//! use link_bridge::Redirector;
14//! use std::fs;
15//!
16//! // Create a redirector for a URL path
17//! let mut redirector = Redirector::new("api/v1/users").unwrap();
18//!
19//! // Optionally set a custom output directory
20//! redirector.set_path("doc_test_output");
21//!
22//! // Write the redirect HTML file
23//! let redirect_path = redirector.write_redirect().unwrap();
24//!
25//! // Clean up test files
26//! fs::remove_dir_all("doc_test_output").ok();
27//! ```
28
29mod url_path;
30
31use std::collections::HashMap;
32use std::ffi::OsString;
33use std::fs::File;
34use std::io::Write;
35use std::path::{Path, PathBuf};
36use std::{fmt, fs};
37use thiserror::Error;
38
39use chrono::Utc;
40
41use crate::redirector::url_path::UrlPath;
42
43/// Errors that can occur during redirect operations.
44#[derive(Debug, Error)]
45pub enum RedirectorError {
46    /// An I/O error occurred while creating or writing redirect files.
47    ///
48    /// This includes errors like permission denied, disk full, or invalid file paths.
49    #[error("Failed to create redirect file")]
50    FileCreationError(#[from] std::io::Error),
51
52    /// The short link has not been generated (should not occur in normal usage).
53    ///
54    /// This error is included for completeness but should not happen since
55    /// short links are automatically generated during `Redirector::new()`.
56    #[error("Short link not found")]
57    ShortLinkNotFound,
58
59    /// The provided URL path is invalid.
60    ///
61    /// This occurs when the path contains invalid characters like query parameters (?),
62    /// semicolons (;), or other forbidden characters.
63    #[error("Invalid URL path: {0}")]
64    InvalidUrlPath(#[from] url_path::UrlPathError),
65
66    /// An error occurred while reading or writing the redirect registry.
67    ///
68    /// This occurs when the `registry.json` file cannot be read, parsed, or written.
69    /// Common causes include corrupted JSON, permission issues, or filesystem errors.
70    #[error("Failed to read redirect registry")]
71    FailedToReadRegistry(#[from] serde_json::Error),
72}
73
74/// Manages URL redirection by generating short links and HTML redirect pages.
75///
76/// The `Redirector` creates HTML files that automatically redirect users to longer URLs
77/// on your website. It handles the entire process from URL validation to file generation.
78///
79/// # Key Features
80///
81/// - **URL Validation**: Ensures paths contain only valid characters
82/// - **Unique Naming**: Generates unique file names using base62 encoding and timestamps
83/// - **HTML Generation**: Creates complete HTML pages with meta refresh and JavaScript fallbacks
84/// - **File Management**: Handles directory creation and file writing operations
85/// - **Registry System**: Maintains a JSON registry to track existing redirects and prevent duplicates
86///
87/// # Short Link Generation
88///
89/// Short file names are generated using:
90/// - Current timestamp in milliseconds
91/// - Sum of UTF-16 code units from the URL path
92/// - Base62 encoding for compact, URL-safe names
93/// - `.html` extension for web server compatibility
94///
95/// # Registry System
96///
97/// The redirector maintains a `registry.json` file in each output directory that tracks:
98/// - Mapping from URL paths to their corresponding redirect files
99/// - Prevents duplicate files for the same URL path
100/// - Ensures consistent redirect behaviour across multiple calls
101/// - Automatically created and updated when redirects are written
102///
103/// # HTML Output
104///
105/// Generated HTML files include:
106/// - Meta refresh tag for immediate redirection
107/// - JavaScript fallback for better compatibility
108/// - User-friendly link for manual navigation
109/// - Proper HTML5 structure and encoding
110#[derive(Debug, Clone, PartialEq, Default)]
111pub struct Redirector {
112    /// The validated and normalized URL path to redirect to.
113    long_path: UrlPath,
114    /// The generated short file name (including .html extension).
115    short_file_name: OsString,
116    /// The directory path where redirect HTML files will be stored.
117    path: PathBuf,
118}
119
120impl Redirector {
121    /// Creates a new `Redirector` instance for the specified URL path.
122    ///
123    /// Validates the provided path and automatically generates a unique short file name.
124    /// The redirector is initialized with a default output directory of "s".
125    ///
126    /// # Arguments
127    ///
128    /// * `long_path` - The URL path to create a redirect for (e.g., "api/v1/users")
129    ///
130    /// # Returns
131    ///
132    /// * `Ok(Redirector)` - A configured redirector ready to generate redirect files
133    /// * `Err(RedirectorError::InvalidUrlPath)` - If the path contains invalid characters
134    ///
135    /// # Examples
136    ///
137    /// ```rust
138    /// use link_bridge::Redirector;
139    ///
140    /// // Valid paths
141    /// let redirector1 = Redirector::new("api/v1").unwrap();
142    /// let redirector2 = Redirector::new("/docs/getting-started/").unwrap();
143    /// let redirector3 = Redirector::new("user-profile").unwrap();
144    ///
145    /// // Invalid paths (will return errors)
146    /// assert!(Redirector::new("api?param=value").is_err()); // Query parameters
147    /// assert!(Redirector::new("api;session=123").is_err());  // Semicolons
148    /// assert!(Redirector::new("").is_err());                 // Empty string
149    /// ```
150    pub fn new<S: ToString>(long_path: S) -> Result<Self, RedirectorError> {
151        let long_path = UrlPath::new(long_path.to_string())?;
152
153        let short_file_name = Redirector::generate_short_file_name(&long_path);
154
155        Ok(Redirector {
156            long_path,
157            short_file_name,
158            path: PathBuf::from("s"),
159        })
160    }
161
162    /// Generates a unique short file name based on timestamp and URL path content.
163    ///
164    /// Creates a unique identifier by combining the current timestamp with the URL path's
165    /// UTF-16 character values, then encoding the result using base62 for a compact,
166    /// URL-safe file name.
167    ///
168    /// # Algorithm
169    ///
170    /// 1. Get current timestamp in milliseconds
171    /// 2. Sum all UTF-16 code units from the URL path
172    /// 3. Add timestamp and UTF-16 sum together
173    /// 4. Encode the result using base62 (0-9, A-Z, a-z)
174    /// 5. Append ".html" extension
175    ///
176    /// # Returns
177    ///
178    /// An `OsString` containing the generated file name with `.html` extension.
179    fn generate_short_file_name(long_path: &UrlPath) -> OsString {
180        let name = base62::encode(
181            Utc::now().timestamp_millis() as u64
182                + long_path.encode_utf16().iter().sum::<u16>() as u64,
183        );
184        OsString::from(format!("{name}.html"))
185    }
186
187    /// Sets the output directory where redirect HTML files will be stored.
188    ///
189    /// By default, redirector uses "s" as the output directory. Use this method
190    /// to specify a custom directory path. The directory will be created automatically
191    /// when `write_redirect()` is called if it doesn't exist.
192    ///
193    /// # Arguments
194    ///
195    /// * `path` - A path-like value (String, &str, PathBuf, etc.) specifying the directory
196    ///
197    /// # Examples
198    ///
199    /// ```rust
200    /// use link_bridge::Redirector;
201    ///
202    /// let mut redirector = Redirector::new("api/v1").unwrap();
203    ///
204    /// // Set various types of paths
205    /// redirector.set_path("redirects");           // &str
206    /// redirector.set_path("output/html".to_string()); // String
207    /// redirector.set_path(std::path::PathBuf::from("custom/path")); // PathBuf
208    /// ```
209    pub fn set_path<P: Into<PathBuf>>(&mut self, path: P) {
210        self.path = path.into();
211    }
212
213    /// Writes the redirect HTML file to the filesystem with registry support.
214    ///
215    /// Creates the output directory (if it doesn't exist) and generates a complete
216    /// HTML redirect page that automatically redirects users to the target URL.
217    /// The file name is the automatically generated short name with `.html` extension.
218    ///
219    /// # Registry System
220    ///
221    /// This method maintains a registry (`registry.json`) in the output directory to track
222    /// existing redirects. If a redirect for the same URL path already exists, it returns
223    /// the path to the existing file instead of creating a duplicate. This ensures:
224    /// - No duplicate files for the same URL path
225    /// - Consistent redirect behaviour across multiple calls
226    /// - Efficient reuse of existing redirects
227    ///
228    /// # File Structure
229    ///
230    /// The generated HTML includes:
231    /// - DOCTYPE and proper HTML5 structure
232    /// - Meta charset and refresh tags for immediate redirection
233    /// - JavaScript fallback for better browser compatibility
234    /// - User-friendly fallback link for manual navigation
235    ///
236    /// # Returns
237    ///
238    /// * `Ok(String)` - The path to the created redirect file if successful
239    /// * `Err(RedirectorError::FileCreationError)` - If file operations fail
240    ///
241    /// # Errors
242    ///
243    /// This method can return the following errors:
244    ///
245    /// ## `FileCreationError`
246    /// - Permission denied (insufficient write permissions)
247    /// - Disk full or insufficient space
248    /// - Invalid characters in the file path
249    /// - Parent directory cannot be created
250    ///
251    /// ## `FailedToReadRegistry`
252    /// - Corrupted or invalid JSON in `registry.json`
253    /// - Permission denied when reading/writing registry file
254    /// - Registry file locked by another process
255    ///
256    /// # Examples
257    ///
258    /// ## Basic Usage
259    ///
260    /// ```rust
261    /// use link_bridge::Redirector;
262    /// use std::fs;
263    ///
264    /// let mut redirector = Redirector::new("api/v1/users").unwrap();
265    /// redirector.set_path("doc_test_redirects");
266    ///
267    /// // First call creates a new redirect file and registry entry
268    /// let redirect_path = redirector.write_redirect().unwrap();
269    /// println!("Created redirect at: {}", redirect_path);
270    ///
271    /// // Clean up after the test
272    /// fs::remove_dir_all("doc_test_redirects").ok();
273    /// ```
274    ///
275    /// ## Registry behaviour
276    ///
277    /// ```rust
278    /// use link_bridge::Redirector;
279    /// use std::fs;
280    ///
281    /// let mut redirector1 = Redirector::new("api/v1/users").unwrap();
282    /// redirector1.set_path("doc_test_registry");
283    ///
284    /// let mut redirector2 = Redirector::new("api/v1/users").unwrap();
285    /// redirector2.set_path("doc_test_registry");
286    ///
287    /// // First call creates the file
288    /// let path1 = redirector1.write_redirect().unwrap();
289    ///
290    /// // Second call returns the same path (no duplicate file created)
291    /// let path2 = redirector2.write_redirect().unwrap();
292    /// assert_eq!(path1, path2);
293    ///
294    /// // Clean up
295    /// fs::remove_dir_all("doc_test_registry").ok();
296    /// ```
297    pub fn write_redirect(&self) -> Result<String, RedirectorError> {
298        // create store directory if it doesn't exist
299        if !Path::new(&self.path).exists() {
300            fs::create_dir_all(&self.path)?;
301        }
302        const REDIRECT_REGISTRY: &str = "registry.json";
303        let mut registry: HashMap<String, String> = HashMap::new();
304        if Path::new(&self.path).join(REDIRECT_REGISTRY).exists() {
305            registry = serde_json::from_reader::<_, HashMap<String, String>>(File::open(
306                self.path.join(REDIRECT_REGISTRY),
307            )?)?;
308        }
309
310        let file_path = self.path.join(&self.short_file_name);
311
312        if let Some(existing_path) = registry.get(&self.long_path.to_string()) {
313            // A link already exists for this path, return the existing file path
314            Ok(existing_path.clone())
315        } else {
316            let file_name = file_path.to_string_lossy();
317            let mut file = File::create(file_name.as_ref())?;
318
319            file.write_all(self.to_string().as_bytes())?;
320            file.sync_all()?;
321
322            registry.insert(
323                self.long_path.to_string(),
324                file_path.to_string_lossy().to_string(),
325            );
326
327            serde_json::to_writer_pretty(
328                File::create(self.path.join(REDIRECT_REGISTRY))?,
329                &registry,
330            )?;
331
332            Ok(file_path.to_string_lossy().to_string())
333        }
334    }
335}
336
337impl fmt::Display for Redirector {
338    /// Generates the complete HTML redirect page content.
339    ///
340    /// Creates a standard HTML5 page that redirects to the target URL using
341    /// multiple methods for maximum compatibility:
342    /// - Meta refresh tag (works in all browsers)
343    /// - JavaScript redirect (faster, works when JS is enabled)
344    /// - Fallback link (for manual navigation if automatic redirect fails)
345    ///
346    /// The HTML follows web standards and includes proper accessibility features.
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        let target = self.long_path.to_string();
349        write!(
350            f,
351            r#"
352    <!DOCTYPE HTML>
353    <html lang="en-US">
354
355    <head>
356        <meta charset="UTF-8">
357        <meta http-equiv="refresh" content="0; url={target}">
358        <script type="text/javascript">
359            window.location.href = "{target}";
360        </script>
361        <title>Page Redirection</title>
362    </head>
363
364    <body>
365        <!-- Note: don't tell people to `click` the link, just tell them that it is a link. -->
366        If you are not redirected automatically, follow this <a href='{target}'>link to page</a>.
367    </body>
368
369    </html>
370    "#
371        )
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use std::fs;
379    use std::thread;
380    use std::time::Duration;
381
382    #[test]
383    fn test_new_redirector() {
384        let long_link = "/some/path";
385        let redirector = Redirector::new(long_link).unwrap();
386
387        assert_eq!(
388            redirector.long_path,
389            UrlPath::new(long_link.to_string()).unwrap()
390        );
391        assert!(!redirector.short_file_name.is_empty());
392        assert_eq!(redirector.path, PathBuf::from("s"));
393    }
394
395    #[test]
396    fn test_generate_short_link_unique() {
397        let redirector1 = Redirector::new("/some/path").unwrap();
398        thread::sleep(Duration::from_millis(1));
399        let redirector2 = Redirector::new("/some/path").unwrap();
400
401        assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
402    }
403
404    #[test]
405    fn test_set_path() {
406        let mut redirector = Redirector::new("/some/path/").unwrap();
407
408        redirector.set_path("custom_path");
409        assert_eq!(redirector.path, PathBuf::from("custom_path"));
410
411        redirector.set_path("another/path".to_string());
412        assert_eq!(redirector.path, PathBuf::from("another/path"));
413    }
414
415    #[test]
416    fn test_display_renders_html() {
417        let redirector = Redirector::new("some/path").unwrap();
418        let output = format!("{redirector}");
419
420        assert!(output.contains("<!DOCTYPE HTML>"));
421        assert!(output.contains("/some/path/"));
422        assert!(output.contains("meta http-equiv=\"refresh\""));
423        assert!(output.contains("window.location.href"));
424    }
425
426    #[test]
427    fn test_display_with_complex_path() {
428        let redirector = Redirector::new("api/v2/users").unwrap();
429
430        let output = format!("{redirector}");
431
432        assert!(output.contains("<!DOCTYPE HTML>"));
433        assert!(output.contains("/api/v2/users/"));
434        assert!(output.contains("meta http-equiv=\"refresh\""));
435        assert!(output.contains("window.location.href"));
436    }
437
438    #[test]
439    fn test_write_redirect_with_valid_path() {
440        let test_dir = format!(
441            "test_write_redirect_with_valid_path_{}",
442            Utc::now().timestamp_nanos_opt().unwrap_or(0)
443        );
444        let mut redirector = Redirector::new("some/path").unwrap();
445        redirector.set_path(&test_dir);
446
447        let result = redirector.write_redirect();
448
449        // Should succeed since short link is generated in new()
450        assert!(result.is_ok());
451
452        // Clean up
453        fs::remove_dir_all(&test_dir).ok();
454    }
455
456    #[test]
457    fn test_write_redirect_success() {
458        let test_dir = format!(
459            "test_write_redirect_success_{}",
460            Utc::now().timestamp_nanos_opt().unwrap_or(0)
461        );
462        let mut redirector = Redirector::new("some/path").unwrap();
463        redirector.set_path(&test_dir);
464
465        let result = redirector.write_redirect();
466        assert!(result.is_ok());
467
468        let file_path = result.unwrap();
469
470        assert!(Path::new(&file_path).exists());
471
472        let content = fs::read_to_string(&file_path).unwrap();
473        assert!(content.contains("<!DOCTYPE HTML>"));
474        assert!(content.contains("meta http-equiv=\"refresh\""));
475        assert!(content.contains("window.location.href"));
476        assert!(content.contains("If you are not redirected automatically"));
477        assert!(content.contains("/some/path/"));
478
479        // Clean up
480        fs::remove_dir_all(&test_dir).unwrap();
481    }
482
483    #[test]
484    fn test_write_redirect_creates_directory() {
485        let test_dir = format!(
486            "test_write_redirect_creates_directory_{}",
487            Utc::now().timestamp_nanos_opt().unwrap_or(0)
488        );
489        let subdir_path = format!("{test_dir}/subdir");
490        let mut redirector = Redirector::new("some/path").unwrap();
491        redirector.set_path(&subdir_path);
492
493        assert!(!Path::new(&test_dir).exists());
494
495        let result = redirector.write_redirect();
496        assert!(result.is_ok());
497
498        assert!(Path::new(&subdir_path).exists());
499
500        let file_path = result.unwrap();
501        assert!(Path::new(&file_path).exists());
502
503        // Clean up
504        fs::remove_dir_all(&test_dir).unwrap();
505    }
506
507    #[test]
508    fn test_redirector_clone() {
509        let mut redirector = Redirector::new("some/path").unwrap();
510        redirector.set_path("custom");
511
512        let cloned = redirector.clone();
513
514        assert_eq!(redirector, cloned);
515        assert_eq!(redirector.long_path, cloned.long_path);
516        assert_eq!(redirector.short_file_name, cloned.short_file_name);
517        assert_eq!(redirector.path, cloned.path);
518    }
519
520    #[test]
521    fn test_redirector_default() {
522        let redirector = Redirector::default();
523
524        assert_eq!(redirector.long_path, UrlPath::default());
525        assert_eq!(redirector.path, PathBuf::new());
526        assert!(redirector.short_file_name.is_empty());
527    }
528
529    #[test]
530    fn test_write_redirect_returns_correct_path() {
531        let test_dir = format!(
532            "test_write_redirect_returns_correct_path_{}",
533            Utc::now().timestamp_nanos_opt().unwrap_or(0)
534        );
535        let mut redirector = Redirector::new("some/path").unwrap();
536        redirector.set_path(&test_dir);
537
538        let result = redirector.write_redirect();
539        assert!(result.is_ok());
540
541        let returned_path = result.unwrap();
542        let expected_path = redirector.path.join(&redirector.short_file_name);
543
544        assert_eq!(returned_path, expected_path.to_string_lossy());
545        assert!(Path::new(&returned_path).exists());
546
547        // Clean up
548        fs::remove_dir_all(&test_dir).unwrap();
549    }
550
551    #[test]
552    fn test_write_redirect_registry_functionality() {
553        let test_dir = format!(
554            "test_write_redirect_registry_functionality_{}",
555            Utc::now().timestamp_nanos_opt().unwrap_or(0)
556        );
557        let mut redirector1 = Redirector::new("some/path").unwrap();
558        redirector1.set_path(&test_dir);
559
560        let mut redirector2 = Redirector::new("some/path").unwrap();
561        redirector2.set_path(&test_dir);
562
563        // First call should create a new file
564        let result1 = redirector1.write_redirect();
565        assert!(result1.is_ok());
566        let path1 = result1.unwrap();
567
568        // Second call with same path should return the existing file path
569        let result2 = redirector2.write_redirect();
570        assert!(result2.is_ok());
571        let path2 = result2.unwrap();
572
573        // Should return the same path
574        assert_eq!(path1, path2);
575
576        // Verify registry file exists
577        let registry_path = PathBuf::from(&test_dir).join("registry.json");
578        assert!(registry_path.exists());
579
580        // Clean up
581        fs::remove_dir_all(&test_dir).unwrap();
582    }
583
584    #[test]
585    fn test_write_redirect_different_paths_different_files() {
586        let test_dir = format!(
587            "test_write_redirect_different_paths_different_files_{}",
588            Utc::now().timestamp_nanos_opt().unwrap_or(0)
589        );
590        let mut redirector1 = Redirector::new("some/path").unwrap();
591        redirector1.set_path(&test_dir);
592
593        let mut redirector2 = Redirector::new("other/path").unwrap();
594        redirector2.set_path(&test_dir);
595
596        let result1 = redirector1.write_redirect();
597        assert!(result1.is_ok());
598        let path1 = result1.unwrap();
599
600        let result2 = redirector2.write_redirect();
601        assert!(result2.is_ok());
602        let path2 = result2.unwrap();
603
604        // Should create different files for different paths
605        assert_ne!(path1, path2);
606        assert!(Path::new(&path1).exists());
607        assert!(Path::new(&path2).exists());
608
609        // Clean up
610        fs::remove_dir_all(&test_dir).unwrap();
611    }
612
613    #[test]
614    fn test_new_redirector_error_handling() {
615        // Test invalid path - single segment should be okay now
616        let result = Redirector::new("api");
617        assert!(result.is_ok());
618
619        // Test empty path
620        let result = Redirector::new("");
621        assert!(result.is_err());
622
623        // Test invalid characters
624        let result = Redirector::new("api?param=value");
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn test_generate_short_link_different_paths() {
630        let redirector1 = Redirector::new("api/v1").unwrap();
631        let redirector2 = Redirector::new("api/v2").unwrap();
632
633        // Different paths should generate different short links
634        assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
635    }
636
637    #[test]
638    fn test_short_file_name_format() {
639        let redirector = Redirector::new("some/path").unwrap();
640        let file_name = redirector.short_file_name.to_string_lossy();
641
642        // Should end with .html
643        assert!(file_name.ends_with(".html"));
644        // Should not be empty
645        assert!(!file_name.is_empty());
646    }
647
648    #[test]
649    fn test_debug_and_partialeq_traits() {
650        let redirector1 = Redirector::new("some/path").unwrap();
651        let redirector2 = redirector1.clone();
652
653        // Test PartialEq
654        assert_eq!(redirector1, redirector2);
655
656        // Test Debug
657        let debug_output = format!("{redirector1:?}");
658        assert!(debug_output.contains("Redirector"));
659    }
660}