1use crate::lockfile::{Lockfile, LockfileEntry};
4use crate::registry::NpmRegistry;
5use crate::resolver::{ResolvedPackage, Resolver};
6use crate::types::install_bundled_types;
7use flate2::read::GzDecoder;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tar::Archive;
12
13pub struct Installer {
15 registry: NpmRegistry,
16 node_modules: PathBuf,
17 cache_dir: PathBuf,
18}
19
20impl Installer {
21 pub fn new(project_dir: &Path) -> Self {
22 Self {
23 registry: NpmRegistry::new(),
24 node_modules: project_dir.join("node_modules"),
25 cache_dir: dirs::cache_dir()
26 .unwrap_or_else(|| PathBuf::from("."))
27 .join("otter/packages"),
28 }
29 }
30
31 pub async fn install(&mut self, package_json: &Path) -> Result<Lockfile, InstallError> {
33 let content =
35 fs::read_to_string(package_json).map_err(|e| InstallError::Io(e.to_string()))?;
36
37 let pkg: PackageJson =
38 serde_json::from_str(&content).map_err(|e| InstallError::Parse(e.to_string()))?;
39
40 let mut deps = pkg.dependencies.unwrap_or_default();
42 if let Some(dev_deps) = pkg.dev_dependencies {
43 deps.extend(dev_deps);
44 }
45
46 if deps.is_empty() {
47 println!("No dependencies to install.");
48 return Ok(Lockfile::new());
49 }
50
51 println!("Resolving {} dependencies...", deps.len());
52
53 let mut resolver = Resolver::new(std::mem::take(&mut self.registry));
55 let resolved = resolver
56 .resolve(&deps)
57 .await
58 .map_err(|e| InstallError::Resolve(e.to_string()))?;
59
60 self.registry = resolver.into_registry();
61
62 println!("Installing {} packages...", resolved.len());
63
64 fs::create_dir_all(&self.node_modules).map_err(|e| InstallError::Io(e.to_string()))?;
66
67 let mut lockfile = Lockfile::new();
69
70 for pkg in &resolved {
71 self.install_package(pkg).await?;
72
73 lockfile.packages.insert(
74 pkg.name.clone(),
75 LockfileEntry {
76 version: pkg.version.clone(),
77 resolved: pkg.tarball_url.clone(),
78 integrity: pkg.integrity.clone(),
79 dependencies: pkg.dependencies.clone(),
80 },
81 );
82 }
83
84 install_bundled_types(&self.node_modules).map_err(|e| InstallError::Io(e.to_string()))?;
86
87 let lockfile_path = package_json.parent().unwrap().join("otter.lock");
89 lockfile
90 .save(&lockfile_path)
91 .map_err(|e| InstallError::Io(e.to_string()))?;
92
93 println!(
94 "Done! Installed {} packages + bundled types.",
95 resolved.len()
96 );
97
98 Ok(lockfile)
99 }
100
101 async fn install_package(&mut self, pkg: &ResolvedPackage) -> Result<(), InstallError> {
103 let pkg_dir = if pkg.name.starts_with('@') {
104 self.node_modules.join(&pkg.name)
106 } else {
107 self.node_modules.join(&pkg.name)
108 };
109
110 let pkg_json = pkg_dir.join("package.json");
112 if pkg_json.exists()
113 && let Ok(content) = fs::read_to_string(&pkg_json)
114 && let Ok(existing) = serde_json::from_str::<PackageJson>(&content)
115 && existing.version.as_deref() == Some(&pkg.version)
116 {
117 return Ok(());
118 }
119
120 print!(" Installing {}@{}...", pkg.name, pkg.version);
121
122 let cache_path = self.get_cache_path(&pkg.name, &pkg.version);
124 let tarball = if cache_path.exists() {
125 fs::read(&cache_path).map_err(|e| InstallError::Io(e.to_string()))?
126 } else {
127 let data = self
129 .registry
130 .download_tarball(&pkg.name, &pkg.version)
131 .await
132 .map_err(|e| InstallError::Network(e.to_string()))?;
133
134 if let Some(parent) = cache_path.parent() {
136 fs::create_dir_all(parent).ok();
137 }
138 fs::write(&cache_path, &data).ok();
139
140 data
141 };
142
143 self.extract_tarball(&tarball, &pkg_dir)?;
145
146 println!(" done");
147 Ok(())
148 }
149
150 fn extract_tarball(&self, tarball: &[u8], dest: &Path) -> Result<(), InstallError> {
152 let gz = GzDecoder::new(tarball);
154 let mut archive = Archive::new(gz);
155
156 if dest.exists() {
158 fs::remove_dir_all(dest).map_err(|e| InstallError::Io(e.to_string()))?;
159 }
160 if let Some(parent) = dest.parent() {
161 fs::create_dir_all(parent).map_err(|e| InstallError::Io(e.to_string()))?;
162 }
163 fs::create_dir_all(dest).map_err(|e| InstallError::Io(e.to_string()))?;
164
165 for entry in archive
167 .entries()
168 .map_err(|e| InstallError::Io(e.to_string()))?
169 {
170 let mut entry = entry.map_err(|e| InstallError::Io(e.to_string()))?;
171
172 let path = entry.path().map_err(|e| InstallError::Io(e.to_string()))?;
173
174 let path = path.strip_prefix("package").unwrap_or(&path);
176 let full_path = dest.join(path);
177
178 if let Some(parent) = full_path.parent() {
180 fs::create_dir_all(parent).map_err(|e| InstallError::Io(e.to_string()))?;
181 }
182
183 entry
185 .unpack(&full_path)
186 .map_err(|e| InstallError::Io(e.to_string()))?;
187 }
188
189 Ok(())
190 }
191
192 fn get_cache_path(&self, name: &str, version: &str) -> PathBuf {
194 let safe_name = name.replace('/', "-").replace('@', "");
195 self.cache_dir
196 .join(format!("{}-{}.tgz", safe_name, version))
197 }
198}
199
200impl Default for Installer {
201 fn default() -> Self {
202 Self::new(Path::new("."))
203 }
204}
205
206#[derive(Debug, serde::Deserialize)]
208pub struct PackageJson {
209 pub name: Option<String>,
210 pub version: Option<String>,
211 #[serde(default)]
212 pub dependencies: Option<HashMap<String, String>>,
213 #[serde(rename = "devDependencies", default)]
214 pub dev_dependencies: Option<HashMap<String, String>>,
215}
216
217#[derive(Debug, thiserror::Error)]
218pub enum InstallError {
219 #[error("IO error: {0}")]
220 Io(String),
221
222 #[error("Parse error: {0}")]
223 Parse(String),
224
225 #[error("Network error: {0}")]
226 Network(String),
227
228 #[error("Resolve error: {0}")]
229 Resolve(String),
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_installer_new() {
238 let installer = Installer::new(Path::new("/tmp/test"));
239 assert_eq!(
240 installer.node_modules,
241 PathBuf::from("/tmp/test/node_modules")
242 );
243 }
244
245 #[test]
246 fn test_cache_path() {
247 let installer = Installer::new(Path::new("/tmp/test"));
248
249 let path = installer.get_cache_path("lodash", "4.17.21");
250 assert!(path.to_string_lossy().contains("lodash-4.17.21.tgz"));
251
252 let scoped = installer.get_cache_path("@types/node", "18.0.0");
253 assert!(scoped.to_string_lossy().contains("types-node-18.0.0.tgz"));
254 }
255
256 #[test]
257 fn test_package_json_parse() {
258 let json = r#"{
259 "name": "test-project",
260 "version": "1.0.0",
261 "dependencies": {
262 "lodash": "^4.17.0"
263 },
264 "devDependencies": {
265 "typescript": "^5.0.0"
266 }
267 }"#;
268
269 let pkg: PackageJson = serde_json::from_str(json).unwrap();
270 assert_eq!(pkg.name, Some("test-project".to_string()));
271 assert!(pkg.dependencies.is_some());
272 assert!(pkg.dev_dependencies.is_some());
273 }
274}