/ src / util.rs
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  }