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    /// Reports the short file name of the redirect HTML file.
188    ///
189    /// # Returns
190    ///
191    /// An `OsString` containing the generated file name with `.html` extension.
192    ///
193    /// # Examples
194    ///
195    /// ```rust
196    /// use link_bridge::Redirector;
197    ///
198    /// let redirector = Redirector::new("api/v1").unwrap();
199    ///
200    /// assert_eq!(redirector.short_file_name(), OsString::from("12aaBB.html"));
201    /// ```
202    pub fn short_file_name(&self) -> OsString {
203        self.short_file_name.clone()
204    }
205
206    /// Sets the output directory where redirect HTML files will be stored.
207    ///
208    /// By default, redirector uses "s" as the output directory. Use this method
209    /// to specify a custom directory path. The directory will be created automatically
210    /// when `write_redirect()` is called if it doesn't exist.
211    ///
212    /// # Arguments
213    ///
214    /// * `path` - A path-like value (String, &str, PathBuf, etc.) specifying the directory
215    ///
216    /// # Examples
217    ///
218    /// ```rust
219    /// use link_bridge::Redirector;
220    ///
221    /// let mut redirector = Redirector::new("api/v1").unwrap();
222    ///
223    /// // Set various types of paths
224    /// redirector.set_path("redirects");           // &str
225    /// redirector.set_path("output/html".to_string()); // String
226    /// redirector.set_path(std::path::PathBuf::from("custom/path")); // PathBuf
227    /// ```
228    pub fn set_path<P: Into<PathBuf>>(&mut self, path: P) {
229        self.path = path.into();
230    }
231
232    /// Writes the redirect HTML file to the filesystem with registry support.
233    ///
234    /// Creates the output directory (if it doesn't exist) and generates a complete
235    /// HTML redirect page that automatically redirects users to the target URL.
236    /// The file name is the automatically generated short name with `.html` extension.
237    ///
238    /// # Registry System
239    ///
240    /// This method maintains a registry (`registry.json`) in the output directory to track
241    /// existing redirects. If a redirect for the same URL path already exists, it returns
242    /// the path to the existing file instead of creating a duplicate. This ensures:
243    /// - No duplicate files for the same URL path
244    /// - Consistent redirect behaviour across multiple calls
245    /// - Efficient reuse of existing redirects
246    ///
247    /// # File Structure
248    ///
249    /// The generated HTML includes:
250    /// - DOCTYPE and proper HTML5 structure
251    /// - Meta charset and refresh tags for immediate redirection
252    /// - JavaScript fallback for better browser compatibility
253    /// - User-friendly fallback link for manual navigation
254    ///
255    /// # Returns
256    ///
257    /// * `Ok(String)` - The path to the created redirect file if successful
258    /// * `Err(RedirectorError::FileCreationError)` - If file operations fail
259    ///
260    /// # Errors
261    ///
262    /// This method can return the following errors:
263    ///
264    /// ## `FileCreationError`
265    /// - Permission denied (insufficient write permissions)
266    /// - Disk full or insufficient space
267    /// - Invalid characters in the file path
268    /// - Parent directory cannot be created
269    ///
270    /// ## `FailedToReadRegistry`
271    /// - Corrupted or invalid JSON in `registry.json`
272    /// - Permission denied when reading/writing registry file
273    /// - Registry file locked by another process
274    ///
275    /// # Examples
276    ///
277    /// ## Basic Usage
278    ///
279    /// ```rust
280    /// use link_bridge::Redirector;
281    /// use std::fs;
282    ///
283    /// let mut redirector = Redirector::new("api/v1/users").unwrap();
284    /// redirector.set_path("doc_test_redirects");
285    ///
286    /// // First call creates a new redirect file and registry entry
287    /// let redirect_path = redirector.write_redirect().unwrap();
288    /// println!("Created redirect at: {}", redirect_path);
289    ///
290    /// // Clean up after the test
291    /// fs::remove_dir_all("doc_test_redirects").ok();
292    /// ```
293    ///
294    /// ## Registry behaviour
295    ///
296    /// ```rust
297    /// use link_bridge::Redirector;
298    /// use std::fs;
299    ///
300    /// let mut redirector1 = Redirector::new("api/v1/users").unwrap();
301    /// redirector1.set_path("doc_test_registry");
302    ///
303    /// let mut redirector2 = Redirector::new("api/v1/users").unwrap();
304    /// redirector2.set_path("doc_test_registry");
305    ///
306    /// // First call creates the file
307    /// let path1 = redirector1.write_redirect().unwrap();
308    ///
309    /// // Second call returns the same path (no duplicate file created)
310    /// let path2 = redirector2.write_redirect().unwrap();
311    /// assert_eq!(path1, path2);
312    ///
313    /// // Clean up
314    /// fs::remove_dir_all("doc_test_registry").ok();
315    /// ```
316    pub fn write_redirect(&self) -> Result<String, RedirectorError> {
317        // create store directory if it doesn't exist
318        if !Path::new(&self.path).exists() {
319            fs::create_dir_all(&self.path)?;
320        }
321        const REDIRECT_REGISTRY: &str = "registry.json";
322        let mut registry: HashMap<String, String> = HashMap::new();
323        if Path::new(&self.path).join(REDIRECT_REGISTRY).exists() {
324            registry = serde_json::from_reader::<_, HashMap<String, String>>(File::open(
325                self.path.join(REDIRECT_REGISTRY),
326            )?)?;
327        }
328
329        let file_path = self.path.join(&self.short_file_name);
330
331        if let Some(existing_path) = registry.get(&self.long_path.to_string()) {
332            // A link already exists for this path, return the existing file path
333            Ok(existing_path.clone())
334        } else {
335            let mut file = File::create(&file_path)?;
336
337            file.write_all(self.to_string().as_bytes())?;
338            file.sync_all()?;
339
340            registry.insert(
341                self.long_path.to_string(),
342                file_path.to_string_lossy().to_string(),
343            );
344
345            serde_json::to_writer_pretty(
346                File::create(self.path.join(REDIRECT_REGISTRY))?,
347                &registry,
348            )?;
349
350            Ok(file_path.to_string_lossy().to_string())
351        }
352    }
353}
354
355impl fmt::Display for Redirector {
356    /// Generates the complete HTML redirect page content.
357    ///
358    /// Creates a standard HTML5 page that redirects to the target URL using
359    /// multiple methods for maximum compatibility:
360    /// - Meta refresh tag (works in all browsers)
361    /// - JavaScript redirect (faster, works when JS is enabled)
362    /// - Fallback link (for manual navigation if automatic redirect fails)
363    ///
364    /// The HTML follows web standards and includes proper accessibility features.
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        let target = self.long_path.to_string();
367        write!(
368            f,
369            r#"
370    <!DOCTYPE HTML>
371    <html lang="en-US">
372
373    <head>
374        <meta charset="UTF-8">
375        <meta http-equiv="refresh" content="0; url={target}">
376        <script type="text/javascript">
377            window.location.href = "{target}";
378        </script>
379        <title>Page Redirection</title>
380    </head>
381
382    <body>
383        <!-- Note: don't tell people to `click` the link, just tell them that it is a link. -->
384        If you are not redirected automatically, follow this <a href='{target}'>link to page</a>.
385    </body>
386
387    </html>
388    "#
389        )
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::fs;
397    use std::thread;
398    use std::time::Duration;
399
400    #[test]
401    fn test_new_redirector() {
402        let long_link = "/some/path";
403        let redirector = Redirector::new(long_link).unwrap();
404
405        assert_eq!(
406            redirector.long_path,
407            UrlPath::new(long_link.to_string()).unwrap()
408        );
409        assert!(!redirector.short_file_name.is_empty());
410        assert_eq!(redirector.path, PathBuf::from("s"));
411    }
412
413    #[test]
414    fn test_generate_short_link_unique() {
415        let redirector1 = Redirector::new("/some/path").unwrap();
416        thread::sleep(Duration::from_millis(1));
417        let redirector2 = Redirector::new("/some/path").unwrap();
418
419        assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
420    }
421
422    #[test]
423    fn test_set_path() {
424        let mut redirector = Redirector::new("/some/path/").unwrap();
425
426        redirector.set_path("custom_path");
427        assert_eq!(redirector.path, PathBuf::from("custom_path"));
428
429        redirector.set_path("another/path".to_string());
430        assert_eq!(redirector.path, PathBuf::from("another/path"));
431    }
432
433    #[test]
434    fn test_display_renders_html() {
435        let redirector = Redirector::new("some/path").unwrap();
436        let output = format!("{redirector}");
437
438        assert!(output.contains("<!DOCTYPE HTML>"));
439        assert!(output.contains("/some/path/"));
440        assert!(output.contains("meta http-equiv=\"refresh\""));
441        assert!(output.contains("window.location.href"));
442    }
443
444    #[test]
445    fn test_display_with_complex_path() {
446        let redirector = Redirector::new("api/v2/users").unwrap();
447
448        let output = format!("{redirector}");
449
450        assert!(output.contains("<!DOCTYPE HTML>"));
451        assert!(output.contains("/api/v2/users/"));
452        assert!(output.contains("meta http-equiv=\"refresh\""));
453        assert!(output.contains("window.location.href"));
454    }
455
456    #[test]
457    fn test_write_redirect_with_valid_path() {
458        let test_dir = format!(
459            "test_write_redirect_with_valid_path_{}",
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
467        // Should succeed since short link is generated in new()
468        assert!(result.is_ok());
469
470        // Clean up
471        fs::remove_dir_all(&test_dir).ok();
472    }
473
474    #[test]
475    fn test_write_redirect_success() {
476        let test_dir = format!(
477            "test_write_redirect_success_{}",
478            Utc::now().timestamp_nanos_opt().unwrap_or(0)
479        );
480        let mut redirector = Redirector::new("some/path").unwrap();
481        redirector.set_path(&test_dir);
482
483        let result = redirector.write_redirect();
484        assert!(result.is_ok());
485
486        let file_path = result.unwrap();
487
488        assert!(Path::new(&file_path).exists());
489
490        let content = fs::read_to_string(&file_path).unwrap();
491        assert!(content.contains("<!DOCTYPE HTML>"));
492        assert!(content.contains("meta http-equiv=\"refresh\""));
493        assert!(content.contains("window.location.href"));
494        assert!(content.contains("If you are not redirected automatically"));
495        assert!(content.contains("/some/path/"));
496
497        // Clean up
498        fs::remove_dir_all(&test_dir).unwrap();
499    }
500
501    #[test]
502    fn test_write_redirect_creates_directory() {
503        let test_dir = format!(
504            "test_write_redirect_creates_directory_{}",
505            Utc::now().timestamp_nanos_opt().unwrap_or(0)
506        );
507        let subdir_path = format!("{test_dir}/subdir");
508        let mut redirector = Redirector::new("some/path").unwrap();
509        redirector.set_path(&subdir_path);
510
511        assert!(!Path::new(&test_dir).exists());
512
513        let result = redirector.write_redirect();
514        assert!(result.is_ok());
515
516        assert!(Path::new(&subdir_path).exists());
517
518        let file_path = result.unwrap();
519        assert!(Path::new(&file_path).exists());
520
521        // Clean up
522        fs::remove_dir_all(&test_dir).unwrap();
523    }
524
525    #[test]
526    fn test_redirector_clone() {
527        let mut redirector = Redirector::new("some/path").unwrap();
528        redirector.set_path("custom");
529
530        let cloned = redirector.clone();
531
532        assert_eq!(redirector, cloned);
533        assert_eq!(redirector.long_path, cloned.long_path);
534        assert_eq!(redirector.short_file_name, cloned.short_file_name);
535        assert_eq!(redirector.path, cloned.path);
536    }
537
538    #[test]
539    fn test_redirector_default() {
540        let redirector = Redirector::default();
541
542        assert_eq!(redirector.long_path, UrlPath::default());
543        assert_eq!(redirector.path, PathBuf::new());
544        assert!(redirector.short_file_name.is_empty());
545    }
546
547    #[test]
548    fn test_write_redirect_returns_correct_path() {
549        let test_dir = format!(
550            "test_write_redirect_returns_correct_path_{}",
551            Utc::now().timestamp_nanos_opt().unwrap_or(0)
552        );
553        let mut redirector = Redirector::new("some/path").unwrap();
554        redirector.set_path(&test_dir);
555
556        let result = redirector.write_redirect();
557        assert!(result.is_ok());
558
559        let returned_path = result.unwrap();
560        let expected_path = redirector.path.join(&redirector.short_file_name);
561
562        assert_eq!(returned_path, expected_path.to_string_lossy());
563        assert!(Path::new(&returned_path).exists());
564
565        // Clean up
566        fs::remove_dir_all(&test_dir).unwrap();
567    }
568
569    #[test]
570    fn test_write_redirect_registry_functionality() {
571        let test_dir = format!(
572            "test_write_redirect_registry_functionality_{}",
573            Utc::now().timestamp_nanos_opt().unwrap_or(0)
574        );
575        let mut redirector1 = Redirector::new("some/path").unwrap();
576        redirector1.set_path(&test_dir);
577
578        let mut redirector2 = Redirector::new("some/path").unwrap();
579        redirector2.set_path(&test_dir);
580
581        // First call should create a new file
582        let result1 = redirector1.write_redirect();
583        assert!(result1.is_ok());
584        let path1 = result1.unwrap();
585
586        // Second call with same path should return the existing file path
587        let result2 = redirector2.write_redirect();
588        assert!(result2.is_ok());
589        let path2 = result2.unwrap();
590
591        // Should return the same path
592        assert_eq!(path1, path2);
593
594        // Verify registry file exists
595        let registry_path = PathBuf::from(&test_dir).join("registry.json");
596        assert!(registry_path.exists());
597
598        // Clean up
599        fs::remove_dir_all(&test_dir).unwrap();
600    }
601
602    #[test]
603    fn test_write_redirect_different_paths_different_files() {
604        let test_dir = format!(
605            "test_write_redirect_different_paths_different_files_{}",
606            Utc::now().timestamp_nanos_opt().unwrap_or(0)
607        );
608        let mut redirector1 = Redirector::new("some/path").unwrap();
609        redirector1.set_path(&test_dir);
610
611        let mut redirector2 = Redirector::new("other/path").unwrap();
612        redirector2.set_path(&test_dir);
613
614        let result1 = redirector1.write_redirect();
615        assert!(result1.is_ok());
616        let path1 = result1.unwrap();
617
618        let result2 = redirector2.write_redirect();
619        assert!(result2.is_ok());
620        let path2 = result2.unwrap();
621
622        // Should create different files for different paths
623        assert_ne!(path1, path2);
624        assert!(Path::new(&path1).exists());
625        assert!(Path::new(&path2).exists());
626
627        // Clean up
628        fs::remove_dir_all(&test_dir).unwrap();
629    }
630
631    #[test]
632    fn test_new_redirector_error_handling() {
633        // Test invalid path - single segment should be okay now
634        let result = Redirector::new("api");
635        assert!(result.is_ok());
636
637        // Test empty path
638        let result = Redirector::new("");
639        assert!(result.is_err());
640
641        // Test invalid characters
642        let result = Redirector::new("api?param=value");
643        assert!(result.is_err());
644    }
645
646    #[test]
647    fn test_generate_short_link_different_paths() {
648        let redirector1 = Redirector::new("api/v1").unwrap();
649        let redirector2 = Redirector::new("api/v2").unwrap();
650
651        // Different paths should generate different short links
652        assert_ne!(redirector1.short_file_name, redirector2.short_file_name);
653    }
654
655    #[test]
656    fn test_short_file_name_format() {
657        let redirector = Redirector::new("some/path").unwrap();
658        let file_name = redirector.short_file_name.to_string_lossy();
659
660        // Should end with .html
661        assert!(file_name.ends_with(".html"));
662        // Should not be empty
663        assert!(!file_name.is_empty());
664    }
665
666    #[test]
667    fn test_debug_and_partialeq_traits() {
668        let redirector1 = Redirector::new("some/path").unwrap();
669        let redirector2 = redirector1.clone();
670
671        // Test PartialEq
672        assert_eq!(redirector1, redirector2);
673
674        // Test Debug
675        let debug_output = format!("{redirector1:?}");
676        assert!(debug_output.contains("Redirector"));
677    }
678}