npm_run_scripts/package/
scripts.rs

1//! Script parsing from package.json.
2
3use std::path::Path;
4
5use anyhow::{bail, Context, Result};
6
7use super::descriptions::extract_descriptions;
8use super::types::{Package, Script, Scripts};
9
10/// Parse a package.json file from a directory.
11///
12/// # Arguments
13///
14/// * `project_dir` - The directory containing package.json
15///
16/// # Errors
17///
18/// Returns an error if:
19/// - The package.json file cannot be read
20/// - The JSON is malformed
21pub 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
29/// Parse the full package.json structure.
30///
31/// # Arguments
32///
33/// * `content` - The raw JSON content
34///
35/// # Errors
36///
37/// Returns an error if the JSON is malformed.
38pub fn parse_package_json(content: &str) -> Result<Package> {
39    // First, try to parse as valid JSON
40    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    // Then deserialize into our Package struct
46    let package: Package =
47        serde_json::from_value(json).context("Failed to parse package.json structure")?;
48
49    Ok(package)
50}
51
52/// Parse scripts from package.json content.
53///
54/// # Arguments
55///
56/// * `content` - The raw JSON content
57///
58/// # Errors
59///
60/// Returns an error if the JSON is malformed.
61///
62/// # Examples
63///
64/// ```
65/// use npm_run_scripts::package::scripts::parse_scripts_from_json;
66///
67/// let json = r#"{"scripts": {"dev": "vite", "build": "vite build"}}"#;
68/// let scripts = parse_scripts_from_json(json).unwrap();
69/// assert_eq!(scripts.len(), 2);
70/// ```
71pub fn parse_scripts_from_json(content: &str) -> Result<Scripts> {
72    let package = parse_package_json(content)?;
73    extract_scripts_from_package(&package)
74}
75
76/// Parse scripts from package.json content, returning error if no scripts exist.
77///
78/// # Errors
79///
80/// Returns an error if:
81/// - The JSON is malformed
82/// - No scripts are defined in package.json
83pub 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
93/// Extract scripts from a parsed Package.
94pub fn extract_scripts_from_package(package: &Package) -> Result<Scripts> {
95    // Extract descriptions from various sources
96    let descriptions = extract_descriptions(package);
97
98    let mut scripts = Scripts::new();
99
100    for (name, command) in &package.scripts {
101        // Skip comment entries (keys starting with //)
102        if name.starts_with("//") {
103            continue;
104        }
105
106        let mut script = Script::new(name, command);
107
108        // Add description if available
109        if let Some(desc) = descriptions.get(name) {
110            script.set_description(desc);
111        }
112
113        scripts.add(script);
114    }
115
116    // Sort alphabetically for consistent ordering
117    scripts.sort_alphabetically();
118
119    Ok(scripts)
120}
121
122/// Format a JSON parsing error with context.
123fn format_json_error(content: &str, error: &serde_json::Error) -> String {
124    let line = error.line();
125    let column = error.column();
126
127    // Try to show the problematic line
128    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    // ==================== Edge Case Tests ====================
358
359    #[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        // Generate a package.json with 1000 scripts
446        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        // package.json doesn't support actual multiline strings, but escaped newlines
493        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        // Standard JSON doesn't allow trailing commas, verify we reject it
506        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        // Standard JSON doesn't allow comments, verify we reject them
519        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        // scripts should be an object, not an array
540        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        // scripts should be an object, not a string
551        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        // Script values should be strings, not numbers
572        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        // Script values should be strings, not objects
585        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        // Should show line and column information
607        assert!(err.contains("line"));
608        assert!(err.contains("column"));
609    }
610
611    #[test]
612    fn test_parse_whitespace_in_script_names() {
613        // While unusual, whitespace in keys is valid JSON
614        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}