use crate::compile::env::{CstiWorkspace, HmiConfig};
use crate::compile::web::HmiBuildCommand;
use crate::progress::BuildReporter;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command;
pub fn inject_csti(cmd: &mut Command, workspace: Option<&CstiWorkspace>) {
if let Some(workspace) = workspace {
for (key, value) in workspace.env_pairs() {
cmd.env(key, value);
}
}
}
pub fn inject_hmi(cmd: &mut Command, config: Option<&HmiConfig>) {
if let Some(hmi) = config {
for (key, value) in hmi.env_pairs() {
cmd.env(key, value);
}
}
}
pub async fn run_command(
cmd: &mut Command,
reporter: Option<&dyn BuildReporter>,
) -> std::io::Result<ExitStatus> {
let Some(reporter) = reporter else {
return cmd.status().await;
};
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_stream = stream_output(stdout, reporter);
let stderr_stream = stream_output(stderr, reporter);
let wait = child.wait();
let (stdout_result, stderr_result, status) = tokio::join!(stdout_stream, stderr_stream, wait);
stdout_result?;
stderr_result?;
status
}
async fn stream_output<R>(reader: Option<R>, reporter: &dyn BuildReporter) -> std::io::Result<()>
where
R: AsyncRead + Unpin,
{
let Some(mut reader) = reader else {
return Ok(());
};
let mut buf = [0_u8; 8192];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
return Ok(());
}
reporter.command_output(&String::from_utf8_lossy(&buf[..n]));
}
}
pub fn clean_path(native_sysroot: &str) -> String {
let path = std::env::var("PATH").unwrap_or_default();
std::env::split_paths(&path)
.filter(|p| {
if native_sysroot.is_empty() {
let s = p.to_string_lossy();
!s.contains("aarch64-ostl_sdk-linux") && !s.contains("aarch64-ostl-linux")
} else {
!p.starts_with(native_sysroot)
}
})
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(":")
}
pub fn resolve_cross_tool(tool: &str) -> Result<String, String> {
let path = std::env::var("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path) {
let candidate = dir.join(tool);
if candidate.is_file() {
return Ok(candidate.to_string_lossy().into_owned());
}
}
Err(format!(
"Cross tool '{}' not found on PATH. \
Is the ST environment-setup script sourced?",
tool
))
}
pub fn resolve_hmi_build_command() -> Result<HmiBuildCommand, String> {
resolve_hmi_build_command_from(std::env::var("CONSORTIUM_HMI_BUILD_CMD").ok(), |tool| {
resolve_cross_tool(tool).ok()
})
}
fn resolve_hmi_build_command_from(
env_cmd: Option<String>,
resolve_tool: impl Fn(&str) -> Option<String>,
) -> Result<HmiBuildCommand, String> {
if let Some(cmd) = env_cmd {
let parts: Vec<String> = cmd.split_whitespace().map(String::from).collect();
if parts.is_empty() {
return Err("CONSORTIUM_HMI_BUILD_CMD is set but empty".to_string());
}
return Ok(HmiBuildCommand::Custom(parts));
}
if let Some(program) = resolve_tool("vp") {
return Ok(HmiBuildCommand::Resolved {
program,
args: vec!["build".to_string()],
});
}
if let Some(program) = resolve_tool("npm") {
return Ok(HmiBuildCommand::Resolved {
program,
args: vec!["run".to_string(), "build".to_string()],
});
}
Err(
"no HMI build tool found: set CONSORTIUM_HMI_BUILD_CMD, or install `vp` \
or `npm` on PATH"
.to_string(),
)
}
pub fn resolve_pocket_build_command(app: &str, outdir: &Path) -> Result<HmiBuildCommand, String> {
resolve_pocket_build_command_from(
std::env::var("CONSORTIUM_HMI_BUILD_CMD").ok(),
app,
outdir,
|tool| resolve_cross_tool(tool).ok(),
)
}
fn resolve_pocket_build_command_from(
env_cmd: Option<String>,
app: &str,
outdir: &Path,
resolve_tool: impl Fn(&str) -> Option<String>,
) -> Result<HmiBuildCommand, String> {
if let Some(cmd) = env_cmd {
let parts: Vec<String> = cmd.split_whitespace().map(String::from).collect();
if parts.is_empty() {
return Err("CONSORTIUM_HMI_BUILD_CMD is set but empty".to_string());
}
return Ok(HmiBuildCommand::Custom(parts));
}
let program = resolve_tool("bun").ok_or_else(|| {
"PocketJS build needs `bun`: install it (https://bun.sh) or set \
CONSORTIUM_HMI_BUILD_CMD"
.to_string()
})?;
Ok(HmiBuildCommand::Resolved {
program,
args: vec![
"tools/build.ts".to_string(),
app.to_string(),
format!("--outdir={}", outdir.display()),
],
})
}
pub fn resolve_hmi_fetch_command() -> Result<HmiBuildCommand, String> {
resolve_hmi_fetch_command_from(std::env::var(HMI_FETCH_CMD_ENV).ok(), |tool| {
resolve_cross_tool(tool).ok()
})
}
pub const HMI_FETCH_CMD_ENV: &str = "CONSORTIUM_HMI_FETCH_CMD";
fn resolve_hmi_fetch_command_from(
env_cmd: Option<String>,
resolve_tool: impl Fn(&str) -> Option<String>,
) -> Result<HmiBuildCommand, String> {
if let Some(cmd) = custom_command(env_cmd)? {
return Ok(cmd);
}
for tool in ["vp", "npm"] {
if let Some(program) = resolve_tool(tool) {
return Ok(HmiBuildCommand::Resolved {
program,
args: vec!["install".to_string()],
});
}
}
Err(format!(
"no HMI package manager found: set {HMI_FETCH_CMD_ENV}, or install `vp` \
or `npm` on PATH"
))
}
pub fn resolve_pocket_fetch_command() -> Result<HmiBuildCommand, String> {
resolve_pocket_fetch_command_from(std::env::var(HMI_FETCH_CMD_ENV).ok(), |tool| {
resolve_cross_tool(tool).ok()
})
}
fn resolve_pocket_fetch_command_from(
env_cmd: Option<String>,
resolve_tool: impl Fn(&str) -> Option<String>,
) -> Result<HmiBuildCommand, String> {
if let Some(cmd) = custom_command(env_cmd)? {
return Ok(cmd);
}
let program = resolve_tool("bun").ok_or_else(|| {
format!(
"PocketJS dependency install needs `bun`: install it (https://bun.sh) \
or set {HMI_FETCH_CMD_ENV}"
)
})?;
Ok(HmiBuildCommand::Resolved {
program,
args: vec!["install".to_string()],
})
}
fn custom_command(env_cmd: Option<String>) -> Result<Option<HmiBuildCommand>, String> {
let Some(cmd) = env_cmd else {
return Ok(None);
};
let parts: Vec<String> = cmd.split_whitespace().map(String::from).collect();
if parts.is_empty() {
return Err("HMI command override is set but empty".to_string());
}
Ok(Some(HmiBuildCommand::Custom(parts)))
}
pub async fn resolve_rustup(tool: &str) -> Result<String, String> {
let output = Command::new("rustup")
.args(["which", "--toolchain", "stable", tool])
.output()
.await
.map_err(|e| format!("Failed to run rustup: {}", e))?;
if !output.status.success() {
return Err(format!(
"rustup could not resolve '{}' for the stable toolchain: {}",
tool,
String::from_utf8_lossy(&output.stderr).trim(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn binary_name(path: &Path) -> Result<String, String> {
let cargo_toml_path = path.join("Cargo.toml");
let content = tokio::fs::read_to_string(&cargo_toml_path)
.await
.map_err(|e| format!("Failed to read Cargo.toml at {:?}: {}", cargo_toml_path, e))?;
let manifest: toml::Value =
toml::from_str(&content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
if let Some(name) = manifest
.get("bin")
.and_then(|b| b.as_array())
.and_then(|arr| arr.first())
.and_then(|b| b.get("name"))
.and_then(|n| n.as_str())
{
return Ok(name.to_string());
}
manifest
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|n| n.to_string())
.ok_or_else(|| "Could not determine binary name from Cargo.toml".to_string())
}
pub async fn verify_elf(path: &PathBuf) -> Result<(), String> {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|e| format!("Failed to open ELF file {:?}: {}", path, e))?;
let mut magic = [0u8; 4];
file.read_exact(&mut magic)
.await
.map_err(|e| format!("Failed to read ELF magic bytes: {}", e))?;
if magic != [0x7f, b'E', b'L', b'F'] {
return Err(format!(
"File {:?} is not a valid ELF binary (magic: {:02x?})",
path, magic
));
}
Ok(())
}
pub async fn resolve_cargo() -> Result<String, String> {
match std::env::var("CONSORTIUM_CARGO") {
Ok(v) => Ok(v),
Err(_) => resolve_rustup("cargo").await,
}
}
pub async fn resolve_rustc() -> Result<String, String> {
match std::env::var("CONSORTIUM_RUSTC") {
Ok(v) => Ok(v),
Err(_) => resolve_rustup("rustc").await,
}
}
pub async fn elf_path(
path: &Path,
workspace: Option<&CstiWorkspace>,
target: &str,
release: bool,
) -> Result<PathBuf, String> {
let binary_name = binary_name(path).await?;
let profile = if release { "release" } else { "debug" };
let base = workspace.map_or(path, |w| w.target_base(path));
Ok(base
.join("target")
.join(target)
.join(profile)
.join(binary_name))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::OsStr;
fn injected_vars(cmd: &Command) -> HashMap<String, String> {
cmd.as_std()
.get_envs()
.filter_map(|(k, v)| {
let key = k.to_str()?;
if !key.starts_with("CONSORTIUM_") {
return None;
}
Some((key.to_string(), v?.to_str()?.to_string()))
})
.collect()
}
#[test]
fn inject_hmi_sets_dist_and_debug_url() {
let hmi = HmiConfig::Cog {
dist: "/opt/app/dist".to_string(),
debug_url: Some("http://localhost:5173".to_string()),
};
let mut cmd = Command::new("true");
inject_hmi(&mut cmd, Some(&hmi));
let vars = injected_vars(&cmd);
assert_eq!(
vars.get("CONSORTIUM_HMI_DIST").map(String::as_str),
Some("/opt/app/dist")
);
assert_eq!(
vars.get("CONSORTIUM_HMI_DEBUG_URL").map(String::as_str),
Some("http://localhost:5173")
);
assert_eq!(vars.len(), 2);
}
#[test]
fn inject_hmi_omits_debug_url_when_absent() {
let hmi = HmiConfig::Cog {
dist: "/opt/app/dist".to_string(),
debug_url: None,
};
let mut cmd = Command::new("true");
inject_hmi(&mut cmd, Some(&hmi));
let vars = injected_vars(&cmd);
assert!(vars.contains_key("CONSORTIUM_HMI_DIST"));
assert!(!vars.contains_key("CONSORTIUM_HMI_DEBUG_URL"));
}
#[test]
fn inject_hmi_none_injects_nothing() {
let mut cmd = Command::new("true");
inject_hmi(&mut cmd, None);
assert!(injected_vars(&cmd).is_empty());
}
#[test]
fn toml_hmi_section_round_trips_through_injected_env() {
#[derive(serde::Deserialize)]
struct Doc {
hmi: HmiConfig,
}
let manifest = "\
[hmi]\n\
engine = \"cog\"\n\
dist = \"dist\"\n\
debug_url = \"http://localhost:5173\"\n";
let doc: Doc = toml::from_str(manifest).expect("[hmi] should deserialize");
let mut cmd = Command::new("true");
inject_hmi(&mut cmd, Some(&doc.hmi));
let vars = injected_vars(&cmd);
assert_eq!(
vars.get("CONSORTIUM_HMI_DIST").map(String::as_str),
Some("dist")
);
assert_eq!(
vars.get("CONSORTIUM_HMI_DEBUG_URL").map(String::as_str),
Some("http://localhost:5173")
);
}
#[test]
fn toml_tee_section_round_trips_to_injected_uuid() {
use consortium_cfg_common::config::{TEE_UUID_ENV, Tee};
#[derive(serde::Deserialize)]
struct Doc {
tee: Tee,
}
let manifest = "[tee]\nuuid = \"12345678-1234-1234-1234-123456789012\"\n";
let doc: Doc = toml::from_str(manifest).expect("[tee] should deserialize");
let mut cmd = Command::new("true");
cmd.env(TEE_UUID_ENV, doc.tee.uuid.to_string());
let vars = injected_vars(&cmd);
assert_eq!(
vars.get(TEE_UUID_ENV).map(String::as_str),
Some("12345678-1234-1234-1234-123456789012")
);
assert_eq!(TEE_UUID_ENV, "CONSORTIUM_TEE_UUID");
}
#[test]
fn hmi_command_override_beats_path_tools() {
let cmd = resolve_hmi_build_command_from(Some("pnpm build".to_string()), |_| {
Some("/tools/ignored".to_string())
})
.expect("override resolves");
assert_eq!(
cmd,
HmiBuildCommand::Custom(vec!["pnpm".to_string(), "build".to_string()])
);
}
#[test]
fn hmi_command_prefers_vp_then_npm() {
let cmd = resolve_hmi_build_command_from(None, |tool| Some(format!("/tools/{tool}")))
.expect("vp resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/vp".to_string(),
args: vec!["build".to_string()],
}
);
let cmd = resolve_hmi_build_command_from(None, |tool| {
(tool == "npm").then(|| "/tools/npm".to_string())
})
.expect("npm resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
}
);
}
#[test]
fn hmi_command_errors_when_nothing_available() {
let err = resolve_hmi_build_command_from(None, |_| None)
.expect_err("no tool and no override is an error");
assert!(err.contains("CONSORTIUM_HMI_BUILD_CMD"));
let err = resolve_hmi_build_command_from(Some(" ".to_string()), |_| None)
.expect_err("empty override is an error");
assert!(err.contains("empty"));
}
#[test]
fn pocket_command_runs_bun_build_with_outdir() {
let cmd =
resolve_pocket_build_command_from(None, "hero-main", Path::new("/proj/dist"), |tool| {
(tool == "bun").then(|| "/tools/bun".to_string())
})
.expect("bun resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/bun".to_string(),
args: vec![
"tools/build.ts".to_string(),
"hero-main".to_string(),
"--outdir=/proj/dist".to_string(),
],
}
);
}
#[test]
fn pocket_command_override_beats_bun() {
let cmd = resolve_pocket_build_command_from(
Some("just pocket hero-main".to_string()),
"hero-main",
Path::new("/proj/dist"),
|_| Some("/tools/ignored".to_string()),
)
.expect("override resolves");
assert_eq!(
cmd,
HmiBuildCommand::Custom(vec![
"just".to_string(),
"pocket".to_string(),
"hero-main".to_string(),
])
);
}
#[test]
fn pocket_command_errors_without_bun() {
let err =
resolve_pocket_build_command_from(None, "hero-main", Path::new("/proj/dist"), |_| None)
.expect_err("missing bun is an error");
assert!(err.contains("bun"));
}
#[test]
fn hmi_fetch_command_prefers_vp_then_npm() {
let cmd = resolve_hmi_fetch_command_from(None, |tool| Some(format!("/tools/{tool}")))
.expect("vp resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/vp".to_string(),
args: vec!["install".to_string()],
}
);
let cmd = resolve_hmi_fetch_command_from(None, |tool| {
(tool == "npm").then(|| "/tools/npm".to_string())
})
.expect("npm resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/npm".to_string(),
args: vec!["install".to_string()],
}
);
}
#[test]
fn hmi_fetch_command_override_beats_path_tools() {
let cmd = resolve_hmi_fetch_command_from(Some("/bin/true".to_string()), |_| {
Some("/tools/ignored".to_string())
})
.expect("override resolves");
assert_eq!(cmd, HmiBuildCommand::Custom(vec!["/bin/true".to_string()]));
}
#[test]
fn hmi_fetch_command_errors_when_nothing_available() {
let err = resolve_hmi_fetch_command_from(None, |_| None)
.expect_err("no package manager and no override is an error");
assert!(err.contains(HMI_FETCH_CMD_ENV));
let err = resolve_hmi_fetch_command_from(Some(" ".to_string()), |_| None)
.expect_err("empty override is an error");
assert!(err.contains("empty"));
}
#[test]
fn pocket_fetch_command_runs_bun_install() {
let cmd = resolve_pocket_fetch_command_from(None, |tool| {
(tool == "bun").then(|| "/tools/bun".to_string())
})
.expect("bun resolves");
assert_eq!(
cmd,
HmiBuildCommand::Resolved {
program: "/tools/bun".to_string(),
args: vec!["install".to_string()],
}
);
let err =
resolve_pocket_fetch_command_from(None, |_| None).expect_err("missing bun is an error");
assert!(err.contains("bun"));
}
#[test]
fn injected_vars_helper_ignores_unrelated_env() {
let mut cmd = Command::new("true");
cmd.env(OsStr::new("PATH"), "/usr/bin");
assert!(injected_vars(&cmd).is_empty());
}
}