rvpm 3.15.1

Fast Neovim plugin manager with pre-compiled loader and merge optimization
//! `<config_root>/rvpm.lock` の read/write ヘルパー。
//!
//! lockfile は「config.toml に記載されたプラグインを、どの commit に pin して
//! 実在化しているか」を記録する TOML ファイル。`lazy.nvim` の `lazy-lock.json`
//! と同じ思想で、dotfiles に checkin することで複数マシンでプラグイン状態を
//! 同一化できる。
//!
//! 設計:
//! - **形式**: TOML (rvpm 全体の TOML 文化と揃える)、`[[plugins]]` 配列でプラグイン単位に
//!   `{ name, url, commit }` を並べる。map 形式より:
//!   - 順序を明示保証できる (sort by name で決定論的な diff)
//!   - 将来 `branch`, `tag`, `timestamp` などを壊さず追加できる
//! - **場所**: `<config_root>/rvpm.lock` (= `~/.config/rvpm/<appname>/rvpm.lock` by default)。
//!   dotfiles で config.toml と一緒に管理される前提。
//! - **atomic write**: `update_log.rs`/`merge_conflicts.rs` と同じ tempfile + rename。
//! - **malformed / missing は empty LockFile にフォールバック** (resilience)。
//!
//! スキーマ例:
//! ```toml
//! # rvpm.lock — generated by rvpm. Commit this alongside config.toml for reproducibility.
//! version = 1
//!
//! [[plugins]]
//! name = "snacks.nvim"
//! url = "folke/snacks.nvim"
//! commit = "abc123def456..."
//!
//! [[plugins]]
//! name = "telescope.nvim"
//! url = "nvim-telescope/telescope.nvim"
//! commit = "789abcdef012..."
//! ```

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

/// 現行スキーマバージョン。壊すときは bump + migration を足す。
pub const CURRENT_VERSION: u32 = 1;

/// lockfile ルート構造。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockFile {
    /// スキーマバージョン。将来のフォーマット変更時に参照する。
    #[serde(default = "default_version")]
    pub version: u32,
    /// プラグインエントリ。保存時は name でソートされる。
    #[serde(default, rename = "plugins")]
    pub plugins: Vec<LockEntry>,
}

fn default_version() -> u32 {
    CURRENT_VERSION
}

impl Default for LockFile {
    fn default() -> Self {
        Self {
            version: CURRENT_VERSION,
            plugins: Vec::new(),
        }
    }
}

/// 1 プラグイン分の lock エントリ。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
    /// `Plugin::display_name()` 由来の名前 (lookup キー)。
    pub name: String,
    /// config.toml に書かれた元 URL (short or full)。
    /// 同じ display_name に別 URL が紛れ込んだ時の検知用。
    pub url: String,
    /// pin する commit hash (40 桁の full SHA)。
    pub commit: String,
}

impl LockFile {
    /// `path` から lockfile を読み出す。
    /// - ファイルが存在しない → `LockFile::default()` (empty)
    /// - パース失敗 → warn を stderr に出して `LockFile::default()`
    pub fn load(path: &Path) -> Self {
        let content = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
            Err(e) => {
                eprintln!(
                    "\u{26a0} lockfile: failed to read {}: {} (treating as empty)",
                    path.display(),
                    e
                );
                return Self::default();
            }
        };
        let lock = toml::from_str::<LockFile>(&content).unwrap_or_else(|e| {
            eprintln!(
                "\u{26a0} lockfile: failed to parse {}: {} (treating as empty)",
                path.display(),
                e
            );
            Self::default()
        });
        // 未対応の schema version は empty 扱いに落とす。古い rvpm で新フォーマットを
        // silently 誤解釈して間違った commit に pin するのを防ぐ (malformed 時と同じ
        // fallback パターン)。
        if lock.version != CURRENT_VERSION {
            eprintln!(
                "\u{26a0} lockfile: unsupported version {} in {} (expected {}; treating as empty)",
                lock.version,
                path.display(),
                CURRENT_VERSION
            );
            return Self::default();
        }
        lock
    }

    /// `path` に atomic write する。書き出し前に `plugins` を name で安定 sort
    /// することで、同じ内容なら毎回同じバイト列になり dotfile diff が最小化される。
    pub fn save(&mut self, path: &Path) -> Result<()> {
        self.plugins.sort_by(|a, b| a.name.cmp(&b.name));
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create_dir_all {}", parent.display()))?;
        }
        let header = "# rvpm.lock — generated by rvpm. Commit this alongside config.toml for reproducibility.\n# Do not edit by hand; run `rvpm sync` or `rvpm update` to refresh.\n\n";
        let body = toml::to_string_pretty(self).context("serialize lockfile")?;
        let content = format!("{}{}", header, body);
        let parent = path.parent().unwrap_or(Path::new("."));
        let tmp = tempfile::Builder::new()
            .prefix(".rvpm-lock-")
            .suffix(".tmp")
            .tempfile_in(parent)
            .with_context(|| format!("create tempfile in {}", parent.display()))?;
        std::fs::write(tmp.path(), content.as_bytes())
            .with_context(|| format!("write tempfile {}", tmp.path().display()))?;
        tmp.persist(path)
            .map_err(|e| anyhow::anyhow!("rename tempfile to {}: {}", path.display(), e))?;
        Ok(())
    }

    /// `name` で既存エントリを検索。
    pub fn find(&self, name: &str) -> Option<&LockEntry> {
        self.plugins.iter().find(|e| e.name == name)
    }

    /// 既存 entry があれば置換、無ければ append。
    /// `name` をキーとして扱う (URL 変更は上書きで反映される)。
    pub fn upsert(&mut self, entry: LockEntry) {
        if let Some(slot) = self.plugins.iter_mut().find(|e| e.name == entry.name) {
            *slot = entry;
        } else {
            self.plugins.push(entry);
        }
    }

    /// `names` に含まれない entry を drop する (config.toml から外されたプラグイン)。
    /// 戻り値は drop された名前のリスト (sort 順)。
    pub fn retain_by_names(&mut self, names: &std::collections::HashSet<String>) -> Vec<String> {
        let mut dropped: Vec<String> = self
            .plugins
            .iter()
            .filter(|e| !names.contains(&e.name))
            .map(|e| e.name.clone())
            .collect();
        dropped.sort();
        self.plugins.retain(|e| names.contains(&e.name));
        dropped
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn mk(name: &str, commit: &str) -> LockEntry {
        LockEntry {
            name: name.to_string(),
            url: format!("owner/{}", name),
            commit: commit.to_string(),
        }
    }

    #[test]
    fn test_load_missing_returns_default() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nonexistent.lock");
        let lock = LockFile::load(&path);
        assert_eq!(lock.version, CURRENT_VERSION);
        assert!(lock.plugins.is_empty());
    }

    #[test]
    fn test_load_malformed_returns_default() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("bad.lock");
        std::fs::write(&path, "this is not valid toml = = =").unwrap();
        let lock = LockFile::load(&path);
        assert!(lock.plugins.is_empty());
    }

    #[test]
    fn test_save_then_load_roundtrip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("rvpm.lock");
        let mut lock = LockFile::default();
        lock.plugins.push(mk("a", "111"));
        lock.plugins.push(mk("b", "222"));
        lock.save(&path).unwrap();

        let loaded = LockFile::load(&path);
        assert_eq!(loaded.version, CURRENT_VERSION);
        assert_eq!(loaded.plugins.len(), 2);
        assert_eq!(loaded.plugins[0].name, "a");
        assert_eq!(loaded.plugins[1].name, "b");
    }

    #[test]
    fn test_save_sorts_by_name() {
        // 入力順が逆でも name 順で保存される (dotfile diff を最小化)。
        let dir = tempdir().unwrap();
        let path = dir.path().join("rvpm.lock");
        let mut lock = LockFile::default();
        lock.plugins.push(mk("zeta", "z"));
        lock.plugins.push(mk("alpha", "a"));
        lock.plugins.push(mk("mid", "m"));
        lock.save(&path).unwrap();

        let loaded = LockFile::load(&path);
        let names: Vec<_> = loaded.plugins.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, vec!["alpha", "mid", "zeta"]);
    }

    #[test]
    fn test_save_contains_header_comment() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("rvpm.lock");
        LockFile::default().save(&path).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(
            content.starts_with("# rvpm.lock"),
            "must have banner comment for users"
        );
        assert!(
            content.contains("rvpm sync"),
            "comment should point users at the command to regenerate"
        );
    }

    #[test]
    fn test_upsert_inserts_new_entry() {
        let mut lock = LockFile::default();
        lock.upsert(mk("a", "111"));
        assert_eq!(lock.plugins.len(), 1);
        assert_eq!(lock.plugins[0].commit, "111");
    }

    #[test]
    fn test_upsert_replaces_existing_entry() {
        let mut lock = LockFile::default();
        lock.upsert(mk("a", "111"));
        lock.upsert(mk("a", "222"));
        assert_eq!(lock.plugins.len(), 1);
        assert_eq!(lock.plugins[0].commit, "222");
    }

    #[test]
    fn test_find_returns_matching_entry() {
        let mut lock = LockFile::default();
        lock.upsert(mk("a", "111"));
        lock.upsert(mk("b", "222"));
        assert_eq!(lock.find("a").map(|e| e.commit.as_str()), Some("111"));
        assert_eq!(lock.find("missing"), None);
    }

    #[test]
    fn test_retain_by_names_drops_orphans_and_returns_dropped() {
        let mut lock = LockFile::default();
        lock.upsert(mk("a", "1"));
        lock.upsert(mk("b", "2"));
        lock.upsert(mk("c", "3"));

        let mut keep = std::collections::HashSet::new();
        keep.insert("a".to_string());
        keep.insert("c".to_string());

        let dropped = lock.retain_by_names(&keep);
        assert_eq!(dropped, vec!["b".to_string()]);
        let kept: Vec<_> = lock.plugins.iter().map(|e| e.name.as_str()).collect();
        assert!(kept.contains(&"a"));
        assert!(kept.contains(&"c"));
        assert_eq!(kept.len(), 2);
    }

    #[test]
    fn test_load_rejects_future_schema_version() {
        // 古い rvpm バイナリが未対応の新フォーマット lockfile を読み込んでも、
        // silently 誤解釈して commit を間違えないよう empty にフォールバックする。
        let dir = tempdir().unwrap();
        let path = dir.path().join("future.lock");
        std::fs::write(
            &path,
            r#"version = 99
[[plugins]]
name = "x"
url = "o/x"
commit = "abc"
"#,
        )
        .unwrap();
        let lock = LockFile::load(&path);
        assert!(
            lock.plugins.is_empty(),
            "unsupported version must fall back to empty lockfile"
        );
        assert_eq!(
            lock.version, CURRENT_VERSION,
            "default always reports current schema version"
        );
    }

    #[test]
    fn test_load_accepts_minimal_schema_without_version() {
        // 古い手書き lockfile ("version" key が無い) でも壊れず読める。
        // `#[serde(default)]` 相当で CURRENT_VERSION に fallback することを担保。
        let dir = tempdir().unwrap();
        let path = dir.path().join("legacy.lock");
        std::fs::write(
            &path,
            r#"[[plugins]]
name = "only"
url = "x/y"
commit = "abc"
"#,
        )
        .unwrap();
        let lock = LockFile::load(&path);
        assert_eq!(lock.plugins.len(), 1);
        assert_eq!(lock.version, CURRENT_VERSION);
    }
}