npm_run_scripts/package/
types.rs

1//! Type definitions for package.json parsing.
2
3use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8/// A script defined in package.json.
9#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Script {
11    name: String,
12    command: String,
13    description: Option<String>,
14}
15
16impl Script {
17    /// Create a new script.
18    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
19        Self {
20            name: name.into(),
21            command: command.into(),
22            description: None,
23        }
24    }
25
26    /// Create a new script with a description.
27    pub fn with_description(
28        name: impl Into<String>,
29        command: impl Into<String>,
30        description: impl Into<String>,
31    ) -> Self {
32        Self {
33            name: name.into(),
34            command: command.into(),
35            description: Some(description.into()),
36        }
37    }
38
39    /// Get the script name.
40    pub fn name(&self) -> &str {
41        &self.name
42    }
43
44    /// Get the script command.
45    pub fn command(&self) -> &str {
46        &self.command
47    }
48
49    /// Get the script description.
50    pub fn description(&self) -> Option<&str> {
51        self.description.as_deref()
52    }
53
54    /// Set the description.
55    pub fn set_description(&mut self, description: impl Into<String>) {
56        self.description = Some(description.into());
57    }
58
59    /// Check if this is a lifecycle script.
60    pub fn is_lifecycle(&self) -> bool {
61        is_lifecycle_script(&self.name)
62    }
63
64    /// Check if this is a pre/post script for another script.
65    pub fn is_hook_for(&self, script_name: &str) -> bool {
66        self.name == format!("pre{script_name}") || self.name == format!("post{script_name}")
67    }
68}
69
70impl fmt::Debug for Script {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.debug_struct("Script")
73            .field("name", &self.name)
74            .field("command", &self.command)
75            .field("description", &self.description)
76            .finish()
77    }
78}
79
80impl fmt::Display for Script {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        if let Some(desc) = &self.description {
83            write!(f, "{}: {} ({})", self.name, self.command, desc)
84        } else {
85            write!(f, "{}: {}", self.name, self.command)
86        }
87    }
88}
89
90/// Collection of scripts from a project.
91#[derive(Debug, Clone, Default)]
92pub struct Scripts {
93    scripts: Vec<Script>,
94}
95
96impl Scripts {
97    /// Create a new empty scripts collection.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Create from a vector of scripts.
103    pub fn from_vec(scripts: Vec<Script>) -> Self {
104        Self { scripts }
105    }
106
107    /// Add a script to the collection.
108    pub fn add(&mut self, script: Script) {
109        self.scripts.push(script);
110    }
111
112    /// Get the number of scripts.
113    pub fn len(&self) -> usize {
114        self.scripts.len()
115    }
116
117    /// Check if the collection is empty.
118    pub fn is_empty(&self) -> bool {
119        self.scripts.is_empty()
120    }
121
122    /// Get an iterator over the scripts.
123    pub fn iter(&self) -> impl Iterator<Item = &Script> {
124        self.scripts.iter()
125    }
126
127    /// Get a mutable iterator over the scripts.
128    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Script> {
129        self.scripts.iter_mut()
130    }
131
132    /// Get the scripts as a slice.
133    pub fn as_slice(&self) -> &[Script] {
134        &self.scripts
135    }
136
137    /// Get a script by name.
138    pub fn get(&self, name: &str) -> Option<&Script> {
139        self.scripts.iter().find(|s| s.name == name)
140    }
141
142    /// Get a mutable script by name.
143    pub fn get_mut(&mut self, name: &str) -> Option<&mut Script> {
144        self.scripts.iter_mut().find(|s| s.name == name)
145    }
146
147    /// Filter out lifecycle scripts.
148    pub fn without_lifecycle(&self) -> Self {
149        Self {
150            scripts: self
151                .scripts
152                .iter()
153                .filter(|s| !s.is_lifecycle())
154                .cloned()
155                .collect(),
156        }
157    }
158
159    /// Filter out scripts matching the given patterns.
160    /// Supports glob patterns with '*' wildcard.
161    pub fn without_matching(&self, patterns: &[String]) -> Self {
162        if patterns.is_empty() {
163            return self.clone();
164        }
165
166        Self {
167            scripts: self
168                .scripts
169                .iter()
170                .filter(|s| !matches_any_pattern(s.name(), patterns))
171                .cloned()
172                .collect(),
173        }
174    }
175
176    /// Get script names as a vector.
177    pub fn names(&self) -> Vec<&str> {
178        self.scripts.iter().map(|s| s.name()).collect()
179    }
180
181    /// Sort scripts alphabetically by name.
182    pub fn sort_alphabetically(&mut self) {
183        self.scripts.sort_by(|a, b| a.name.cmp(&b.name));
184    }
185}
186
187impl IntoIterator for Scripts {
188    type Item = Script;
189    type IntoIter = std::vec::IntoIter<Script>;
190
191    fn into_iter(self) -> Self::IntoIter {
192        self.scripts.into_iter()
193    }
194}
195
196impl<'a> IntoIterator for &'a Scripts {
197    type Item = &'a Script;
198    type IntoIter = std::slice::Iter<'a, Script>;
199
200    fn into_iter(self) -> Self::IntoIter {
201        self.scripts.iter()
202    }
203}
204
205/// Parsed package.json structure.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct Package {
208    /// Package name.
209    #[serde(default)]
210    pub name: String,
211
212    /// Package version.
213    #[serde(default)]
214    pub version: String,
215
216    /// Package description.
217    #[serde(default)]
218    pub description: Option<String>,
219
220    /// Package manager specification (e.g., "pnpm@8.0.0").
221    #[serde(default, rename = "packageManager")]
222    pub package_manager: Option<String>,
223
224    /// Raw scripts object.
225    #[serde(default)]
226    pub scripts: HashMap<String, String>,
227
228    /// Scripts info for descriptions.
229    #[serde(default, rename = "scripts-info")]
230    pub scripts_info: HashMap<String, String>,
231
232    /// NTL configuration (for descriptions).
233    #[serde(default)]
234    pub ntl: Option<NtlConfig>,
235
236    /// Workspaces configuration.
237    #[serde(default)]
238    pub workspaces: Option<WorkspacesConfig>,
239}
240
241impl Package {
242    /// Get the package name, or "unnamed" if not set.
243    pub fn display_name(&self) -> &str {
244        if self.name.is_empty() {
245            "unnamed"
246        } else {
247            &self.name
248        }
249    }
250
251    /// Check if this package has any scripts.
252    pub fn has_scripts(&self) -> bool {
253        !self.scripts.is_empty()
254    }
255
256    /// Get the number of scripts.
257    pub fn script_count(&self) -> usize {
258        self.scripts.keys().filter(|k| !k.starts_with("//")).count()
259    }
260
261    /// Check if this is a monorepo (has workspaces).
262    pub fn is_monorepo(&self) -> bool {
263        self.workspaces.is_some()
264    }
265
266    /// Extract the package manager name from the packageManager field.
267    pub fn package_manager_name(&self) -> Option<&str> {
268        self.package_manager
269            .as_ref()
270            .map(|pm| pm.split('@').next().unwrap_or(pm))
271    }
272}
273
274impl fmt::Display for Package {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        write!(f, "{}@{}", self.display_name(), self.version)
277    }
278}
279
280/// NTL configuration structure.
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct NtlConfig {
283    /// Script descriptions.
284    #[serde(default)]
285    pub descriptions: HashMap<String, String>,
286}
287
288/// Workspaces configuration.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(untagged)]
291pub enum WorkspacesConfig {
292    /// Simple array of glob patterns.
293    Array(Vec<String>),
294    /// Object with packages field.
295    Object { packages: Vec<String> },
296}
297
298impl WorkspacesConfig {
299    /// Get the workspace patterns.
300    pub fn patterns(&self) -> &[String] {
301        match self {
302            WorkspacesConfig::Array(patterns) => patterns,
303            WorkspacesConfig::Object { packages } => packages,
304        }
305    }
306}
307
308/// Lifecycle scripts that are hidden by default.
309pub const LIFECYCLE_SCRIPTS: &[&str] = &[
310    "preinstall",
311    "install",
312    "postinstall",
313    "preuninstall",
314    "uninstall",
315    "postuninstall",
316    "prepublish",
317    "prepublishOnly",
318    "publish",
319    "postpublish",
320    "preversion",
321    "version",
322    "postversion",
323    "prepack",
324    "pack",
325    "postpack",
326    "prepare",
327    "preshrinkwrap",
328    "shrinkwrap",
329    "postshrinkwrap",
330];
331
332/// Check if a script name is a lifecycle script.
333pub fn is_lifecycle_script(name: &str) -> bool {
334    LIFECYCLE_SCRIPTS.contains(&name)
335}
336
337/// Check if a name matches any of the given patterns.
338/// Supports simple glob patterns:
339/// - `*` matches any sequence of characters
340/// - Exact match if no wildcards
341fn matches_any_pattern(name: &str, patterns: &[String]) -> bool {
342    patterns
343        .iter()
344        .any(|pattern| matches_pattern(name, pattern))
345}
346
347/// Check if a name matches a simple glob pattern.
348fn matches_pattern(name: &str, pattern: &str) -> bool {
349    if !pattern.contains('*') {
350        // Exact match
351        return name == pattern;
352    }
353
354    // Simple glob matching with * wildcard
355    let parts: Vec<&str> = pattern.split('*').collect();
356
357    if parts.len() == 2 {
358        // Single wildcard
359        let (prefix, suffix) = (parts[0], parts[1]);
360        name.starts_with(prefix) && name.ends_with(suffix)
361    } else if parts.len() == 1 {
362        // Just a wildcard (matches everything)
363        true
364    } else {
365        // Multiple wildcards - do a more complex match
366        let mut remaining = name;
367        for (i, part) in parts.iter().enumerate() {
368            if part.is_empty() {
369                continue;
370            }
371            if i == 0 {
372                // First part must be a prefix
373                if !remaining.starts_with(part) {
374                    return false;
375                }
376                remaining = &remaining[part.len()..];
377            } else if i == parts.len() - 1 {
378                // Last part must be a suffix
379                if !remaining.ends_with(part) {
380                    return false;
381                }
382            } else {
383                // Middle parts must exist somewhere
384                if let Some(pos) = remaining.find(part) {
385                    remaining = &remaining[pos + part.len()..];
386                } else {
387                    return false;
388                }
389            }
390        }
391        true
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_script_display() {
401        let script = Script::new("dev", "vite");
402        assert_eq!(format!("{script}"), "dev: vite");
403
404        let script_with_desc = Script::with_description("build", "vite build", "Build for prod");
405        assert_eq!(
406            format!("{script_with_desc}"),
407            "build: vite build (Build for prod)"
408        );
409    }
410
411    #[test]
412    fn test_script_is_lifecycle() {
413        let dev = Script::new("dev", "vite");
414        assert!(!dev.is_lifecycle());
415
416        let install = Script::new("postinstall", "husky install");
417        assert!(install.is_lifecycle());
418    }
419
420    #[test]
421    fn test_script_is_hook_for() {
422        let prebuild = Script::new("prebuild", "echo 'before build'");
423        assert!(prebuild.is_hook_for("build"));
424        assert!(!prebuild.is_hook_for("test"));
425
426        let posttest = Script::new("posttest", "echo 'after test'");
427        assert!(posttest.is_hook_for("test"));
428    }
429
430    #[test]
431    fn test_scripts_collection() {
432        let mut scripts = Scripts::new();
433        scripts.add(Script::new("dev", "vite"));
434        scripts.add(Script::new("build", "vite build"));
435
436        assert_eq!(scripts.len(), 2);
437        assert!(!scripts.is_empty());
438        assert!(scripts.get("dev").is_some());
439        assert!(scripts.get("unknown").is_none());
440    }
441
442    #[test]
443    fn test_scripts_names() {
444        let mut scripts = Scripts::new();
445        scripts.add(Script::new("build", "vite build"));
446        scripts.add(Script::new("dev", "vite"));
447
448        let names = scripts.names();
449        assert!(names.contains(&"dev"));
450        assert!(names.contains(&"build"));
451    }
452
453    #[test]
454    fn test_package_display_name() {
455        let pkg = Package {
456            name: "my-app".to_string(),
457            version: "1.0.0".to_string(),
458            ..Default::default()
459        };
460        assert_eq!(pkg.display_name(), "my-app");
461
462        let unnamed = Package::default();
463        assert_eq!(unnamed.display_name(), "unnamed");
464    }
465
466    #[test]
467    fn test_package_manager_name() {
468        let pkg = Package {
469            package_manager: Some("pnpm@8.0.0".to_string()),
470            ..Default::default()
471        };
472        assert_eq!(pkg.package_manager_name(), Some("pnpm"));
473
474        let pkg_no_version = Package {
475            package_manager: Some("yarn".to_string()),
476            ..Default::default()
477        };
478        assert_eq!(pkg_no_version.package_manager_name(), Some("yarn"));
479    }
480
481    #[test]
482    fn test_lifecycle_scripts() {
483        assert!(is_lifecycle_script("preinstall"));
484        assert!(is_lifecycle_script("postpublish"));
485        assert!(is_lifecycle_script("prepare"));
486        assert!(!is_lifecycle_script("dev"));
487        assert!(!is_lifecycle_script("build"));
488        assert!(!is_lifecycle_script("test"));
489    }
490}