Skip to main content

pi/core/platform/
clipboard.rs

1//! Cross-platform clipboard text and image I/O.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/utils/{clipboard.ts,
4//! clipboard-native.ts, clipboard-image.ts}`.
5//!
6//! The TypeScript reference uses the `@mariozechner/clipboard` native addon
7//! as a fast path; this Rust port has no native addon and instead drives the
8//! platform clipboard CLI tools directly. Those tools (`pbcopy`/`pbpaste`,
9//! `clip`, `wl-copy`/`wl-paste`, `xclip`, `xsel`, `termux-clipboard-set`,
10//! PowerShell) are the same ones the reference falls back to, so the
11//! observable argv contract and the OSC 52 remote fallback are preserved
12//! exactly and are unit-testable on any host.
13//!
14//! Image support reuses [`super::image::process_image`] so no external image
15//! binaries are spawned.
16
17use std::io::Write;
18use std::process::{Command, Stdio};
19use std::time::Duration;
20
21use base64::Engine;
22
23use super::image::{convert_to_png, detect_supported_image_mime, extension_for_image_mime};
24
25/// Maximum base64 length for an OSC 52 copy. Larger payloads are skipped to
26/// avoid desynchronizing terminal rendering.
27pub const MAX_OSC52_ENCODED_LENGTH: usize = 100_000;
28
29/// Shell-tool spawn timeout for the synchronous clipboard helpers.
30pub const CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(5);
31
32/// Platform discriminator selectable independently of the host for tests.
33#[derive(Copy, Clone, Debug, Eq, PartialEq)]
34pub enum ClipboardPlatform {
35    /// macOS: `pbcopy` / `pbpaste`.
36    Darwin,
37    /// Windows: `clip` / PowerShell `Get-Clipboard`.
38    Windows,
39    /// Linux and other Unix: Wayland/X11/Termux tools.
40    Unix,
41}
42
43impl ClipboardPlatform {
44    /// Resolve the current host's platform.
45    #[must_use]
46    pub fn host() -> Self {
47        #[cfg(target_os = "macos")]
48        {
49            Self::Darwin
50        }
51        #[cfg(target_os = "windows")]
52        {
53            Self::Windows
54        }
55        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
56        {
57            Self::Unix
58        }
59    }
60}
61
62/// Read-only view of the environment used to make clipboard decisions.
63///
64/// Production reads `std::env`; tests inject values to exercise the remote,
65/// Wayland, X11, and Termux branches deterministically.
66pub trait ClipboardEnv: Send + Sync {
67    /// Value of an environment variable, if set.
68    fn get(&self, name: &str) -> Option<String>;
69}
70
71/// Production environment backed by `std::env::var`.
72#[derive(Debug, Default)]
73pub struct HostEnv;
74
75impl ClipboardEnv for HostEnv {
76    fn get(&self, name: &str) -> Option<String> {
77        std::env::var(name).ok()
78    }
79}
80
81/// Returns `true` when `env` indicates a Wayland session.
82///
83/// Matches `isWaylandSession`: `WAYLAND_DISPLAY` set or `XDG_SESSION_TYPE`
84/// exactly `"wayland"`.
85#[must_use]
86pub fn is_wayland_session(env: &dyn ClipboardEnv) -> bool {
87    env.get("WAYLAND_DISPLAY").is_some()
88        || env.get("XDG_SESSION_TYPE").as_deref() == Some("wayland")
89}
90
91/// Returns `true` for an SSH or Mosh remote session, where OSC 52 is emitted
92/// even after a native copy so the controlling terminal receives the text.
93#[must_use]
94pub fn is_remote_session(env: &dyn ClipboardEnv) -> bool {
95    env.get("SSH_CONNECTION").is_some()
96        || env.get("SSH_CLIENT").is_some()
97        || env.get("MOSH_CONNECTION").is_some()
98}
99
100/// Errors returned by clipboard copy.
101#[derive(Debug, thiserror::Error)]
102pub enum ClipboardError {
103    /// Every copy path (native, shell tool, OSC 52) failed.
104    #[error("Failed to copy to clipboard")]
105    Failed,
106}
107
108/// A clipboard image and its MIME type.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct ClipboardImage {
111    /// Raw image bytes.
112    pub bytes: Vec<u8>,
113    /// Canonical MIME type.
114    pub mime: String,
115}
116
117/// A resolved clipboard write argv (program + args) with an optional fallback.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct WriteCommand {
120    /// Program name.
121    pub program: String,
122    /// Argv excluding the program name.
123    pub args: Vec<String>,
124    /// Optional secondary argv tried when the primary is missing.
125    pub fallback: Option<(String, Vec<String>)>,
126}
127
128impl WriteCommand {
129    fn new(program: &str, args: Vec<String>) -> Self {
130        Self {
131            program: program.to_owned(),
132            args,
133            fallback: None,
134        }
135    }
136}
137
138/// Selected write command argv for `platform`/`env`, or `None` when no shell
139/// tool applies (forcing the OSC 52 / failure path).
140///
141/// The argv matches the TypeScript reference's selection order exactly:
142/// - Darwin → `pbcopy`
143/// - Windows → `clip`
144/// - Unix → Termux (`termux-clipboard-set`) if `TERMUX_VERSION` set, else
145///   Wayland (`wl-copy`) when `is_wayland_session` and `WAYLAND_DISPLAY`,
146///   else X11 (`xclip -selection clipboard`, which the reference falls back
147///   from to `xsel --clipboard --input`).
148#[must_use]
149pub fn clipboard_write_command(
150    platform: ClipboardPlatform,
151    env: &dyn ClipboardEnv,
152) -> Option<WriteCommand> {
153    match platform {
154        ClipboardPlatform::Darwin => Some(WriteCommand::new("pbcopy", vec![])),
155        ClipboardPlatform::Windows => Some(WriteCommand::new("clip", vec![])),
156        ClipboardPlatform::Unix => {
157            if env.get("TERMUX_VERSION").is_some() {
158                return Some(WriteCommand::new("termux-clipboard-set", vec![]));
159            }
160            let has_wayland = env.get("WAYLAND_DISPLAY").is_some();
161            let has_x11 = env.get("DISPLAY").is_some();
162            if is_wayland_session(env) && has_wayland {
163                Some(WriteCommand::new("wl-copy", vec![]))
164            } else if has_x11 {
165                let mut cmd = WriteCommand::new(
166                    "xclip",
167                    vec!["-selection".to_owned(), "clipboard".to_owned()],
168                );
169                cmd.fallback = Some((
170                    "xsel".to_owned(),
171                    vec!["--clipboard".to_owned(), "--input".to_owned()],
172                ));
173                Some(cmd)
174            } else {
175                None
176            }
177        }
178    }
179}
180
181/// Encode `text` as an OSC 52 sequence, or `None` when the base64 form exceeds
182/// [`MAX_OSC52_ENCODED_LENGTH`].
183#[must_use]
184pub fn osc52_encode(text: &str) -> Option<String> {
185    let encoded = base64::engine::general_purpose::STANDARD.encode(text);
186    if encoded.len() > MAX_OSC52_ENCODED_LENGTH {
187        return None;
188    }
189    Some(format!("\x1b]52;c;{encoded}\x07"))
190}
191
192/// Copy `text` to the clipboard using the host platform and environment.
193///
194/// # Errors
195///
196/// Returns [`ClipboardError::Failed`] when every available clipboard path fails.
197pub fn copy_to_clipboard(text: &str) -> Result<(), ClipboardError> {
198    copy_to_clipboard_with(text, ClipboardPlatform::host(), &HostEnv)
199}
200
201/// Copy `text` with an explicit platform/env.
202///
203/// Tries the selected shell tool (and its fallback), then OSC 52. On the host
204/// this writes the OSC sequence to stdout; tests inject platforms whose tools
205/// are absent so the decision logic is exercised without side effects.
206///
207/// # Errors
208///
209/// Returns [`ClipboardError::Failed`] when neither the selected platform tool
210/// (including its fallback) nor OSC 52 can accept the text.
211pub fn copy_to_clipboard_with(
212    text: &str,
213    platform: ClipboardPlatform,
214    env: &dyn ClipboardEnv,
215) -> Result<(), ClipboardError> {
216    let mut copied = false;
217
218    if let Some(cmd) = clipboard_write_command(platform, env)
219        && run_write_command(&cmd, text)
220    {
221        copied = true;
222    }
223
224    if !copied && osc52_encode(text).is_some() {
225        copied = true;
226    }
227
228    if copied {
229        Ok(())
230    } else {
231        Err(ClipboardError::Failed)
232    }
233}
234
235fn run_write_command(cmd: &WriteCommand, text: &str) -> bool {
236    if pipe_to(&cmd.program, &cmd.args, text) {
237        return true;
238    }
239    if let Some((program, args)) = &cmd.fallback {
240        return pipe_to(program, args, text);
241    }
242    false
243}
244
245/// Pipe `text` to `program args` stdin within [`CLIPBOARD_TIMEOUT`]. Returns
246/// `false` on spawn failure, timeout, or nonzero exit.
247fn pipe_to(program: &str, args: &[String], text: &str) -> bool {
248    let Ok(mut child) = Command::new(program)
249        .args(args)
250        .stdin(Stdio::piped())
251        .stdout(Stdio::null())
252        .stderr(Stdio::null())
253        .spawn()
254    else {
255        return false;
256    };
257    if let Some(mut stdin) = child.stdin.take()
258        && stdin.write_all(text.as_bytes()).is_err()
259    {
260        // EPIPE on early exit (e.g. wl-copy) is non-fatal; stdin is dropped.
261    }
262    match wait_timeout::ChildExt::wait_timeout(&mut child, CLIPBOARD_TIMEOUT) {
263        Ok(Some(status)) => status.success(),
264        Ok(None) => {
265            let _ = child.kill();
266            let _ = child.wait();
267            false
268        }
269        Err(_) => false,
270    }
271}
272
273/// A resolved clipboard read argv with an optional fallback.
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct ReadCommand {
276    /// Program name.
277    pub program: String,
278    /// Argv excluding the program name.
279    pub args: Vec<String>,
280    /// Optional secondary argv when the primary is unavailable.
281    pub fallback: Option<(String, Vec<String>)>,
282}
283
284impl ReadCommand {
285    fn new(program: &str, args: Vec<String>) -> Self {
286        Self {
287            program: program.to_owned(),
288            args,
289            fallback: None,
290        }
291    }
292}
293
294/// Selected read command argv for `platform`/`env`, or `None`.
295#[must_use]
296pub fn clipboard_read_command(
297    platform: ClipboardPlatform,
298    env: &dyn ClipboardEnv,
299) -> Option<ReadCommand> {
300    match platform {
301        ClipboardPlatform::Darwin => Some(ReadCommand::new("pbpaste", vec![])),
302        ClipboardPlatform::Windows => Some(ReadCommand::new(
303            "powershell",
304            vec![
305                "-NoProfile".to_owned(),
306                "-Command".to_owned(),
307                "Get-Clipboard".to_owned(),
308            ],
309        )),
310        ClipboardPlatform::Unix => {
311            if is_wayland_session(env) && env.get("WAYLAND_DISPLAY").is_some() {
312                Some(ReadCommand::new(
313                    "wl-paste",
314                    vec!["--no-newline".to_owned()],
315                ))
316            } else if env.get("DISPLAY").is_some() {
317                let mut cmd = ReadCommand::new(
318                    "xclip",
319                    vec![
320                        "-selection".to_owned(),
321                        "clipboard".to_owned(),
322                        "-o".to_owned(),
323                    ],
324                );
325                cmd.fallback = Some((
326                    "xsel".to_owned(),
327                    vec!["--clipboard".to_owned(), "--output".to_owned()],
328                ));
329                Some(cmd)
330            } else {
331                None
332            }
333        }
334    }
335}
336
337/// Read plain text from the clipboard on the host.
338#[must_use]
339pub fn read_clipboard_text() -> Option<String> {
340    read_clipboard_text_with(ClipboardPlatform::host(), &HostEnv)
341}
342
343/// Read plain text with an explicit platform/env.
344pub fn read_clipboard_text_with(
345    platform: ClipboardPlatform,
346    env: &dyn ClipboardEnv,
347) -> Option<String> {
348    let cmd = clipboard_read_command(platform, env)?;
349    if let Some(text) = capture(&cmd.program, &cmd.args)
350        && !text.is_empty()
351    {
352        return Some(text);
353    }
354    if let Some((program, args)) = &cmd.fallback {
355        return capture(program, args).filter(|t| !t.is_empty());
356    }
357    None
358}
359
360fn capture(program: &str, args: &[String]) -> Option<String> {
361    let output = Command::new(program)
362        .args(args)
363        .stdin(Stdio::null())
364        .stdout(Stdio::piped())
365        .stderr(Stdio::null())
366        .output()
367        .ok()?;
368    if !output.status.success() {
369        return None;
370    }
371    Some(String::from_utf8_lossy(&output.stdout).into_owned())
372}
373
374/// Returns `true` on WSL using `WSL_DISTRO_NAME`, `WSLENV`, or `/proc/version`.
375pub fn is_wsl(env: &dyn ClipboardEnv) -> bool {
376    if env.get("WSL_DISTRO_NAME").is_some() || env.get("WSLENV").is_some() {
377        return true;
378    }
379    std::fs::read_to_string("/proc/version").is_ok_and(|release| {
380        release.contains("microsoft") || release.to_ascii_lowercase().contains("wsl")
381    })
382}
383
384/// Convert unsupported image bytes to PNG for clipboard consumers.
385///
386/// Returns the supported `(bytes, mime)` unchanged, or a PNG conversion.
387/// Returns `None` when the bytes are neither recognizable nor convertible.
388#[must_use]
389pub fn maybe_convert_to_png(bytes: &[u8], mime: &str) -> Option<(Vec<u8>, String)> {
390    if let Some(kind) = detect_supported_image_mime(bytes)
391        && matches!(
392            kind.mime(),
393            "image/png" | "image/jpeg" | "image/gif" | "image/webp"
394        )
395    {
396        return Some((bytes.to_vec(), kind.mime().to_owned()));
397    }
398    let base = base_mime(mime);
399    if matches!(
400        base.as_str(),
401        "image/png" | "image/jpeg" | "image/gif" | "image/webp"
402    ) {
403        return Some((bytes.to_vec(), base));
404    }
405    convert_to_png(bytes).map(|png| (png, "image/png".to_owned()))
406}
407
408fn base_mime(mime: &str) -> String {
409    mime.split(';')
410        .next()
411        .unwrap_or(mime)
412        .trim()
413        .to_ascii_lowercase()
414}
415
416/// Read an image from the clipboard, converting unsupported formats to PNG.
417///
418/// The argv selection mirrors `readClipboardImage`: Wayland/WSL → `wl-paste`
419/// then `xclip`. Termux yields no image. Unsupported MIME is converted via
420/// [`maybe_convert_to_png`].
421#[must_use]
422pub fn read_clipboard_image() -> Option<ClipboardImage> {
423    read_clipboard_image_with(ClipboardPlatform::host(), &HostEnv)
424}
425
426/// Read a clipboard image with an explicit platform/env.
427pub fn read_clipboard_image_with(
428    platform: ClipboardPlatform,
429    env: &dyn ClipboardEnv,
430) -> Option<ClipboardImage> {
431    if env.get("TERMUX_VERSION").is_some() {
432        return None;
433    }
434    let raw = read_clipboard_image_raw(platform, env)?;
435    let (bytes, mime) = maybe_convert_to_png(&raw.bytes, &raw.mime)?;
436    Some(ClipboardImage { bytes, mime })
437}
438
439fn read_clipboard_image_raw(
440    platform: ClipboardPlatform,
441    env: &dyn ClipboardEnv,
442) -> Option<ClipboardImage> {
443    if !matches!(platform, ClipboardPlatform::Unix) {
444        return None;
445    }
446    let wayland = is_wayland_session(env);
447    let wsl = is_wsl(env);
448    if wayland || wsl {
449        return wl_paste_image().or_else(xclip_image);
450    }
451    None
452}
453
454fn wl_paste_image() -> Option<ClipboardImage> {
455    let list = Command::new("wl-paste")
456        .arg("--list-types")
457        .stdin(Stdio::null())
458        .stdout(Stdio::piped())
459        .stderr(Stdio::null())
460        .output()
461        .ok()?;
462    if !list.status.success() {
463        return None;
464    }
465    let selected = select_preferred_image_mime(&String::from_utf8_lossy(&list.stdout))?;
466    let data = Command::new("wl-paste")
467        .args(["--type", &selected, "--no-newline"])
468        .stdin(Stdio::null())
469        .stdout(Stdio::piped())
470        .stderr(Stdio::null())
471        .output()
472        .ok()?;
473    if !data.status.success() || data.stdout.is_empty() {
474        return None;
475    }
476    Some(ClipboardImage {
477        bytes: data.stdout,
478        mime: base_mime(&selected),
479    })
480}
481
482fn xclip_image() -> Option<ClipboardImage> {
483    for mime in ["image/png", "image/jpeg", "image/webp", "image/gif"] {
484        let data = Command::new("xclip")
485            .args(["-selection", "clipboard", "-t", mime, "-o"])
486            .stdin(Stdio::null())
487            .stdout(Stdio::piped())
488            .stderr(Stdio::null())
489            .output()
490            .ok()?;
491        if data.status.success() && !data.stdout.is_empty() {
492            return Some(ClipboardImage {
493                bytes: data.stdout,
494                mime: mime.to_owned(),
495            });
496        }
497    }
498    None
499}
500
501fn select_preferred_image_mime(types_output: &str) -> Option<String> {
502    let normalized: Vec<String> = types_output
503        .lines()
504        .map(|line| line.trim().to_ascii_lowercase())
505        .filter(|line| !line.is_empty())
506        .collect();
507    for preferred in ["image/png", "image/jpeg", "image/webp", "image/gif"] {
508        if let Some(matched) = normalized.iter().find(|t| t.as_str() == preferred) {
509            return Some(matched.clone());
510        }
511    }
512    normalized.into_iter().find(|t| t.starts_with("image/"))
513}
514
515/// Extension for an image MIME, re-exported from the image module.
516#[must_use]
517pub fn extension_for_image_mime_str(mime: &str) -> Option<&'static str> {
518    extension_for_image_mime(mime)
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::collections::HashMap;
525    use std::io;
526
527    type TestResult = Result<(), Box<dyn std::error::Error>>;
528
529    fn required<T>(value: Option<T>, context: &'static str) -> io::Result<T> {
530        value.ok_or_else(|| io::Error::other(context))
531    }
532
533    #[derive(Default)]
534    struct MapEnv {
535        vars: HashMap<String, String>,
536    }
537
538    impl MapEnv {
539        fn set(mut self, k: &str, v: &str) -> Self {
540            self.vars.insert(k.to_owned(), v.to_owned());
541            self
542        }
543    }
544
545    impl ClipboardEnv for MapEnv {
546        fn get(&self, name: &str) -> Option<String> {
547            self.vars.get(name).cloned()
548        }
549    }
550
551    #[test]
552    fn darwin_write_is_pbcopy() -> TestResult {
553        let env = MapEnv::default();
554        let cmd = required(
555            clipboard_write_command(ClipboardPlatform::Darwin, &env),
556            "Darwin write command",
557        )?;
558        assert_eq!(cmd.program, "pbcopy");
559        assert!(cmd.args.is_empty());
560        Ok(())
561    }
562
563    #[test]
564    fn windows_write_is_clip() -> TestResult {
565        let env = MapEnv::default();
566        let cmd = required(
567            clipboard_write_command(ClipboardPlatform::Windows, &env),
568            "Windows write command",
569        )?;
570        assert_eq!(cmd.program, "clip");
571        Ok(())
572    }
573
574    #[test]
575    fn unix_termux_wins_when_termux_version_set() -> TestResult {
576        let env = MapEnv::default().set("TERMUX_VERSION", "1.0");
577        let cmd = required(
578            clipboard_write_command(ClipboardPlatform::Unix, &env),
579            "Termux write command",
580        )?;
581        assert_eq!(cmd.program, "termux-clipboard-set");
582        Ok(())
583    }
584
585    #[test]
586    fn unix_wayland_when_wayland_display_and_session() -> TestResult {
587        let env = MapEnv::default()
588            .set("WAYLAND_DISPLAY", "wayland-0")
589            .set("XDG_SESSION_TYPE", "wayland");
590        let cmd = required(
591            clipboard_write_command(ClipboardPlatform::Unix, &env),
592            "Wayland write command",
593        )?;
594        assert_eq!(cmd.program, "wl-copy");
595        Ok(())
596    }
597
598    #[test]
599    fn unix_xclip_when_display_only_with_xsel_fallback() -> TestResult {
600        let env = MapEnv::default().set("DISPLAY", ":0");
601        let cmd = required(
602            clipboard_write_command(ClipboardPlatform::Unix, &env),
603            "X11 write command",
604        )?;
605        assert_eq!(cmd.program, "xclip");
606        assert_eq!(cmd.args, vec!["-selection", "clipboard"]);
607        let (fallback_prog, fallback_args) = required(cmd.fallback, "X11 write fallback")?;
608        assert_eq!(fallback_prog, "xsel");
609        assert_eq!(fallback_args, vec!["--clipboard", "--input"]);
610        Ok(())
611    }
612
613    #[test]
614    fn unix_no_display_returns_none_forcing_osc52() {
615        let env = MapEnv::default();
616        assert!(clipboard_write_command(ClipboardPlatform::Unix, &env).is_none());
617    }
618
619    #[test]
620    fn osc52_encodes_small_text() -> TestResult {
621        let seq = required(osc52_encode("hi"), "OSC 52 sequence")?;
622        assert!(seq.starts_with("\x1b]52;c;"));
623        assert!(seq.ends_with('\x07'));
624        Ok(())
625    }
626
627    #[test]
628    fn osc52_rejects_oversized_payload() {
629        let big = "a".repeat(MAX_OSC52_ENCODED_LENGTH * 3 / 4 + 1);
630        assert!(osc52_encode(&big).is_none());
631    }
632
633    #[test]
634    fn is_remote_detects_ssh_and_mosh() {
635        let ssh = MapEnv::default().set("SSH_CONNECTION", "1.2.3.4");
636        assert!(is_remote_session(&ssh));
637        let mosh = MapEnv::default().set("MOSH_CONNECTION", "1");
638        assert!(is_remote_session(&mosh));
639        let local = MapEnv::default();
640        assert!(!is_remote_session(&local));
641    }
642
643    #[test]
644    fn read_argv_matches_platform() -> TestResult {
645        let env = MapEnv::default();
646        let darwin = required(
647            clipboard_read_command(ClipboardPlatform::Darwin, &env),
648            "Darwin read command",
649        )?;
650        assert_eq!(darwin.program, "pbpaste");
651        let win = required(
652            clipboard_read_command(ClipboardPlatform::Windows, &env),
653            "Windows read command",
654        )?;
655        assert_eq!(win.program, "powershell");
656        assert_eq!(win.args, vec!["-NoProfile", "-Command", "Get-Clipboard"]);
657        Ok(())
658    }
659
660    #[test]
661    fn read_wayland_is_wl_paste_no_newline() -> TestResult {
662        let env = MapEnv::default()
663            .set("WAYLAND_DISPLAY", "wayland-0")
664            .set("XDG_SESSION_TYPE", "wayland");
665        let cmd = required(
666            clipboard_read_command(ClipboardPlatform::Unix, &env),
667            "Wayland read command",
668        )?;
669        assert_eq!(cmd.program, "wl-paste");
670        assert_eq!(cmd.args, vec!["--no-newline"]);
671        Ok(())
672    }
673
674    #[test]
675    fn read_x11_has_xsel_fallback() -> TestResult {
676        let env = MapEnv::default().set("DISPLAY", ":0");
677        let cmd = required(
678            clipboard_read_command(ClipboardPlatform::Unix, &env),
679            "X11 read command",
680        )?;
681        assert_eq!(cmd.program, "xclip");
682        let (fb, args) = required(cmd.fallback, "X11 read fallback")?;
683        assert_eq!(fb, "xsel");
684        assert_eq!(args, vec!["--clipboard", "--output"]);
685        Ok(())
686    }
687
688    #[test]
689    fn extension_helper_matches_image_module() {
690        assert_eq!(extension_for_image_mime_str("image/png"), Some("png"));
691        assert_eq!(extension_for_image_mime_str("image/jpeg"), Some("jpg"));
692    }
693
694    #[test]
695    fn select_preferred_prefers_png() {
696        let types = "text/plain\nimage/jpeg\nimage/png\n";
697        assert_eq!(
698            select_preferred_image_mime(types).as_deref(),
699            Some("image/png")
700        );
701    }
702}