1use std::path::Path;
4
5use anyhow::{bail, Context, Result};
6
7use super::descriptions::extract_descriptions;
8use super::types::{Package, Script, Scripts};
9
10pub fn parse_scripts(project_dir: &Path) -> Result<Scripts> {
22 let package_json = project_dir.join("package.json");
23 let content = std::fs::read_to_string(&package_json)
24 .with_context(|| format!("Failed to read {}", package_json.display()))?;
25
26 parse_scripts_from_json(&content)
27}
28
29pub fn parse_package_json(content: &str) -> Result<Package> {
39 let json: serde_json::Value = serde_json::from_str(content).map_err(|e| {
41 let msg = format_json_error(content, &e);
42 anyhow::anyhow!("Failed to parse package.json: {msg}")
43 })?;
44
45 let package: Package =
47 serde_json::from_value(json).context("Failed to parse package.json structure")?;
48
49 Ok(package)
50}
51
52pub fn parse_scripts_from_json(content: &str) -> Result<Scripts> {
72 let package = parse_package_json(content)?;
73 extract_scripts_from_package(&package)
74}
75
76pub fn parse_scripts_required(content: &str) -> Result<Scripts> {
84 let scripts = parse_scripts_from_json(content)?;
85
86 if scripts.is_empty() {
87 bail!("No scripts defined in package.json");
88 }
89
90 Ok(scripts)
91}
92
93pub fn extract_scripts_from_package(package: &Package) -> Result<Scripts> {
95 let descriptions = extract_descriptions(package);
97
98 let mut scripts = Scripts::new();
99
100 for (name, command) in &package.scripts {
101 if name.starts_with("//") {
103 continue;
104 }
105
106 let mut script = Script::new(name, command);
107
108 if let Some(desc) = descriptions.get(name) {
110 script.set_description(desc);
111 }
112
113 scripts.add(script);
114 }
115
116 scripts.sort_alphabetically();
118
119 Ok(scripts)
120}
121
122fn format_json_error(content: &str, error: &serde_json::Error) -> String {
124 let line = error.line();
125 let column = error.column();
126
127 if let Some(error_line) = content.lines().nth(line.saturating_sub(1)) {
129 let pointer = " ".repeat(column.saturating_sub(1)) + "^";
130 format!(
131 "{}\n at line {}, column {}:\n {}\n {}",
132 error, line, column, error_line, pointer
133 )
134 } else {
135 format!("{} at line {}, column {}", error, line, column)
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_parse_basic_scripts() {
145 let json = r#"{
146 "name": "test-project",
147 "scripts": {
148 "dev": "vite",
149 "build": "vite build"
150 }
151 }"#;
152
153 let scripts = parse_scripts_from_json(json).unwrap();
154 assert_eq!(scripts.len(), 2);
155 assert!(scripts.get("dev").is_some());
156 assert_eq!(scripts.get("dev").unwrap().command(), "vite");
157 }
158
159 #[test]
160 fn test_parse_empty_scripts() {
161 let json = r#"{
162 "name": "test-project",
163 "scripts": {}
164 }"#;
165
166 let scripts = parse_scripts_from_json(json).unwrap();
167 assert!(scripts.is_empty());
168 }
169
170 #[test]
171 fn test_parse_no_scripts_field() {
172 let json = r#"{
173 "name": "test-project"
174 }"#;
175
176 let scripts = parse_scripts_from_json(json).unwrap();
177 assert!(scripts.is_empty());
178 }
179
180 #[test]
181 fn test_parse_scripts_required_fails_when_empty() {
182 let json = r#"{
183 "name": "test-project",
184 "scripts": {}
185 }"#;
186
187 let result = parse_scripts_required(json);
188 assert!(result.is_err());
189 assert!(result
190 .unwrap_err()
191 .to_string()
192 .contains("No scripts defined"));
193 }
194
195 #[test]
196 fn test_parse_invalid_json() {
197 let json = r#"{ invalid json }"#;
198
199 let result = parse_scripts_from_json(json);
200 assert!(result.is_err());
201 let err = result.unwrap_err().to_string();
202 assert!(err.contains("Failed to parse"));
203 }
204
205 #[test]
206 fn test_parse_skips_comment_entries() {
207 let json = r#"{
208 "scripts": {
209 "//dev": "This is a comment",
210 "dev": "vite",
211 "// build": "Another comment",
212 "build": "vite build"
213 }
214 }"#;
215
216 let scripts = parse_scripts_from_json(json).unwrap();
217 assert_eq!(scripts.len(), 2);
218 assert!(scripts.get("dev").is_some());
219 assert!(scripts.get("build").is_some());
220 assert!(scripts.get("//dev").is_none());
221 }
222
223 #[test]
224 fn test_parse_with_scripts_info_descriptions() {
225 let json = r#"{
226 "scripts": {
227 "dev": "vite",
228 "build": "vite build"
229 },
230 "scripts-info": {
231 "dev": "Start development server",
232 "build": "Build for production"
233 }
234 }"#;
235
236 let scripts = parse_scripts_from_json(json).unwrap();
237 assert_eq!(
238 scripts.get("dev").unwrap().description(),
239 Some("Start development server")
240 );
241 assert_eq!(
242 scripts.get("build").unwrap().description(),
243 Some("Build for production")
244 );
245 }
246
247 #[test]
248 fn test_parse_with_ntl_descriptions() {
249 let json = r#"{
250 "scripts": {
251 "test": "vitest"
252 },
253 "ntl": {
254 "descriptions": {
255 "test": "Run tests with vitest"
256 }
257 }
258 }"#;
259
260 let scripts = parse_scripts_from_json(json).unwrap();
261 assert_eq!(
262 scripts.get("test").unwrap().description(),
263 Some("Run tests with vitest")
264 );
265 }
266
267 #[test]
268 fn test_parse_with_comment_descriptions() {
269 let json = r#"{
270 "scripts": {
271 "//lint": "Run ESLint",
272 "lint": "eslint ."
273 }
274 }"#;
275
276 let scripts = parse_scripts_from_json(json).unwrap();
277 assert_eq!(
278 scripts.get("lint").unwrap().description(),
279 Some("Run ESLint")
280 );
281 }
282
283 #[test]
284 fn test_parse_scripts_sorted_alphabetically() {
285 let json = r#"{
286 "scripts": {
287 "zebra": "echo z",
288 "alpha": "echo a",
289 "middle": "echo m"
290 }
291 }"#;
292
293 let scripts = parse_scripts_from_json(json).unwrap();
294 let names: Vec<_> = scripts.iter().map(|s| s.name()).collect();
295 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
296 }
297
298 #[test]
299 fn test_parse_package_json_full() {
300 let json = r#"{
301 "name": "my-app",
302 "version": "1.0.0",
303 "description": "A test application",
304 "packageManager": "pnpm@8.0.0",
305 "scripts": {
306 "dev": "vite"
307 }
308 }"#;
309
310 let package = parse_package_json(json).unwrap();
311 assert_eq!(package.name, "my-app");
312 assert_eq!(package.version, "1.0.0");
313 assert_eq!(package.description, Some("A test application".to_string()));
314 assert_eq!(package.package_manager, Some("pnpm@8.0.0".to_string()));
315 assert!(package.has_scripts());
316 }
317
318 #[test]
319 fn test_parse_special_characters_in_script_names() {
320 let json = r#"{
321 "scripts": {
322 "build:prod": "vite build --mode production",
323 "test:unit": "vitest",
324 "lint:fix": "eslint --fix ."
325 }
326 }"#;
327
328 let scripts = parse_scripts_from_json(json).unwrap();
329 assert_eq!(scripts.len(), 3);
330 assert!(scripts.get("build:prod").is_some());
331 assert!(scripts.get("test:unit").is_some());
332 assert!(scripts.get("lint:fix").is_some());
333 }
334
335 #[test]
336 fn test_lifecycle_scripts_filtered() {
337 let json = r#"{
338 "scripts": {
339 "dev": "vite",
340 "preinstall": "echo preinstall",
341 "postinstall": "husky install",
342 "build": "vite build"
343 }
344 }"#;
345
346 let scripts = parse_scripts_from_json(json).unwrap();
347 assert_eq!(scripts.len(), 4);
348
349 let filtered = scripts.without_lifecycle();
350 assert_eq!(filtered.len(), 2);
351 assert!(filtered.get("dev").is_some());
352 assert!(filtered.get("build").is_some());
353 assert!(filtered.get("preinstall").is_none());
354 assert!(filtered.get("postinstall").is_none());
355 }
356
357 #[test]
360 fn test_parse_unicode_script_names() {
361 let json = r#"{
362 "scripts": {
363 "开发": "vite",
364 "ビルド": "vite build",
365 "тест": "vitest",
366 "développement": "vite dev"
367 }
368 }"#;
369
370 let scripts = parse_scripts_from_json(json).unwrap();
371 assert_eq!(scripts.len(), 4);
372 assert!(scripts.get("开发").is_some());
373 assert!(scripts.get("ビルド").is_some());
374 assert!(scripts.get("тест").is_some());
375 assert!(scripts.get("développement").is_some());
376 }
377
378 #[test]
379 fn test_parse_emoji_script_names() {
380 let json = r#"{
381 "scripts": {
382 "🚀": "npm start",
383 "🔧:fix": "eslint --fix .",
384 "test:🎉": "vitest"
385 }
386 }"#;
387
388 let scripts = parse_scripts_from_json(json).unwrap();
389 assert_eq!(scripts.len(), 3);
390 assert!(scripts.get("🚀").is_some());
391 assert!(scripts.get("🔧:fix").is_some());
392 assert!(scripts.get("test:🎉").is_some());
393 }
394
395 #[test]
396 fn test_parse_empty_command() {
397 let json = r#"{
398 "scripts": {
399 "empty": "",
400 "normal": "echo hello"
401 }
402 }"#;
403
404 let scripts = parse_scripts_from_json(json).unwrap();
405 assert_eq!(scripts.len(), 2);
406 assert_eq!(scripts.get("empty").unwrap().command(), "");
407 assert_eq!(scripts.get("normal").unwrap().command(), "echo hello");
408 }
409
410 #[test]
411 fn test_parse_very_long_script_name() {
412 let long_name = "a".repeat(200);
413 let json = format!(
414 r#"{{
415 "scripts": {{
416 "{}": "echo test"
417 }}
418 }}"#,
419 long_name
420 );
421
422 let scripts = parse_scripts_from_json(&json).unwrap();
423 assert_eq!(scripts.len(), 1);
424 assert!(scripts.get(&long_name).is_some());
425 }
426
427 #[test]
428 fn test_parse_very_long_command() {
429 let long_command = "echo ".to_string() + &"x".repeat(10000);
430 let json = format!(
431 r#"{{
432 "scripts": {{
433 "test": "{}"
434 }}
435 }}"#,
436 long_command
437 );
438
439 let scripts = parse_scripts_from_json(&json).unwrap();
440 assert_eq!(scripts.get("test").unwrap().command(), long_command);
441 }
442
443 #[test]
444 fn test_parse_many_scripts() {
445 let mut scripts_obj = String::from("{");
447 for i in 0..1000 {
448 if i > 0 {
449 scripts_obj.push(',');
450 }
451 scripts_obj.push_str(&format!(r#""script_{}": "echo {}""#, i, i));
452 }
453 scripts_obj.push('}');
454
455 let json = format!(r#"{{"scripts": {}}}"#, scripts_obj);
456
457 let scripts = parse_scripts_from_json(&json).unwrap();
458 assert_eq!(scripts.len(), 1000);
459 assert!(scripts.get("script_0").is_some());
460 assert!(scripts.get("script_999").is_some());
461 }
462
463 #[test]
464 fn test_parse_special_characters_in_command() {
465 let json = r#"{
466 "scripts": {
467 "test": "echo \"hello world\" && echo 'single quotes'",
468 "env": "FOO=bar BAZ=\"quoted value\" npm start",
469 "redirect": "npm build > output.log 2>&1",
470 "pipe": "cat file.txt | grep pattern | wc -l",
471 "subshell": "$(npm bin)/eslint .",
472 "semicolon": "echo first; echo second",
473 "escape": "echo \\\"escaped\\\"",
474 "dollar": "echo $HOME $USER ${PWD}"
475 }
476 }"#;
477
478 let scripts = parse_scripts_from_json(json).unwrap();
479 assert_eq!(scripts.len(), 8);
480 assert!(scripts.get("test").is_some());
481 assert!(scripts.get("env").is_some());
482 assert!(scripts.get("redirect").is_some());
483 assert!(scripts.get("pipe").is_some());
484 assert!(scripts.get("subshell").is_some());
485 assert!(scripts.get("semicolon").is_some());
486 assert!(scripts.get("escape").is_some());
487 assert!(scripts.get("dollar").is_some());
488 }
489
490 #[test]
491 fn test_parse_multiline_command() {
492 let json = r#"{
494 "scripts": {
495 "complex": "echo start && npm test && npm build && echo done"
496 }
497 }"#;
498
499 let scripts = parse_scripts_from_json(json).unwrap();
500 assert!(scripts.get("complex").is_some());
501 }
502
503 #[test]
504 fn test_parse_json_with_trailing_comma() {
505 let json = r#"{
507 "scripts": {
508 "dev": "vite",
509 }
510 }"#;
511
512 let result = parse_scripts_from_json(json);
513 assert!(result.is_err());
514 }
515
516 #[test]
517 fn test_parse_json_with_comments() {
518 let json = r#"{
520 // This is a comment
521 "scripts": {
522 "dev": "vite"
523 }
524 }"#;
525
526 let result = parse_scripts_from_json(json);
527 assert!(result.is_err());
528 }
529
530 #[test]
531 fn test_parse_minimal_valid_json() {
532 let json = r#"{}"#;
533 let scripts = parse_scripts_from_json(json).unwrap();
534 assert!(scripts.is_empty());
535 }
536
537 #[test]
538 fn test_parse_scripts_field_as_array() {
539 let json = r#"{
541 "scripts": ["dev", "build"]
542 }"#;
543
544 let result = parse_scripts_from_json(json);
545 assert!(result.is_err());
546 }
547
548 #[test]
549 fn test_parse_scripts_field_as_string() {
550 let json = r#"{
552 "scripts": "dev"
553 }"#;
554
555 let result = parse_scripts_from_json(json);
556 assert!(result.is_err());
557 }
558
559 #[test]
560 fn test_parse_scripts_field_as_null() {
561 let json = r#"{
562 "scripts": null
563 }"#;
564
565 let result = parse_scripts_from_json(json);
566 assert!(result.is_err());
567 }
568
569 #[test]
570 fn test_parse_script_value_as_number() {
571 let json = r#"{
573 "scripts": {
574 "test": 123
575 }
576 }"#;
577
578 let result = parse_scripts_from_json(json);
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn test_parse_script_value_as_object() {
584 let json = r#"{
586 "scripts": {
587 "test": {"command": "vitest"}
588 }
589 }"#;
590
591 let result = parse_scripts_from_json(json);
592 assert!(result.is_err());
593 }
594
595 #[test]
596 fn test_format_json_error_shows_context() {
597 let json = r#"{
598 "scripts": {
599 "dev": vite
600 }
601}"#;
602
603 let result = parse_scripts_from_json(json);
604 assert!(result.is_err());
605 let err = result.unwrap_err().to_string();
606 assert!(err.contains("line"));
608 assert!(err.contains("column"));
609 }
610
611 #[test]
612 fn test_parse_whitespace_in_script_names() {
613 let json = r#"{
615 "scripts": {
616 " dev ": "vite",
617 "build test": "vite build"
618 }
619 }"#;
620
621 let scripts = parse_scripts_from_json(json).unwrap();
622 assert_eq!(scripts.len(), 2);
623 assert!(scripts.get(" dev ").is_some());
624 assert!(scripts.get("build test").is_some());
625 }
626
627 #[test]
628 fn test_parse_hyphen_and_underscore_names() {
629 let json = r#"{
630 "scripts": {
631 "my-script": "echo hyphen",
632 "my_script": "echo underscore",
633 "my-long-script-name": "echo long",
634 "__internal__": "echo internal"
635 }
636 }"#;
637
638 let scripts = parse_scripts_from_json(json).unwrap();
639 assert_eq!(scripts.len(), 4);
640 assert!(scripts.get("my-script").is_some());
641 assert!(scripts.get("my_script").is_some());
642 assert!(scripts.get("my-long-script-name").is_some());
643 assert!(scripts.get("__internal__").is_some());
644 }
645}