1use std::path::{Path, PathBuf};
2use std::sync::{Arc, OnceLock};
3use std::time::Duration;
4
5use dashmap::DashMap;
6
7use crate::error::{Result, RustmotionError};
8
9type GifFrame = (Vec<u8>, u32, u32);
10type GifData = Arc<(Vec<GifFrame>, Vec<f64>, f64)>;
11type GifCacheMap = Arc<DashMap<String, GifData>>;
12
13type VideoFrame = (f64, Vec<u8>, u32, u32);
14type VideoFrameList = Arc<Vec<VideoFrame>>;
15type VideoFrameCacheMap = Arc<DashMap<String, VideoFrameList>>;
16
17static ASSET_CACHE: OnceLock<Arc<DashMap<String, skia_safe::Image>>> = OnceLock::new();
18
19pub fn asset_cache() -> &'static Arc<DashMap<String, skia_safe::Image>> {
20 ASSET_CACHE.get_or_init(|| Arc::new(DashMap::new()))
21}
22
23pub fn clear_asset_cache() {
24 if let Some(cache) = ASSET_CACHE.get() {
25 cache.clear();
26 }
27}
28
29static GIF_CACHE: OnceLock<GifCacheMap> = OnceLock::new();
30
31pub fn gif_cache() -> &'static GifCacheMap {
32 GIF_CACHE.get_or_init(|| Arc::new(DashMap::new()))
33}
34
35static HTTP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
36
37pub fn http_agent() -> &'static ureq::Agent {
38 HTTP_AGENT.get_or_init(|| {
39 let config = ureq::config::Config::builder()
40 .timeout_global(Some(Duration::from_secs(20)))
41 .timeout_connect(Some(Duration::from_secs(5)))
42 .build();
43 ureq::Agent::new_with_config(config)
44 })
45}
46
47pub const ICON_OVERSAMPLE: u32 = 2;
48
49pub fn icon_cache_key(icon: &str, color: &str, target_w: u32, target_h: u32) -> (u32, u32, String) {
50 let render_w = target_w.max(1) * ICON_OVERSAMPLE;
51 let render_h = target_h.max(1) * ICON_OVERSAMPLE;
52 let cache_key = format!("icon:{icon}:{color}:{render_w}x{render_h}");
53 (render_w, render_h, cache_key)
54}
55
56pub fn icon_cache_dir() -> PathBuf {
57 #[cfg(target_os = "windows")]
58 let base = std::env::var_os("LOCALAPPDATA")
59 .map(PathBuf::from)
60 .unwrap_or_else(|| PathBuf::from("."));
61
62 #[cfg(not(target_os = "windows"))]
63 let base = std::env::var_os("HOME")
64 .map(|h| PathBuf::from(h).join(".cache"))
65 .unwrap_or_else(|| PathBuf::from(".cache"));
66
67 base.join("rustmotion").join("icons")
68}
69
70pub fn icon_source_cache_file(cache_dir: &Path, icon: &str) -> PathBuf {
71 cache_dir.join(format!("{}.svg", icon.replace(':', "_")))
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum RemoteIconPolicy {
76 #[default]
77 Deny,
78 Allow,
79}
80
81static REMOTE_ICON_POLICY: std::sync::atomic::AtomicBool =
82 std::sync::atomic::AtomicBool::new(false);
83
84pub fn set_remote_icon_policy(policy: RemoteIconPolicy) {
85 REMOTE_ICON_POLICY.store(
86 policy == RemoteIconPolicy::Allow,
87 std::sync::atomic::Ordering::Release,
88 );
89}
90
91pub fn remote_icon_policy() -> RemoteIconPolicy {
92 if REMOTE_ICON_POLICY.load(std::sync::atomic::Ordering::Acquire) {
93 RemoteIconPolicy::Allow
94 } else {
95 RemoteIconPolicy::Deny
96 }
97}
98
99pub fn icon_source_url(prefix: &str, name: &str) -> String {
100 format!("https://api.iconify.design/{prefix}/{name}.svg")
101}
102
103fn set_root_attribute(svg: &str, attribute: &str, value: &str) -> String {
104 let Some(tag_start) = svg.find("<svg") else {
105 return svg.to_string();
106 };
107 let Some(tag_len) = svg[tag_start..].find('>') else {
108 return svg.to_string();
109 };
110 let tag = &svg[tag_start..tag_start + tag_len];
111
112 let needle = format!(" {attribute}=\"");
113 let replaced = match tag.find(&needle) {
114 Some(at) => {
115 let value_start = at + needle.len();
116 match tag[value_start..].find('"') {
117 Some(value_len) => format!(
118 "{}{}\"{}",
119 &tag[..value_start],
120 value,
121 &tag[value_start + value_len + 1..]
122 ),
123 None => tag.to_string(),
124 }
125 }
126 None => format!("<svg {attribute}=\"{value}\"{}", &tag["<svg".len()..]),
127 };
128
129 format!(
130 "{}{}{}",
131 &svg[..tag_start],
132 replaced,
133 &svg[tag_start + tag_len..]
134 )
135}
136
137fn recolour_and_resize(source: &[u8], hex_color: &str, width: u32, height: u32) -> Vec<u8> {
138 let coloured =
139 String::from_utf8_lossy(source).replace("currentColor", &format!("#{hex_color}"));
140 let sized = set_root_attribute(&coloured, "width", &width.to_string());
141 set_root_attribute(&sized, "height", &height.to_string()).into_bytes()
142}
143
144fn legacy_icon_source(cache_dir: &Path, icon: &str) -> Option<Vec<u8>> {
145 let slug = icon.replace(':', "_");
146 let prefix = format!("{slug}-");
147 let entries = std::fs::read_dir(cache_dir).ok()?;
148 for entry in entries.flatten() {
149 let name = entry.file_name();
150 let name = name.to_str()?;
151 let Some(rest) = name.strip_prefix(&prefix) else {
152 continue;
153 };
154 let Some(rest) = rest.strip_suffix(".svg") else {
155 continue;
156 };
157 let Some((colour, size)) = rest.split_once('-') else {
158 continue;
159 };
160 if colour.len() != 6 || !colour.chars().all(|c| c.is_ascii_hexdigit()) {
161 continue;
162 }
163 let Some((w, h)) = size.split_once('x') else {
164 continue;
165 };
166 if w.parse::<u32>().is_err() || h.parse::<u32>().is_err() {
167 continue;
168 }
169 let baked = std::fs::read_to_string(entry.path()).ok()?;
170 let restored = baked
171 .replace(&format!("#{}", colour.to_lowercase()), "currentColor")
172 .replace(&format!("#{}", colour.to_uppercase()), "currentColor");
173 if restored.contains("currentColor") {
174 return Some(restored.into_bytes());
175 }
176 }
177 None
178}
179
180const ICON_FETCH_ATTEMPTS: u32 = 4;
181
182fn status_is_worth_retrying(error: &ureq::Error) -> bool {
183 matches!(error, ureq::Error::StatusCode(code) if *code == 429 || *code >= 500)
184}
185
186fn fetch_icon_source(icon: &str, prefix: &str, name: &str) -> Result<Vec<u8>> {
187 let url = icon_source_url(prefix, name);
188 let mut backoff = Duration::from_millis(250);
189 let mut last_reason = String::new();
190
191 for attempt in 0..ICON_FETCH_ATTEMPTS {
192 match http_agent().get(&url).call() {
193 Ok(response) => {
194 return response
195 .into_body()
196 .read_to_vec()
197 .map_err(|e| RustmotionError::IconFetch {
198 icon: icon.to_string(),
199 reason: e.to_string(),
200 })
201 }
202 Err(e) => {
203 last_reason = e.to_string();
204 let is_last_attempt = attempt + 1 == ICON_FETCH_ATTEMPTS;
205 if !status_is_worth_retrying(&e) || is_last_attempt {
206 break;
207 }
208 std::thread::sleep(backoff);
209 backoff *= 2;
210 }
211 }
212 }
213
214 Err(RustmotionError::IconFetch {
215 icon: icon.to_string(),
216 reason: last_reason,
217 })
218}
219
220pub fn fetch_icon_svg(icon: &str, color: &str, width: u32, height: u32) -> Result<Vec<u8>> {
221 fetch_icon_svg_in(icon, color, width, height, &icon_cache_dir())
222}
223
224pub fn fetch_icon_svg_in(
225 icon: &str,
226 color: &str,
227 width: u32,
228 height: u32,
229 cache_dir: &Path,
230) -> Result<Vec<u8>> {
231 let (prefix, name) =
232 icon.split_once(':')
233 .ok_or_else(|| RustmotionError::InvalidIconFormat {
234 icon: icon.to_string(),
235 })?;
236 let hex_color = color.trim_start_matches('#');
237 let width = width.max(1);
238 let height = height.max(1);
239
240 let hex_color = hex_color.to_lowercase();
241 let source_file = icon_source_cache_file(cache_dir, icon);
242
243 let source = match std::fs::read(&source_file) {
244 Ok(data) if !data.is_empty() => data,
245 _ => match legacy_icon_source(cache_dir, icon) {
246 Some(migrated) => {
247 let _ = std::fs::write(&source_file, &migrated);
248 migrated
249 }
250 None => {
251 if remote_icon_policy() != RemoteIconPolicy::Allow {
252 return Err(RustmotionError::RemoteIconDenied {
253 icon: icon.to_string(),
254 url: icon_source_url(prefix, name),
255 cache_hint: source_file.display().to_string(),
256 });
257 }
258 let fetched = fetch_icon_source(icon, prefix, name)?;
259 if std::fs::create_dir_all(cache_dir).is_ok() {
260 let _ = std::fs::write(&source_file, &fetched);
261 }
262 fetched
263 }
264 },
265 };
266
267 Ok(recolour_and_resize(&source, &hex_color, width, height))
268}
269
270static VIDEO_FRAME_CACHE: OnceLock<VideoFrameCacheMap> = OnceLock::new();
271
272pub fn video_frame_cache() -> &'static VideoFrameCacheMap {
273 VIDEO_FRAME_CACHE.get_or_init(|| Arc::new(DashMap::new()))
274}
275
276pub fn find_closest_frame(
277 frames: &[(f64, Vec<u8>, u32, u32)],
278 target_time: f64,
279) -> Option<(&[u8], u32, u32)> {
280 if frames.is_empty() {
281 return None;
282 }
283 let idx = frames.partition_point(|(t, _, _, _)| *t < target_time);
284 let best = if idx == 0 {
285 0
286 } else if idx >= frames.len() {
287 frames.len() - 1
288 } else {
289 if (frames[idx].0 - target_time).abs() < (frames[idx - 1].0 - target_time).abs() {
290 idx
291 } else {
292 idx - 1
293 }
294 };
295 let (_, ref rgba, w, h) = frames[best];
296 Some((rgba, w, h))
297}
298
299pub fn ffmpeg_available() -> bool {
300 std::process::Command::new("ffmpeg")
301 .args(["-version"])
302 .stdout(std::process::Stdio::null())
303 .stderr(std::process::Stdio::null())
304 .status()
305 .map(|s| s.success())
306 .unwrap_or(false)
307}
308
309fn reject_remote_video_src(src: &str) -> Result<()> {
310 let Some(scheme_end) = src.find("://") else {
311 return Ok(());
312 };
313 let scheme = &src[..scheme_end];
314 let looks_like_scheme = !scheme.is_empty()
315 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
316 && scheme
317 .chars()
318 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
319 if looks_like_scheme {
320 return Err(RustmotionError::Generic(format!(
321 "video src '{src}' names a '{scheme}://' URL — rustmotion does not fetch video \
322 over the network, only local file paths are accepted (RM-42)"
323 )));
324 }
325 Ok(())
326}
327
328pub fn extract_video_frame(src: &str, time: f64, width: u32, height: u32) -> Result<Vec<u8>> {
329 reject_remote_video_src(src)?;
330 let output = std::process::Command::new("ffmpeg")
331 .args([
332 "-protocol_whitelist",
333 "file",
334 "-ss",
335 &format!("{:.3}", time),
336 "-i",
337 src,
338 "-vframes",
339 "1",
340 "-vf",
341 &format!("scale={}:{}", width, height),
342 "-f",
343 "image2pipe",
344 "-vcodec",
345 "png",
346 "-y",
347 "pipe:1",
348 ])
349 .stdout(std::process::Stdio::piped())
350 .stderr(std::process::Stdio::null())
351 .output()
352 .map_err(|e| RustmotionError::FfmpegSpawn {
353 reason: e.to_string(),
354 })?;
355
356 if !output.status.success() {
357 return Err(RustmotionError::FfmpegFrameExtract {
358 src: src.to_string(),
359 });
360 }
361
362 Ok(output.stdout)
363}
364
365pub fn probe_image_dimensions(path: &str) -> Result<(u32, u32)> {
366 let reader = image::ImageReader::open(path)
367 .map_err(|e| RustmotionError::ImageLoad {
368 path: path.to_string(),
369 reason: e.to_string(),
370 })?
371 .with_guessed_format()
372 .map_err(|e| RustmotionError::ImageLoad {
373 path: path.to_string(),
374 reason: e.to_string(),
375 })?;
376 reader
377 .into_dimensions()
378 .map_err(|e| RustmotionError::ImageLoad {
379 path: path.to_string(),
380 reason: e.to_string(),
381 })
382}
383
384pub fn ffprobe_available() -> bool {
385 std::process::Command::new("ffprobe")
386 .args(["-version"])
387 .stdout(std::process::Stdio::null())
388 .stderr(std::process::Stdio::null())
389 .status()
390 .map(|s| s.success())
391 .unwrap_or(false)
392}
393
394#[derive(Debug, Clone, PartialEq)]
395pub struct VideoProbe {
396 pub width: u32,
397 pub height: u32,
398 pub duration_secs: f64,
399 pub fps: Option<f64>,
400}
401
402#[derive(Debug, Default, serde::Deserialize)]
403struct FfprobeOutput {
404 #[serde(default)]
405 streams: Vec<FfprobeStream>,
406 #[serde(default)]
407 format: Option<FfprobeFormat>,
408}
409
410#[derive(Debug, Default, serde::Deserialize)]
411struct FfprobeStream {
412 #[serde(default)]
413 width: Option<u32>,
414 #[serde(default)]
415 height: Option<u32>,
416 #[serde(default)]
417 r_frame_rate: Option<String>,
418 #[serde(default)]
419 duration: Option<String>,
420}
421
422#[derive(Debug, Default, serde::Deserialize)]
423struct FfprobeFormat {
424 #[serde(default)]
425 duration: Option<String>,
426}
427
428fn parse_frame_rate(s: &str) -> Option<f64> {
429 let (num, den) = s.split_once('/')?;
430 let num: f64 = num.trim().parse().ok()?;
431 let den: f64 = den.trim().parse().ok()?;
432 if den == 0.0 {
433 return None;
434 }
435 Some(num / den)
436}
437
438pub fn probe_video_metadata(src: &str) -> Result<VideoProbe> {
439 if !ffprobe_available() {
440 return Err(RustmotionError::Generic(format!(
441 "Cannot read metadata for '{src}': ffprobe not found on PATH. ffprobe ships with \
442 ffmpeg — install it with `brew install ffmpeg` (macOS) or see \
443 https://ffmpeg.org/download.html."
444 )));
445 }
446
447 let output = std::process::Command::new("ffprobe")
448 .args([
449 "-v",
450 "error",
451 "-select_streams",
452 "v:0",
453 "-show_streams",
454 "-show_format",
455 "-of",
456 "json",
457 src,
458 ])
459 .output()
460 .map_err(|e| RustmotionError::Generic(format!("Failed to run ffprobe on '{src}': {e}")))?;
461
462 if !output.status.success() {
463 let stderr = String::from_utf8_lossy(&output.stderr);
464 return Err(RustmotionError::Generic(format!(
465 "ffprobe could not read '{src}': {}",
466 stderr.trim()
467 )));
468 }
469
470 let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout).map_err(|e| {
471 RustmotionError::Generic(format!(
472 "ffprobe produced output that could not be parsed for '{src}': {e}"
473 ))
474 })?;
475
476 let stream = parsed.streams.first().ok_or_else(|| {
477 RustmotionError::Generic(format!("'{src}' has no video stream ffprobe could find"))
478 })?;
479
480 let width = stream
481 .width
482 .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no width")))?;
483 let height = stream
484 .height
485 .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no height")))?;
486
487 let duration_secs = stream
488 .duration
489 .as_deref()
490 .and_then(|d| d.parse::<f64>().ok())
491 .or_else(|| {
492 parsed
493 .format
494 .as_ref()
495 .and_then(|f| f.duration.as_deref())
496 .and_then(|d| d.parse::<f64>().ok())
497 })
498 .ok_or_else(|| {
499 RustmotionError::Generic(format!("'{src}': ffprobe reported no duration"))
500 })?;
501
502 let fps = stream.r_frame_rate.as_deref().and_then(parse_frame_rate);
503
504 Ok(VideoProbe {
505 width,
506 height,
507 duration_secs,
508 fps,
509 })
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn unique_temp_dir(name: &str) -> PathBuf {
517 let dir = std::env::temp_dir()
518 .join("rustmotion-test-icons")
519 .join(format!(
520 "{name}-{}-{}",
521 std::process::id(),
522 std::time::SystemTime::now()
523 .duration_since(std::time::UNIX_EPOCH)
524 .unwrap()
525 .as_nanos()
526 ));
527 std::fs::create_dir_all(&dir).expect("create test cache dir");
528 dir
529 }
530
531 #[test]
532 fn oversamples_the_target_size_and_keys_on_the_oversampled_size() {
533 let (render_w, render_h, key) = icon_cache_key("lucide:home", "#FFFFFF", 40, 40);
534 assert_eq!(render_w, 40 * ICON_OVERSAMPLE);
535 assert_eq!(render_h, 40 * ICON_OVERSAMPLE);
536 assert_eq!(key, "icon:lucide:home:#FFFFFF:80x80");
537 }
538
539 #[test]
540 fn zero_target_size_is_clamped_to_at_least_one_before_oversampling() {
541 let (render_w, render_h, _key) = icon_cache_key("lucide:home", "#FFFFFF", 0, 0);
542 assert_eq!(render_w, ICON_OVERSAMPLE);
543 assert_eq!(render_h, ICON_OVERSAMPLE);
544 }
545
546 #[test]
547 fn distinct_icons_or_colors_never_collide() {
548 let (_, _, key_a) = icon_cache_key("lucide:home", "#FFFFFF", 40, 40);
549 let (_, _, key_b) = icon_cache_key("lucide:home", "#000000", 40, 40);
550 let (_, _, key_c) = icon_cache_key("lucide:settings", "#FFFFFF", 40, 40);
551 assert_ne!(key_a, key_b);
552 assert_ne!(key_a, key_c);
553 }
554
555 #[test]
556 fn disk_cache_hit_returns_bytes_without_touching_the_network() {
557 let cache_dir = unique_temp_dir("cache-hit");
558 let icon = "test-suite:offline-icon";
559 let color = "#ABCDEF";
560 let (w, h) = (48, 48);
561 let svg_bytes = b"<svg>fake cached icon for the test suite</svg>".to_vec();
562
563 let cache_file = icon_source_cache_file(&cache_dir, icon);
564 std::fs::create_dir_all(&cache_dir).unwrap();
565 std::fs::write(&cache_file, &svg_bytes).unwrap();
566
567 let result = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
568 let result = String::from_utf8(result).unwrap();
569 assert!(
570 result.contains("fake cached icon for the test suite"),
571 "the cached source must be what is served — the network was never reached: {result}"
572 );
573 assert!(
574 result.contains(r#"width="48""#),
575 "and it is resized on the way out, which is what lets one cached source serve \
576 every colour and size: {result}"
577 );
578 }
579
580 #[test]
581 fn disk_cache_is_keyed_by_the_icon_alone() {
582 let cache_dir = unique_temp_dir("cache-keying");
583 assert_eq!(
584 icon_source_cache_file(&cache_dir, "lucide:home"),
585 icon_source_cache_file(&cache_dir, "lucide:home"),
586 );
587 assert_ne!(
588 icon_source_cache_file(&cache_dir, "lucide:home"),
589 icon_source_cache_file(&cache_dir, "lucide:check"),
590 );
591 }
592
593 #[test]
594 fn missing_colon_fails_fast_without_touching_disk_or_network() {
595 let cache_dir = unique_temp_dir("invalid-format");
596 let result = fetch_icon_svg_in("not-a-valid-icon-id", "#FFFFFF", 40, 40, &cache_dir);
597 assert!(matches!(
598 result,
599 Err(RustmotionError::InvalidIconFormat { .. })
600 ));
601 }
602
603 #[test]
604 #[ignore = "requires network access"]
605 fn live_fetch_writes_through_to_the_disk_cache() {
606 let cache_dir = unique_temp_dir("live-fetch");
607 let icon = "lucide:home";
608 let color = "#FFFFFF";
609 let (w, h) = (32, 32);
610
611 let first = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("live fetch");
612 assert!(!first.is_empty());
613
614 let cache_file = icon_source_cache_file(&cache_dir, icon);
615 assert!(
616 cache_file.exists(),
617 "a successful live fetch must be persisted to disk"
618 );
619
620 let second = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
621 assert_eq!(first, second);
622 }
623
624 #[test]
625 fn ffmpeg_available_does_not_panic_either_way() {
626 let _ = ffmpeg_available();
627 }
628
629 fn scratch_path(name: &str) -> PathBuf {
630 std::env::temp_dir().join(format!(
631 "rm_assets_probe_test_{}_{}_{}",
632 std::process::id(),
633 std::time::SystemTime::now()
634 .duration_since(std::time::UNIX_EPOCH)
635 .unwrap()
636 .as_nanos(),
637 name
638 ))
639 }
640
641 fn write_test_png(path: &Path, w: u32, h: u32) {
642 let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
643 img.save(path).expect("write PNG fixture");
644 }
645
646 fn write_test_gif(path: &Path, w: u32, h: u32) {
647 use image::codecs::gif::GifEncoder;
648 let file = std::fs::File::create(path).expect("create GIF fixture");
649 let mut encoder = GifEncoder::new(file);
650 let frame = image::Frame::new(image::RgbaImage::from_pixel(
651 w,
652 h,
653 image::Rgba([200, 50, 10, 255]),
654 ));
655 encoder.encode_frame(frame).expect("encode GIF fixture");
656 }
657
658 #[test]
659 fn probe_image_dimensions_reads_a_png_header() {
660 let path = scratch_path("dims.png");
661 write_test_png(&path, 37, 21);
662 let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read PNG dims");
663 assert_eq!((w, h), (37, 21));
664 let _ = std::fs::remove_file(&path);
665 }
666
667 #[test]
668 fn probe_image_dimensions_reads_a_gif_header() {
669 let path = scratch_path("dims.gif");
670 write_test_gif(&path, 12, 9);
671 let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read GIF dims");
672 assert_eq!((w, h), (12, 9));
673 let _ = std::fs::remove_file(&path);
674 }
675
676 #[test]
677 fn probe_image_dimensions_on_a_missing_file_is_an_error_not_a_panic() {
678 let path = scratch_path("does-not-exist.png");
679 let result = probe_image_dimensions(path.to_str().unwrap());
680 assert!(
681 result.is_err(),
682 "missing file must be an error, not a panic"
683 );
684 }
685
686 #[test]
687 fn probe_image_dimensions_on_garbage_bytes_is_an_error_not_a_panic() {
688 let path = scratch_path("garbage.png");
689 std::fs::write(&path, b"this is not an image").unwrap();
690 let result = probe_image_dimensions(path.to_str().unwrap());
691 assert!(
692 result.is_err(),
693 "unreadable content must be an error, not a panic"
694 );
695 let _ = std::fs::remove_file(&path);
696 }
697
698 #[test]
699 fn parse_frame_rate_reads_integer_and_ntsc_fractions() {
700 assert_eq!(parse_frame_rate("30/1"), Some(30.0));
701 assert!((parse_frame_rate("30000/1001").unwrap() - 29.97).abs() < 0.01);
702 }
703
704 #[test]
705 fn parse_frame_rate_rejects_zero_denominator_and_garbage() {
706 assert_eq!(parse_frame_rate("30/0"), None);
707 assert_eq!(parse_frame_rate("not-a-rate"), None);
708 }
709
710 fn make_test_video(path: &Path, width: u32, height: u32, fps: u32, duration_s: u32) -> bool {
711 std::process::Command::new("ffmpeg")
712 .args([
713 "-y",
714 "-loglevel",
715 "error",
716 "-f",
717 "lavfi",
718 "-i",
719 &format!("testsrc=size={width}x{height}:rate={fps}:duration={duration_s}"),
720 "-pix_fmt",
721 "yuv420p",
722 ])
723 .arg(path)
724 .status()
725 .map(|s| s.success())
726 .unwrap_or(false)
727 }
728
729 #[test]
730 fn probe_video_metadata_reads_dimensions_duration_and_fps() {
731 if !ffmpeg_available() || !ffprobe_available() {
732 eprintln!(
733 "probe_video_metadata_reads_dimensions_duration_and_fps: ffmpeg/ffprobe not \
734 found on PATH — skipping"
735 );
736 return;
737 }
738 let path = scratch_path("probe.mp4");
739 assert!(
740 make_test_video(&path, 64, 36, 25, 2),
741 "fixture video must encode"
742 );
743
744 let probe =
745 probe_video_metadata(path.to_str().unwrap()).expect("must probe video metadata");
746 assert_eq!(probe.width, 64);
747 assert_eq!(probe.height, 36);
748 assert!(
749 (probe.duration_secs - 2.0).abs() < 0.2,
750 "duration: {}",
751 probe.duration_secs
752 );
753 assert!(probe.fps.is_some(), "expected a frame rate");
754 assert!(
755 (probe.fps.unwrap() - 25.0).abs() < 0.1,
756 "fps: {:?}",
757 probe.fps
758 );
759
760 let _ = std::fs::remove_file(&path);
761 }
762
763 #[test]
764 fn probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic() {
765 if !ffprobe_available() {
766 eprintln!(
767 "probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic: ffprobe not \
768 found on PATH — skipping"
769 );
770 return;
771 }
772 let path = scratch_path("does-not-exist.mp4");
773 let result = probe_video_metadata(path.to_str().unwrap());
774 assert!(
775 result.is_err(),
776 "missing file must be an error, not a panic"
777 );
778 }
779
780 #[test]
781 fn probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic() {
782 if !ffprobe_available() {
783 eprintln!(
784 "probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic: ffprobe not \
785 found on PATH — skipping"
786 );
787 return;
788 }
789 let path = scratch_path("garbage.mp4");
790 std::fs::write(&path, b"not a real video file").unwrap();
791 let result = probe_video_metadata(path.to_str().unwrap());
792 assert!(
793 result.is_err(),
794 "unreadable content must be an error, not a panic"
795 );
796 let _ = std::fs::remove_file(&path);
797 }
798
799 #[test]
800 fn ffprobe_available_does_not_panic_either_way() {
801 let _ = ffprobe_available();
802 }
803}
804
805#[cfg(test)]
806mod icon_source_cache_tests {
807 use super::*;
808
809 const LUCIDE_CHECK: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>"#;
810
811 #[test]
812 fn the_disk_entry_is_named_by_the_icon_alone() {
813 let dir = Path::new("/tmp/icons");
814 assert_eq!(
815 icon_source_cache_file(dir, "lucide:check"),
816 dir.join("lucide_check.svg"),
817 "one entry per icon: naming it by colour and size is what filled a cache with 20 \
818 copies of the same glyph and made a colour change hit the network"
819 );
820 }
821
822 fn a_cache_dir(tag: &str) -> PathBuf {
823 let dir = std::env::temp_dir().join(format!(
824 "rm_legacy_icons_{}_{}_{tag}",
825 std::process::id(),
826 std::time::SystemTime::now()
827 .duration_since(std::time::UNIX_EPOCH)
828 .unwrap()
829 .as_nanos()
830 ));
831 std::fs::create_dir_all(&dir).expect("cache dir");
832 dir
833 }
834
835 #[test]
836 fn a_file_left_by_the_old_naming_is_migrated_instead_of_refetched() {
837 let dir = a_cache_dir("migrate");
838 let baked = LUCIDE_CHECK.replace("currentColor", "#3B6FD4");
839 std::fs::write(dir.join("lucide_check-3b6fd4-26x26.svg"), &baked).expect("seed");
840
841 let migrated = legacy_icon_source(&dir, "lucide:check")
842 .expect("674 of these sit in a real cache; an offline render must be able to use one");
843 let text = String::from_utf8(migrated).unwrap();
844 assert!(
845 text.contains("currentColor"),
846 "the baked colour has to come back out, or the icon can never be recoloured: {text}"
847 );
848 assert!(!text.contains("3B6FD4"), "got {text}");
849 std::fs::remove_dir_all(&dir).ok();
850 }
851
852 #[test]
853 fn migration_survives_an_icon_whose_own_name_contains_a_dash() {
854 let dir = a_cache_dir("dashed");
855 let baked = LUCIDE_CHECK.replace("currentColor", "#8b5cf6");
856 std::fs::write(dir.join("lucide_arrow-down-8b5cf6-176x176.svg"), &baked).expect("seed");
857 assert!(
858 legacy_icon_source(&dir, "lucide:arrow-down").is_some(),
859 "the colour and size have to be parsed from the right, not from the first dash"
860 );
861 std::fs::remove_dir_all(&dir).ok();
862 }
863
864 #[test]
865 fn an_unrelated_file_is_not_mistaken_for_a_legacy_entry() {
866 let dir = a_cache_dir("unrelated");
867 std::fs::write(dir.join("lucide_check-notes.svg"), LUCIDE_CHECK).expect("seed");
868 std::fs::write(dir.join("lucide_checkmark.svg"), LUCIDE_CHECK).expect("seed");
869 assert!(
870 legacy_icon_source(&dir, "lucide:check").is_none(),
871 "only a name ending in -{{6 hex}}-{{w}}x{{h}} is a legacy entry"
872 );
873 std::fs::remove_dir_all(&dir).ok();
874 }
875
876 #[test]
877 fn the_rewritten_icon_still_parses_as_svg() {
878 let out = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "2563eb", 84, 84);
879 let text = String::from_utf8(out.clone()).unwrap();
880 assert!(
881 !text.contains("\"\""),
882 "the rewrite must not leave the previous value's closing quote behind: {text}"
883 );
884 usvg::Tree::from_data(&out, &usvg::Options::default()).unwrap_or_else(|e| {
885 panic!(
886 "a rewritten icon has to parse, or nothing renders and the only sign is a \
887 warning on stderr: {e}\n{text}"
888 )
889 });
890 }
891
892 #[test]
893 fn rewriting_an_attribute_that_already_has_a_value_replaces_it_exactly_once() {
894 let source = r#"<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path d="M4 12h16"/></svg>"#;
895 let out = String::from_utf8(recolour_and_resize(source.as_bytes(), "000000", 84, 96))
896 .expect("utf8");
897 assert_eq!(
898 out.matches("width=").count(),
899 1,
900 "one width attribute, not two: {out}"
901 );
902 assert!(out.contains(r#"width="84""#), "got {out}");
903 assert!(out.contains(r#"height="96""#), "got {out}");
904 assert!(
905 !out.contains("1em"),
906 "the 1em Iconify ships must be gone: {out}"
907 );
908 }
909
910 #[test]
911 fn recolouring_substitutes_current_color_everywhere() {
912 let out = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "2a2f6b", 84, 84);
913 let out = String::from_utf8(out).unwrap();
914 assert!(!out.contains("currentColor"));
915 assert!(out.contains(r##"stroke="#2a2f6b""##), "got {out}");
916 }
917
918 #[test]
919 fn resizing_rewrites_the_root_size_and_leaves_the_viewbox_alone() {
920 let out = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "ffffff", 84, 96);
921 let out = String::from_utf8(out).unwrap();
922 assert!(out.contains(r#"width="84""#), "got {out}");
923 assert!(out.contains(r#"height="96""#), "got {out}");
924 assert!(
925 out.contains(r#"viewBox="0 0 24 24""#),
926 "the viewBox is the glyph's own coordinate space and must survive a resize: {out}"
927 );
928 }
929
930 #[test]
931 fn a_root_without_a_size_gains_one() {
932 let bare = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
933 let out =
934 String::from_utf8(recolour_and_resize(bare.as_bytes(), "000000", 32, 32)).unwrap();
935 assert!(
936 out.contains(r#"width="32""#) && out.contains(r#"height="32""#),
937 "got {out}"
938 );
939 }
940
941 #[test]
942 fn two_colours_of_one_icon_share_a_single_source_entry() {
943 let dir = Path::new("/tmp/icons");
944 assert_eq!(
945 icon_source_cache_file(dir, "lucide:sparkles"),
946 icon_source_cache_file(dir, "lucide:sparkles")
947 );
948 let red = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "ff0000", 24, 24);
949 let blue = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "0000ff", 24, 24);
950 assert_ne!(
951 red, blue,
952 "the same source still produces different renders"
953 );
954 }
955
956 #[test]
957 fn only_rate_limits_and_server_faults_are_retried() {
958 assert!(status_is_worth_retrying(&ureq::Error::StatusCode(429)));
959 assert!(status_is_worth_retrying(&ureq::Error::StatusCode(503)));
960 assert!(
961 !status_is_worth_retrying(&ureq::Error::StatusCode(404)),
962 "a missing icon is not going to appear on the fourth try — retrying it would just \
963 make a typo take four times as long to report"
964 );
965 }
966}
967
968#[cfg(test)]
969mod remote_icon_policy_tests {
970 use super::*;
971
972 struct Restore(RemoteIconPolicy);
973 impl Drop for Restore {
974 fn drop(&mut self) {
975 set_remote_icon_policy(self.0);
976 }
977 }
978
979 fn a_cache_dir(name: &str) -> PathBuf {
980 let dir = std::env::temp_dir().join(format!(
981 "rm_icon_policy_{}_{}_{name}",
982 std::process::id(),
983 std::time::SystemTime::now()
984 .duration_since(std::time::UNIX_EPOCH)
985 .unwrap()
986 .as_nanos()
987 ));
988 std::fs::create_dir_all(&dir).expect("cache dir");
989 dir
990 }
991
992 const A_SQUARE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
993 <rect x="2" y="2" width="20" height="20" fill="currentColor"/>
994 </svg>"#;
995
996 #[test]
997 fn the_default_is_deny() {
998 assert_eq!(
999 RemoteIconPolicy::default(),
1000 RemoteIconPolicy::Deny,
1001 "a scenario chooses which icon is fetched, so it chooses the target of the \
1002 request — the same reason fonts are denied by default"
1003 );
1004 }
1005
1006 #[test]
1007 fn an_icon_that_is_not_cached_is_refused_by_name_without_touching_the_network() {
1008 let _restore = Restore(remote_icon_policy());
1009 set_remote_icon_policy(RemoteIconPolicy::Deny);
1010 let dir = a_cache_dir("denied");
1011
1012 let message = fetch_icon_svg_in("lucide:sparkles", "#FFFFFF", 24, 24, &dir)
1013 .expect_err("an icon absent from the cache would have to be fetched")
1014 .to_string();
1015
1016 assert!(
1017 message.contains("lucide:sparkles"),
1018 "the refusal has to name the icon: {message}"
1019 );
1020 assert!(
1021 message.contains("api.iconify.design"),
1022 "and the target it declined to reach: {message}"
1023 );
1024 assert!(
1025 message.contains("rustmotion icons prefetch")
1026 && message.contains("--allow-remote-icons"),
1027 "and both ways out — fill the cache once, or opt in for this run: {message}"
1028 );
1029 std::fs::remove_dir_all(&dir).ok();
1030 }
1031
1032 #[test]
1033 fn an_icon_already_in_the_cache_needs_no_flag() {
1034 let _restore = Restore(remote_icon_policy());
1035 set_remote_icon_policy(RemoteIconPolicy::Deny);
1036 let dir = a_cache_dir("cached");
1037 std::fs::write(icon_source_cache_file(&dir, "lucide:check"), A_SQUARE).expect("seed");
1038
1039 let svg = fetch_icon_svg_in("lucide:check", "#FF3366", 24, 24, &dir)
1040 .expect("nothing has to be fetched, so nothing is denied");
1041 assert!(
1042 String::from_utf8_lossy(&svg).contains("ff3366"),
1043 "the cached source must still be recoloured and returned under a deny policy"
1044 );
1045 std::fs::remove_dir_all(&dir).ok();
1046 }
1047
1048 #[test]
1049 fn a_cache_left_by_the_old_naming_is_migrated_without_the_flag() {
1050 let _restore = Restore(remote_icon_policy());
1051 set_remote_icon_policy(RemoteIconPolicy::Deny);
1052 let dir = a_cache_dir("legacy");
1053 let baked = A_SQUARE.replace("currentColor", "#ffffff");
1054 std::fs::write(dir.join("lucide_home-ffffff-24x24.svg"), baked).expect("seed");
1055
1056 let svg = fetch_icon_svg_in("lucide:home", "#33CCFF", 24, 24, &dir)
1057 .expect("the legacy entry is on disk, so the deny gate must never be reached");
1058 assert!(
1059 String::from_utf8_lossy(&svg).contains("33ccff"),
1060 "an offline render that already has its icons under the pre-#425 naming must keep \
1061 working: the gate belongs after the migration, not before it"
1062 );
1063 std::fs::remove_dir_all(&dir).ok();
1064 }
1065}