diff --git a/Cargo.lock b/Cargo.lock index f973334..a225c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,9 +2441,13 @@ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" name = "libee" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "slint", "slint-build", + "tempfile", "thiserror 2.0.21", + "which", ] [[package]] @@ -5117,6 +5121,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index e43012b..a52d428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" slint = { version = "1", features = ["unstable-winit-030"] } thiserror = "2" +which = "8.0.6" [build-dependencies] slint-build = "1" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src/managers/manifest.rs b/src/managers/manifest.rs new file mode 100644 index 0000000..c1372dd --- /dev/null +++ b/src/managers/manifest.rs @@ -0,0 +1,109 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde::Deserialize; +use serde::de::IgnoredAny; + +/// The parts of a Node `package.json` Libee cares about. Shared by npm, pnpm and Yarn. +#[derive(Debug, Default, Deserialize)] +pub struct Manifest { + pub description: Option, + #[serde(default, deserialize_with = "homepage")] + pub homepage: Option, + #[serde(default)] + bin: Option, + #[serde(default)] + dependencies: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Bin { + Named(BTreeMap), + Single(IgnoredAny), +} + +/// Some manifests put an object or a list here instead of a URL; ignore those. +fn homepage<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Ok(match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::String(url) => Some(url), + _ => None, + }) +} + +impl Manifest { + /// A missing or broken manifest must not abort the whole scan. + pub fn read(package_dir: &Path) -> Option { + let content = fs::read_to_string(package_dir.join("package.json")).ok()?; + serde_json::from_str(&content).ok() + } + + pub fn binaries(&self, package_name: &str) -> Vec { + match &self.bin { + Some(Bin::Named(bins)) => bins.keys().cloned().collect(), + Some(Bin::Single(_)) => { + let unscoped = package_name.rsplit('/').next().unwrap_or(package_name); + vec![unscoped.to_owned()] + } + None => Vec::new(), + } + } + + pub fn dependencies(&self) -> Vec { + self.dependencies.keys().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::Manifest; + + fn manifest(json: &str) -> Manifest { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), json).unwrap(); + Manifest::read(dir.path()).unwrap() + } + + #[test] + fn reads_named_binaries_and_dependencies() { + let manifest = manifest( + r#"{ "description": "TypeScript compiler", "homepage": "https://www.typescriptlang.org", + "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, + "dependencies": { "semver": "^7.0.0" } }"#, + ); + + assert_eq!(manifest.description.as_deref(), Some("TypeScript compiler")); + assert_eq!( + manifest.homepage.as_deref(), + Some("https://www.typescriptlang.org") + ); + assert_eq!(manifest.binaries("typescript"), ["tsc", "tsserver"]); + assert_eq!(manifest.dependencies(), ["semver"]); + } + + #[test] + fn single_binary_is_named_after_the_unscoped_package() { + let manifest = manifest(r#"{ "bin": "./cli.js" }"#); + + assert_eq!(manifest.binaries("@angular/cli"), ["cli"]); + } + + #[test] + fn ignores_non_string_homepage() { + let manifest = manifest(r#"{ "homepage": { "url": "x" } }"#); + + assert!(manifest.homepage.is_none()); + } + + #[test] + fn missing_or_invalid_manifest_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert!(Manifest::read(dir.path()).is_none()); + + fs::write(dir.path().join("package.json"), "{ not json").unwrap(); + assert!(Manifest::read(dir.path()).is_none()); + } +} diff --git a/src/managers/mod.rs b/src/managers/mod.rs index 07568df..7e6e44a 100644 --- a/src/managers/mod.rs +++ b/src/managers/mod.rs @@ -1,7 +1,10 @@ mod adapter; mod error; +mod manifest; +mod npm; mod runner; pub use adapter::{Capabilities, PackageManager}; pub use error::ManagerError; +pub use npm::Npm; pub use runner::{CommandOutput, CommandRunner, SystemRunner}; diff --git a/src/managers/npm.rs b/src/managers/npm.rs new file mode 100644 index 0000000..ded94c6 --- /dev/null +++ b/src/managers/npm.rs @@ -0,0 +1,266 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +use super::manifest::Manifest; +use super::{Capabilities, CommandRunner, ManagerError, PackageManager, SystemRunner}; +use crate::models::{Cache, Manager, ManagerKind, Package}; +use crate::shared::CommandSpec; + +pub struct Npm { + runner: R, +} + +impl Npm { + pub fn new() -> Self { + Self::with_runner(SystemRunner) + } +} + +impl Default for Npm { + fn default() -> Self { + Self::new() + } +} + +impl Npm { + pub fn with_runner(runner: R) -> Self { + Self { runner } + } + + fn command(args: &[&str]) -> CommandSpec { + CommandSpec::new(ManagerKind::Npm.executable()).args(args.iter().copied()) + } + + fn query(&self, args: &[&str]) -> Result { + Ok(self.runner.stdout(&Self::command(args))?.trim().to_owned()) + } + + fn global_root(&self) -> Result { + self.query(&["root", "-g"]).map(PathBuf::from) + } + + /// `npm ls` exits with an error when the tree has problems (missing peers, + /// invalid versions) but still prints the full JSON, so the output is kept. + fn list_global(&self) -> Result { + let command = Self::command(&["ls", "-g", "--json", "--depth=0"]); + let output = self.runner.run(&command)?; + + if output.stdout.trim().is_empty() { + return Err(ManagerError::CommandFailed { + command: command.to_string(), + code: output.code, + stderr: output.stderr.trim().to_owned(), + }); + } + + serde_json::from_str(&output.stdout).map_err(|error| ManagerError::UnexpectedOutput { + command: command.to_string(), + reason: error.to_string(), + }) + } +} + +#[derive(Debug, Deserialize)] +struct GlobalList { + #[serde(default)] + dependencies: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct GlobalEntry { + version: Option, +} + +impl PackageManager for Npm { + fn kind(&self) -> ManagerKind { + ManagerKind::Npm + } + + fn capabilities(&self) -> Capabilities { + Capabilities { + packages: true, + cache: true, + uninstall: true, + dry_run: true, + } + } + + fn detect(&self) -> Result, ManagerError> { + let version = match self.query(&["--version"]) { + Ok(version) => version, + Err(ManagerError::NotInstalled(_)) => return Ok(None), + Err(error) => return Err(error), + }; + + let config_files = self + .query(&["config", "get", "userconfig"]) + .map(PathBuf::from) + .into_iter() + .filter(|path| path.is_file()) + .collect(); + + Ok(Some(Manager { + kind: ManagerKind::Npm, + version, + executable: which::which(ManagerKind::Npm.executable()) + .unwrap_or_else(|_| PathBuf::from(ManagerKind::Npm.executable())), + install_root: self.global_root().ok(), + config_files, + })) + } + + fn packages(&self) -> Result, ManagerError> { + let root = self.global_root()?; + let list = self.list_global()?; + + Ok(list + .dependencies + .into_iter() + .map(|(name, entry)| { + let path = root.join(&name); + let manifest = Manifest::read(&path).unwrap_or_default(); + + let mut package = + Package::new(ManagerKind::Npm, &name, entry.version.unwrap_or_default()); + package.binaries = manifest.binaries(&name); + package.dependencies = manifest.dependencies(); + package.description = manifest.description; + package.homepage = manifest.homepage; + package.explicit = Some(true); + package.installed_at = fs::metadata(&path).and_then(|meta| meta.created()).ok(); + package.path = path; + package + }) + .collect()) + } + + fn cache(&self) -> Result, ManagerError> { + let location = PathBuf::from(self.query(&["config", "get", "cache"])?); + + Ok(Some(Cache { + manager: ManagerKind::Npm, + location, + size: None, + cleanup: Some(Self::command(&["cache", "clean", "--force"])), + })) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use super::Npm; + use crate::managers::runner::fake::FakeRunner; + use crate::managers::{ManagerError, PackageManager}; + + const LS_GLOBAL: &str = r#"{ + "name": "lib", + "dependencies": { + "@angular/cli": { "version": "19.0.0", "overridden": false }, + "corepack": { "version": "0.36.0", "overridden": false }, + "typescript": { "version": "7.0.2", "overridden": false } + } + }"#; + + fn npm_with_root(root: &Path) -> Npm { + let runner = FakeRunner::default() + .with("npm --version", "11.19.0\n") + .with("npm root -g", &format!("{}\n", root.display())) + .with("npm config get userconfig", "/nowhere/.npmrc\n") + .with("npm config get cache", "/home/dev/.npm\n") + .with("npm ls -g --json --depth=0", LS_GLOBAL); + Npm::with_runner(runner) + } + + #[test] + fn missing_npm_is_not_detected() { + let npm = Npm::with_runner(FakeRunner::default()); + + assert!(npm.detect().unwrap().is_none()); + } + + #[test] + fn detects_version_and_global_root() { + let npm = npm_with_root(Path::new("/home/dev/.nvm/lib/node_modules")); + + let manager = npm.detect().unwrap().unwrap(); + + assert_eq!(manager.version, "11.19.0"); + assert_eq!( + manager.install_root.unwrap(), + Path::new("/home/dev/.nvm/lib/node_modules") + ); + assert!(manager.config_files.is_empty()); + } + + #[test] + fn lists_global_packages_with_manifest_details() { + let root = tempfile::tempdir().unwrap(); + let typescript = root.path().join("typescript"); + fs::create_dir_all(&typescript).unwrap(); + fs::write( + typescript.join("package.json"), + r#"{ "description": "TypeScript compiler", "bin": { "tsc": "bin/tsc" }, "dependencies": { "semver": "^7" } }"#, + ) + .unwrap(); + + let packages = npm_with_root(root.path()).packages().unwrap(); + + let names: Vec<_> = packages + .iter() + .map(|package| package.name.as_str()) + .collect(); + assert_eq!(names, ["@angular/cli", "corepack", "typescript"]); + + let typescript = &packages[2]; + assert_eq!(typescript.version, "7.0.2"); + assert_eq!(typescript.binaries, ["tsc"]); + assert_eq!(typescript.dependencies, ["semver"]); + assert_eq!( + typescript.description.as_deref(), + Some("TypeScript compiler") + ); + assert_eq!(typescript.path, root.path().join("typescript")); + assert!(typescript.installed_at.is_some()); + + assert!(packages[0].binaries.is_empty(), "no manifest on disk"); + } + + #[test] + fn keeps_the_list_when_npm_reports_tree_problems() { + let mut runner = FakeRunner::default().with("npm root -g", "/tmp/libee-none\n"); + runner = + runner.failing_with_stdout("npm ls -g --json --depth=0", 1, LS_GLOBAL, "ELSPROBLEMS"); + + let packages = Npm::with_runner(runner).packages().unwrap(); + + assert_eq!(packages.len(), 3); + } + + #[test] + fn rejects_unexpected_output() { + let runner = FakeRunner::default() + .with("npm root -g", "/tmp\n") + .with("npm ls -g --json --depth=0", "not json"); + + let error = Npm::with_runner(runner).packages().unwrap_err(); + + assert!(matches!(error, ManagerError::UnexpectedOutput { .. })); + } + + #[test] + fn exposes_cache_with_cleanup_command() { + let cache = npm_with_root(Path::new("/tmp")).cache().unwrap().unwrap(); + + assert_eq!(cache.location, Path::new("/home/dev/.npm")); + assert_eq!( + cache.cleanup.unwrap().to_string(), + "npm cache clean --force" + ); + } +} diff --git a/src/managers/runner.rs b/src/managers/runner.rs index f2d7d85..ce65911 100644 --- a/src/managers/runner.rs +++ b/src/managers/runner.rs @@ -77,11 +77,21 @@ pub(crate) mod fake { self } - pub fn failing(mut self, command: &str, code: i32, stderr: &str) -> Self { + pub fn failing(self, command: &str, code: i32, stderr: &str) -> Self { + self.failing_with_stdout(command, code, "", stderr) + } + + pub fn failing_with_stdout( + mut self, + command: &str, + code: i32, + stdout: &str, + stderr: &str, + ) -> Self { let output = CommandOutput { success: false, code: Some(code), - stdout: String::new(), + stdout: stdout.to_owned(), stderr: stderr.to_owned(), }; self.outputs.insert(command.to_owned(), output); diff --git a/src/models/mod.rs b/src/models/mod.rs index b1cd705..843343e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,4 +4,4 @@ mod package; pub use cache::Cache; pub use manager::{Manager, ManagerKind}; -pub use package::{Package, Scope, Status}; +pub use package::{Package, PackageKind, Scope, Status}; diff --git a/src/models/package.rs b/src/models/package.rs index 8c0f3c9..b16bfcf 100644 --- a/src/models/package.rs +++ b/src/models/package.rs @@ -23,12 +23,26 @@ pub enum Status { Unknown, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum PackageKind { + /// Command line tool or library. + #[default] + Tool, + /// Desktop application, e.g. a Homebrew cask. + App, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Package { pub name: String, pub version: String, pub manager: ManagerKind, + pub kind: PackageKind, pub scope: Scope, + pub description: Option, + pub homepage: Option, + /// `Some(false)` when it was only pulled in as a dependency of something else. + pub explicit: Option, pub path: PathBuf, pub size: Option, /// Commands put on the PATH by this package, e.g. `tsc` for `typescript`. @@ -44,7 +58,11 @@ impl Package { name: name.into(), version: version.into(), manager, + kind: PackageKind::default(), scope: Scope::Global, + description: None, + homepage: None, + explicit: None, path: PathBuf::new(), size: None, binaries: Vec::new(), @@ -57,7 +75,7 @@ impl Package { #[cfg(test)] mod tests { - use super::{Package, Scope, Status}; + use super::{Package, PackageKind, Scope, Status}; use crate::models::ManagerKind; #[test] @@ -67,6 +85,8 @@ mod tests { assert_eq!(package.name, "typescript"); assert_eq!(package.scope, Scope::Global); assert_eq!(package.status, Status::Unknown); + assert_eq!(package.kind, PackageKind::Tool); + assert!(package.explicit.is_none()); assert!(package.size.is_none()); } }