use std::path::{Path, PathBuf};
use consortium_cfg_common::config::HmiEngine;
use thiserror::Error;
use tokio::process::Command;
use super::Compiler;
use crate::compile::utils::run_command;
use crate::progress::BuildReporter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HmiBuildCommand {
VpBuild,
NpmRunBuild,
Resolved {
program: String,
args: Vec<String>,
},
Custom(Vec<String>),
}
impl HmiBuildCommand {
pub(crate) fn program_args(&self) -> (&str, Vec<&str>) {
match self {
Self::VpBuild => ("vp", vec!["build"]),
Self::NpmRunBuild => ("npm", vec!["run", "build"]),
Self::Resolved { program, args } => {
(program.as_str(), args.iter().map(String::as_str).collect())
}
Self::Custom(parts) => {
let program = parts.first().map(String::as_str).unwrap_or_default();
let args = parts.iter().skip(1).map(String::as_str).collect();
(program, args)
}
}
}
}
#[derive(Error, Debug)]
pub enum HmiCompileError {
#[error("Failed to resolve HMI build command: {0}")]
CommandResolveError(String),
#[error("HMI project directory not found: {0:?}")]
ProjectDirNotFound(PathBuf),
#[error("HMI build failed: {0}")]
BuildFailed(String),
#[error("HMI dist directory not found after build: {0:?}")]
DistNotFound(PathBuf),
#[error("HMI dist directory is empty after build: {0:?}")]
DistEmpty(PathBuf),
#[error("PocketJS artifact not found after build: {0:?}")]
PocketArtifactNotFound(PathBuf),
#[error("PocketJS build requires `[hmi].app` (the entry name, e.g. \"hero-main\")")]
PocketAppMissing,
#[error("{0}")]
UtilityError(String),
}
#[derive(Clone)]
pub struct HmiConfiguration {
pub engine: HmiEngine,
pub app: Option<String>,
pub project_dir: PathBuf,
pub dist_dir: PathBuf,
pub command: HmiBuildCommand,
}
pub struct HmiCompiler;
impl HmiCompiler {
pub async fn build_with_reporter(
config: HmiConfiguration,
reporter: Option<&dyn BuildReporter>,
) -> Result<PathBuf, HmiCompileError> {
let (program, args) = config.command.program_args();
if program.is_empty() {
return Err(HmiCompileError::CommandResolveError(
"HMI build command is empty".to_string(),
));
}
match tokio::fs::metadata(&config.project_dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) | Err(_) => {
return Err(HmiCompileError::ProjectDirNotFound(config.project_dir));
}
}
let mut cmd = Command::new(program);
cmd.args(&args).current_dir(&config.project_dir);
let status = run_command(&mut cmd, reporter).await.map_err(|e| {
HmiCompileError::BuildFailed(format!("failed to launch `{program}`: {e}"))
})?;
if !status.success() {
return Err(HmiCompileError::BuildFailed(format!(
"`{program}` exited with status: {status}"
)));
}
match config.engine {
HmiEngine::Cog => verify_dist(&config.dist_dir).await?,
HmiEngine::Pocket => {
verify_pocket_artifacts(&config.dist_dir, config.app.as_deref()).await?
}
}
Ok(config.dist_dir)
}
}
impl Compiler for HmiCompiler {
type Configuration = HmiConfiguration;
type Error = HmiCompileError;
type Output = PathBuf;
async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
Self::build_with_reporter(config, None).await
}
async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
Ok(config.dist_dir.clone())
}
}
async fn verify_dist(dist: &PathBuf) -> Result<(), HmiCompileError> {
match tokio::fs::metadata(dist).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => return Err(HmiCompileError::DistNotFound(dist.clone())),
Err(_) => return Err(HmiCompileError::DistNotFound(dist.clone())),
}
let mut entries = tokio::fs::read_dir(dist)
.await
.map_err(|e| HmiCompileError::UtilityError(format!("reading dist {dist:?}: {e}")))?;
match entries.next_entry().await {
Ok(Some(_)) => Ok(()),
Ok(None) => Err(HmiCompileError::DistEmpty(dist.clone())),
Err(e) => Err(HmiCompileError::UtilityError(format!(
"reading dist entries {dist:?}: {e}"
))),
}
}
async fn verify_pocket_artifacts(dist: &Path, app: Option<&str>) -> Result<(), HmiCompileError> {
let app = app
.map(str::trim)
.filter(|a| !a.is_empty())
.ok_or(HmiCompileError::PocketAppMissing)?;
for ext in ["js", "pak"] {
let artifact = dist.join(format!("{app}.{ext}"));
match tokio::fs::metadata(&artifact).await {
Ok(meta) if meta.is_file() => {}
_ => return Err(HmiCompileError::PocketArtifactNotFound(artifact)),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(tag: &str) -> PathBuf {
let base = std::env::var("SCRATCH_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let dir = base.join(format!("consortium-hmi-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn program_args_map_each_command() {
assert_eq!(
HmiBuildCommand::VpBuild.program_args(),
("vp", vec!["build"])
);
assert_eq!(
HmiBuildCommand::NpmRunBuild.program_args(),
("npm", vec!["run", "build"])
);
assert_eq!(
HmiBuildCommand::Resolved {
program: "/tools/vp".into(),
args: vec!["build".into()],
}
.program_args(),
("/tools/vp", vec!["build"])
);
assert_eq!(
HmiBuildCommand::Custom(vec!["pnpm".into(), "build".into()]).program_args(),
("pnpm", vec!["build"])
);
}
#[tokio::test]
async fn build_runs_command_and_verifies_dist() {
let project = tmp_dir("ok");
let dist = project.join("dist");
let cfg = HmiConfiguration {
engine: HmiEngine::Cog,
app: None,
project_dir: project.clone(),
dist_dir: dist.clone(),
command: HmiBuildCommand::Custom(vec![
"sh".into(),
"-c".into(),
"mkdir -p dist && printf '<html></html>' > dist/index.html".into(),
]),
};
let out = HmiCompiler::build(cfg).await.expect("hmi build succeeds");
assert_eq!(out, dist);
assert!(dist.join("index.html").exists());
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
async fn build_errors_when_project_dir_missing() {
let root = tmp_dir("missing-project");
let project = root.join("website");
let cfg = HmiConfiguration {
engine: HmiEngine::Cog,
app: None,
project_dir: project.clone(),
dist_dir: project.join("dist"),
command: HmiBuildCommand::Custom(vec!["true".into()]),
};
let err = HmiCompiler::build(cfg)
.await
.expect_err("missing project dir is an error");
assert!(matches!(
err,
HmiCompileError::ProjectDirNotFound(path) if path == project
));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn build_errors_when_dist_missing() {
let project = tmp_dir("nodist");
let dist = project.join("dist");
let cfg = HmiConfiguration {
engine: HmiEngine::Cog,
app: None,
project_dir: project.clone(),
dist_dir: dist.clone(),
command: HmiBuildCommand::Custom(vec!["true".into()]),
};
let err = HmiCompiler::build(cfg)
.await
.expect_err("missing dist is an error");
assert!(matches!(err, HmiCompileError::DistNotFound(_)));
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
async fn build_errors_when_command_fails() {
let project = tmp_dir("fail");
let cfg = HmiConfiguration {
engine: HmiEngine::Cog,
app: None,
project_dir: project.clone(),
dist_dir: project.join("dist"),
command: HmiBuildCommand::Custom(vec!["false".into()]),
};
let err = HmiCompiler::build(cfg)
.await
.expect_err("failing command is an error");
assert!(matches!(err, HmiCompileError::BuildFailed(_)));
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
async fn pocket_build_verifies_bundle_and_pak() {
let project = tmp_dir("pocket-ok");
let dist = project.join("dist");
let cfg = HmiConfiguration {
engine: HmiEngine::Pocket,
app: Some("hero-main".into()),
project_dir: project.clone(),
dist_dir: dist.clone(),
command: HmiBuildCommand::Custom(vec![
"sh".into(),
"-c".into(),
"mkdir -p dist && : > dist/hero-main.js && : > dist/hero-main.pak".into(),
]),
};
let out = HmiCompiler::build(cfg)
.await
.expect("pocket build succeeds");
assert_eq!(out, dist);
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
async fn pocket_build_errors_when_pak_missing() {
let project = tmp_dir("pocket-nopak");
let dist = project.join("dist");
let cfg = HmiConfiguration {
engine: HmiEngine::Pocket,
app: Some("hero-main".into()),
project_dir: project.clone(),
dist_dir: dist.clone(),
command: HmiBuildCommand::Custom(vec![
"sh".into(),
"-c".into(),
"mkdir -p dist && : > dist/hero-main.js".into(),
]),
};
let err = HmiCompiler::build(cfg)
.await
.expect_err("missing pak is an error");
assert!(matches!(
err,
HmiCompileError::PocketArtifactNotFound(path) if path.ends_with("hero-main.pak")
));
let _ = std::fs::remove_dir_all(&project);
}
}