ferry-cli 0.1.6

The ferry command for terminal-native LAN file transfer with QUIC, discovery, resume, TUI, and JSON output.
Documentation
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use serde_json::Value;

const FINGERPRINT: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

#[test]
fn version_json_mode_emits_one_newline_delimited_event() {
    let output = ferry_command()
        .args(["--json", "version"])
        .output()
        .expect("ferry version runs");

    assert!(
        output.status.success(),
        "ferry version failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8(output.stdout).expect("stdout is utf8");
    let lines = stdout.lines().collect::<Vec<_>>();
    assert_eq!(lines.len(), 1);

    let event: Value = serde_json::from_str(lines[0]).expect("version event is json");
    assert_eq!(event["event"], "version");
    assert!(event["version"].as_str().is_some());
}

#[test]
fn peers_json_mode_persists_and_lists_trusted_peer() {
    let config = IsolatedConfig::new("peers-json");

    let trust = ferry_command()
        .envs(config.envs())
        .args(["--json", "peers", "trust", FINGERPRINT])
        .output()
        .expect("ferry peers trust runs");
    assert!(
        trust.status.success(),
        "ferry peers trust failed: {}",
        String::from_utf8_lossy(&trust.stderr)
    );
    let trusted: Value = serde_json::from_slice(&trust.stdout).expect("peer trusted event is json");
    assert_eq!(trusted["event"], "peer_trusted");
    assert_eq!(trusted["fingerprint"], FINGERPRINT);

    let list = ferry_command()
        .envs(config.envs())
        .args(["--json", "--no-discovery", "peers"])
        .output()
        .expect("ferry peers runs");
    assert!(
        list.status.success(),
        "ferry peers failed: {}",
        String::from_utf8_lossy(&list.stderr)
    );
    let listed: Value = serde_json::from_slice(&list.stdout).expect("peer list event is json");
    assert_eq!(listed["event"], "peers");
    assert_eq!(listed["peers"][0]["fingerprint"], FINGERPRINT);
    assert_eq!(listed["peers"][0]["trust_state"], "trusted");
}

#[test]
fn json_mode_emits_error_event_on_failure() {
    let output = ferry_command()
        .args([
            "--json",
            "--no-discovery",
            "send",
            "127.0.0.1",
            "missing.txt",
        ])
        .output()
        .expect("ferry send runs");

    assert!(!output.status.success(), "ferry send should fail");
    let stdout = String::from_utf8(output.stdout).expect("stdout is utf8");
    let lines = stdout.lines().collect::<Vec<_>>();
    assert_eq!(lines.len(), 1);

    let event: Value = serde_json::from_str(lines[0]).expect("error event is json");
    assert_eq!(event["event"], "error");
    assert_eq!(event["exit_code"], 2);
    assert!(
        event["message"]
            .as_str()
            .is_some_and(|message| { message.contains("invalid peer address") })
    );
}

fn ferry_command() -> Command {
    Command::new(env!("CARGO_BIN_EXE_ferry"))
}

struct IsolatedConfig {
    root: PathBuf,
}

impl IsolatedConfig {
    fn new(name: &str) -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock is after unix epoch")
            .as_nanos();
        let root =
            std::env::temp_dir().join(format!("fileferry-{name}-{}-{nonce}", std::process::id()));
        std::fs::create_dir_all(root.join("home")).expect("home dir created");
        std::fs::create_dir_all(root.join("xdg-config")).expect("xdg config dir created");
        std::fs::create_dir_all(root.join("appdata")).expect("appdata dir created");
        Self { root }
    }

    fn envs(&self) -> Vec<(&'static str, PathBuf)> {
        vec![
            ("HOME", self.root.join("home")),
            ("USERPROFILE", self.root.join("home")),
            ("XDG_CONFIG_HOME", self.root.join("xdg-config")),
            ("APPDATA", self.root.join("appdata")),
        ]
    }
}

impl Drop for IsolatedConfig {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.root);
    }
}