Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions crates/pm/src/model/package.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use std::env;
use std::path::{Path, PathBuf};
use utoo_ruborist::model::package_json::parse_bin_field;

use crate::util::json::load_package_json_from_path;
use crate::{service::script::ScriptService, util::linker::link};
Expand Down Expand Up @@ -67,25 +68,11 @@ impl PackageInfo {
.ok_or_else(|| anyhow::anyhow!("Failed to get package name from package.json"))?
.to_string();

// Parse binary files
let bin_files = if let Some(bin) = data.get("bin") {
if bin.is_object() {
bin.as_object()
.map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
.collect()
})
.unwrap_or_default()
} else if bin.is_string() {
let bin_path = bin.as_str().unwrap_or_default().to_string();
vec![(name.clone(), bin_path)]
} else {
Vec::new()
}
} else {
Vec::new()
};
// Parse binary files (empty bin paths are filtered out)
let bin_files = data
.get("bin")
.map(|bin| parse_bin_field(bin, &name))
.unwrap_or_default();

// Parse scripts
let scripts = Scripts {
Expand Down
7 changes: 6 additions & 1 deletion crates/pm/src/service/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use utoo_ruborist::compat::{is_cpu_compatible, is_os_compatible};
use utoo_ruborist::lock::PackageLock;
use utoo_ruborist::model::package_json::parse_bin_field;

use super::script::ScriptService;

Expand Down Expand Up @@ -156,7 +157,11 @@ impl PackageService {
// Early filtering based on ignore_scripts parameter
let has_scripts = lock_package.has_install_scripts();
let package_name = lock_package.get_name(path);
let bin_files = lock_package.parse_bin_files(&package_name);
let bin_files = lock_package
.bin
.as_ref()
.map(|bin| parse_bin_field(bin, &package_name))
.unwrap_or_default();
let has_bin = !bin_files.is_empty();

// Skip packages that don't meet the filter criteria
Expand Down
101 changes: 77 additions & 24 deletions crates/ruborist/src/model/package_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ pub struct PackageJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<HashMap<String, String>>,

/// Binary definitions
/// Binary definitions (string or object)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bin: Option<BinConfig>,
pub bin: Option<Value>,

/// Package license
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -103,24 +103,23 @@ impl WorkspacesConfig {
}
}

/// Binary configuration (can be string or map).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BinConfig {
/// Single binary with package name
Single(String),
/// Multiple binaries
Map(HashMap<String, String>),
}

impl BinConfig {
/// Get all binary entries as (name, path) pairs.
pub fn entries(&self, package_name: &str) -> Vec<(String, String)> {
match self {
BinConfig::Single(path) => vec![(package_name.to_string(), path.clone())],
BinConfig::Map(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
}
}
/// Parse bin field from JSON Value.
/// Handles both string and object formats, filters out empty paths.
pub fn parse_bin_field(bin: &Value, package_name: &str) -> Vec<(String, String)> {
bin.as_object()
.map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
.collect()
})
.or_else(|| {
bin.as_str()
.map(|s| vec![(package_name.to_string(), s.to_string())])
})
.unwrap_or_default()
.into_iter()
.filter(|(_, path)| !path.is_empty())
.collect()
}

/// License configuration (can be string or object).
Expand Down Expand Up @@ -216,6 +215,14 @@ impl PackageJson {
pub fn integrity(&self) -> Option<&str> {
self.dist.as_ref().and_then(|d| d.integrity.as_deref())
}

/// Get binary entries as (name, path) pairs.
pub fn bin_entries(&self) -> Vec<(String, String)> {
self.bin
.as_ref()
.map(|bin| parse_bin_field(bin, &self.name))
.unwrap_or_default()
}
}

#[cfg(test)]
Expand Down Expand Up @@ -275,26 +282,72 @@ mod tests {
});

let pkg = PackageJson::from_value(&value).unwrap();
let entries = pkg.bin.unwrap().entries("my-cli");
assert_eq!(
entries,
pkg.bin_entries(),
vec![("my-cli".to_string(), "./cli.js".to_string())]
);
}

#[test]
fn test_parse_bin_map() {
// Single key with custom name
let value = json!({
"name": "my-cli",
"bin": {
"a": "./index.js"
}
});
let pkg = PackageJson::from_value(&value).unwrap();
assert_eq!(
pkg.bin_entries(),
vec![("a".to_string(), "./index.js".to_string())]
);

// Multiple keys
let value = json!({
"name": "my-tools",
"bin": {
"tool1": "./bin/tool1.js",
"tool2": "./bin/tool2.js"
}
});

let pkg = PackageJson::from_value(&value).unwrap();
let entries = pkg.bin.unwrap().entries("my-tools");
let entries = pkg.bin_entries();
assert_eq!(entries.len(), 2);
assert!(
entries
.iter()
.any(|(k, v)| k == "tool1" && v == "./bin/tool1.js")
);
assert!(
entries
.iter()
.any(|(k, v)| k == "tool2" && v == "./bin/tool2.js")
);
}

#[test]
fn test_parse_bin_empty_filtered() {
// Empty bin string should be filtered out
let value = json!({
"name": "my-cli",
"bin": ""
});
let pkg = PackageJson::from_value(&value).unwrap();
assert_eq!(pkg.bin_entries().len(), 0);

// Empty bin in map should be filtered out
let value = json!({
"name": "my-tools",
"bin": {
"tool1": "./bin/tool1.js",
"empty": ""
}
});
let pkg = PackageJson::from_value(&value).unwrap();
let entries = pkg.bin_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "tool1");
}

#[test]
Expand Down
34 changes: 0 additions & 34 deletions crates/ruborist/src/model/package_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,6 @@ impl LockPackage {
.unwrap_or_else(|| "unknown".to_string())
}

/// Parse bin files from the bin field.
pub fn parse_bin_files(&self, package_name: &str) -> Vec<(String, String)> {
match &self.bin {
Some(serde_json::Value::Object(obj)) => obj
.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
.collect(),
Some(serde_json::Value::String(s)) => vec![(package_name.to_string(), s.clone())],
_ => Vec::new(),
}
}

/// Check if package has install scripts.
pub fn has_install_scripts(&self) -> bool {
self.has_install_script.unwrap_or(false)
Expand Down Expand Up @@ -517,28 +505,6 @@ mod tests {
assert_eq!(package.get_name(""), "root");
}

#[test]
fn test_parse_bin_files() {
let mut package = LockPackage {
bin: Some(json!({"cli": "bin/cli.js", "tool": "bin/tool.js"})),
..LockPackage::default()
};

let bin_files = package.parse_bin_files("test-package");
assert_eq!(bin_files.len(), 2);

package.bin = Some(json!("index.js"));
let bin_files = package.parse_bin_files("test-package");
assert_eq!(bin_files.len(), 1);
assert_eq!(
bin_files[0],
("test-package".to_string(), "index.js".to_string())
);

package.bin = None;
assert_eq!(package.parse_bin_files("test-package").len(), 0);
}

#[test]
fn test_license_field_parsing() {
let json_string = r#"{"version": "1.0.0", "license": "MIT"}"#;
Expand Down
Loading