use std::{collections::BTreeMap, path::Path};
use dialoguer::{FuzzySelect, theme::ColorfulTheme};
use crate::core::{
error::{HniError, HniResult},
pkg_json::read_package_json,
};
pub fn read_scripts(cwd: &Path) -> HniResult<Vec<ScriptEntry>> {
let Some(pkg) = read_package_json(cwd)? else {
return Ok(Vec::new());
};
let scripts = pkg.scripts.unwrap_or_default();
let scripts_info = pkg.scripts_info.unwrap_or_default();
Ok(build_script_entries(&scripts, &scripts_info))
}
pub fn choose_script_interactive(cwd: &Path) -> HniResult<String> {
let scripts = read_scripts(cwd)?;
if scripts.is_empty() {
return Err(HniError::interactive("no scripts found in package.json"));
}
let labels: Vec<String> = scripts
.iter()
.map(|entry| format!("{}: {}", entry.name, entry.description))
.collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Choose script to run")
.items(&labels)
.default(0)
.interact_opt()
.map_err(|error| {
HniError::interactive(format!("failed to read script selection: {error}"))
})?
.ok_or_else(|| HniError::interactive("script selection canceled"))?;
Ok(scripts[idx].name.clone())
}
#[derive(Debug, Clone)]
pub struct ScriptEntry {
pub name: String,
pub description: String,
}
fn build_script_entries(
scripts: &BTreeMap<String, String>,
scripts_info: &BTreeMap<String, String>,
) -> Vec<ScriptEntry> {
scripts
.iter()
.filter(|(name, _)| !name.starts_with('?'))
.map(|(name, cmd)| {
let description = scripts_info
.get(name)
.cloned()
.or_else(|| scripts.get(&format!("?{name}")).cloned())
.unwrap_or_else(|| cmd.clone());
ScriptEntry {
name: name.clone(),
description,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_hidden_question_mark_scripts() {
let scripts = BTreeMap::from([
("dev".to_string(), "vite".to_string()),
("?dev".to_string(), "hidden".to_string()),
]);
let out = build_script_entries(&scripts, &BTreeMap::new());
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "dev");
}
#[test]
fn prefers_scripts_info_description() {
let scripts = BTreeMap::from([("dev".to_string(), "vite".to_string())]);
let scripts_info = BTreeMap::from([("dev".to_string(), "Start dev server".to_string())]);
let out = build_script_entries(&scripts, &scripts_info);
assert_eq!(out[0].description, "Start dev server");
}
#[test]
fn falls_back_to_question_mark_script_description() {
let scripts = BTreeMap::from([
("dev".to_string(), "vite".to_string()),
("?dev".to_string(), "Run local dev server".to_string()),
]);
let out = build_script_entries(&scripts, &BTreeMap::new());
assert_eq!(out[0].description, "Run local dev server");
}
#[test]
fn falls_back_to_script_command_when_no_description_available() {
let scripts = BTreeMap::from([("build".to_string(), "vite build".to_string())]);
let out = build_script_entries(&scripts, &BTreeMap::new());
assert_eq!(out[0].description, "vite build");
}
#[test]
fn entries_are_stably_sorted_by_script_name() {
let scripts = BTreeMap::from([
("test".to_string(), "vitest".to_string()),
("build".to_string(), "vite build".to_string()),
]);
let out = build_script_entries(&scripts, &BTreeMap::new());
assert_eq!(out[0].name, "build");
assert_eq!(out[1].name, "test");
}
}