use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Script {
name: String,
command: String,
description: Option<String>,
}
impl Script {
pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
name: name.into(),
command: command.into(),
description: None,
}
}
pub fn with_description(
name: impl Into<String>,
command: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
command: command.into(),
description: Some(description.into()),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn command(&self) -> &str {
&self.command
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn set_description(&mut self, description: impl Into<String>) {
self.description = Some(description.into());
}
pub fn is_lifecycle(&self) -> bool {
is_lifecycle_script(&self.name)
}
pub fn is_hook_for(&self, script_name: &str) -> bool {
self.name == format!("pre{script_name}") || self.name == format!("post{script_name}")
}
}
impl fmt::Debug for Script {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Script")
.field("name", &self.name)
.field("command", &self.command)
.field("description", &self.description)
.finish()
}
}
impl fmt::Display for Script {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(desc) = &self.description {
write!(f, "{}: {} ({})", self.name, self.command, desc)
} else {
write!(f, "{}: {}", self.name, self.command)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Scripts {
scripts: Vec<Script>,
}
impl Scripts {
pub fn new() -> Self {
Self::default()
}
pub fn from_vec(scripts: Vec<Script>) -> Self {
Self { scripts }
}
pub fn add(&mut self, script: Script) {
self.scripts.push(script);
}
pub fn len(&self) -> usize {
self.scripts.len()
}
pub fn is_empty(&self) -> bool {
self.scripts.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Script> {
self.scripts.iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Script> {
self.scripts.iter_mut()
}
pub fn as_slice(&self) -> &[Script] {
&self.scripts
}
pub fn get(&self, name: &str) -> Option<&Script> {
self.scripts.iter().find(|s| s.name == name)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut Script> {
self.scripts.iter_mut().find(|s| s.name == name)
}
pub fn without_lifecycle(&self) -> Self {
Self {
scripts: self
.scripts
.iter()
.filter(|s| !s.is_lifecycle())
.cloned()
.collect(),
}
}
pub fn without_matching(&self, patterns: &[String]) -> Self {
if patterns.is_empty() {
return self.clone();
}
Self {
scripts: self
.scripts
.iter()
.filter(|s| !matches_any_pattern(s.name(), patterns))
.cloned()
.collect(),
}
}
pub fn names(&self) -> Vec<&str> {
self.scripts.iter().map(|s| s.name()).collect()
}
pub fn sort_alphabetically(&mut self) {
self.scripts.sort_by(|a, b| a.name.cmp(&b.name));
}
}
impl IntoIterator for Scripts {
type Item = Script;
type IntoIter = std::vec::IntoIter<Script>;
fn into_iter(self) -> Self::IntoIter {
self.scripts.into_iter()
}
}
impl<'a> IntoIterator for &'a Scripts {
type Item = &'a Script;
type IntoIter = std::slice::Iter<'a, Script>;
fn into_iter(self) -> Self::IntoIter {
self.scripts.iter()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Package {
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default, rename = "packageManager")]
pub package_manager: Option<String>,
#[serde(default)]
pub scripts: HashMap<String, String>,
#[serde(default, rename = "scripts-info")]
pub scripts_info: HashMap<String, String>,
#[serde(default)]
pub ntl: Option<NtlConfig>,
#[serde(default)]
pub workspaces: Option<WorkspacesConfig>,
}
impl Package {
pub fn display_name(&self) -> &str {
if self.name.is_empty() {
"unnamed"
} else {
&self.name
}
}
pub fn has_scripts(&self) -> bool {
!self.scripts.is_empty()
}
pub fn script_count(&self) -> usize {
self.scripts.keys().filter(|k| !k.starts_with("//")).count()
}
pub fn is_monorepo(&self) -> bool {
self.workspaces.is_some()
}
pub fn package_manager_name(&self) -> Option<&str> {
self.package_manager
.as_ref()
.map(|pm| pm.split('@').next().unwrap_or(pm))
}
}
impl fmt::Display for Package {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.display_name(), self.version)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NtlConfig {
#[serde(default)]
pub descriptions: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WorkspacesConfig {
Array(Vec<String>),
Object { packages: Vec<String> },
}
impl WorkspacesConfig {
pub fn patterns(&self) -> &[String] {
match self {
WorkspacesConfig::Array(patterns) => patterns,
WorkspacesConfig::Object { packages } => packages,
}
}
}
pub const LIFECYCLE_SCRIPTS: &[&str] = &[
"preinstall",
"install",
"postinstall",
"preuninstall",
"uninstall",
"postuninstall",
"prepublish",
"prepublishOnly",
"publish",
"postpublish",
"preversion",
"version",
"postversion",
"prepack",
"pack",
"postpack",
"prepare",
"preshrinkwrap",
"shrinkwrap",
"postshrinkwrap",
];
pub fn is_lifecycle_script(name: &str) -> bool {
LIFECYCLE_SCRIPTS.contains(&name)
}
fn matches_any_pattern(name: &str, patterns: &[String]) -> bool {
patterns
.iter()
.any(|pattern| matches_pattern(name, pattern))
}
fn matches_pattern(name: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return name == pattern;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let (prefix, suffix) = (parts[0], parts[1]);
name.starts_with(prefix) && name.ends_with(suffix)
} else if parts.len() == 1 {
true
} else {
let mut remaining = name;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
} else {
if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_script_display() {
let script = Script::new("dev", "vite");
assert_eq!(format!("{script}"), "dev: vite");
let script_with_desc = Script::with_description("build", "vite build", "Build for prod");
assert_eq!(
format!("{script_with_desc}"),
"build: vite build (Build for prod)"
);
}
#[test]
fn test_script_is_lifecycle() {
let dev = Script::new("dev", "vite");
assert!(!dev.is_lifecycle());
let install = Script::new("postinstall", "husky install");
assert!(install.is_lifecycle());
}
#[test]
fn test_script_is_hook_for() {
let prebuild = Script::new("prebuild", "echo 'before build'");
assert!(prebuild.is_hook_for("build"));
assert!(!prebuild.is_hook_for("test"));
let posttest = Script::new("posttest", "echo 'after test'");
assert!(posttest.is_hook_for("test"));
}
#[test]
fn test_scripts_collection() {
let mut scripts = Scripts::new();
scripts.add(Script::new("dev", "vite"));
scripts.add(Script::new("build", "vite build"));
assert_eq!(scripts.len(), 2);
assert!(!scripts.is_empty());
assert!(scripts.get("dev").is_some());
assert!(scripts.get("unknown").is_none());
}
#[test]
fn test_scripts_names() {
let mut scripts = Scripts::new();
scripts.add(Script::new("build", "vite build"));
scripts.add(Script::new("dev", "vite"));
let names = scripts.names();
assert!(names.contains(&"dev"));
assert!(names.contains(&"build"));
}
#[test]
fn test_package_display_name() {
let pkg = Package {
name: "my-app".to_string(),
version: "1.0.0".to_string(),
..Default::default()
};
assert_eq!(pkg.display_name(), "my-app");
let unnamed = Package::default();
assert_eq!(unnamed.display_name(), "unnamed");
}
#[test]
fn test_package_manager_name() {
let pkg = Package {
package_manager: Some("pnpm@8.0.0".to_string()),
..Default::default()
};
assert_eq!(pkg.package_manager_name(), Some("pnpm"));
let pkg_no_version = Package {
package_manager: Some("yarn".to_string()),
..Default::default()
};
assert_eq!(pkg_no_version.package_manager_name(), Some("yarn"));
}
#[test]
fn test_lifecycle_scripts() {
assert!(is_lifecycle_script("preinstall"));
assert!(is_lifecycle_script("postpublish"));
assert!(is_lifecycle_script("prepare"));
assert!(!is_lifecycle_script("dev"));
assert!(!is_lifecycle_script("build"));
assert!(!is_lifecycle_script("test"));
}
}