Skip to main content

yosh_plugin_manager/
lockfile.rs

1use std::io::Write;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5use tempfile::NamedTempFile;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct LockFile {
9    #[serde(default)]
10    pub plugin: Vec<LockEntry>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct LockEntry {
15    pub name: String,
16    pub path: String,
17    #[serde(default = "default_true")]
18    pub enabled: bool,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub capabilities: Option<Vec<String>>,
21    pub sha256: String,
22    pub source: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub version: Option<String>,
25}
26
27fn default_true() -> bool {
28    true
29}
30
31pub fn load_lockfile(path: &Path) -> Result<LockFile, String> {
32    if !path.exists() {
33        return Ok(LockFile { plugin: Vec::new() });
34    }
35    let content =
36        std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
37    toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
38}
39
40pub fn save_lockfile(path: &Path, lockfile: &LockFile) -> Result<(), String> {
41    let content =
42        toml::to_string_pretty(lockfile).map_err(|e| format!("serialize lock file: {}", e))?;
43    let parent = path
44        .parent()
45        .ok_or_else(|| format!("{}: no parent directory", path.display()))?;
46    std::fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
47    let mut tmp =
48        NamedTempFile::new_in(parent).map_err(|e| format!("{}: {}", path.display(), e))?;
49    tmp.write_all(content.as_bytes())
50        .map_err(|e| format!("{}: {}", path.display(), e))?;
51    tmp.persist(path)
52        .map_err(|e| format!("{}: {}", path.display(), e))?;
53    Ok(())
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn sample_entry() -> LockEntry {
61        LockEntry {
62            name: "git-status".into(),
63            path: "~/.yosh/plugins/git-status/libgit_status.dylib".into(),
64            enabled: true,
65            capabilities: Some(vec!["variables:read".into(), "io".into()]),
66            sha256: "abc123".into(),
67            source: "github:user/repo".into(),
68            version: Some("1.2.3".into()),
69        }
70    }
71
72    #[test]
73    fn round_trip() {
74        let dir = tempfile::tempdir().unwrap();
75        let lock_path = dir.path().join("plugins.lock");
76        let original = LockFile {
77            plugin: vec![sample_entry()],
78        };
79        save_lockfile(&lock_path, &original).unwrap();
80        let loaded = load_lockfile(&lock_path).unwrap();
81        assert_eq!(original, loaded);
82    }
83
84    #[test]
85    fn load_nonexistent_returns_empty() {
86        let lf = load_lockfile(Path::new("/nonexistent/plugins.lock")).unwrap();
87        assert!(lf.plugin.is_empty());
88    }
89
90    #[test]
91    fn save_creates_parent_dirs() {
92        let dir = tempfile::tempdir().unwrap();
93        let lock_path = dir.path().join("sub/dir/plugins.lock");
94        let lf = LockFile {
95            plugin: vec![sample_entry()],
96        };
97        save_lockfile(&lock_path, &lf).unwrap();
98        assert!(lock_path.exists());
99    }
100
101    #[test]
102    fn local_entry_without_version() {
103        let entry = LockEntry {
104            name: "local-tool".into(),
105            path: "~/.yosh/plugins/liblocal.dylib".into(),
106            enabled: true,
107            capabilities: Some(vec!["io".into()]),
108            sha256: "def456".into(),
109            source: "local:~/.yosh/plugins/liblocal.dylib".into(),
110            version: None,
111        };
112        let dir = tempfile::tempdir().unwrap();
113        let lock_path = dir.path().join("plugins.lock");
114        let original = LockFile {
115            plugin: vec![entry],
116        };
117        save_lockfile(&lock_path, &original).unwrap();
118        let loaded = load_lockfile(&lock_path).unwrap();
119        assert_eq!(original, loaded);
120        assert!(loaded.plugin[0].version.is_none());
121    }
122
123    #[test]
124    fn partial_write_only_successful_entries() {
125        let dir = tempfile::tempdir().unwrap();
126        let lock_path = dir.path().join("plugins.lock");
127        let lf = LockFile {
128            plugin: vec![sample_entry()],
129        };
130        save_lockfile(&lock_path, &lf).unwrap();
131        let loaded = load_lockfile(&lock_path).unwrap();
132        assert_eq!(loaded.plugin.len(), 1);
133    }
134
135    #[test]
136    fn empty_lockfile() {
137        let dir = tempfile::tempdir().unwrap();
138        let lock_path = dir.path().join("plugins.lock");
139        let lf = LockFile { plugin: vec![] };
140        save_lockfile(&lock_path, &lf).unwrap();
141        let loaded = load_lockfile(&lock_path).unwrap();
142        assert!(loaded.plugin.is_empty());
143    }
144}