use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use ignore::WalkBuilder;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
Pnpm,
Npm,
Yarn,
}
impl PackageManager {
pub fn bin(self) -> &'static str {
match self {
PackageManager::Pnpm => "pnpm",
PackageManager::Npm => "npm",
PackageManager::Yarn => "yarn",
}
}
pub fn label(self) -> &'static str {
self.bin()
}
}
#[derive(Debug, Default, Deserialize)]
pub struct PackageJson {
pub name: Option<String>,
#[serde(default)]
pub scripts: BTreeMap<String, String>,
#[serde(rename = "packageManager")]
pub package_manager: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptEntry {
pub package_name: String,
pub package_path: PathBuf,
pub script: String,
pub command: String,
pub is_current: bool,
}
impl ScriptEntry {
pub fn key(&self) -> String {
format!("{}::{}", self.package_path.display(), self.script)
}
}
pub struct Discovery {
pub cwd: PathBuf,
pub package_manager: PackageManager,
pub entries: Vec<ScriptEntry>,
}
pub fn parse_package_json(raw: &str) -> Option<PackageJson> {
serde_json::from_str(raw).ok()
}
pub fn parse_package_manager_field(value: Option<&str>) -> Option<PackageManager> {
let name = value?.split('@').next()?.trim();
match name {
"pnpm" => Some(PackageManager::Pnpm),
"npm" => Some(PackageManager::Npm),
"yarn" => Some(PackageManager::Yarn),
_ => None,
}
}
pub fn build_script_entries(packages: &[(PathBuf, PackageJson)], cwd: &Path) -> Vec<ScriptEntry> {
let base_names: Vec<(PathBuf, String)> = packages
.iter()
.map(|(path, pkg)| {
(
path.clone(),
pkg.name.clone().unwrap_or_else(|| dir_basename(path)),
)
})
.collect();
let mut entries: Vec<ScriptEntry> = Vec::new();
for (path, pkg) in packages {
if pkg.scripts.is_empty() {
continue;
}
let package_name = hierarchical_label(path, &base_names, cwd);
let is_current = path == cwd;
for (script, command) in &pkg.scripts {
entries.push(ScriptEntry {
package_name: package_name.clone(),
package_path: path.clone(),
script: script.clone(),
command: command.clone(),
is_current,
});
}
}
entries.sort_by(|a, b| {
b.is_current
.cmp(&a.is_current)
.then_with(|| a.package_name.cmp(&b.package_name))
.then_with(|| a.script.cmp(&b.script))
});
entries
}
pub fn filter_scripts(entries: &[ScriptEntry], query: &str) -> Vec<ScriptEntry> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return entries.to_vec();
}
entries
.iter()
.filter(|entry| {
entry.package_name.to_lowercase().contains(&query)
|| entry.script.to_lowercase().contains(&query)
|| entry.command.to_lowercase().contains(&query)
})
.cloned()
.collect()
}
#[derive(Debug, Clone)]
pub struct PackageGroup {
pub package_name: String,
pub is_current: bool,
pub scripts: Vec<ScriptEntry>,
}
pub fn group_by_package(entries: &[ScriptEntry]) -> Vec<PackageGroup> {
let mut groups: Vec<PackageGroup> = Vec::new();
let mut index_by_path: std::collections::HashMap<PathBuf, usize> =
std::collections::HashMap::new();
for entry in entries {
if let Some(&index) = index_by_path.get(&entry.package_path) {
groups[index].scripts.push(entry.clone());
continue;
}
index_by_path.insert(entry.package_path.clone(), groups.len());
groups.push(PackageGroup {
package_name: entry.package_name.clone(),
is_current: entry.is_current,
scripts: vec![entry.clone()],
});
}
groups
}
pub fn count_selected(group: &PackageGroup, selected: &HashSet<String>) -> usize {
group
.scripts
.iter()
.filter(|entry| selected.contains(&entry.key()))
.count()
}
pub fn format_script_label(entry: &ScriptEntry, package_max: usize) -> String {
let name = &entry.package_name;
let name = if name.chars().count() > package_max && package_max > 1 {
let head: String = name.chars().take(package_max - 1).collect();
format!("{head}…")
} else {
name.clone()
};
format!("{name} · {}", entry.script)
}
fn hierarchical_label(path: &Path, base_names: &[(PathBuf, String)], cwd: &Path) -> String {
let own = base_names
.iter()
.find(|(p, _)| p == path)
.map(|(_, name)| name.clone())
.unwrap_or_else(|| dir_basename(path));
let mut ancestors: Vec<(usize, &str)> = base_names
.iter()
.filter(|(p, _)| p != path && p != cwd && path.starts_with(p))
.map(|(p, name)| (p.components().count(), name.as_str()))
.collect();
if ancestors.is_empty() {
return own;
}
ancestors.sort_by_key(|(depth, _)| *depth);
let mut parts: Vec<String> = ancestors
.into_iter()
.map(|(_, name)| name.to_string())
.collect();
parts.push(own);
parts.join(" -> ")
}
fn dir_basename(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
const PRUNE_DIRS: &[&str] = &[
"node_modules",
"dist",
".git",
"coverage",
".worktrees",
".worktree",
"target",
];
fn collect_packages(cwd: &Path) -> Vec<(PathBuf, PackageJson)> {
let mut found: Vec<(PathBuf, PackageJson)> = Vec::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
let walker = WalkBuilder::new(cwd)
.hidden(false)
.git_ignore(true)
.git_global(false)
.filter_entry(|entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy();
return !PRUNE_DIRS.contains(&name.as_ref());
}
true
})
.build();
for result in walker {
let Ok(entry) = result else { continue };
if entry.file_name() != "package.json" {
continue;
}
let Some(dir) = entry.path().parent() else {
continue;
};
let dir = dir.to_path_buf();
if !seen.insert(dir.clone()) {
continue;
}
if let Ok(raw) = fs::read_to_string(entry.path()) {
if let Some(pkg) = parse_package_json(&raw) {
found.push((dir, pkg));
}
}
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
fn detect_package_manager(cwd: &Path) -> PackageManager {
let mut dir = Some(cwd);
while let Some(current) = dir {
if let Ok(raw) = fs::read_to_string(current.join("package.json")) {
if let Some(pkg) = parse_package_json(&raw) {
if let Some(pm) = parse_package_manager_field(pkg.package_manager.as_deref()) {
return pm;
}
}
}
if current.join("pnpm-lock.yaml").exists() {
return PackageManager::Pnpm;
}
if current.join("yarn.lock").exists() {
return PackageManager::Yarn;
}
if current.join("package-lock.json").exists() {
return PackageManager::Npm;
}
dir = current.parent();
}
PackageManager::Npm
}
fn is_inside_git_repo(cwd: &Path) -> bool {
let mut dir = Some(cwd);
while let Some(current) = dir {
if current.join(".git").exists() {
return true;
}
dir = current.parent();
}
false
}
pub fn discover_scripts(cwd: &Path) -> Result<Discovery> {
let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
if !is_inside_git_repo(&cwd) {
return Err(anyhow!(
"当前目录不在 git 仓库内,请先 cd 到项目目录再运行。"
));
}
let packages = collect_packages(&cwd);
let entries = build_script_entries(&packages, &cwd);
if entries.is_empty() {
return Err(anyhow!("当前目录及子目录下未找到可运行的 scripts。"));
}
Ok(Discovery {
package_manager: detect_package_manager(&cwd),
cwd,
entries,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, scripts: &[(&str, &str)]) -> PackageJson {
PackageJson {
name: Some(name.to_string()),
scripts: scripts
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
package_manager: None,
}
}
#[test]
fn parse_reads_scripts_and_name() {
let json = r#"{"name":"demo","scripts":{"dev":"vite","build":"tsc"}}"#;
let parsed = parse_package_json(json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("demo"));
assert_eq!(parsed.scripts.get("dev").map(String::as_str), Some("vite"));
}
#[test]
fn parse_invalid_returns_none() {
assert!(parse_package_json("{ not json").is_none());
}
#[test]
fn parse_missing_scripts_defaults_empty() {
let parsed = parse_package_json(r#"{"name":"x"}"#).unwrap();
assert!(parsed.scripts.is_empty());
}
#[test]
fn package_manager_field_parses() {
assert_eq!(
parse_package_manager_field(Some("pnpm@9.1.0")),
Some(PackageManager::Pnpm)
);
assert_eq!(
parse_package_manager_field(Some("yarn")),
Some(PackageManager::Yarn)
);
assert_eq!(parse_package_manager_field(Some("bun@1")), None);
assert_eq!(parse_package_manager_field(None), None);
}
#[test]
fn build_sorts_current_first_then_name_then_script() {
let cwd = PathBuf::from("/repo/app");
let packages = vec![
(
PathBuf::from("/repo/lib"),
pkg("lib", &[("test", "vitest")]),
),
(
PathBuf::from("/repo/app"),
pkg("app", &[("dev", "vite"), ("build", "tsc")]),
),
];
let entries = build_script_entries(&packages, &cwd);
assert_eq!(entries[0].package_name, "app");
assert_eq!(entries[0].script, "build");
assert_eq!(entries[1].script, "dev");
assert!(entries[0].is_current);
assert_eq!(entries[2].package_name, "lib");
assert!(!entries[2].is_current);
}
#[test]
fn nested_package_shows_hierarchy() {
let cwd = PathBuf::from("/repo");
let packages = vec![
(PathBuf::from("/repo"), pkg("root", &[("dev", "x")])),
(PathBuf::from("/repo/app"), pkg("app", &[("dev", "vite")])),
(
PathBuf::from("/repo/app/plugins/foo"),
pkg("foo", &[("build", "tsup")]),
),
];
let entries = build_script_entries(&packages, &cwd);
let by_path = |p: &str| {
entries
.iter()
.find(|e| e.package_path.as_path() == Path::new(p))
.map(|e| e.package_name.clone())
.unwrap()
};
assert_eq!(by_path("/repo"), "root");
assert_eq!(by_path("/repo/app"), "app");
assert_eq!(by_path("/repo/app/plugins/foo"), "app -> foo");
}
#[test]
fn deeper_nesting_chains_all_package_ancestors() {
let cwd = PathBuf::from("/repo");
let packages = vec![
(PathBuf::from("/repo/a"), pkg("a", &[("dev", "x")])),
(PathBuf::from("/repo/a/b"), pkg("b", &[("dev", "x")])),
(PathBuf::from("/repo/a/b/c"), pkg("c", &[("dev", "x")])),
];
let entries = build_script_entries(&packages, &cwd);
let c = entries
.iter()
.find(|e| e.package_path.as_path() == Path::new("/repo/a/b/c"))
.unwrap();
assert_eq!(c.package_name, "a -> b -> c");
}
#[test]
fn build_skips_packages_without_scripts() {
let cwd = PathBuf::from("/repo");
let packages = vec![(PathBuf::from("/repo/empty"), pkg("empty", &[]))];
assert!(build_script_entries(&packages, &cwd).is_empty());
}
#[test]
fn filter_matches_name_script_command() {
let cwd = PathBuf::from("/repo");
let packages = vec![(
PathBuf::from("/repo"),
pkg("web", &[("dev", "vite"), ("lint", "eslint .")]),
)];
let entries = build_script_entries(&packages, &cwd);
assert_eq!(filter_scripts(&entries, "dev").len(), 1);
assert_eq!(filter_scripts(&entries, "eslint").len(), 1);
assert_eq!(filter_scripts(&entries, "web").len(), 2);
assert_eq!(filter_scripts(&entries, "").len(), 2);
assert_eq!(filter_scripts(&entries, "zzz").len(), 0);
}
#[test]
fn label_truncates_long_package_name() {
let entry = ScriptEntry {
package_name: "a-very-long-package-name".to_string(),
package_path: PathBuf::from("/x"),
script: "dev".to_string(),
command: "vite".to_string(),
is_current: false,
};
let label = format_script_label(&entry, 8);
assert_eq!(label, "a-very-… · dev");
}
#[test]
fn group_by_package_collects_scripts() {
let cwd = PathBuf::from("/repo/app");
let packages = vec![
(
PathBuf::from("/repo/app"),
pkg("app", &[("dev", "vite"), ("build", "tsc")]),
),
(
PathBuf::from("/repo/lib"),
pkg("lib", &[("test", "vitest")]),
),
];
let entries = build_script_entries(&packages, &cwd);
let groups = group_by_package(&entries);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].package_name, "app");
assert_eq!(groups[0].scripts.len(), 2);
assert!(groups[0].is_current);
assert_eq!(groups[1].package_name, "lib");
assert_eq!(groups[1].scripts.len(), 1);
}
#[test]
fn count_selected_counts_within_group() {
let cwd = PathBuf::from("/repo");
let packages = vec![(
PathBuf::from("/repo"),
pkg("web", &[("dev", "vite"), ("build", "tsc")]),
)];
let entries = build_script_entries(&packages, &cwd);
let groups = group_by_package(&entries);
let mut selected = HashSet::new();
selected.insert("/repo::dev".to_string());
assert_eq!(count_selected(&groups[0], &selected), 1);
}
#[test]
fn key_is_path_and_script() {
let entry = ScriptEntry {
package_name: "x".to_string(),
package_path: PathBuf::from("/repo/app"),
script: "dev".to_string(),
command: "vite".to_string(),
is_current: false,
};
assert_eq!(entry.key(), "/repo/app::dev");
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vkit-run-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn is_inside_git_repo_walks_up() {
let root = temp_dir("git-walk");
let nested = root.join("packages").join("web");
fs::create_dir_all(&nested).unwrap();
assert!(!is_inside_git_repo(&nested));
fs::create_dir(root.join(".git")).unwrap();
assert!(is_inside_git_repo(&nested));
assert!(is_inside_git_repo(&root));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discover_rejects_non_git_directory() {
let dir = temp_dir("nogit");
fs::write(
dir.join("package.json"),
r#"{"name":"x","scripts":{"dev":"vite"}}"#,
)
.unwrap();
match discover_scripts(&dir) {
Err(err) => assert!(err.to_string().contains("git")),
Ok(_) => panic!("expected non-git directory to be rejected"),
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn discover_accepts_git_directory() {
let dir = temp_dir("withgit");
fs::create_dir(dir.join(".git")).unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"x","scripts":{"dev":"vite"}}"#,
)
.unwrap();
let discovery = discover_scripts(&dir).unwrap();
assert_eq!(discovery.entries.len(), 1);
assert_eq!(discovery.entries[0].script, "dev");
let _ = fs::remove_dir_all(&dir);
}
}