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