1use std::collections::HashMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Script {
11 name: String,
12 command: String,
13 description: Option<String>,
14}
15
16impl Script {
17 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 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 pub fn name(&self) -> &str {
41 &self.name
42 }
43
44 pub fn command(&self) -> &str {
46 &self.command
47 }
48
49 pub fn description(&self) -> Option<&str> {
51 self.description.as_deref()
52 }
53
54 pub fn set_description(&mut self, description: impl Into<String>) {
56 self.description = Some(description.into());
57 }
58
59 pub fn is_lifecycle(&self) -> bool {
61 is_lifecycle_script(&self.name)
62 }
63
64 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#[derive(Debug, Clone, Default)]
92pub struct Scripts {
93 scripts: Vec<Script>,
94}
95
96impl Scripts {
97 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn from_vec(scripts: Vec<Script>) -> Self {
104 Self { scripts }
105 }
106
107 pub fn add(&mut self, script: Script) {
109 self.scripts.push(script);
110 }
111
112 pub fn len(&self) -> usize {
114 self.scripts.len()
115 }
116
117 pub fn is_empty(&self) -> bool {
119 self.scripts.is_empty()
120 }
121
122 pub fn iter(&self) -> impl Iterator<Item = &Script> {
124 self.scripts.iter()
125 }
126
127 pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Script> {
129 self.scripts.iter_mut()
130 }
131
132 pub fn as_slice(&self) -> &[Script] {
134 &self.scripts
135 }
136
137 pub fn get(&self, name: &str) -> Option<&Script> {
139 self.scripts.iter().find(|s| s.name == name)
140 }
141
142 pub fn get_mut(&mut self, name: &str) -> Option<&mut Script> {
144 self.scripts.iter_mut().find(|s| s.name == name)
145 }
146
147 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 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 pub fn names(&self) -> Vec<&str> {
178 self.scripts.iter().map(|s| s.name()).collect()
179 }
180
181 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct Package {
208 #[serde(default)]
210 pub name: String,
211
212 #[serde(default)]
214 pub version: String,
215
216 #[serde(default)]
218 pub description: Option<String>,
219
220 #[serde(default, rename = "packageManager")]
222 pub package_manager: Option<String>,
223
224 #[serde(default)]
226 pub scripts: HashMap<String, String>,
227
228 #[serde(default, rename = "scripts-info")]
230 pub scripts_info: HashMap<String, String>,
231
232 #[serde(default)]
234 pub ntl: Option<NtlConfig>,
235
236 #[serde(default)]
238 pub workspaces: Option<WorkspacesConfig>,
239}
240
241impl Package {
242 pub fn display_name(&self) -> &str {
244 if self.name.is_empty() {
245 "unnamed"
246 } else {
247 &self.name
248 }
249 }
250
251 pub fn has_scripts(&self) -> bool {
253 !self.scripts.is_empty()
254 }
255
256 pub fn script_count(&self) -> usize {
258 self.scripts.keys().filter(|k| !k.starts_with("//")).count()
259 }
260
261 pub fn is_monorepo(&self) -> bool {
263 self.workspaces.is_some()
264 }
265
266 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct NtlConfig {
283 #[serde(default)]
285 pub descriptions: HashMap<String, String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(untagged)]
291pub enum WorkspacesConfig {
292 Array(Vec<String>),
294 Object { packages: Vec<String> },
296}
297
298impl WorkspacesConfig {
299 pub fn patterns(&self) -> &[String] {
301 match self {
302 WorkspacesConfig::Array(patterns) => patterns,
303 WorkspacesConfig::Object { packages } => packages,
304 }
305 }
306}
307
308pub 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
332pub fn is_lifecycle_script(name: &str) -> bool {
334 LIFECYCLE_SCRIPTS.contains(&name)
335}
336
337fn matches_any_pattern(name: &str, patterns: &[String]) -> bool {
342 patterns
343 .iter()
344 .any(|pattern| matches_pattern(name, pattern))
345}
346
347fn matches_pattern(name: &str, pattern: &str) -> bool {
349 if !pattern.contains('*') {
350 return name == pattern;
352 }
353
354 let parts: Vec<&str> = pattern.split('*').collect();
356
357 if parts.len() == 2 {
358 let (prefix, suffix) = (parts[0], parts[1]);
360 name.starts_with(prefix) && name.ends_with(suffix)
361 } else if parts.len() == 1 {
362 true
364 } else {
365 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 if !remaining.starts_with(part) {
374 return false;
375 }
376 remaining = &remaining[part.len()..];
377 } else if i == parts.len() - 1 {
378 if !remaining.ends_with(part) {
380 return false;
381 }
382 } else {
383 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}