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