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 ®istry,
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}