1use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Runner {
16 #[default]
18 Npm,
19 Yarn,
21 Pnpm,
23 Bun,
25}
26
27impl Runner {
28 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 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 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 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 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 pub fn format_command(&self, script: &str) -> String {
117 self.run_command(script).join(" ")
118 }
119
120 pub fn format_command_with_args(&self, script: &str, args: &[String]) -> String {
134 self.run_command_with_args(script, args).join(" ")
135 }
136
137 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 pub fn icon(&self) -> &'static str {
149 match self {
150 Runner::Npm => "\u{1F4E6}", Runner::Yarn => "\u{1F9F6}", Runner::Pnpm => "\u{1F4C0}", Runner::Bun => "\u{1F95F}", }
155 }
156
157 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 pub fn all() -> &'static [Runner] {
169 &[Runner::Npm, Runner::Yarn, Runner::Pnpm, Runner::Bun]
170 }
171
172 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 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 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 pub fn format_workspace_command(&self, workspace: &str, script: &str) -> String {
237 self.workspace_command(workspace, script).join(" ")
238 }
239
240 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
274pub fn detect_runner(project_dir: &Path) -> Runner {
299 detect_runner_reason(project_dir).0
300}
301
302pub fn detect_runner_reason(project_dir: &Path) -> (Runner, String) {
321 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 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 (Runner::Npm, "default (no lock file found)".to_string())
354}
355
356fn 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_package_manager_field(pm)
372}
373
374fn parse_package_manager_field(value: &str) -> Option<Runner> {
381 let name = value.split('@').next()?;
383 name.parse().ok()
384}
385
386pub 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 assert_eq!(detect_runner(temp.path()), Runner::Npm);
720 }
721
722 #[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 #[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}