1mod context;
22mod dispatch;
23
24pub use context::{CallbackHandler, CallbackRegistry, ScriptContext};
25pub use dispatch::{dispatch, EntryOutcome};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Entry {
31 Shell(String),
33 Php(String),
35 Composer(String),
38 PutEnv { key: String, val: String },
41 Alias(String),
43 Callback { class: String, method: String },
46}
47
48#[derive(Debug, Clone, Default)]
54pub struct Scripts(Vec<(String, Vec<Entry>)>);
55
56impl Scripts {
57 #[must_use]
66 pub fn parse(root_composer_json: &serde_json::Value) -> Self {
67 let mut out: Vec<(String, Vec<Entry>)> = Vec::new();
68 let Some(obj) = root_composer_json.get("scripts").and_then(serde_json::Value::as_object)
69 else {
70 return Self(out);
71 };
72 for (event, value) in obj {
73 let entries = match value {
74 serde_json::Value::String(s) => vec![classify(s)],
75 serde_json::Value::Array(a) => a
76 .iter()
77 .filter_map(serde_json::Value::as_str)
78 .map(classify)
79 .collect(),
80 _ => continue,
83 };
84 out.push((event.clone(), entries));
85 }
86 Self(out)
87 }
88
89 #[must_use]
91 pub fn get(&self, name: &str) -> Option<&[Entry]> {
92 self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_slice())
93 }
94
95 #[must_use]
97 pub fn is_empty(&self) -> bool {
98 self.0.is_empty()
99 }
100}
101
102fn classify(raw: &str) -> Entry {
104 let trimmed = raw.trim_start();
105 if let Some(rest) = trimmed.strip_prefix("@php")
108 && (rest.is_empty() || rest.starts_with(char::is_whitespace))
109 {
110 return Entry::Php(rest.trim_start().to_string());
111 }
112 if let Some(rest) = trimmed.strip_prefix("@composer")
113 && (rest.is_empty() || rest.starts_with(char::is_whitespace))
114 {
115 return Entry::Composer(rest.trim_start().to_string());
116 }
117 if let Some(rest) = trimmed.strip_prefix("@putenv")
118 && rest.starts_with(char::is_whitespace)
119 {
120 let assignment = rest.trim_start();
121 let (key, val) = assignment.split_once('=').unwrap_or((assignment, ""));
122 return Entry::PutEnv { key: key.trim().to_string(), val: val.to_string() };
123 }
124 if let Some(name) = trimmed.strip_prefix('@')
125 && !name.is_empty()
126 && !name.contains(char::is_whitespace)
127 {
128 return Entry::Alias(name.to_string());
130 }
131 if let Some((class, method)) = as_callback(trimmed) {
132 return Entry::Callback { class, method };
133 }
134 Entry::Shell(raw.to_string())
136}
137
138fn as_callback(s: &str) -> Option<(String, String)> {
142 if s.contains(char::is_whitespace) {
143 return None;
144 }
145 let (class, method) = s.split_once("::")?;
146 if class.is_empty() || method.is_empty() || method.contains("::") {
147 return None;
148 }
149 let class_ok = class.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '\\');
150 let method_ok = method.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
151 (class_ok && method_ok).then(|| (class.to_string(), method.to_string()))
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use serde_json::json;
158
159 #[test]
160 fn classifies_each_entry_form() {
161 assert_eq!(classify("@php artisan migrate"), Entry::Php("artisan migrate".into()));
162 assert_eq!(classify("@php"), Entry::Php(String::new()));
163 assert_eq!(classify("@composer dump-autoload"), Entry::Composer("dump-autoload".into()));
164 assert_eq!(
165 classify("@putenv APP_ENV=testing"),
166 Entry::PutEnv { key: "APP_ENV".into(), val: "testing".into() }
167 );
168 assert_eq!(classify("@build"), Entry::Alias("build".into()));
169 assert_eq!(
170 classify("Illuminate\\Foundation\\ComposerScripts::postAutoloadDump"),
171 Entry::Callback {
172 class: "Illuminate\\Foundation\\ComposerScripts".into(),
173 method: "postAutoloadDump".into(),
174 }
175 );
176 assert_eq!(classify("phpunit --colors"), Entry::Shell("phpunit --colors".into()));
177 }
178
179 #[test]
180 fn php_prefix_does_not_swallow_longer_token() {
181 assert_eq!(classify("@phpstan"), Entry::Alias("phpstan".into()));
183 }
184
185 #[test]
186 fn callback_requires_no_whitespace() {
187 assert_eq!(classify("echo a::b c"), Entry::Shell("echo a::b c".into()));
189 }
190
191 #[test]
192 fn parse_preserves_order_and_single_string_form() {
193 let scripts = Scripts::parse(&json!({
194 "scripts": {
195 "post-install-cmd": ["@php artisan migrate", "phpunit"],
196 "test": "phpunit"
197 }
198 }));
199 assert_eq!(
200 scripts.get("post-install-cmd"),
201 Some(&[Entry::Php("artisan migrate".into()), Entry::Shell("phpunit".into())][..])
202 );
203 assert_eq!(scripts.get("test"), Some(&[Entry::Shell("phpunit".into())][..]));
204 assert!(scripts.get("missing").is_none());
205 }
206
207 #[test]
208 fn parse_empty_when_no_scripts() {
209 assert!(Scripts::parse(&json!({})).is_empty());
210 assert!(Scripts::parse(&json!({"scripts": 5})).is_empty());
211 }
212}