util.rs
1 //! Small utility helpers used across the app. 2 3 use std::path::{Path, PathBuf}; 4 use url::Url; 5 6 /// Replace anything sketchy with `_` so we can safely write to disk. 7 pub fn sanitize_filename(input: &str) -> String { 8 let mut out = String::with_capacity(input.len()); 9 let mut last_was_replacement = false; 10 for ch in input.chars() { 11 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' { 12 out.push(ch); 13 last_was_replacement = false; 14 } else if !last_was_replacement { 15 out.push('_'); 16 last_was_replacement = true; 17 } 18 } 19 // Avoid empty / dot-only names. 20 if out.is_empty() || out == "." || out == ".." { 21 "file".to_string() 22 } else { 23 out 24 } 25 } 26 27 /// Decide where a download should be saved, avoiding collisions. 28 pub fn pick_download_path( 29 download_dir: &Path, 30 url: &Url, 31 content_type: Option<&str>, 32 fallback_stem: &str, 33 ) -> PathBuf { 34 let url_name = url 35 .path_segments() 36 .and_then(|mut s| s.next_back()) 37 .filter(|s| !s.is_empty()); 38 39 let raw = url_name.unwrap_or(fallback_stem); 40 let mut name = sanitize_filename(raw); 41 42 // Ensure an extension exists (best effort). 43 if Path::new(&name).extension().is_none() { 44 if let Some(ct) = content_type { 45 if let Some(ext) = extension_from_content_type(ct) { 46 name.push('.'); 47 name.push_str(ext); 48 } 49 } 50 // Still none? default. 51 if Path::new(&name).extension().is_none() { 52 name.push_str(".bin"); 53 } 54 } 55 56 // Avoid collisions: append _N. 57 let mut candidate = download_dir.join(&name); 58 if !candidate.exists() { 59 return candidate; 60 } 61 62 let stem = Path::new(&name) 63 .file_stem() 64 .and_then(|s| s.to_str()) 65 .unwrap_or("file"); 66 let ext = Path::new(&name) 67 .extension() 68 .and_then(|s| s.to_str()) 69 .unwrap_or("bin"); 70 71 for i in 1..=9999 { 72 let alt = format!("{}_{}.{}", stem, i, ext); 73 candidate = download_dir.join(alt); 74 if !candidate.exists() { 75 return candidate; 76 } 77 } 78 79 // Extremely unlikely. 80 download_dir.join(format!("{}_overflow.{}", stem, ext)) 81 } 82 83 /// Best-effort extension mapping from a Content-Type header. 84 pub fn extension_from_content_type(content_type: &str) -> Option<&'static str> { 85 let ct = content_type.trim().to_ascii_lowercase(); 86 87 // Handle common image types. 88 if ct.starts_with("image/jpeg") { 89 Some("jpg") 90 } else if ct.starts_with("image/png") { 91 Some("png") 92 } else if ct.starts_with("image/webp") { 93 Some("webp") 94 } else if ct.starts_with("image/gif") { 95 Some("gif") 96 } else if ct.starts_with("image/svg") { 97 Some("svg") 98 } else if ct.starts_with("image/avif") { 99 Some("avif") 100 } else { 101 None 102 } 103 } 104 105 /// Format bytes as a human-ish size string. 106 pub fn human_bytes(n: u64) -> String { 107 const KB: f64 = 1024.0; 108 const MB: f64 = KB * 1024.0; 109 const GB: f64 = MB * 1024.0; 110 111 let nf = n as f64; 112 if nf < KB { 113 format!("{} B", n) 114 } else if nf < MB { 115 format!("{:.1} KB", nf / KB) 116 } else if nf < GB { 117 format!("{:.1} MB", nf / MB) 118 } else { 119 format!("{:.1} GB", nf / GB) 120 } 121 } 122 123 #[cfg(test)] 124 mod tests { 125 use super::*; 126 use pretty_assertions::assert_eq; 127 128 #[test] 129 fn sanitize_filename_basic() { 130 assert_eq!(sanitize_filename("hello.png"), "hello.png"); 131 assert_eq!(sanitize_filename("a b c.png"), "a_b_c.png"); 132 assert_eq!(sanitize_filename(".."), "file"); 133 assert_eq!(sanitize_filename(""), "file"); 134 assert_eq!(sanitize_filename("日本語.png"), "_.png"); // non-ascii -> underscores 135 } 136 137 #[test] 138 fn extension_from_content_type_maps_known() { 139 assert_eq!(extension_from_content_type("image/jpeg"), Some("jpg")); 140 assert_eq!( 141 extension_from_content_type("image/png; charset=binary"), 142 Some("png") 143 ); 144 assert_eq!(extension_from_content_type("image/webp"), Some("webp")); 145 assert_eq!(extension_from_content_type("text/html"), None); 146 } 147 148 #[test] 149 fn human_bytes_formats() { 150 assert_eq!(human_bytes(999), "999 B"); 151 assert_eq!(human_bytes(1024), "1.0 KB"); 152 } 153 154 #[test] 155 fn pick_download_path_uses_url_filename() { 156 let tmp = Path::new("downloads_test_tmp"); 157 let url = Url::parse("https://example.com/images/cat.png?x=1").unwrap(); 158 let p = pick_download_path(tmp, &url, Some("image/png"), "image_1"); 159 assert_eq!(p.file_name().unwrap().to_str().unwrap(), "cat.png"); 160 } 161 162 #[test] 163 fn pick_download_path_uses_content_type_when_missing_extension() { 164 let tmp = Path::new("downloads_test_tmp"); 165 let url = Url::parse("https://example.com/images/cat").unwrap(); 166 let p = pick_download_path(tmp, &url, Some("image/jpeg"), "image_1"); 167 assert_eq!(p.file_name().unwrap().to_str().unwrap(), "cat.jpg"); 168 } 169 170 #[test] 171 fn pick_download_path_falls_back_when_no_path_segments() { 172 let tmp = Path::new("downloads_test_tmp"); 173 let url = Url::parse("https://example.com").unwrap(); 174 let p = pick_download_path(tmp, &url, Some("image/png"), "image_1"); 175 assert_eq!(p.file_name().unwrap().to_str().unwrap(), "image_1.png"); 176 } 177 }