nils-common 0.7.3

Library crate for nils-common in the nils-cli workspace.
Documentation
use crate::process;
use std::io;
use std::io::Write;
use std::process::{Command, Stdio};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardTool {
    Pbcopy,
    WlCopy,
    Xclip,
    Xsel,
    Clip,
}

impl ClipboardTool {
    fn program(self) -> &'static str {
        match self {
            Self::Pbcopy => "pbcopy",
            Self::WlCopy => "wl-copy",
            Self::Xclip => "xclip",
            Self::Xsel => "xsel",
            Self::Clip => "clip",
        }
    }

    fn args(self) -> &'static [&'static str] {
        match self {
            Self::Pbcopy => &[],
            Self::WlCopy => &[],
            Self::Xclip => &["-selection", "clipboard"],
            Self::Xsel => &["--clipboard", "--input"],
            Self::Clip => &[],
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct ClipboardPolicy<'a> {
    pub tool_order: &'a [ClipboardTool],
}

impl<'a> ClipboardPolicy<'a> {
    pub const fn new(tool_order: &'a [ClipboardTool]) -> Self {
        Self { tool_order }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardOutcome {
    Copied(ClipboardTool),
    SkippedNoTool,
    SkippedFailure,
}

pub fn copy_best_effort(text: &str, policy: &ClipboardPolicy<'_>) -> ClipboardOutcome {
    let mut saw_tool = false;

    for tool in policy.tool_order {
        let program = tool.program();
        if !process::cmd_exists(program) {
            continue;
        }

        saw_tool = true;
        if pipe_to_command(program, tool.args(), text).is_ok() {
            return ClipboardOutcome::Copied(*tool);
        }
    }

    if saw_tool {
        ClipboardOutcome::SkippedFailure
    } else {
        ClipboardOutcome::SkippedNoTool
    }
}

fn pipe_to_command(cmd: &str, args: &[&str], text: &str) -> io::Result<()> {
    let mut child = Command::new(cmd)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()?;

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(text.as_bytes())?;
    }

    let status = child.wait()?;
    if status.success() {
        Ok(())
    } else {
        Err(io::Error::other(format!(
            "{cmd} exited with status {status}"
        )))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir, prepend_path};
    use pretty_assertions::assert_eq;
    use std::fs;
    use tempfile::TempDir;

    fn write_clipboard_stub(stubs: &StubBinDir, name: &str) {
        stubs.write_exe(
            name,
            &format!(
                r#"#!/bin/bash
set -euo pipefail
chosen="${{CLIPBOARD_TOOL_CHOSEN:?CLIPBOARD_TOOL_CHOSEN is required}}"
payload="${{CLIPBOARD_PAYLOAD_OUT:?CLIPBOARD_PAYLOAD_OUT is required}}"
printf "%s" "{name}" > "$chosen"
/bin/cat > "$payload"
"#
            ),
        );
    }

    #[test]
    fn copy_best_effort_respects_tool_order() {
        let lock = GlobalStateLock::new();
        let stubs = StubBinDir::new();
        write_clipboard_stub(&stubs, "pbcopy");
        write_clipboard_stub(&stubs, "wl-copy");
        write_clipboard_stub(&stubs, "xclip");
        write_clipboard_stub(&stubs, "xsel");

        let out_dir = TempDir::new().expect("tempdir");
        let chosen_path = out_dir.path().join("chosen.txt");
        let payload_path = out_dir.path().join("payload.txt");

        let _path_guard = prepend_path(&lock, stubs.path());
        let chosen_path_str = chosen_path.to_string_lossy();
        let payload_path_str = payload_path.to_string_lossy();
        let _chosen_guard = EnvGuard::set(&lock, "CLIPBOARD_TOOL_CHOSEN", chosen_path_str.as_ref());
        let _payload_guard =
            EnvGuard::set(&lock, "CLIPBOARD_PAYLOAD_OUT", payload_path_str.as_ref());

        let tool_order = [
            ClipboardTool::Pbcopy,
            ClipboardTool::WlCopy,
            ClipboardTool::Xclip,
            ClipboardTool::Xsel,
        ];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::Copied(ClipboardTool::Pbcopy));
        assert_eq!(
            fs::read_to_string(chosen_path).expect("chosen"),
            "pbcopy".to_string()
        );
        assert_eq!(fs::read_to_string(payload_path).expect("payload"), "hello");
    }

    #[test]
    fn copy_best_effort_honors_custom_order() {
        let lock = GlobalStateLock::new();
        let stubs = StubBinDir::new();
        write_clipboard_stub(&stubs, "xclip");
        write_clipboard_stub(&stubs, "xsel");

        let out_dir = TempDir::new().expect("tempdir");
        let chosen_path = out_dir.path().join("chosen.txt");
        let payload_path = out_dir.path().join("payload.txt");

        let _path_guard = prepend_path(&lock, stubs.path());
        let chosen_path_str = chosen_path.to_string_lossy();
        let payload_path_str = payload_path.to_string_lossy();
        let _chosen_guard = EnvGuard::set(&lock, "CLIPBOARD_TOOL_CHOSEN", chosen_path_str.as_ref());
        let _payload_guard =
            EnvGuard::set(&lock, "CLIPBOARD_PAYLOAD_OUT", payload_path_str.as_ref());

        let tool_order = [ClipboardTool::Xsel, ClipboardTool::Xclip];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::Copied(ClipboardTool::Xsel));
        assert_eq!(fs::read_to_string(chosen_path).expect("chosen"), "xsel");
        assert_eq!(fs::read_to_string(payload_path).expect("payload"), "hello");
    }

    #[test]
    fn copy_best_effort_returns_skipped_when_no_tool_exists() {
        let tool_order: [ClipboardTool; 0] = [];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::SkippedNoTool);
    }

    #[test]
    fn copy_best_effort_falls_back_when_first_tool_fails() {
        let lock = GlobalStateLock::new();
        let stubs = StubBinDir::new();
        stubs.write_exe(
            "pbcopy",
            r#"#!/bin/bash
exit 1
"#,
        );
        stubs.write_exe(
            "xclip",
            r#"#!/bin/bash
set -euo pipefail
payload="${CLIPBOARD_PAYLOAD_OUT:?CLIPBOARD_PAYLOAD_OUT is required}"
/bin/cat > "$payload"
"#,
        );

        let out_dir = TempDir::new().expect("tempdir");
        let payload_path = out_dir.path().join("payload.txt");

        let _path_guard = prepend_path(&lock, stubs.path());
        let payload_path_str = payload_path.to_string_lossy();
        let _payload_guard =
            EnvGuard::set(&lock, "CLIPBOARD_PAYLOAD_OUT", payload_path_str.as_ref());

        let tool_order = [ClipboardTool::Pbcopy, ClipboardTool::Xclip];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::Copied(ClipboardTool::Xclip));
        assert_eq!(fs::read_to_string(payload_path).expect("payload"), "hello");
    }

    #[test]
    fn copy_best_effort_returns_skipped_failure_when_all_tools_fail() {
        let lock = GlobalStateLock::new();
        let stubs = StubBinDir::new();
        stubs.write_exe(
            "pbcopy",
            r#"#!/bin/bash
exit 1
"#,
        );
        stubs.write_exe(
            "xclip",
            r#"#!/bin/bash
exit 2
"#,
        );

        let _path_guard = prepend_path(&lock, stubs.path());

        let tool_order = [ClipboardTool::Pbcopy, ClipboardTool::Xclip];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::SkippedFailure);
    }

    #[test]
    fn copy_best_effort_passes_tool_specific_args() {
        let lock = GlobalStateLock::new();
        let stubs = StubBinDir::new();
        stubs.write_exe(
            "xclip",
            r#"#!/bin/bash
set -euo pipefail
args_out="${CLIPBOARD_ARGS_OUT:?CLIPBOARD_ARGS_OUT is required}"
printf "%s" "$*" > "$args_out"
cat > /dev/null
"#,
        );

        let out_dir = TempDir::new().expect("tempdir");
        let args_path = out_dir.path().join("args.txt");

        let _path_guard = prepend_path(&lock, stubs.path());
        let args_path_str = args_path.to_string_lossy();
        let _args_guard = EnvGuard::set(&lock, "CLIPBOARD_ARGS_OUT", args_path_str.as_ref());

        let tool_order = [ClipboardTool::Xclip];
        let outcome = copy_best_effort("hello", &ClipboardPolicy::new(&tool_order));
        assert_eq!(outcome, ClipboardOutcome::Copied(ClipboardTool::Xclip));
        assert_eq!(
            fs::read_to_string(args_path).expect("args"),
            "-selection clipboard"
        );
    }
}