1use std::io::Write as _;
2use std::path::PathBuf;
3
4use stellar_cli::print::Print;
5use stellar_scaffold_ext_types::{ExtensionManifest, HookName};
6use tokio::io::AsyncWriteExt as _;
7
8use crate::commands::build::env_toml::ExtensionEntry;
9
10#[derive(Debug, Clone)]
12pub struct ResolvedExtension {
13 pub name: String,
15 pub binary: PathBuf,
17 pub manifest: ExtensionManifest,
19 pub config: Option<serde_json::Value>,
21}
22
23pub fn discover(entries: &[ExtensionEntry], printer: &Print) -> Vec<ResolvedExtension> {
30 let search_dirs = path_dirs();
31 discover_in(entries, printer, &search_dirs)
32}
33
34pub async fn run_hook<C: serde::Serialize>(
46 extensions: &[ResolvedExtension],
47 hook: HookName,
48 context: &C,
49 printer: &Print,
50) {
51 let hook_str = hook.as_str();
52
53 let ctx_value = match serde_json::to_value(context) {
56 Ok(v) => v,
57 Err(e) => {
58 printer.errorln(format!(
59 "Extension hook {hook_str:?}: failed to serialize context: {e}"
60 ));
61 return;
62 }
63 };
64
65 for ext in extensions {
66 if !ext.manifest.hooks.iter().any(|h| h == hook_str) {
67 continue;
68 }
69
70 let input_json = match inject_config(&ctx_value, ext.config.as_ref()) {
71 Ok(bytes) => bytes,
72 Err(e) => {
73 printer.errorln(format!(
74 "Extension {:?} hook {hook_str:?}: failed to serialize input: {e}",
75 ext.name
76 ));
77 continue;
78 }
79 };
80
81 let binary_name = binary_name(&ext.name);
82
83 let mut child = match tokio::process::Command::new(&ext.binary)
84 .arg(hook_str)
85 .stdin(std::process::Stdio::piped())
86 .stdout(std::process::Stdio::piped())
87 .stderr(std::process::Stdio::piped())
88 .spawn()
89 {
90 Ok(child) => child,
91 Err(e) => {
92 printer.errorln(format!(
93 "Extension {:?} hook {hook_str:?}: failed to spawn \
94 `{binary_name}`: {e}",
95 ext.name
96 ));
97 continue;
98 }
99 };
100
101 if let Some(mut stdin) = child.stdin.take() {
105 if let Err(e) = stdin.write_all(&input_json).await {
106 printer.errorln(format!(
107 "Extension {:?} hook {hook_str:?}: failed to write context \
108 to stdin: {e}",
109 ext.name
110 ));
111 let _ = child.kill().await;
112 continue;
113 }
114 let _ = stdin.shutdown().await;
115 }
116
117 let output = match child.wait_with_output().await {
118 Ok(output) => output,
119 Err(e) => {
120 printer.errorln(format!(
121 "Extension {:?} hook {hook_str:?}: failed to wait for \
122 `{binary_name}`: {e}",
123 ext.name
124 ));
125 continue;
126 }
127 };
128
129 if !output.stdout.is_empty() {
133 let _ = std::io::stdout().write_all(&output.stdout);
134 }
135
136 if !output.status.success() {
137 let stderr = String::from_utf8_lossy(&output.stderr);
138 printer.errorln(format!(
139 "Extension {:?} hook {hook_str:?}: `{binary_name}` exited \
140 with {}: {stderr}",
141 ext.name, output.status
142 ));
143 }
145 }
146}
147
148fn inject_config(
155 ctx: &serde_json::Value,
156 config: Option<&serde_json::Value>,
157) -> Result<Vec<u8>, serde_json::Error> {
158 let mut map = match ctx {
159 serde_json::Value::Object(m) => m.clone(),
160 _ => serde_json::Map::new(),
161 };
162 map.insert(
163 "config".to_string(),
164 config.cloned().unwrap_or(serde_json::Value::Null),
165 );
166 serde_json::to_vec(&serde_json::Value::Object(map))
167}
168
169#[derive(Debug)]
171pub enum ExtensionListStatus {
172 Found { version: String, hooks: Vec<String> },
174 MissingBinary,
176 ManifestError(String),
178}
179
180#[derive(Debug)]
182pub struct ExtensionListEntry {
183 pub name: String,
184 pub status: ExtensionListStatus,
185}
186
187pub fn list(entries: &[ExtensionEntry]) -> Vec<ExtensionListEntry> {
191 list_in(entries, &path_dirs())
192}
193
194fn list_in(entries: &[ExtensionEntry], search_dirs: &[PathBuf]) -> Vec<ExtensionListEntry> {
195 entries
196 .iter()
197 .map(|entry| {
198 let name = &entry.name;
199 let Some(binary) = find_binary(name, search_dirs) else {
200 return ExtensionListEntry {
201 name: name.clone(),
202 status: ExtensionListStatus::MissingBinary,
203 };
204 };
205
206 let output = match std::process::Command::new(&binary).arg("manifest").output() {
207 Err(e) => {
208 return ExtensionListEntry {
209 name: name.clone(),
210 status: ExtensionListStatus::ManifestError(e.to_string()),
211 };
212 }
213 Ok(o) => o,
214 };
215
216 if !output.status.success() {
217 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
218 return ExtensionListEntry {
219 name: name.clone(),
220 status: ExtensionListStatus::ManifestError(stderr),
221 };
222 }
223
224 match serde_json::from_slice::<ExtensionManifest>(&output.stdout) {
225 Err(e) => ExtensionListEntry {
226 name: name.clone(),
227 status: ExtensionListStatus::ManifestError(e.to_string()),
228 },
229 Ok(manifest) => ExtensionListEntry {
230 name: name.clone(),
231 status: ExtensionListStatus::Found {
232 version: manifest.version,
233 hooks: manifest.hooks,
234 },
235 },
236 }
237 })
238 .collect()
239}
240
241fn path_dirs() -> Vec<PathBuf> {
242 std::env::var_os("PATH")
243 .map(|p| std::env::split_paths(&p).collect())
244 .unwrap_or_default()
245}
246
247fn find_binary(name: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
248 let binary_name = binary_name(name);
249 search_dirs
250 .iter()
251 .map(|dir| dir.join(&binary_name))
252 .find(|p| p.is_file())
253}
254
255#[cfg(windows)]
256fn binary_name(name: &str) -> String {
257 format!("stellar-scaffold-{name}.exe")
258}
259
260#[cfg(not(windows))]
261fn binary_name(name: &str) -> String {
262 format!("stellar-scaffold-{name}")
263}
264
265fn discover_in(
266 entries: &[ExtensionEntry],
267 printer: &Print,
268 search_dirs: &[PathBuf],
269) -> Vec<ResolvedExtension> {
270 let mut resolved = Vec::new();
271
272 for entry in entries {
273 let name = &entry.name;
274 let binary_name = binary_name(name);
275
276 let Some(binary) = find_binary(name, search_dirs) else {
277 printer.warnln(format!(
278 "Extension {name:?}: binary {binary_name:?} not found on PATH, skipping"
279 ));
280 continue;
281 };
282
283 let output = match std::process::Command::new(&binary).arg("manifest").output() {
284 Ok(output) => output,
285 Err(e) => {
286 printer.warnln(format!(
287 "Extension {name:?}: failed to run `{binary_name} manifest`: {e}, skipping"
288 ));
289 continue;
290 }
291 };
292
293 if !output.status.success() {
294 let stderr = String::from_utf8_lossy(&output.stderr);
295 printer.warnln(format!(
296 "Extension {name:?}: `{binary_name} manifest` exited with {}: {stderr}skipping",
297 output.status
298 ));
299 continue;
300 }
301
302 let manifest: ExtensionManifest = match serde_json::from_slice(&output.stdout) {
303 Ok(m) => m,
304 Err(e) => {
305 printer.warnln(format!(
306 "Extension {name:?}: malformed manifest from `{binary_name} manifest`: \
307 {e}, skipping"
308 ));
309 continue;
310 }
311 };
312
313 resolved.push(ResolvedExtension {
314 name: name.clone(),
315 binary,
316 manifest,
317 config: entry.config.clone(),
318 });
319 }
320
321 if !resolved.is_empty() {
322 let names: Vec<&str> = resolved.iter().map(|e| e.name.as_str()).collect();
323 printer.infoln(format!("Registered extensions: {}", names.join(", ")));
324 }
325
326 resolved
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use stellar_scaffold_ext_types::HookName;
333
334 fn printer() -> Print {
335 Print::new(true) }
337
338 fn entry(name: &str) -> ExtensionEntry {
339 ExtensionEntry {
340 name: name.to_owned(),
341 config: None,
342 }
343 }
344
345 fn entry_with_config(name: &str, config: serde_json::Value) -> ExtensionEntry {
346 ExtensionEntry {
347 name: name.to_owned(),
348 config: Some(config),
349 }
350 }
351
352 #[cfg(unix)]
354 fn make_script(dir: &tempfile::TempDir, name: &str, body: &str) -> PathBuf {
355 use std::os::unix::fs::PermissionsExt;
356 let path = dir.path().join(binary_name(name));
357 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
358 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
359 path
360 }
361
362 #[cfg(unix)]
364 fn valid_manifest_script(dir: &tempfile::TempDir, name: &str, hooks: &[&str]) {
365 let hooks_json = hooks
366 .iter()
367 .map(|h| format!("\"{h}\""))
368 .collect::<Vec<_>>()
369 .join(",");
370 make_script(
371 dir,
372 name,
373 &format!(r#"echo '{{"name":"{name}","version":"1.0.0","hooks":[{hooks_json}]}}'"#),
374 );
375 }
376
377 #[test]
378 #[cfg(unix)]
379 fn discovers_valid_extension() {
380 let dir = tempfile::TempDir::new().unwrap();
381 valid_manifest_script(&dir, "reporter", &["post-compile", "post-deploy"]);
382
383 let entries = vec![entry("reporter")];
384 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
385
386 assert_eq!(result.len(), 1);
387 assert_eq!(result[0].name, "reporter");
388 assert_eq!(result[0].manifest.name, "reporter");
389 assert_eq!(
390 result[0].manifest.hooks,
391 vec!["post-compile", "post-deploy"]
392 );
393 assert!(result[0].config.is_none());
394 }
395
396 #[test]
397 #[cfg(unix)]
398 fn passes_config_through_to_resolved() {
399 let dir = tempfile::TempDir::new().unwrap();
400 valid_manifest_script(&dir, "reporter", &["post-compile"]);
401
402 let config = serde_json::json!({ "warn_size_kb": 128 });
403 let entries = vec![entry_with_config("reporter", config.clone())];
404 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
405
406 assert_eq!(result.len(), 1);
407 assert_eq!(result[0].config, Some(config));
408 }
409
410 #[test]
411 fn skips_missing_binary() {
412 let dir = tempfile::TempDir::new().unwrap();
413 let entries = vec![entry("missing")];
416 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
417
418 assert!(result.is_empty());
419 }
420
421 #[test]
422 #[cfg(unix)]
423 fn skips_failing_manifest_subcommand() {
424 let dir = tempfile::TempDir::new().unwrap();
425 make_script(&dir, "bad-exit", "exit 1");
426
427 let entries = vec![entry("bad-exit")];
428 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
429
430 assert!(result.is_empty());
431 }
432
433 #[test]
434 #[cfg(unix)]
435 fn skips_malformed_manifest_json() {
436 let dir = tempfile::TempDir::new().unwrap();
437 make_script(&dir, "bad-json", "echo 'not valid json'");
438
439 let entries = vec![entry("bad-json")];
440 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
441
442 assert!(result.is_empty());
443 }
444
445 #[test]
446 #[cfg(unix)]
447 fn preserves_order_and_skips_bad_entries() {
448 let dir = tempfile::TempDir::new().unwrap();
449 valid_manifest_script(&dir, "first", &["pre-compile"]);
450 valid_manifest_script(&dir, "third", &["post-compile"]);
452
453 let entries = vec![entry("first"), entry("missing"), entry("third")];
454 let result = discover_in(&entries, &printer(), &[dir.path().to_path_buf()]);
455
456 assert_eq!(result.len(), 2);
457 assert_eq!(result[0].name, "first");
458 assert_eq!(result[1].name, "third");
459 }
460
461 #[cfg(unix)]
467 fn make_resolved(name: &str, binary: PathBuf, hooks: &[&str]) -> ResolvedExtension {
468 ResolvedExtension {
469 name: name.to_owned(),
470 binary,
471 manifest: ExtensionManifest {
472 name: name.to_owned(),
473 version: "1.0.0".to_owned(),
474 hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
475 },
476 config: None,
477 }
478 }
479
480 #[tokio::test]
481 #[cfg(unix)]
482 async fn run_hook_sends_context_to_stdin() {
483 let dir = tempfile::TempDir::new().unwrap();
484 make_script(&dir, "reporter", r#"cat > "$(dirname "$0")/received.json""#);
487
488 #[derive(serde::Serialize)]
489 #[allow(clippy::items_after_statements)]
490 struct Ctx {
491 env: String,
492 }
493 let exts = vec![make_resolved(
494 "reporter",
495 dir.path().join(binary_name("reporter")),
496 &["post-compile"],
497 )];
498 run_hook(
499 &exts,
500 HookName::PostCompile,
501 &Ctx {
502 env: "development".to_owned(),
503 },
504 &printer(),
505 )
506 .await;
507
508 let received = std::fs::read_to_string(dir.path().join("received.json")).unwrap();
509 let parsed: serde_json::Value = serde_json::from_str(&received).unwrap();
510 assert_eq!(parsed["env"], "development");
511 }
512
513 #[tokio::test]
514 #[cfg(unix)]
515 async fn run_hook_skips_extension_not_registered_for_hook() {
516 let dir = tempfile::TempDir::new().unwrap();
517 make_script(&dir, "reporter", r#"touch "$(dirname "$0")/was_invoked""#);
519 let exts = vec![make_resolved(
520 "reporter",
521 dir.path().join(binary_name("reporter")),
522 &["post-compile"], )];
524 run_hook(
525 &exts,
526 HookName::PostDeploy,
527 &serde_json::json!({}),
528 &printer(),
529 )
530 .await;
531
532 assert!(!dir.path().join("was_invoked").exists());
533 }
534
535 #[tokio::test]
536 #[cfg(unix)]
537 async fn run_hook_continues_after_non_zero_exit() {
538 let dir = tempfile::TempDir::new().unwrap();
539 make_script(&dir, "failing", "exit 1");
541 make_script(
543 &dir,
544 "succeeding",
545 r#"cat > "$(dirname "$0")/received.json""#,
546 );
547
548 let exts = vec![
549 make_resolved(
550 "failing",
551 dir.path().join(binary_name("failing")),
552 &["post-compile"],
553 ),
554 make_resolved(
555 "succeeding",
556 dir.path().join(binary_name("succeeding")),
557 &["post-compile"],
558 ),
559 ];
560
561 run_hook(
562 &exts,
563 HookName::PostCompile,
564 &serde_json::json!({ "env": "test" }),
565 &printer(),
566 )
567 .await;
568
569 assert!(dir.path().join("received.json").exists());
571 }
572}