1use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use crate::{Error, Result};
10
11#[derive(Debug, Clone)]
13pub struct CachedTool {
14 pub package: String,
16 pub version: Option<String>,
18 pub venv_path: PathBuf,
20 pub cached_at: SystemTime,
22}
23
24impl CachedTool {
25 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 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 pub fn has_executable(&self, name: &str) -> bool {
51 self.executable(name).exists()
52 }
53}
54
55pub struct ToolCache {
59 cache_dir: PathBuf,
61}
62
63impl ToolCache {
64 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 pub fn with_dir(cache_dir: PathBuf) -> Self {
76 Self { cache_dir }
77 }
78
79 pub fn cache_dir(&self) -> &Path {
81 &self.cache_dir
82 }
83
84 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 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 let cached_at = fs::metadata(&venv_path)
110 .and_then(|m| m.modified())
111 .unwrap_or(SystemTime::UNIX_EPOCH);
112
113 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 pub fn prepare(&self, package: &str) -> Result<PathBuf> {
128 let venv_path = self.tool_dir(package);
129
130 fs::create_dir_all(&self.cache_dir).map_err(Error::Io)?;
132
133 if venv_path.exists() {
135 fs::remove_dir_all(&venv_path).map_err(Error::Io)?;
136 }
137
138 Ok(venv_path)
139 }
140
141 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 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 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 tools.sort_by(|a, b| a.package.cmp(&b.package));
179
180 Ok(tools)
181 }
182
183 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 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 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 fn tool_dir(&self, package: &str) -> PathBuf {
214 let normalized = package.to_lowercase().replace('-', "_");
216 self.cache_dir.join(&normalized)
217 }
218
219 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}