unity-cli 0.15.1

Rust CLI for Unity Editor automation over the Unity TCP protocol
use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

const REFERENCE_SUBDIR: &str = "UnityCsReference";
const META_FILE_NAME: &str = ".unity-cli-meta.json";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheMeta {
    pub version: String,
    pub branch: String,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub commit_sha: Option<String>,
    #[serde(default)]
    pub source_ref: Option<String>,
    #[serde(default)]
    pub exact_match: Option<bool>,
    #[serde(default)]
    pub selection_reason: Option<String>,
    pub fetched_at: String,
    pub source_url: String,
}

pub fn reference_root() -> Result<PathBuf> {
    Ok(crate::core::managed_binaries::cache_root()?.join(REFERENCE_SUBDIR))
}

pub fn version_dir(version: &str) -> Result<PathBuf> {
    if version.trim().is_empty()
        || version == "."
        || version == ".."
        || version.contains(['/', '\\', ':', '\0'])
    {
        return Err(anyhow!(
            "version must be a non-empty cache name, not a path"
        ));
    }
    Ok(reference_root()?.join(version))
}

pub fn read_meta(version: &str) -> Result<CacheMeta> {
    let path = version_dir(version)?.join(META_FILE_NAME);
    let contents =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let meta: CacheMeta = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(meta)
}

pub fn write_meta(meta: &CacheMeta) -> Result<()> {
    let dir = version_dir(&meta.version)?;
    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
    let path = dir.join(META_FILE_NAME);
    let contents = serde_json::to_string_pretty(meta)
        .with_context(|| format!("failed to serialize meta for {}", meta.version))?;
    fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

pub fn list_versions() -> Result<Vec<String>> {
    let root = reference_root()?;
    if !root.exists() {
        return Ok(Vec::new());
    }
    let mut versions = Vec::new();
    for entry in
        fs::read_dir(&root).with_context(|| format!("failed to read {}", root.display()))?
    {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        if let Some(name) = entry.file_name().to_str() {
            versions.push(name.to_string());
        }
    }
    versions.sort();
    Ok(versions)
}

pub fn gc(keep: usize, dry_run: bool) -> Result<Vec<PathBuf>> {
    let root = reference_root()?;
    if !root.exists() {
        return Ok(Vec::new());
    }
    let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new();
    for entry in
        fs::read_dir(&root).with_context(|| format!("failed to read {}", root.display()))?
    {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let meta = entry.metadata()?;
        let mtime = meta.modified()?;
        entries.push((entry.path(), mtime));
    }
    entries.sort_by_key(|entry| std::cmp::Reverse(entry.1));
    let removed: Vec<PathBuf> = entries.into_iter().skip(keep).map(|(p, _)| p).collect();
    if !dry_run {
        for path in &removed {
            fs::remove_dir_all(path)
                .with_context(|| format!("failed to remove {}", path.display()))?;
        }
    }
    Ok(removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::env;
    use std::time::{Duration, SystemTime, UNIX_EPOCH};

    struct EnvVarGuard {
        key: &'static str,
        previous: Option<String>,
    }

    impl EnvVarGuard {
        fn set(key: &'static str, value: &str) -> Self {
            let previous = env::var(key).ok();
            env::set_var(key, value);
            Self { key, previous }
        }
    }

    impl Drop for EnvVarGuard {
        fn drop(&mut self) {
            if let Some(value) = &self.previous {
                env::set_var(self.key, value);
            } else {
                env::remove_var(self.key);
            }
        }
    }

    fn unique_temp_path(label: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or(Duration::from_secs(0))
            .as_nanos();
        env::temp_dir().join(format!("unity-cli-reference-{label}-{nanos}"))
    }

    #[test]
    fn version_dir_under_cache_root() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("vdir");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let dir = version_dir("2023.2.20f1").expect("version_dir resolves");
        assert_eq!(dir, root.join("UnityCsReference").join("2023.2.20f1"));
    }

    #[test]
    fn version_dir_rejects_empty() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("vdir-empty");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let err = version_dir("").unwrap_err();
        assert!(format!("{err:#}").contains("version"));
    }

    #[test]
    fn version_dir_rejects_paths_outside_single_cache_entry() {
        for version in [
            "/tmp/other",
            "../other",
            "a/b",
            "a\\b",
            ".",
            "..",
            "C:\\other",
        ] {
            assert!(
                version_dir(version).is_err(),
                "accepted unsafe version: {version}"
            );
        }
    }

    #[test]
    fn meta_roundtrip() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("meta");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let meta = CacheMeta {
            version: "2023.2.20f1".to_string(),
            branch: "2023.2/staging".to_string(),
            commit_sha: Some("abcdef".to_string()),
            source_ref: None,
            exact_match: None,
            selection_reason: None,
            fetched_at: "2026-05-11T11:00:00Z".to_string(),
            source_url: "https://github.com/Unity-Technologies/UnityCsReference.git".to_string(),
        };
        write_meta(&meta).expect("write meta");
        let loaded = read_meta(&meta.version).expect("read meta");
        assert_eq!(loaded, meta);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn list_versions_returns_existing_dirs() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("list");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let base = root.join("UnityCsReference");
        for v in &["2022.3.0f1", "2023.2.20f1", "2024.0.0f1"] {
            fs::create_dir_all(base.join(v)).unwrap();
        }
        let listed = list_versions().expect("list_versions resolves");
        assert_eq!(
            listed,
            vec![
                "2022.3.0f1".to_string(),
                "2023.2.20f1".to_string(),
                "2024.0.0f1".to_string(),
            ]
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn gc_keeps_n_newest_and_returns_removed() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("gc");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let base = root.join("UnityCsReference");
        for v in &["a", "b", "c"] {
            fs::create_dir_all(base.join(v)).unwrap();
        }
        let removed_dry = gc(1, true).expect("gc dry_run resolves");
        assert_eq!(removed_dry.len(), 2);
        assert!(base.join("a").exists() && base.join("b").exists() && base.join("c").exists());
        let removed = gc(1, false).expect("gc actual resolves");
        assert_eq!(removed.len(), 2);
        let remaining: Vec<String> = fs::read_dir(&base)
            .unwrap()
            .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
            .collect();
        assert_eq!(remaining.len(), 1);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn list_versions_empty_when_root_missing() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("list-missing");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        assert!(list_versions().unwrap().is_empty());
    }

    #[test]
    fn gc_keep_zero_removes_all_versions() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("gc-zero");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let base = root.join("UnityCsReference");
        for v in &["x", "y"] {
            fs::create_dir_all(base.join(v)).unwrap();
        }
        let removed = gc(0, false).unwrap();
        assert_eq!(removed.len(), 2);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn gc_skips_non_directory_entries() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("gc-mixed");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let base = root.join("UnityCsReference");
        fs::create_dir_all(&base).unwrap();
        fs::create_dir_all(base.join("v1")).unwrap();
        fs::write(base.join("stray.txt"), b"ignore").unwrap();
        let removed = gc(1, true).unwrap();
        assert!(removed.is_empty());
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn gc_empty_root_returns_empty() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("gc-empty");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let removed = gc(1, true).unwrap();
        assert!(removed.is_empty());
    }

    #[test]
    fn read_meta_returns_error_when_missing() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("read-meta-miss");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let err = read_meta("not-fetched").unwrap_err();
        assert!(format!("{err:#}").contains("failed to read"));
    }

    #[test]
    fn read_meta_returns_error_for_invalid_json() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("read-meta-bad");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let dir = version_dir("bad-version").unwrap();
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join(".unity-cli-meta.json"), "not json {").unwrap();
        let err = read_meta("bad-version").unwrap_err();
        assert!(format!("{err:#}").contains("failed to parse"));
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn list_versions_skips_files_in_root() {
        let _guard = crate::test_env::env_lock()
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let root = unique_temp_path("list-files");
        let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap());
        let base = root.join("UnityCsReference");
        fs::create_dir_all(&base).unwrap();
        fs::create_dir_all(base.join("v1")).unwrap();
        fs::write(base.join("stray.txt"), b"x").unwrap();
        let listed = list_versions().unwrap();
        assert_eq!(listed, vec!["v1".to_string()]);
        let _ = fs::remove_dir_all(&root);
    }
}