otter_pm/
install.rs

1//! Package installation
2
3use 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
13/// Package installer
14pub 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    /// Install dependencies from package.json
32    pub async fn install(&mut self, package_json: &Path) -> Result<Lockfile, InstallError> {
33        // Read package.json
34        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        // Collect all dependencies
41        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        // Resolve dependencies
54        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        // Create node_modules
65        fs::create_dir_all(&self.node_modules).map_err(|e| InstallError::Io(e.to_string()))?;
66
67        // Install each package
68        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 (@types/otter, @types/node)
85        install_bundled_types(&self.node_modules).map_err(|e| InstallError::Io(e.to_string()))?;
86
87        // Write lockfile
88        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    /// Install a single package
102    async fn install_package(&mut self, pkg: &ResolvedPackage) -> Result<(), InstallError> {
103        let pkg_dir = if pkg.name.starts_with('@') {
104            // Scoped packages: node_modules/@scope/package
105            self.node_modules.join(&pkg.name)
106        } else {
107            self.node_modules.join(&pkg.name)
108        };
109
110        // Skip if already installed with correct version
111        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        // Check cache first
123        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            // Download tarball
128            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            // Cache it
135            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        // Extract tarball
144        self.extract_tarball(&tarball, &pkg_dir)?;
145
146        println!(" done");
147        Ok(())
148    }
149
150    /// Extract tarball to directory
151    fn extract_tarball(&self, tarball: &[u8], dest: &Path) -> Result<(), InstallError> {
152        // npm tarballs are gzipped
153        let gz = GzDecoder::new(tarball);
154        let mut archive = Archive::new(gz);
155
156        // Create destination (including parent for scoped packages)
157        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        // Extract entries (npm tarballs have "package/" prefix)
166        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            // Strip "package/" prefix
175            let path = path.strip_prefix("package").unwrap_or(&path);
176            let full_path = dest.join(path);
177
178            // Create parent directories
179            if let Some(parent) = full_path.parent() {
180                fs::create_dir_all(parent).map_err(|e| InstallError::Io(e.to_string()))?;
181            }
182
183            // Extract file
184            entry
185                .unpack(&full_path)
186                .map_err(|e| InstallError::Io(e.to_string()))?;
187        }
188
189        Ok(())
190    }
191
192    /// Get cache path for a package
193    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/// Minimal package.json structure
207#[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}