1use std::path::{Path, PathBuf};
16use std::process::Stdio;
17
18use cargo_metadata::MetadataCommand;
19use path_slash::PathExt as _;
20use semver::Version;
21
22use crate::commands::{container::shared, global};
23use crate::print::Print;
24
25use super::{get_wasm_target, BuiltContract, Cmd, WASM_TARGET, WASM_TARGET_OLD};
26
27const LOCKED_MIN: &str = "25.2.0";
31
32const OPTIMIZE_FLAG_MIN: &str = "23.2.0";
37
38const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0";
42
43#[derive(thiserror::Error, Debug)]
44pub enum Error {
45 #[error(transparent)]
46 Engine(#[from] shared::Error),
47
48 #[error("could not pull image {image}")]
49 PullImageFailed { image: String },
50
51 #[error(
52 "could not determine the image's default Rust toolchain via `rustup default`; \
53 the image must provide rustup so the build toolchain can be pinned"
54 )]
55 ToolchainProbeFailed,
56
57 #[error("cargo metadata failed: {0}")]
58 Metadata(#[from] cargo_metadata::Error),
59
60 #[error("container build exited with status {status}. To reproduce manually:\n {command}")]
61 ContainerExit { status: i64, command: String },
62
63 #[error("build interrupted; stopped the build container")]
64 Interrupted,
65}
66
67pub async fn run(
68 cmd: &Cmd,
69 _global_args: &global::Args,
70 print: &Print,
71) -> Result<Vec<BuiltContract>, super::Error> {
72 let image = cmd
73 .image
74 .as_deref()
75 .expect("container::run is only called when --image is set");
76
77 let docker = cmd.container_args.clone();
78 docker.warn_if_host_ignored(print);
79
80 let md = metadata(cmd).map_err(Error::from)?;
85 let workspace_root = md.workspace_root.clone().into_std_path_buf();
86
87 let print_only = cmd.print_commands_only;
90
91 if !print_only && cmd.pull {
98 pull_image(&docker, image, print).await?;
99 }
100
101 let probe = if print_only {
107 None
108 } else {
109 Some(probe_image(image, &docker).await?)
110 };
111 let cli_version = match &probe {
114 Some(p) if p.version.is_none() => {
115 print.warnln("Could not probe container cli version; assuming a current image");
116 None
117 }
118 Some(p) => p.version.clone(),
119 None => None,
120 };
121 let at_least = |min: &str| {
122 cli_version
123 .as_ref()
124 .is_none_or(|v| *v >= Version::parse(min).unwrap())
125 };
126 let supports_locked = at_least(LOCKED_MIN);
129 let supports_optimize_flag = at_least(OPTIMIZE_FLAG_MIN);
130 let supports_optimize_false = at_least(OPTIMIZE_NEW_SYNTAX_MIN);
131 if cmd.locked && !supports_locked {
132 print.warnln(
133 "The build image's `contract build` does not support --locked; \
134 building without it.",
135 );
136 }
137 if cmd.build_args.optimize && !supports_optimize_flag {
138 print.warnln(format!(
139 "The build image's `contract build` does not support --optimize \
140 (added in cli v{OPTIMIZE_FLAG_MIN}); building without optimization.",
141 ));
142 }
143
144 let packages = resolve_packages(cmd, &md);
148 if cmd.package.is_none() && !packages.is_empty() {
149 print.infoln(format!("Building packages: {}", packages.join(", ")));
150 }
151 let targets: Vec<Option<&str>> = if packages.is_empty() {
152 vec![None]
153 } else {
154 packages.iter().map(|p| Some(p.as_str())).collect()
155 };
156 let container_cmds: Vec<Vec<String>> = targets
157 .iter()
158 .map(|target| {
159 forwarded_build_args(
160 cmd,
161 &workspace_root,
162 *target,
163 supports_locked,
164 supports_optimize_flag,
165 supports_optimize_false,
166 )
167 })
168 .collect();
169
170 let mut env: Vec<String> = vec!["CARGO_TARGET_DIR=/source/target".to_string()];
174
175 if let Some(p) = &probe {
184 print.infoln(format!("Using Rust toolchain {}", p.toolchain));
185 env.push(format!("RUSTUP_TOOLCHAIN={}", p.toolchain));
186 }
187
188 let bin = probe
193 .as_ref()
194 .map_or_else(|| "stellar".to_string(), |p| p.bin.clone());
195
196 run_in_container(
197 image,
198 &workspace_root,
199 &container_cmds,
200 &env,
201 &docker,
202 &cmd.run_args,
203 &bin,
204 print,
205 print_only,
206 )
207 .await?;
208
209 if print_only {
211 return Ok(Vec::new());
212 }
213
214 collect_built_contracts(cmd, &md, &workspace_root)
215}
216
217fn metadata(cmd: &Cmd) -> Result<cargo_metadata::Metadata, cargo_metadata::Error> {
218 let mut mc = MetadataCommand::new();
219 mc.no_deps();
220 if let Some(p) = &cmd.manifest_path {
221 mc.manifest_path(p);
222 }
223 mc.exec()
224}
225
226fn resolve_packages(cmd: &Cmd, md: &cargo_metadata::Metadata) -> Vec<String> {
231 if let Some(pkg) = &cmd.package {
232 return vec![pkg.clone()];
233 }
234 let mut names: Vec<String> = md
235 .packages
236 .iter()
237 .filter(|p| md.workspace_default_members.contains(&p.id))
238 .filter(|p| {
239 p.targets
240 .iter()
241 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"))
242 })
243 .map(|p| p.name.clone())
244 .collect();
245 names.sort();
246 names.dedup();
247 names
248}
249
250fn forwarded_build_args(
268 cmd: &Cmd,
269 workspace_root: &Path,
270 package: Option<&str>,
271 supports_locked: bool,
272 supports_optimize_flag: bool,
273 supports_optimize_false: bool,
274) -> Vec<String> {
275 let mut args = vec!["contract".to_string(), "build".to_string()];
276
277 if cmd.locked && supports_locked {
278 args.push("--locked".to_string());
279 }
280 if let Some(path) = &cmd.manifest_path {
281 let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone());
282 let rel = abs
283 .strip_prefix(workspace_root)
284 .map(Path::to_path_buf)
285 .unwrap_or(abs);
286 args.push(format!("--manifest-path={}", rel.to_slash_lossy()));
287 }
288 if cmd.profile != "release" {
289 args.push(format!("--profile={}", cmd.profile));
290 }
291 if let Some(features) = &cmd.features {
292 args.push(format!("--features={features}"));
293 }
294 if cmd.all_features {
295 args.push("--all-features".to_string());
296 }
297 if cmd.no_default_features {
298 args.push("--no-default-features".to_string());
299 }
300 if let Some(pkg) = package {
301 args.push(format!("--package={pkg}"));
302 }
303 for (k, v) in &cmd.build_args.meta {
304 args.push(format!("--meta={k}={v}"));
305 }
306 if cmd.build_args.optimize {
311 if supports_optimize_flag {
312 args.push("--optimize".to_string());
313 }
314 } else if supports_optimize_false {
315 args.push("--optimize=false".to_string());
316 }
317
318 args
319}
320
321async fn pull_image(docker: &shared::Args, image: &str, print: &Print) -> Result<(), Error> {
322 print.infoln(format!("Pulling image {image}"));
323 let (stdout, stderr) = if print.quiet {
324 (Stdio::null(), Stdio::null())
325 } else {
326 (Stdio::inherit(), Stdio::inherit())
327 };
328 let status = docker
329 .pull_command(image)
330 .stdout(stdout)
331 .stderr(stderr)
332 .status()
333 .await
334 .map_err(|e| docker.io_error(e))?;
335 if !status.success() {
336 return Err(Error::PullImageFailed {
337 image: image.to_string(),
338 });
339 }
340 Ok(())
341}
342
343async fn run_probe(
348 image: &str,
349 docker: &shared::Args,
350 entrypoint: Option<&str>,
351 cmd: Vec<String>,
352) -> Result<String, Error> {
353 let mut command = docker.base_command();
354 command.args(["run", "--rm"]);
355 if let Some(entrypoint) = entrypoint {
356 command.args(["--entrypoint", entrypoint]);
357 }
358 command.arg(image);
359 command.args(&cmd);
360
361 let output = command.output().await.map_err(|e| docker.io_error(e))?;
362 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
363}
364
365struct ImageProbe {
368 bin: String,
372 version: Option<Version>,
375 toolchain: String,
379}
380
381async fn probe_image(image: &str, docker: &shared::Args) -> Result<ImageProbe, Error> {
388 let script = "\
392 bin=\"$(command -v stellar >/dev/null 2>&1 && echo stellar || echo soroban)\"\n\
393 printf 'BIN:%s\\n' \"$bin\"\n\
394 printf 'VERSION:%s\\n' \"$(\"$bin\" version 2>/dev/null | head -n1)\"\n\
395 printf 'TOOLCHAIN:%s\\n' \"$(rustup default 2>/dev/null)\"\n";
396 let stdout = run_probe(
397 image,
398 docker,
399 Some("/bin/sh"),
400 vec!["-c".to_string(), script.to_string()],
401 )
402 .await?;
403
404 let bin = match probe_value(&stdout, "BIN:") {
405 "" => "stellar".to_string(),
406 b => b.to_string(),
407 };
408 let version = parse_cli_version(probe_value(&stdout, "VERSION:"));
409 let toolchain = parse_default_toolchain(probe_value(&stdout, "TOOLCHAIN:"))
410 .ok_or(Error::ToolchainProbeFailed)?;
411
412 Ok(ImageProbe {
413 bin,
414 version,
415 toolchain,
416 })
417}
418
419fn probe_value<'a>(stdout: &'a str, tag: &str) -> &'a str {
422 stdout
423 .lines()
424 .find_map(|l| l.strip_prefix(tag))
425 .map(str::trim)
426 .unwrap_or_default()
427}
428
429fn parse_cli_version(stdout: &str) -> Option<Version> {
434 stdout
435 .lines()
436 .next()?
437 .split_whitespace()
438 .find_map(|tok| Version::parse(tok).ok())
439}
440
441fn parse_default_toolchain(stdout: &str) -> Option<String> {
445 stdout.split_whitespace().next().map(str::to_string)
446}
447
448#[allow(clippy::too_many_arguments)]
449async fn run_in_container(
450 image: &str,
451 workspace_root: &Path,
452 container_cmds: &[Vec<String>],
453 env: &[String],
454 docker: &shared::Args,
455 run_args: &shared::RunArgs,
456 bin: &str,
457 print: &Print,
458 print_only: bool,
459) -> Result<(), Error> {
460 let bind = format!("{}:/source", workspace_root.display());
461 let prefix = docker.command_prefix();
464
465 let mut env_flags = String::new();
467 for e in env {
468 env_flags.push_str(" -e ");
469 env_flags.push_str(&shell_escape::escape(e.as_str().into()));
470 }
471
472 let user_flags: Vec<String> = current_user_flags();
482
483 let mut run_flags = String::new();
486 for f in run_args.flags().iter().chain(user_flags.iter()) {
487 run_flags.push(' ');
488 run_flags.push_str(&shell_escape::escape(f.as_str().into()));
489 }
490
491 let (entrypoint, post_image, reproduce) = compose_invocation(
492 &prefix,
493 &run_flags,
494 &bind,
495 &env_flags,
496 image,
497 bin,
498 container_cmds,
499 );
500
501 if print_only {
504 println!("{reproduce}");
505 return Ok(());
506 }
507
508 print.infoln(format!("Building in {image} (mount {bind})"));
509 print.infoln(format!("Running: {reproduce}"));
510
511 let container_name = format!(
516 "stellar-contract-build-{}-{:08x}",
517 std::process::id(),
518 rand::random::<u32>()
519 );
520
521 let mut command = docker.base_command();
522 command.args(["run", "--rm", "--name", &container_name]);
523 run_args.apply(&mut command);
524 command.args(&user_flags);
525 command.args(["-v", &bind, "-w", "/source"]);
526 for e in env {
527 command.args(["-e", e]);
528 }
529 if let Some(entrypoint) = entrypoint {
530 command.args(["--entrypoint", entrypoint]);
531 }
532 command.arg(image);
533 command.args(&post_image);
534
535 let (stdout, stderr) = if print.quiet {
538 (Stdio::null(), Stdio::null())
539 } else {
540 (Stdio::inherit(), Stdio::inherit())
541 };
542 command.stdout(stdout).stderr(stderr);
543
544 let mut child = command.spawn().map_err(|e| docker.io_error(e))?;
545
546 let status = tokio::select! {
550 result = child.wait() => result.map_err(|e| docker.io_error(e))?,
551 () = wait_for_termination_signal() => {
552 print.warnln("Interrupted; stopping build container");
553 let _ = docker.kill_command(&container_name).output().await;
554 let _ = child.start_kill();
555 return Err(Error::Interrupted);
556 }
557 };
558 if !status.success() {
559 return Err(Error::ContainerExit {
560 status: status.code().unwrap_or(-1).into(),
561 command: reproduce,
562 });
563 }
564
565 Ok(())
566}
567
568#[cfg(target_os = "linux")]
573fn current_user_flags() -> Vec<String> {
574 let uid = rustix::process::getuid().as_raw();
575 let gid = rustix::process::getgid().as_raw();
576 vec!["--user".to_string(), format!("{uid}:{gid}")]
577}
578
579#[cfg(not(target_os = "linux"))]
580fn current_user_flags() -> Vec<String> {
581 Vec::new()
582}
583
584fn compose_invocation(
591 prefix: &str,
592 run_flags: &str,
593 bind: &str,
594 env_flags: &str,
595 image: &str,
596 bin: &str,
597 container_cmds: &[Vec<String>],
598) -> (Option<&'static str>, Vec<String>, String) {
599 let bind = shell_escape::escape(bind.into());
603 let image = shell_escape::escape(image.into());
604 if container_cmds.len() > 1 {
605 let chain = compose_shell_command(bin, container_cmds);
606 let reproduce = format!(
607 "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} --entrypoint /bin/sh {image} -c {}",
608 shell_escape::escape(chain.clone().into())
609 );
610 (Some("/bin/sh"), vec!["-c".to_string(), chain], reproduce)
611 } else {
612 let cmd = container_cmds.first().cloned().unwrap_or_default();
613 let reproduce = format!(
614 "{prefix} run --rm{run_flags} -v {bind} -w /source{env_flags} {image} {}",
615 escape_args(&cmd)
616 );
617 (None, cmd, reproduce)
618 }
619}
620
621fn compose_shell_command(bin: &str, cmds: &[Vec<String>]) -> String {
626 cmds.iter()
627 .map(|cmd| {
628 std::iter::once(bin)
629 .chain(cmd.iter().map(String::as_str))
630 .map(|tok| shell_escape::escape(tok.into()).into_owned())
631 .collect::<Vec<_>>()
632 .join(" ")
633 })
634 .collect::<Vec<_>>()
635 .join(" && ")
636}
637
638fn escape_args(cmd: &[String]) -> String {
641 cmd.iter()
642 .map(|tok| shell_escape::escape(tok.into()).into_owned())
643 .collect::<Vec<_>>()
644 .join(" ")
645}
646
647#[cfg(unix)]
650async fn wait_for_termination_signal() {
651 use tokio::signal::unix::{signal, SignalKind};
652
653 let mut sigint = signal(SignalKind::interrupt());
654 let mut sigterm = signal(SignalKind::terminate());
655 let mut sighup = signal(SignalKind::hangup());
656 let mut sigquit = signal(SignalKind::quit());
657
658 tokio::select! {
659 () = recv_signal(&mut sigint) => {},
660 () = recv_signal(&mut sigterm) => {},
661 () = recv_signal(&mut sighup) => {},
662 () = recv_signal(&mut sigquit) => {},
663 }
664}
665
666#[cfg(unix)]
669async fn recv_signal(s: &mut std::io::Result<tokio::signal::unix::Signal>) {
670 match s {
671 Ok(s) => {
672 s.recv().await;
673 }
674 Err(_) => std::future::pending().await,
675 }
676}
677
678#[cfg(not(unix))]
679async fn wait_for_termination_signal() {
680 let _ = tokio::signal::ctrl_c().await;
681}
682
683fn newest_existing_artifact(candidates: &[PathBuf]) -> Option<PathBuf> {
690 candidates
691 .iter()
692 .filter(|p| p.exists())
693 .max_by_key(|p| {
694 std::fs::metadata(p)
695 .and_then(|m| m.modified())
696 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
697 })
698 .cloned()
699}
700
701fn collect_built_contracts(
707 cmd: &Cmd,
708 md: &cargo_metadata::Metadata,
709 workspace_root: &Path,
710) -> Result<Vec<BuiltContract>, super::Error> {
711 let target_root = workspace_root.join("target");
712
713 let mut out = Vec::new();
714 for p in &md.packages {
715 let is_cdylib = p
716 .targets
717 .iter()
718 .any(|t| t.crate_types.iter().any(|c| c == "cdylib"));
719 if !is_cdylib {
720 continue;
721 }
722 if let Some(name) = &cmd.package {
723 if &p.name != name {
724 continue;
725 }
726 } else if !md.workspace_default_members.contains(&p.id) {
727 continue;
728 }
729
730 let file = format!("{}.wasm", p.name.replace('-', "_"));
731 let candidates: Vec<PathBuf> = [WASM_TARGET, WASM_TARGET_OLD]
738 .iter()
739 .map(|triple| target_root.join(triple).join(&cmd.profile).join(&file))
740 .collect();
741 let src = newest_existing_artifact(&candidates).unwrap_or_else(|| {
742 let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string());
743 target_root.join(triple).join(&cmd.profile).join(&file)
744 });
745
746 let path = if let Some(out_dir) = &cmd.out_dir {
747 std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?;
748 let dest = out_dir.join(&file);
749 if src.exists() {
750 std::fs::copy(&src, &dest).map_err(super::Error::CopyingWasmFile)?;
751 }
752 dest
753 } else {
754 src
755 };
756
757 out.push(BuiltContract {
758 name: p.name.clone(),
759 path,
760 });
761 }
762
763 Ok(out)
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use crate::commands::contract::build::BuildArgs;
770
771 fn ws() -> PathBuf {
772 std::path::absolute(Path::new("/tmp/ws")).unwrap()
776 }
777
778 #[test]
779 fn forwarded_build_args_defaults() {
780 let cmd = Cmd::default();
781 let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
782 assert_eq!(args[..2], ["contract".to_string(), "build".to_string()]);
783 assert!(args.contains(&"--optimize".to_string()));
785 assert!(!args.iter().any(|a| a == "--locked"));
786 assert!(!args.iter().any(|a| a.starts_with("--package")));
787 }
788
789 #[test]
790 fn forwarded_build_args_locked_and_package() {
791 let cmd = Cmd {
792 locked: true,
793 ..Cmd::default()
794 };
795 let args = forwarded_build_args(&cmd, &ws(), Some("contract-a"), true, true, true);
796 assert!(args.contains(&"--locked".to_string()));
797 assert!(args.contains(&"--package=contract-a".to_string()));
798 }
799
800 #[test]
801 fn forwarded_build_args_drops_locked_when_unsupported() {
802 let cmd = Cmd {
804 locked: true,
805 ..Cmd::default()
806 };
807 let args = forwarded_build_args(&cmd, &ws(), None, false, true, true);
808 assert!(!args.iter().any(|a| a == "--locked"));
809 }
810
811 #[test]
812 fn forwarded_build_args_omits_optimize_when_flag_unsupported() {
813 let cmd = Cmd::default();
816 assert!(cmd.build_args.optimize);
817 let args = forwarded_build_args(&cmd, &ws(), None, true, false, false);
818 assert!(!args.iter().any(|a| a.starts_with("--optimize")));
819 }
820
821 #[test]
822 fn forwarded_build_args_features_meta_and_profile() {
823 let cmd = Cmd {
824 profile: "dev".to_string(),
825 features: Some("a,b".to_string()),
826 all_features: true,
827 no_default_features: true,
828 build_args: BuildArgs {
829 meta: vec![
830 ("home_domain".to_string(), "example.com".to_string()),
831 ("author".to_string(), "alice".to_string()),
832 ],
833 optimize: false,
834 },
835 ..Cmd::default()
836 };
837 let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
838 assert!(args.contains(&"--profile=dev".to_string()));
839 assert!(args.contains(&"--features=a,b".to_string()));
840 assert!(args.contains(&"--all-features".to_string()));
841 assert!(args.contains(&"--no-default-features".to_string()));
842 assert!(args.contains(&"--meta=home_domain=example.com".to_string()));
843 assert!(args.contains(&"--meta=author=alice".to_string()));
844 assert!(args.contains(&"--optimize=false".to_string()));
845 }
846
847 #[test]
848 fn forwarded_build_args_optimize_false_old_image_forwards_nothing() {
849 let cmd = Cmd {
852 build_args: BuildArgs {
853 optimize: false,
854 ..BuildArgs::default()
855 },
856 ..Cmd::default()
857 };
858 let args = forwarded_build_args(&cmd, &ws(), None, true, true, false);
859 assert!(!args.iter().any(|a| a.starts_with("--optimize")));
860 }
861
862 #[test]
863 fn forwarded_build_args_relativizes_manifest_path() {
864 let cmd = Cmd {
865 manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")),
866 ..Cmd::default()
867 };
868 let args = forwarded_build_args(&cmd, &ws(), None, true, true, true);
869 assert!(args.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string()));
870 }
871
872 #[test]
873 fn compose_shell_command_chains_and_escapes() {
874 let a = vec![
875 "contract".to_string(),
876 "build".to_string(),
877 "--package=another".to_string(),
878 "--meta=note=added on build".to_string(),
879 ];
880 let b = vec![
881 "contract".to_string(),
882 "build".to_string(),
883 "--package=hello-world".to_string(),
884 ];
885 let s = compose_shell_command("stellar", &[a.clone(), b.clone()]);
886 assert!(s.contains("stellar contract build --package=another"));
887 assert!(s.contains("&&"));
888 assert!(s.contains("stellar contract build --package=hello-world"));
889 assert!(
891 s.contains("'--meta=note=added on build'")
892 || s.contains("\"--meta=note=added on build\""),
893 "expected the spaced token to be quoted, got: {s}"
894 );
895
896 let s = compose_shell_command("soroban", &[a, b]);
898 assert!(s.contains("soroban contract build --package=another"));
899 assert!(s.contains("soroban contract build --package=hello-world"));
900 assert!(!s.contains("stellar"));
901 }
902
903 #[test]
904 fn compose_invocation_single_package_uses_default_entrypoint() {
905 let cmds = vec![vec![
906 "contract".to_string(),
907 "build".to_string(),
908 "--meta=field=value".to_string(),
909 "--optimize".to_string(),
910 ]];
911 let (entrypoint, post_image, reproduce) = compose_invocation(
912 "docker",
913 "",
914 "/ws:/source",
915 " -e CARGO_TARGET_DIR=/source/target",
916 "docker.io/stellar/stellar-cli:latest",
917 "stellar",
918 &cmds,
919 );
920 assert!(entrypoint.is_none());
921 assert_eq!(post_image, cmds[0]);
922 assert_eq!(
925 reproduce,
926 "docker run --rm -v '/ws:/source' -w /source \
927 -e CARGO_TARGET_DIR=/source/target \
928 'docker.io/stellar/stellar-cli:latest' \
929 contract build --meta=field=value --optimize"
930 );
931 }
932
933 #[test]
934 fn compose_invocation_escapes_spaced_bind_and_image() {
935 let cmds = vec![vec!["contract".to_string(), "build".to_string()]];
938 let (_entrypoint, _post_image, reproduce) = compose_invocation(
939 "docker",
940 "",
941 "/Users/me/My Project/ws:/source",
942 "",
943 "my registry/img:tag",
944 "stellar",
945 &cmds,
946 );
947 let tokens = shlex::split(&reproduce).expect("reproduce line must be valid shell");
949 assert!(tokens.contains(&"/Users/me/My Project/ws:/source".to_string()));
950 assert!(tokens.contains(&"my registry/img:tag".to_string()));
951 }
952
953 #[test]
954 fn compose_invocation_includes_engine_prefix_verbatim() {
955 let cmds = vec![vec!["contract".to_string(), "build".to_string()]];
958 let (_entrypoint, _post_image, reproduce) = compose_invocation(
959 "docker -H ssh://host",
960 "",
961 "/ws:/source",
962 "",
963 "img:tag",
964 "stellar",
965 &cmds,
966 );
967 assert!(
968 reproduce.starts_with(
969 "docker -H ssh://host run --rm -v '/ws:/source' -w /source 'img:tag' contract build"
970 ),
971 "got: {reproduce}"
972 );
973 }
974
975 #[test]
976 fn compose_invocation_multi_package_chains_through_shell() {
977 let cmds = vec![
978 vec![
979 "contract".to_string(),
980 "build".to_string(),
981 "--package=a".to_string(),
982 ],
983 vec![
984 "contract".to_string(),
985 "build".to_string(),
986 "--package=b".to_string(),
987 ],
988 ];
989 let (entrypoint, post_image, reproduce) = compose_invocation(
990 "container",
991 " --cpus 2",
992 "/ws:/source",
993 "",
994 "img:tag",
995 "stellar",
996 &cmds,
997 );
998 assert_eq!(entrypoint, Some("/bin/sh"));
999 assert_eq!(post_image[0], "-c");
1000 assert_eq!(
1001 post_image[1],
1002 "stellar contract build --package=a && stellar contract build --package=b"
1003 );
1004 assert!(reproduce
1005 .starts_with("container run --rm --cpus 2 -v '/ws:/source' -w /source --entrypoint /bin/sh 'img:tag' -c "));
1006 assert!(reproduce.contains(
1008 "'stellar contract build --package=a && stellar contract build --package=b'"
1009 ));
1010
1011 let (_entrypoint, post_image, reproduce) = compose_invocation(
1013 "container",
1014 "",
1015 "/ws:/source",
1016 "",
1017 "img:tag",
1018 "soroban",
1019 &cmds,
1020 );
1021 assert_eq!(
1022 post_image[1],
1023 "soroban contract build --package=a && soroban contract build --package=b"
1024 );
1025 assert!(reproduce.contains(
1026 "'soroban contract build --package=a && soroban contract build --package=b'"
1027 ));
1028 }
1029
1030 #[test]
1031 fn parse_cli_version_reads_first_line_only() {
1032 assert_eq!(
1034 parse_cli_version(
1035 "soroban-cli 0.1.2 (70110a1eb3e3af0bee4ac93d005eb2614e9c8e85)\n\
1036 soroban-env-interface-version: 23\n"
1037 ),
1038 Some(Version::parse("0.1.2").unwrap())
1039 );
1040 assert_eq!(
1042 parse_cli_version(
1043 "stellar 27.1.0 (abc123)\n\
1044 stellar-xdr 22.1.0 (def456)\n\
1045 xdr curr (ghi789)\n"
1046 ),
1047 Some(Version::parse("27.1.0").unwrap())
1048 );
1049 assert_eq!(
1051 parse_cli_version("stellar 26.1.0\n"),
1052 Some(Version::parse("26.1.0").unwrap())
1053 );
1054 assert_eq!(parse_cli_version(""), None);
1055 assert_eq!(parse_cli_version("not a version\n"), None);
1056 }
1057
1058 #[test]
1059 fn probe_value_splits_tagged_combined_output() {
1060 let stdout = "BIN:stellar\n\
1061 VERSION:stellar 27.1.0 (abc123)\n\
1062 TOOLCHAIN:1.97.1-aarch64-unknown-linux-gnu (default)\n";
1063 assert_eq!(probe_value(stdout, "BIN:"), "stellar");
1064 assert_eq!(probe_value(stdout, "VERSION:"), "stellar 27.1.0 (abc123)");
1065 assert_eq!(
1066 probe_value(stdout, "TOOLCHAIN:"),
1067 "1.97.1-aarch64-unknown-linux-gnu (default)"
1068 );
1069 assert_eq!(
1071 parse_cli_version(probe_value(stdout, "VERSION:")),
1072 Some(Version::parse("27.1.0").unwrap())
1073 );
1074 assert_eq!(
1075 parse_default_toolchain(probe_value(stdout, "TOOLCHAIN:")).as_deref(),
1076 Some("1.97.1-aarch64-unknown-linux-gnu")
1077 );
1078 assert_eq!(probe_value("BIN:soroban\n", "TOOLCHAIN:"), "");
1080 }
1081
1082 #[test]
1083 fn parse_default_toolchain_extracts_name() {
1084 assert_eq!(
1085 parse_default_toolchain("1.97.1-aarch64-unknown-linux-gnu (default)\n").as_deref(),
1086 Some("1.97.1-aarch64-unknown-linux-gnu")
1087 );
1088 assert_eq!(
1089 parse_default_toolchain("stable-x86_64-unknown-linux-gnu (default)").as_deref(),
1090 Some("stable-x86_64-unknown-linux-gnu")
1091 );
1092 assert_eq!(parse_default_toolchain("").as_deref(), None);
1093 assert_eq!(parse_default_toolchain(" \n").as_deref(), None);
1094 }
1095
1096 #[test]
1097 fn newest_existing_artifact_prefers_freshest_not_first() {
1098 use std::time::{Duration, SystemTime};
1099 let dir = tempfile::tempdir().unwrap();
1100 let old = dir.path().join("old.wasm");
1101 let new = dir.path().join("new.wasm");
1102 std::fs::write(&old, b"old").unwrap();
1103 std::fs::write(&new, b"new").unwrap();
1104 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
1107 std::fs::OpenOptions::new()
1108 .write(true)
1109 .open(&old)
1110 .unwrap()
1111 .set_modified(base)
1112 .unwrap();
1113 std::fs::OpenOptions::new()
1114 .write(true)
1115 .open(&new)
1116 .unwrap()
1117 .set_modified(base + Duration::from_mins(1))
1118 .unwrap();
1119
1120 assert_eq!(
1123 newest_existing_artifact(&[old.clone(), new.clone()]),
1124 Some(new)
1125 );
1126 let missing = dir.path().join("missing.wasm");
1128 assert_eq!(
1129 newest_existing_artifact(&[missing.clone(), old.clone()]),
1130 Some(old)
1131 );
1132 assert_eq!(newest_existing_artifact(&[missing]), None);
1134 }
1135
1136 #[test]
1137 fn escape_args_round_trips_spaced_tokens() {
1138 let cmd = vec![
1139 "contract".to_string(),
1140 "build".to_string(),
1141 "--meta=note=added on build".to_string(),
1142 ];
1143 let s = escape_args(&cmd);
1144 let tokens = shlex::split(&s).expect("reproduce args must be valid shell");
1145 assert_eq!(
1146 tokens,
1147 vec!["contract", "build", "--meta=note=added on build"]
1148 );
1149 }
1150}