npm_run_scripts/package/
manager.rs

1//! Package manager detection and command building.
2//!
3//! Detects the appropriate package manager for a project based on:
4//! 1. `packageManager` field in package.json (highest priority)
5//! 2. Lock file detection
6//! 3. Fallback to npm
7
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12/// Supported package managers.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Runner {
16    /// Node Package Manager (npm)
17    #[default]
18    Npm,
19    /// Yarn package manager
20    Yarn,
21    /// pnpm - Fast, disk space efficient package manager
22    Pnpm,
23    /// Bun - Fast all-in-one JavaScript runtime
24    Bun,
25}
26
27impl Runner {
28    /// Get the executable name for this runner.
29    pub fn executable(&self) -> &'static str {
30        match self {
31            Runner::Npm => "npm",
32            Runner::Yarn => "yarn",
33            Runner::Pnpm => "pnpm",
34            Runner::Bun => "bun",
35        }
36    }
37
38    /// Get the base run command (without script name).
39    ///
40    /// Returns the command prefix used to run scripts:
41    /// - npm: "npm run"
42    /// - yarn: "yarn"
43    /// - pnpm: "pnpm"
44    /// - bun: "bun run"
45    pub fn run_prefix(&self) -> &'static str {
46        match self {
47            Runner::Npm => "npm run",
48            Runner::Yarn => "yarn",
49            Runner::Pnpm => "pnpm",
50            Runner::Bun => "bun run",
51        }
52    }
53
54    /// Get the command to run a script as a vector of arguments.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use npm_run_scripts::package::Runner;
60    ///
61    /// let cmd = Runner::Npm.run_command("dev");
62    /// assert_eq!(cmd, vec!["npm", "run", "dev"]);
63    ///
64    /// let cmd = Runner::Yarn.run_command("build");
65    /// assert_eq!(cmd, vec!["yarn", "build"]);
66    /// ```
67    pub fn run_command(&self, script: &str) -> Vec<String> {
68        match self {
69            Runner::Npm => vec!["npm".into(), "run".into(), script.into()],
70            Runner::Yarn => vec!["yarn".into(), script.into()],
71            Runner::Pnpm => vec!["pnpm".into(), script.into()],
72            Runner::Bun => vec!["bun".into(), "run".into(), script.into()],
73        }
74    }
75
76    /// Get the command to run a script with additional arguments.
77    ///
78    /// # Arguments
79    ///
80    /// * `script` - The script name to run
81    /// * `args` - Additional arguments to pass to the script
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use npm_run_scripts::package::Runner;
87    ///
88    /// let args = vec!["--watch".to_string(), "--coverage".to_string()];
89    /// let cmd = Runner::Npm.run_command_with_args("test", &args);
90    /// assert_eq!(cmd, vec!["npm", "run", "test", "--", "--watch", "--coverage"]);
91    /// ```
92    pub fn run_command_with_args(&self, script: &str, args: &[String]) -> Vec<String> {
93        let mut cmd = self.run_command(script);
94
95        if !args.is_empty() {
96            // npm and pnpm require -- before args to pass them to the script
97            if matches!(self, Runner::Npm | Runner::Pnpm) {
98                cmd.push("--".into());
99            }
100            cmd.extend(args.iter().cloned());
101        }
102
103        cmd
104    }
105
106    /// Format the run command as a string for display.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use npm_run_scripts::package::Runner;
112    ///
113    /// assert_eq!(Runner::Npm.format_command("dev"), "npm run dev");
114    /// assert_eq!(Runner::Yarn.format_command("build"), "yarn build");
115    /// ```
116    pub fn format_command(&self, script: &str) -> String {
117        self.run_command(script).join(" ")
118    }
119
120    /// Format the run command with arguments as a string for display.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use npm_run_scripts::package::Runner;
126    ///
127    /// let args = vec!["--watch".to_string()];
128    /// assert_eq!(
129    ///     Runner::Npm.format_command_with_args("test", &args),
130    ///     "npm run test -- --watch"
131    /// );
132    /// ```
133    pub fn format_command_with_args(&self, script: &str, args: &[String]) -> String {
134        self.run_command_with_args(script, args).join(" ")
135    }
136
137    /// Get the display name for the runner.
138    pub fn display_name(&self) -> &'static str {
139        match self {
140            Runner::Npm => "npm",
141            Runner::Yarn => "yarn",
142            Runner::Pnpm => "pnpm",
143            Runner::Bun => "bun",
144        }
145    }
146
147    /// Get the icon/emoji for the runner.
148    pub fn icon(&self) -> &'static str {
149        match self {
150            Runner::Npm => "\u{1F4E6}",  // ๐Ÿ“ฆ package
151            Runner::Yarn => "\u{1F9F6}", // ๐Ÿงถ yarn
152            Runner::Pnpm => "\u{1F4C0}", // ๐Ÿ“€ disc
153            Runner::Bun => "\u{1F95F}",  // ๐ŸฅŸ dumpling
154        }
155    }
156
157    /// Get the lock file name for this runner.
158    pub fn lock_file(&self) -> &'static str {
159        match self {
160            Runner::Npm => "package-lock.json",
161            Runner::Yarn => "yarn.lock",
162            Runner::Pnpm => "pnpm-lock.yaml",
163            Runner::Bun => "bun.lockb",
164        }
165    }
166
167    /// Get all supported runners.
168    pub fn all() -> &'static [Runner] {
169        &[Runner::Npm, Runner::Yarn, Runner::Pnpm, Runner::Bun]
170    }
171
172    /// Get the command to run a workspace script.
173    ///
174    /// # Arguments
175    ///
176    /// * `workspace` - The workspace/package name
177    /// * `script` - The script name to run
178    pub fn workspace_command(&self, workspace: &str, script: &str) -> Vec<String> {
179        match self {
180            Runner::Npm => vec![
181                "npm".into(),
182                "run".into(),
183                "-w".into(),
184                workspace.into(),
185                script.into(),
186            ],
187            Runner::Yarn => vec![
188                "yarn".into(),
189                "workspace".into(),
190                workspace.into(),
191                script.into(),
192            ],
193            Runner::Pnpm => vec![
194                "pnpm".into(),
195                "--filter".into(),
196                workspace.into(),
197                script.into(),
198            ],
199            Runner::Bun => vec![
200                "bun".into(),
201                "run".into(),
202                "--filter".into(),
203                workspace.into(),
204                script.into(),
205            ],
206        }
207    }
208
209    /// Get the command to run a workspace script with additional arguments.
210    ///
211    /// # Arguments
212    ///
213    /// * `workspace` - The workspace/package name
214    /// * `script` - The script name to run
215    /// * `args` - Additional arguments to pass to the script
216    pub fn workspace_command_with_args(
217        &self,
218        workspace: &str,
219        script: &str,
220        args: &[String],
221    ) -> Vec<String> {
222        let mut cmd = self.workspace_command(workspace, script);
223
224        if !args.is_empty() {
225            // npm and pnpm require -- before args
226            if matches!(self, Runner::Npm | Runner::Pnpm) {
227                cmd.push("--".into());
228            }
229            cmd.extend(args.iter().cloned());
230        }
231
232        cmd
233    }
234
235    /// Format the workspace run command as a string for display.
236    pub fn format_workspace_command(&self, workspace: &str, script: &str) -> String {
237        self.workspace_command(workspace, script).join(" ")
238    }
239
240    /// Format the workspace run command with arguments as a string for display.
241    pub fn format_workspace_command_with_args(
242        &self,
243        workspace: &str,
244        script: &str,
245        args: &[String],
246    ) -> String {
247        self.workspace_command_with_args(workspace, script, args)
248            .join(" ")
249    }
250}
251
252impl std::fmt::Display for Runner {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        write!(f, "{}", self.display_name())
255    }
256}
257
258impl std::str::FromStr for Runner {
259    type Err = String;
260
261    fn from_str(s: &str) -> Result<Self, Self::Err> {
262        match s.to_lowercase().as_str() {
263            "npm" => Ok(Runner::Npm),
264            "yarn" => Ok(Runner::Yarn),
265            "pnpm" => Ok(Runner::Pnpm),
266            "bun" => Ok(Runner::Bun),
267            _ => Err(format!(
268                "Unknown package manager: '{s}'. Valid options are: npm, yarn, pnpm, bun"
269            )),
270        }
271    }
272}
273
274/// Detect the package manager for a project.
275///
276/// Detection priority:
277/// 1. `packageManager` field in package.json (e.g., "pnpm@8.0.0")
278/// 2. Lock file detection:
279///    - `bun.lockb` โ†’ Bun
280///    - `pnpm-lock.yaml` โ†’ pnpm
281///    - `yarn.lock` โ†’ Yarn
282///    - `package-lock.json` โ†’ npm
283/// 3. Fallback to npm
284///
285/// # Arguments
286///
287/// * `project_dir` - Path to the project directory
288///
289/// # Examples
290///
291/// ```no_run
292/// use std::path::Path;
293/// use npm_run_scripts::package::detect_runner;
294///
295/// let runner = detect_runner(Path::new("/path/to/project"));
296/// println!("Using: {}", runner);
297/// ```
298pub fn detect_runner(project_dir: &Path) -> Runner {
299    detect_runner_reason(project_dir).0
300}
301
302/// Detect the package manager for a project and return the reason for the detection.
303///
304/// Returns a tuple of (Runner, reason_string) where reason_string explains
305/// why this package manager was selected.
306///
307/// # Arguments
308///
309/// * `project_dir` - Path to the project directory
310///
311/// # Examples
312///
313/// ```no_run
314/// use std::path::Path;
315/// use npm_run_scripts::package::detect_runner_reason;
316///
317/// let (runner, reason) = detect_runner_reason(Path::new("/path/to/project"));
318/// println!("Using: {} ({})", runner, reason);
319/// ```
320pub fn detect_runner_reason(project_dir: &Path) -> (Runner, String) {
321    // Priority 1: Check packageManager field in package.json
322    if let Some(runner) = detect_from_package_json(project_dir) {
323        let package_json = project_dir.join("package.json");
324        return (
325            runner,
326            format!("packageManager field in {}", package_json.display()),
327        );
328    }
329
330    // Priority 2: Check lock files (in order of specificity)
331    // Bun first as it's the most specific (binary format)
332    let bun_lock = project_dir.join("bun.lockb");
333    if bun_lock.exists() {
334        return (Runner::Bun, format!("found {}", bun_lock.display()));
335    }
336
337    let pnpm_lock = project_dir.join("pnpm-lock.yaml");
338    if pnpm_lock.exists() {
339        return (Runner::Pnpm, format!("found {}", pnpm_lock.display()));
340    }
341
342    let yarn_lock = project_dir.join("yarn.lock");
343    if yarn_lock.exists() {
344        return (Runner::Yarn, format!("found {}", yarn_lock.display()));
345    }
346
347    let npm_lock = project_dir.join("package-lock.json");
348    if npm_lock.exists() {
349        return (Runner::Npm, format!("found {}", npm_lock.display()));
350    }
351
352    // Priority 3: Fallback to npm
353    (Runner::Npm, "default (no lock file found)".to_string())
354}
355
356/// Detect the package manager from the packageManager field in package.json.
357///
358/// The packageManager field can be in formats like:
359/// - "pnpm@8.0.0"
360/// - "yarn@4.0.0"
361/// - "npm@10.0.0"
362/// - "pnpm" (without version)
363fn detect_from_package_json(project_dir: &Path) -> Option<Runner> {
364    let package_json = project_dir.join("package.json");
365    let content = std::fs::read_to_string(package_json).ok()?;
366    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
367
368    let pm = json.get("packageManager")?.as_str()?;
369
370    // Parse "pnpm@8.0.0" or "pnpm" format
371    parse_package_manager_field(pm)
372}
373
374/// Parse the packageManager field value to extract the runner.
375///
376/// Handles formats like:
377/// - "pnpm@8.0.0"
378/// - "yarn@4.0.0+sha256.abc123"
379/// - "npm"
380fn parse_package_manager_field(value: &str) -> Option<Runner> {
381    // Split on @ to get the package manager name
382    let name = value.split('@').next()?;
383    name.parse().ok()
384}
385
386/// Check if a specific lock file exists in the project directory.
387pub fn has_lock_file(project_dir: &Path, runner: Runner) -> bool {
388    project_dir.join(runner.lock_file()).exists()
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::fs;
395    use tempfile::TempDir;
396
397    // ==================== Runner enum tests ====================
398
399    #[test]
400    fn test_runner_from_str() {
401        assert_eq!("npm".parse::<Runner>().unwrap(), Runner::Npm);
402        assert_eq!("yarn".parse::<Runner>().unwrap(), Runner::Yarn);
403        assert_eq!("pnpm".parse::<Runner>().unwrap(), Runner::Pnpm);
404        assert_eq!("bun".parse::<Runner>().unwrap(), Runner::Bun);
405    }
406
407    #[test]
408    fn test_runner_from_str_case_insensitive() {
409        assert_eq!("NPM".parse::<Runner>().unwrap(), Runner::Npm);
410        assert_eq!("YARN".parse::<Runner>().unwrap(), Runner::Yarn);
411        assert_eq!("Pnpm".parse::<Runner>().unwrap(), Runner::Pnpm);
412        assert_eq!("BUN".parse::<Runner>().unwrap(), Runner::Bun);
413    }
414
415    #[test]
416    fn test_runner_from_str_invalid() {
417        let result = "invalid".parse::<Runner>();
418        assert!(result.is_err());
419        assert!(result.unwrap_err().contains("Unknown package manager"));
420    }
421
422    #[test]
423    fn test_runner_display() {
424        assert_eq!(format!("{}", Runner::Npm), "npm");
425        assert_eq!(format!("{}", Runner::Yarn), "yarn");
426        assert_eq!(format!("{}", Runner::Pnpm), "pnpm");
427        assert_eq!(format!("{}", Runner::Bun), "bun");
428    }
429
430    #[test]
431    fn test_runner_executable() {
432        assert_eq!(Runner::Npm.executable(), "npm");
433        assert_eq!(Runner::Yarn.executable(), "yarn");
434        assert_eq!(Runner::Pnpm.executable(), "pnpm");
435        assert_eq!(Runner::Bun.executable(), "bun");
436    }
437
438    #[test]
439    fn test_runner_lock_file() {
440        assert_eq!(Runner::Npm.lock_file(), "package-lock.json");
441        assert_eq!(Runner::Yarn.lock_file(), "yarn.lock");
442        assert_eq!(Runner::Pnpm.lock_file(), "pnpm-lock.yaml");
443        assert_eq!(Runner::Bun.lock_file(), "bun.lockb");
444    }
445
446    #[test]
447    fn test_runner_all() {
448        let all = Runner::all();
449        assert_eq!(all.len(), 4);
450        assert!(all.contains(&Runner::Npm));
451        assert!(all.contains(&Runner::Yarn));
452        assert!(all.contains(&Runner::Pnpm));
453        assert!(all.contains(&Runner::Bun));
454    }
455
456    // ==================== Run command tests ====================
457
458    #[test]
459    fn test_run_command_npm() {
460        assert_eq!(Runner::Npm.run_command("dev"), vec!["npm", "run", "dev"]);
461        assert_eq!(
462            Runner::Npm.run_command("build:prod"),
463            vec!["npm", "run", "build:prod"]
464        );
465    }
466
467    #[test]
468    fn test_run_command_yarn() {
469        assert_eq!(Runner::Yarn.run_command("dev"), vec!["yarn", "dev"]);
470        assert_eq!(Runner::Yarn.run_command("test"), vec!["yarn", "test"]);
471    }
472
473    #[test]
474    fn test_run_command_pnpm() {
475        assert_eq!(Runner::Pnpm.run_command("dev"), vec!["pnpm", "dev"]);
476        assert_eq!(Runner::Pnpm.run_command("build"), vec!["pnpm", "build"]);
477    }
478
479    #[test]
480    fn test_run_command_bun() {
481        assert_eq!(Runner::Bun.run_command("dev"), vec!["bun", "run", "dev"]);
482        assert_eq!(
483            Runner::Bun.run_command("start"),
484            vec!["bun", "run", "start"]
485        );
486    }
487
488    // ==================== Run command with args tests ====================
489
490    #[test]
491    fn test_run_command_with_args_npm() {
492        let args = vec!["--watch".to_string()];
493        assert_eq!(
494            Runner::Npm.run_command_with_args("test", &args),
495            vec!["npm", "run", "test", "--", "--watch"]
496        );
497
498        let args = vec!["--coverage".to_string(), "--verbose".to_string()];
499        assert_eq!(
500            Runner::Npm.run_command_with_args("test", &args),
501            vec!["npm", "run", "test", "--", "--coverage", "--verbose"]
502        );
503    }
504
505    #[test]
506    fn test_run_command_with_args_yarn() {
507        let args = vec!["--watch".to_string()];
508        assert_eq!(
509            Runner::Yarn.run_command_with_args("test", &args),
510            vec!["yarn", "test", "--watch"]
511        );
512    }
513
514    #[test]
515    fn test_run_command_with_args_pnpm() {
516        let args = vec!["--watch".to_string()];
517        assert_eq!(
518            Runner::Pnpm.run_command_with_args("test", &args),
519            vec!["pnpm", "test", "--", "--watch"]
520        );
521    }
522
523    #[test]
524    fn test_run_command_with_args_bun() {
525        let args = vec!["--watch".to_string()];
526        assert_eq!(
527            Runner::Bun.run_command_with_args("test", &args),
528            vec!["bun", "run", "test", "--watch"]
529        );
530    }
531
532    #[test]
533    fn test_run_command_with_empty_args() {
534        let args: Vec<String> = vec![];
535        assert_eq!(
536            Runner::Npm.run_command_with_args("dev", &args),
537            vec!["npm", "run", "dev"]
538        );
539        assert_eq!(
540            Runner::Yarn.run_command_with_args("dev", &args),
541            vec!["yarn", "dev"]
542        );
543    }
544
545    // ==================== Format command tests ====================
546
547    #[test]
548    fn test_format_command() {
549        assert_eq!(Runner::Npm.format_command("dev"), "npm run dev");
550        assert_eq!(Runner::Yarn.format_command("build"), "yarn build");
551        assert_eq!(Runner::Pnpm.format_command("test"), "pnpm test");
552        assert_eq!(Runner::Bun.format_command("start"), "bun run start");
553    }
554
555    #[test]
556    fn test_format_command_with_args() {
557        let args = vec!["--watch".to_string(), "--coverage".to_string()];
558        assert_eq!(
559            Runner::Npm.format_command_with_args("test", &args),
560            "npm run test -- --watch --coverage"
561        );
562        assert_eq!(
563            Runner::Yarn.format_command_with_args("test", &args),
564            "yarn test --watch --coverage"
565        );
566    }
567
568    // ==================== Workspace command tests ====================
569
570    #[test]
571    fn test_workspace_command_npm() {
572        assert_eq!(
573            Runner::Npm.workspace_command("@app/web", "build"),
574            vec!["npm", "run", "-w", "@app/web", "build"]
575        );
576    }
577
578    #[test]
579    fn test_workspace_command_yarn() {
580        assert_eq!(
581            Runner::Yarn.workspace_command("@app/web", "build"),
582            vec!["yarn", "workspace", "@app/web", "build"]
583        );
584    }
585
586    #[test]
587    fn test_workspace_command_pnpm() {
588        assert_eq!(
589            Runner::Pnpm.workspace_command("@app/web", "build"),
590            vec!["pnpm", "--filter", "@app/web", "build"]
591        );
592    }
593
594    #[test]
595    fn test_workspace_command_bun() {
596        assert_eq!(
597            Runner::Bun.workspace_command("@app/web", "build"),
598            vec!["bun", "run", "--filter", "@app/web", "build"]
599        );
600    }
601
602    // ==================== Detection tests ====================
603
604    #[test]
605    fn test_detect_from_package_manager_field() {
606        let temp = TempDir::new().unwrap();
607        fs::write(
608            temp.path().join("package.json"),
609            r#"{"packageManager": "pnpm@8.15.0"}"#,
610        )
611        .unwrap();
612
613        assert_eq!(detect_runner(temp.path()), Runner::Pnpm);
614    }
615
616    #[test]
617    fn test_detect_from_package_manager_field_with_hash() {
618        let temp = TempDir::new().unwrap();
619        fs::write(
620            temp.path().join("package.json"),
621            r#"{"packageManager": "yarn@4.0.0+sha256.abc123"}"#,
622        )
623        .unwrap();
624
625        assert_eq!(detect_runner(temp.path()), Runner::Yarn);
626    }
627
628    #[test]
629    fn test_detect_from_package_manager_field_no_version() {
630        let temp = TempDir::new().unwrap();
631        fs::write(
632            temp.path().join("package.json"),
633            r#"{"packageManager": "bun"}"#,
634        )
635        .unwrap();
636
637        assert_eq!(detect_runner(temp.path()), Runner::Bun);
638    }
639
640    #[test]
641    fn test_detect_from_bun_lock() {
642        let temp = TempDir::new().unwrap();
643        fs::write(temp.path().join("package.json"), "{}").unwrap();
644        fs::write(temp.path().join("bun.lockb"), "binary content").unwrap();
645
646        assert_eq!(detect_runner(temp.path()), Runner::Bun);
647    }
648
649    #[test]
650    fn test_detect_from_pnpm_lock() {
651        let temp = TempDir::new().unwrap();
652        fs::write(temp.path().join("package.json"), "{}").unwrap();
653        fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: 5.4").unwrap();
654
655        assert_eq!(detect_runner(temp.path()), Runner::Pnpm);
656    }
657
658    #[test]
659    fn test_detect_from_yarn_lock() {
660        let temp = TempDir::new().unwrap();
661        fs::write(temp.path().join("package.json"), "{}").unwrap();
662        fs::write(temp.path().join("yarn.lock"), "# yarn lockfile v1").unwrap();
663
664        assert_eq!(detect_runner(temp.path()), Runner::Yarn);
665    }
666
667    #[test]
668    fn test_detect_from_npm_lock() {
669        let temp = TempDir::new().unwrap();
670        fs::write(temp.path().join("package.json"), "{}").unwrap();
671        fs::write(
672            temp.path().join("package-lock.json"),
673            r#"{"lockfileVersion": 3}"#,
674        )
675        .unwrap();
676
677        assert_eq!(detect_runner(temp.path()), Runner::Npm);
678    }
679
680    #[test]
681    fn test_detect_fallback_to_npm() {
682        let temp = TempDir::new().unwrap();
683        fs::write(temp.path().join("package.json"), "{}").unwrap();
684
685        assert_eq!(detect_runner(temp.path()), Runner::Npm);
686    }
687
688    #[test]
689    fn test_detect_priority_package_manager_over_lock_file() {
690        let temp = TempDir::new().unwrap();
691        fs::write(
692            temp.path().join("package.json"),
693            r#"{"packageManager": "pnpm@8.0.0"}"#,
694        )
695        .unwrap();
696        // Create a yarn lock file that should be ignored
697        fs::write(temp.path().join("yarn.lock"), "").unwrap();
698
699        assert_eq!(detect_runner(temp.path()), Runner::Pnpm);
700    }
701
702    #[test]
703    fn test_detect_bun_priority_over_other_lock_files() {
704        let temp = TempDir::new().unwrap();
705        fs::write(temp.path().join("package.json"), "{}").unwrap();
706        // Create multiple lock files
707        fs::write(temp.path().join("bun.lockb"), "").unwrap();
708        fs::write(temp.path().join("yarn.lock"), "").unwrap();
709        fs::write(temp.path().join("package-lock.json"), "{}").unwrap();
710
711        // Bun should win as it's checked first
712        assert_eq!(detect_runner(temp.path()), Runner::Bun);
713    }
714
715    #[test]
716    fn test_detect_no_package_json() {
717        let temp = TempDir::new().unwrap();
718        // No package.json, no lock files
719        assert_eq!(detect_runner(temp.path()), Runner::Npm);
720    }
721
722    // ==================== Parse package manager field tests ====================
723
724    #[test]
725    fn test_parse_package_manager_field_with_version() {
726        assert_eq!(
727            parse_package_manager_field("pnpm@8.15.0"),
728            Some(Runner::Pnpm)
729        );
730        assert_eq!(
731            parse_package_manager_field("yarn@4.0.0"),
732            Some(Runner::Yarn)
733        );
734        assert_eq!(parse_package_manager_field("npm@10.2.0"), Some(Runner::Npm));
735        assert_eq!(parse_package_manager_field("bun@1.0.0"), Some(Runner::Bun));
736    }
737
738    #[test]
739    fn test_parse_package_manager_field_without_version() {
740        assert_eq!(parse_package_manager_field("pnpm"), Some(Runner::Pnpm));
741        assert_eq!(parse_package_manager_field("yarn"), Some(Runner::Yarn));
742        assert_eq!(parse_package_manager_field("npm"), Some(Runner::Npm));
743        assert_eq!(parse_package_manager_field("bun"), Some(Runner::Bun));
744    }
745
746    #[test]
747    fn test_parse_package_manager_field_with_hash() {
748        assert_eq!(
749            parse_package_manager_field("yarn@4.0.0+sha256.abc123def"),
750            Some(Runner::Yarn)
751        );
752    }
753
754    #[test]
755    fn test_parse_package_manager_field_invalid() {
756        assert_eq!(parse_package_manager_field("unknown@1.0.0"), None);
757        assert_eq!(parse_package_manager_field(""), None);
758    }
759
760    // ==================== Has lock file tests ====================
761
762    #[test]
763    fn test_has_lock_file() {
764        let temp = TempDir::new().unwrap();
765        fs::write(temp.path().join("yarn.lock"), "").unwrap();
766
767        assert!(has_lock_file(temp.path(), Runner::Yarn));
768        assert!(!has_lock_file(temp.path(), Runner::Npm));
769        assert!(!has_lock_file(temp.path(), Runner::Pnpm));
770        assert!(!has_lock_file(temp.path(), Runner::Bun));
771    }
772}