plavno 0.3.1

A lightweight terminal music player for Linux, macOS and Windows with service waves and library shuffle
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{fs, path::PathBuf, process::Command, time::SystemTime};

struct ConfigDir(PathBuf);

impl ConfigDir {
    fn new() -> Self {
        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
        let suffix = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let index = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let path = std::env::temp_dir().join(format!(
            "plavno-services-{}-{suffix}-{index}",
            std::process::id()
        ));
        fs::create_dir(&path).unwrap();
        Self(path.canonicalize().unwrap())
    }
    fn command(&self) -> Command {
        let mut command = Command::new(env!("CARGO_BIN_EXE_plavno"));
        command
            .env("XDG_CONFIG_HOME", &self.0)
            .env_remove("YANDEX_MUSIC_TOKEN");
        command
    }
}

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

#[test]
fn version_is_available_without_an_account_or_terminal() {
    let config = ConfigDir::new();
    for flag in ["--version", "-V"] {
        let output = config.command().arg(flag).output().unwrap();
        assert!(output.status.success());
        assert_eq!(
            String::from_utf8(output.stdout).unwrap(),
            format!("plavno {}\n", env!("CARGO_PKG_VERSION"))
        );
        assert!(output.stderr.is_empty());
    }
}

#[test]
fn sources_lists_yandex_with_and_without_a_connected_account() {
    let config = ConfigDir::new();
    for connected in [false, true] {
        let mut command = Command::new(env!("CARGO_BIN_EXE_plavno"));
        command
            .arg("sources")
            .env("XDG_CONFIG_HOME", &config.0)
            .env_remove("YANDEX_MUSIC_TOKEN");
        if connected {
            command.env("YANDEX_MUSIC_TOKEN", "fixture-token");
        }
        let output = command.output().unwrap();
        assert!(output.status.success());
        let text = String::from_utf8(output.stdout).unwrap();
        let keys: Vec<_> = text
            .lines()
            .map(|line| line.split_whitespace().next().unwrap())
            .collect();
        assert_eq!(
            keys,
            [
                "yandex",
                "soundcloud",
                "spotify",
                "vk",
                "ytmusic",
                "local",
                "mixed"
            ]
        );
        let yandex = text.lines().next().unwrap();
        assert!(yandex.contains("Yandex Music"));
        assert!(yandex.contains(if connected {
            "Ready"
        } else {
            "Connect with: plavno login yandex"
        }));
    }
}

#[test]
fn local_directory_is_saved_privately_listed_and_can_be_overridden_without_changing_settings() {
    let config = ConfigDir::new();
    let music = config.0.join("music with spaces");
    let other = config.0.join("other");
    fs::create_dir(&music).unwrap();
    fs::create_dir(&other).unwrap();
    let output = config.command().arg("local").arg(&music).output().unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let settings = config.0.join("plavno/local.json");
    let saved = fs::read(&settings).unwrap();
    #[cfg(unix)]
    assert_eq!(
        fs::metadata(&settings).unwrap().permissions().mode() & 0o777,
        0o600
    );
    let value: serde_json::Value = serde_json::from_slice(&saved).unwrap();
    assert_eq!(
        value["directory"].as_str().unwrap(),
        music.to_str().unwrap()
    );
    let sources = config.command().arg("sources").output().unwrap();
    assert!(sources.status.success());
    let sources = String::from_utf8(sources.stdout).unwrap();
    assert!(sources.lines().any(|line| line.starts_with("local ")
        && line.contains(music.to_str().unwrap())
        && line.contains("recursive shuffle")));

    for source in ["local", "mixed"] {
        let output = config
            .command()
            .args(["--source", source, "--directory"])
            .arg(&other)
            .output()
            .unwrap();
        assert!(!output.status.success());
        let error = String::from_utf8(output.stderr).unwrap();
        if source == "local" {
            assert!(error.contains("interactive terminal"), "{error}");
        } else {
            assert!(error.contains("not connected"), "{error}");
        }
        assert_eq!(fs::read(&settings).unwrap(), saved);
    }
    let output = config
        .command()
        .arg("--directory")
        .arg(&other)
        .output()
        .unwrap();
    assert!(String::from_utf8_lossy(&output.stderr).contains("interactive terminal"));
    fs::remove_dir(&music).unwrap();
    let output = config.command().arg("sources").output().unwrap();
    assert!(output.status.success());
    assert!(String::from_utf8_lossy(&output.stdout).contains("Directory unavailable"));
}

#[test]
fn invalid_local_configuration_and_arguments_do_not_break_other_sources_or_overwrite_settings() {
    let config = ConfigDir::new();
    assert!(
        !config
            .command()
            .arg("local")
            .arg(config.0.join("missing"))
            .output()
            .unwrap()
            .status
            .success()
    );
    assert!(!config.0.join("plavno/local.json").exists());
    assert!(
        !config
            .command()
            .arg("--directory")
            .output()
            .unwrap()
            .status
            .success()
    );
    let output = config
        .command()
        .args(["--file", "track.flac", "--directory", "."])
        .output()
        .unwrap();
    assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be combined"));
    config
        .command()
        .arg("local")
        .arg(&config.0)
        .output()
        .unwrap();
    fs::write(config.0.join("plavno/local.json"), b"{}").unwrap();
    let output = config
        .command()
        .arg("sources")
        .env("YANDEX_MUSIC_TOKEN", "fixture-token")
        .output()
        .unwrap();
    assert!(output.status.success());
    let sources = String::from_utf8(output.stdout).unwrap();
    assert!(sources.contains("Yandex Music     Ready"));
    assert!(sources.contains("Invalid local directory settings"));
}