use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use anyhow::{Context, Result};
use serde::Deserialize;
use tokio::process::Command;
use super::{OutdatedEntry, PackageManagerResolver, read_dev_dependency_names};
pub struct Npm;
#[derive(Debug, Deserialize)]
struct ListOutput {
#[serde(default)]
dependencies: HashMap<String, ListEntry>,
}
#[derive(Debug, Deserialize)]
struct ListEntry {
#[serde(default)]
version: String,
}
#[derive(Debug, Deserialize)]
struct OutdatedOutput {
#[serde(default)]
current: String,
#[serde(default)]
latest: String,
}
impl PackageManagerResolver for Npm {
async fn list_packages(&self, dir: &Path) -> Result<Vec<(String, String, bool)>> {
let output = Command::new("npm")
.args(["list", "--json", "--depth", "0"])
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.with_context(|| format!("Failed to run 'npm list' in {}", dir.display()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Ok(Vec::new());
}
let list: ListOutput = serde_json::from_str(&stdout)
.with_context(|| format!("Failed to parse npm list JSON in {}", dir.display()))?;
let dev_deps = read_dev_dependency_names(dir);
let packages = list
.dependencies
.into_iter()
.map(|(name, entry)| {
let is_dev = dev_deps.contains(&name);
(name, entry.version, is_dev)
})
.collect();
Ok(packages)
}
async fn outdated_packages(&self, dir: &Path) -> Result<HashMap<String, OutdatedEntry>> {
let output = Command::new("npm")
.args(["outdated", "--json"])
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.with_context(|| format!("Failed to run 'npm outdated' in {}", dir.display()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Ok(HashMap::new());
}
let packages: HashMap<String, OutdatedOutput> = serde_json::from_str(&stdout)
.with_context(|| format!("Failed to parse npm outdated JSON in {}", dir.display()))?;
Ok(packages
.into_iter()
.map(|(name, entry)| {
(
name,
OutdatedEntry {
current: entry.current,
latest: entry.latest,
},
)
})
.collect())
}
async fn update_packages(&self, dir: &Path) -> Result<String> {
let output = Command::new("npm")
.args(["update"])
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.with_context(|| format!("Failed to run 'npm update' in {}", dir.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("npm update failed in {}: {}", dir.display(), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_list_output() {
let json =
r#"{"dependencies":{"react":{"version":"18.2.0"},"express":{"version":"4.18.2"}}}"#;
let list: ListOutput = serde_json::from_str(json).unwrap();
assert_eq!(list.dependencies.len(), 2);
assert_eq!(list.dependencies["react"].version, "18.2.0");
assert_eq!(list.dependencies["express"].version, "4.18.2");
}
#[test]
fn parse_list_output_empty_deps() {
let json = r#"{"dependencies":{}}"#;
let list: ListOutput = serde_json::from_str(json).unwrap();
assert!(list.dependencies.is_empty());
}
#[test]
fn parse_list_output_missing_deps_field() {
let json = r#"{}"#;
let list: ListOutput = serde_json::from_str(json).unwrap();
assert!(list.dependencies.is_empty());
}
#[test]
fn parse_outdated_output() {
let json = r#"{"current":"4.18.2","latest":"5.0.0"}"#;
let entry: OutdatedOutput = serde_json::from_str(json).unwrap();
assert_eq!(entry.current, "4.18.2");
assert_eq!(entry.latest, "5.0.0");
}
#[test]
fn parse_outdated_output_as_map() {
let json = r#"{"express":{"current":"4.18.2","latest":"5.0.0"},"react":{"current":"18.2.0","latest":"19.0.0"}}"#;
let packages: HashMap<String, OutdatedOutput> = serde_json::from_str(json).unwrap();
assert_eq!(packages.len(), 2);
assert_eq!(packages["express"].current, "4.18.2");
assert_eq!(packages["express"].latest, "5.0.0");
assert_eq!(packages["react"].current, "18.2.0");
assert_eq!(packages["react"].latest, "19.0.0");
}
#[test]
fn parse_outdated_output_defaults() {
let json = r#"{}"#;
let entry: OutdatedOutput = serde_json::from_str(json).unwrap();
assert_eq!(entry.current, "");
assert_eq!(entry.latest, "");
}
}