mod context;
mod dispatch;
pub use context::{CallbackHandler, CallbackRegistry, ScriptContext};
pub use dispatch::{dispatch, EntryOutcome};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Entry {
Shell(String),
Php(String),
Composer(String),
PutEnv { key: String, val: String },
Alias(String),
Callback { class: String, method: String },
}
#[derive(Debug, Clone, Default)]
pub struct Scripts(Vec<(String, Vec<Entry>)>);
impl Scripts {
#[must_use]
pub fn parse(root_composer_json: &serde_json::Value) -> Self {
let mut out: Vec<(String, Vec<Entry>)> = Vec::new();
let Some(obj) = root_composer_json.get("scripts").and_then(serde_json::Value::as_object)
else {
return Self(out);
};
for (event, value) in obj {
let entries = match value {
serde_json::Value::String(s) => vec![classify(s)],
serde_json::Value::Array(a) => a
.iter()
.filter_map(serde_json::Value::as_str)
.map(classify)
.collect(),
_ => continue,
};
out.push((event.clone(), entries));
}
Self(out)
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&[Entry]> {
self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_slice())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn classify(raw: &str) -> Entry {
let trimmed = raw.trim_start();
if let Some(rest) = trimmed.strip_prefix("@php")
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return Entry::Php(rest.trim_start().to_string());
}
if let Some(rest) = trimmed.strip_prefix("@composer")
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return Entry::Composer(rest.trim_start().to_string());
}
if let Some(rest) = trimmed.strip_prefix("@putenv")
&& rest.starts_with(char::is_whitespace)
{
let assignment = rest.trim_start();
let (key, val) = assignment.split_once('=').unwrap_or((assignment, ""));
return Entry::PutEnv { key: key.trim().to_string(), val: val.to_string() };
}
if let Some(name) = trimmed.strip_prefix('@')
&& !name.is_empty()
&& !name.contains(char::is_whitespace)
{
return Entry::Alias(name.to_string());
}
if let Some((class, method)) = as_callback(trimmed) {
return Entry::Callback { class, method };
}
Entry::Shell(raw.to_string())
}
fn as_callback(s: &str) -> Option<(String, String)> {
if s.contains(char::is_whitespace) {
return None;
}
let (class, method) = s.split_once("::")?;
if class.is_empty() || method.is_empty() || method.contains("::") {
return None;
}
let class_ok = class.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '\\');
let method_ok = method.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
(class_ok && method_ok).then(|| (class.to_string(), method.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classifies_each_entry_form() {
assert_eq!(classify("@php artisan migrate"), Entry::Php("artisan migrate".into()));
assert_eq!(classify("@php"), Entry::Php(String::new()));
assert_eq!(classify("@composer dump-autoload"), Entry::Composer("dump-autoload".into()));
assert_eq!(
classify("@putenv APP_ENV=testing"),
Entry::PutEnv { key: "APP_ENV".into(), val: "testing".into() }
);
assert_eq!(classify("@build"), Entry::Alias("build".into()));
assert_eq!(
classify("Illuminate\\Foundation\\ComposerScripts::postAutoloadDump"),
Entry::Callback {
class: "Illuminate\\Foundation\\ComposerScripts".into(),
method: "postAutoloadDump".into(),
}
);
assert_eq!(classify("phpunit --colors"), Entry::Shell("phpunit --colors".into()));
}
#[test]
fn php_prefix_does_not_swallow_longer_token() {
assert_eq!(classify("@phpstan"), Entry::Alias("phpstan".into()));
}
#[test]
fn callback_requires_no_whitespace() {
assert_eq!(classify("echo a::b c"), Entry::Shell("echo a::b c".into()));
}
#[test]
fn parse_preserves_order_and_single_string_form() {
let scripts = Scripts::parse(&json!({
"scripts": {
"post-install-cmd": ["@php artisan migrate", "phpunit"],
"test": "phpunit"
}
}));
assert_eq!(
scripts.get("post-install-cmd"),
Some(&[Entry::Php("artisan migrate".into()), Entry::Shell("phpunit".into())][..])
);
assert_eq!(scripts.get("test"), Some(&[Entry::Shell("phpunit".into())][..]));
assert!(scripts.get("missing").is_none());
}
#[test]
fn parse_empty_when_no_scripts() {
assert!(Scripts::parse(&json!({})).is_empty());
assert!(Scripts::parse(&json!({"scripts": 5})).is_empty());
}
}