Skip to main content

rustmotion_core/engine/renderer/
assets.rs

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}