Skip to main content

pro_core/tool/
cache.rs

1//! Tool cache management
2//!
3//! Caches installed tools in ephemeral virtual environments for fast re-execution.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use crate::{Error, Result};
10
11/// Metadata about a cached tool
12#[derive(Debug, Clone)]
13pub struct CachedTool {
14    /// Package name
15    pub package: String,
16    /// Version installed
17    pub version: Option<String>,
18    /// Path to the virtual environment
19    pub venv_path: PathBuf,
20    /// When the tool was cached
21    pub cached_at: SystemTime,
22}
23
24impl CachedTool {
25    /// Get the bin directory for this tool
26    pub fn bin_dir(&self) -> PathBuf {
27        #[cfg(unix)]
28        {
29            self.venv_path.join("bin")
30        }
31        #[cfg(windows)]
32        {
33            self.venv_path.join("Scripts")
34        }
35    }
36
37    /// Get the path to the tool's executable
38    pub fn executable(&self, name: &str) -> PathBuf {
39        #[cfg(unix)]
40        {
41            self.bin_dir().join(name)
42        }
43        #[cfg(windows)]
44        {
45            self.bin_dir().join(format!("{}.exe", name))
46        }
47    }
48
49    /// Check if the tool executable exists
50    pub fn has_executable(&self, name: &str) -> bool {
51        self.executable(name).exists()
52    }
53}
54
55/// Tool cache manager
56///
57/// Manages cached tool installations in ~/.local/share/rx/tools/
58pub struct ToolCache {
59    /// Base directory for tool caches
60    cache_dir: PathBuf,
61}
62
63impl ToolCache {
64    /// Create a new tool cache with default directory
65    pub fn new() -> Result<Self> {
66        let data_dir = dirs::data_local_dir()
67            .ok_or_else(|| Error::Config("cannot determine data directory".into()))?;
68
69        Ok(Self {
70            cache_dir: data_dir.join("rx").join("tools"),
71        })
72    }
73
74    /// Create a tool cache with a custom directory
75    pub fn with_dir(cache_dir: PathBuf) -> Self {
76        Self { cache_dir }
77    }
78
79    /// Get the cache directory
80    pub fn cache_dir(&self) -> &Path {
81        &self.cache_dir
82    }
83
84    /// Get a cached tool by package name
85    pub fn get(&self, package: &str) -> Option<CachedTool> {
86        let venv_path = self.tool_dir(package);
87
88        if !venv_path.exists() {
89            return None;
90        }
91
92        // Check if the venv is valid
93        let bin_dir = {
94            #[cfg(unix)]
95            {
96                venv_path.join("bin")
97            }
98            #[cfg(windows)]
99            {
100                venv_path.join("Scripts")
101            }
102        };
103
104        if !bin_dir.exists() {
105            return None;
106        }
107
108        // Try to get metadata
109        let cached_at = fs::metadata(&venv_path)
110            .and_then(|m| m.modified())
111            .unwrap_or(SystemTime::UNIX_EPOCH);
112
113        // Try to read version from marker file
114        let version = self.read_version(package);
115
116        Some(CachedTool {
117            package: package.to_string(),
118            version,
119            venv_path,
120            cached_at,
121        })
122    }
123
124    /// Store a tool in the cache
125    ///
126    /// Returns the path where the venv should be created
127    pub fn prepare(&self, package: &str) -> Result<PathBuf> {
128        let venv_path = self.tool_dir(package);
129
130        // Create parent directories
131        fs::create_dir_all(&self.cache_dir).map_err(Error::Io)?;
132
133        // Remove existing if present
134        if venv_path.exists() {
135            fs::remove_dir_all(&venv_path).map_err(Error::Io)?;
136        }
137
138        Ok(venv_path)
139    }
140
141    /// Record the installed version for a tool
142    pub fn record_version(&self, package: &str, version: &str) -> Result<()> {
143        let marker = self.version_marker(package);
144        fs::write(&marker, version).map_err(Error::Io)?;
145        Ok(())
146    }
147
148    /// Read the recorded version for a tool
149    fn read_version(&self, package: &str) -> Option<String> {
150        let marker = self.version_marker(package);
151        fs::read_to_string(&marker)
152            .ok()
153            .map(|s| s.trim().to_string())
154    }
155
156    /// List all cached tools
157    pub fn list(&self) -> Result<Vec<CachedTool>> {
158        let mut tools = Vec::new();
159
160        if !self.cache_dir.exists() {
161            return Ok(tools);
162        }
163
164        for entry in fs::read_dir(&self.cache_dir).map_err(Error::Io)? {
165            let entry = entry.map_err(Error::Io)?;
166            let path = entry.path();
167
168            if path.is_dir() {
169                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
170                    if let Some(tool) = self.get(name) {
171                        tools.push(tool);
172                    }
173                }
174            }
175        }
176
177        // Sort by name
178        tools.sort_by(|a, b| a.package.cmp(&b.package));
179
180        Ok(tools)
181    }
182
183    /// Clear a specific tool from the cache
184    pub fn clear(&self, package: &str) -> Result<bool> {
185        let venv_path = self.tool_dir(package);
186
187        if venv_path.exists() {
188            fs::remove_dir_all(&venv_path).map_err(Error::Io)?;
189
190            // Also remove version marker
191            let marker = self.version_marker(package);
192            let _ = fs::remove_file(&marker);
193
194            Ok(true)
195        } else {
196            Ok(false)
197        }
198    }
199
200    /// Clear all cached tools
201    pub fn clear_all(&self) -> Result<usize> {
202        let tools = self.list()?;
203        let count = tools.len();
204
205        if self.cache_dir.exists() {
206            fs::remove_dir_all(&self.cache_dir).map_err(Error::Io)?;
207        }
208
209        Ok(count)
210    }
211
212    /// Get the directory for a specific tool
213    fn tool_dir(&self, package: &str) -> PathBuf {
214        // Normalize package name (lowercase, replace - with _)
215        let normalized = package.to_lowercase().replace('-', "_");
216        self.cache_dir.join(&normalized)
217    }
218
219    /// Get the version marker file path
220    fn version_marker(&self, package: &str) -> PathBuf {
221        let normalized = package.to_lowercase().replace('-', "_");
222        self.cache_dir.join(format!(".{}.version", normalized))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use tempfile::tempdir;
230
231    #[test]
232    fn test_tool_dir_normalization() {
233        let temp_dir = tempdir().unwrap();
234        let cache = ToolCache::with_dir(temp_dir.path().to_path_buf());
235
236        let path1 = cache.tool_dir("black");
237        let path2 = cache.tool_dir("Black");
238        let path3 = cache.tool_dir("my-tool");
239        let path4 = cache.tool_dir("my_tool");
240
241        assert_eq!(path1.file_name().unwrap(), "black");
242        assert_eq!(path2.file_name().unwrap(), "black");
243        assert_eq!(path3.file_name().unwrap(), "my_tool");
244        assert_eq!(path4.file_name().unwrap(), "my_tool");
245    }
246
247    #[test]
248    fn test_list_empty() {
249        let temp_dir = tempdir().unwrap();
250        let cache = ToolCache::with_dir(temp_dir.path().to_path_buf());
251
252        let tools = cache.list().unwrap();
253        assert!(tools.is_empty());
254    }
255
256    #[test]
257    fn test_get_nonexistent() {
258        let temp_dir = tempdir().unwrap();
259        let cache = ToolCache::with_dir(temp_dir.path().to_path_buf());
260
261        assert!(cache.get("nonexistent").is_none());
262    }
263}