consortium_builder/compile/
web.rs1use std::path::{Path, PathBuf};
27
28use consortium_cfg_common::config::HmiEngine;
29use thiserror::Error;
30use tokio::process::Command;
31
32use super::Compiler;
33use crate::compile::utils::run_command;
34use crate::progress::BuildReporter;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum HmiBuildCommand {
43 VpBuild,
45 NpmRunBuild,
47 Resolved {
49 program: String,
51 args: Vec<String>,
53 },
54 Custom(Vec<String>),
57}
58
59impl HmiBuildCommand {
60 pub(crate) fn program_args(&self) -> (&str, Vec<&str>) {
62 match self {
63 Self::VpBuild => ("vp", vec!["build"]),
64 Self::NpmRunBuild => ("npm", vec!["run", "build"]),
65 Self::Resolved { program, args } => {
66 (program.as_str(), args.iter().map(String::as_str).collect())
67 }
68 Self::Custom(parts) => {
69 let program = parts.first().map(String::as_str).unwrap_or_default();
70 let args = parts.iter().skip(1).map(String::as_str).collect();
71 (program, args)
72 }
73 }
74 }
75}
76
77#[derive(Error, Debug)]
79pub enum HmiCompileError {
80 #[error("Failed to resolve HMI build command: {0}")]
82 CommandResolveError(String),
83
84 #[error("HMI project directory not found: {0:?}")]
86 ProjectDirNotFound(PathBuf),
87
88 #[error("HMI build failed: {0}")]
90 BuildFailed(String),
91
92 #[error("HMI dist directory not found after build: {0:?}")]
94 DistNotFound(PathBuf),
95
96 #[error("HMI dist directory is empty after build: {0:?}")]
98 DistEmpty(PathBuf),
99
100 #[error("PocketJS artifact not found after build: {0:?}")]
102 PocketArtifactNotFound(PathBuf),
103
104 #[error("PocketJS build requires `[hmi].app` (the entry name, e.g. \"hero-main\")")]
106 PocketAppMissing,
107
108 #[error("{0}")]
110 UtilityError(String),
111}
112
113#[derive(Clone)]
115pub struct HmiConfiguration {
116 pub engine: HmiEngine,
118 pub app: Option<String>,
120 pub project_dir: PathBuf,
122 pub dist_dir: PathBuf,
125 pub command: HmiBuildCommand,
127}
128
129pub struct HmiCompiler;
131
132impl HmiCompiler {
133 pub async fn build_with_reporter(
134 config: HmiConfiguration,
135 reporter: Option<&dyn BuildReporter>,
136 ) -> Result<PathBuf, HmiCompileError> {
137 let (program, args) = config.command.program_args();
138 if program.is_empty() {
139 return Err(HmiCompileError::CommandResolveError(
140 "HMI build command is empty".to_string(),
141 ));
142 }
143
144 match tokio::fs::metadata(&config.project_dir).await {
145 Ok(meta) if meta.is_dir() => {}
146 Ok(_) | Err(_) => {
147 return Err(HmiCompileError::ProjectDirNotFound(config.project_dir));
148 }
149 }
150
151 let mut cmd = Command::new(program);
152 cmd.args(&args).current_dir(&config.project_dir);
153
154 let status = run_command(&mut cmd, reporter).await.map_err(|e| {
155 HmiCompileError::BuildFailed(format!("failed to launch `{program}`: {e}"))
156 })?;
157
158 if !status.success() {
159 return Err(HmiCompileError::BuildFailed(format!(
160 "`{program}` exited with status: {status}"
161 )));
162 }
163
164 match config.engine {
165 HmiEngine::Cog => verify_dist(&config.dist_dir).await?,
166 HmiEngine::Pocket => {
167 verify_pocket_artifacts(&config.dist_dir, config.app.as_deref()).await?
168 }
169 }
170 Ok(config.dist_dir)
171 }
172}
173
174impl Compiler for HmiCompiler {
175 type Configuration = HmiConfiguration;
176 type Error = HmiCompileError;
177 type Output = PathBuf;
178
179 async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
180 Self::build_with_reporter(config, None).await
181 }
182
183 async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
184 Ok(config.dist_dir.clone())
185 }
186}
187
188async fn verify_dist(dist: &PathBuf) -> Result<(), HmiCompileError> {
194 match tokio::fs::metadata(dist).await {
195 Ok(meta) if meta.is_dir() => {}
196 Ok(_) => return Err(HmiCompileError::DistNotFound(dist.clone())),
197 Err(_) => return Err(HmiCompileError::DistNotFound(dist.clone())),
198 }
199
200 let mut entries = tokio::fs::read_dir(dist)
201 .await
202 .map_err(|e| HmiCompileError::UtilityError(format!("reading dist {dist:?}: {e}")))?;
203 match entries.next_entry().await {
204 Ok(Some(_)) => Ok(()),
205 Ok(None) => Err(HmiCompileError::DistEmpty(dist.clone())),
206 Err(e) => Err(HmiCompileError::UtilityError(format!(
207 "reading dist entries {dist:?}: {e}"
208 ))),
209 }
210}
211
212async fn verify_pocket_artifacts(dist: &Path, app: Option<&str>) -> Result<(), HmiCompileError> {
217 let app = app
218 .map(str::trim)
219 .filter(|a| !a.is_empty())
220 .ok_or(HmiCompileError::PocketAppMissing)?;
221 for ext in ["js", "pak"] {
222 let artifact = dist.join(format!("{app}.{ext}"));
223 match tokio::fs::metadata(&artifact).await {
224 Ok(meta) if meta.is_file() => {}
225 _ => return Err(HmiCompileError::PocketArtifactNotFound(artifact)),
226 }
227 }
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn tmp_dir(tag: &str) -> PathBuf {
236 let base = std::env::var("SCRATCH_DIR")
237 .ok()
238 .map(PathBuf::from)
239 .unwrap_or_else(std::env::temp_dir);
240 let dir = base.join(format!("consortium-hmi-test-{tag}-{}", std::process::id()));
241 let _ = std::fs::remove_dir_all(&dir);
242 std::fs::create_dir_all(&dir).expect("create temp dir");
243 dir
244 }
245
246 #[test]
247 fn program_args_map_each_command() {
248 assert_eq!(
249 HmiBuildCommand::VpBuild.program_args(),
250 ("vp", vec!["build"])
251 );
252 assert_eq!(
253 HmiBuildCommand::NpmRunBuild.program_args(),
254 ("npm", vec!["run", "build"])
255 );
256 assert_eq!(
257 HmiBuildCommand::Resolved {
258 program: "/tools/vp".into(),
259 args: vec!["build".into()],
260 }
261 .program_args(),
262 ("/tools/vp", vec!["build"])
263 );
264 assert_eq!(
265 HmiBuildCommand::Custom(vec!["pnpm".into(), "build".into()]).program_args(),
266 ("pnpm", vec!["build"])
267 );
268 }
269
270 #[tokio::test]
271 async fn build_runs_command_and_verifies_dist() {
272 let project = tmp_dir("ok");
273 let dist = project.join("dist");
274
275 let cfg = HmiConfiguration {
278 engine: HmiEngine::Cog,
279 app: None,
280 project_dir: project.clone(),
281 dist_dir: dist.clone(),
282 command: HmiBuildCommand::Custom(vec![
283 "sh".into(),
284 "-c".into(),
285 "mkdir -p dist && printf '<html></html>' > dist/index.html".into(),
286 ]),
287 };
288
289 let out = HmiCompiler::build(cfg).await.expect("hmi build succeeds");
290 assert_eq!(out, dist);
291 assert!(dist.join("index.html").exists());
292
293 let _ = std::fs::remove_dir_all(&project);
294 }
295
296 #[tokio::test]
297 async fn build_errors_when_project_dir_missing() {
298 let root = tmp_dir("missing-project");
299 let project = root.join("website");
300
301 let cfg = HmiConfiguration {
302 engine: HmiEngine::Cog,
303 app: None,
304 project_dir: project.clone(),
305 dist_dir: project.join("dist"),
306 command: HmiBuildCommand::Custom(vec!["true".into()]),
307 };
308
309 let err = HmiCompiler::build(cfg)
310 .await
311 .expect_err("missing project dir is an error");
312 assert!(matches!(
313 err,
314 HmiCompileError::ProjectDirNotFound(path) if path == project
315 ));
316
317 let _ = std::fs::remove_dir_all(&root);
318 }
319
320 #[tokio::test]
321 async fn build_errors_when_dist_missing() {
322 let project = tmp_dir("nodist");
323 let dist = project.join("dist");
324
325 let cfg = HmiConfiguration {
327 engine: HmiEngine::Cog,
328 app: None,
329 project_dir: project.clone(),
330 dist_dir: dist.clone(),
331 command: HmiBuildCommand::Custom(vec!["true".into()]),
332 };
333
334 let err = HmiCompiler::build(cfg)
335 .await
336 .expect_err("missing dist is an error");
337 assert!(matches!(err, HmiCompileError::DistNotFound(_)));
338
339 let _ = std::fs::remove_dir_all(&project);
340 }
341
342 #[tokio::test]
343 async fn build_errors_when_command_fails() {
344 let project = tmp_dir("fail");
345 let cfg = HmiConfiguration {
346 engine: HmiEngine::Cog,
347 app: None,
348 project_dir: project.clone(),
349 dist_dir: project.join("dist"),
350 command: HmiBuildCommand::Custom(vec!["false".into()]),
351 };
352
353 let err = HmiCompiler::build(cfg)
354 .await
355 .expect_err("failing command is an error");
356 assert!(matches!(err, HmiCompileError::BuildFailed(_)));
357
358 let _ = std::fs::remove_dir_all(&project);
359 }
360
361 #[tokio::test]
362 async fn pocket_build_verifies_bundle_and_pak() {
363 let project = tmp_dir("pocket-ok");
364 let dist = project.join("dist");
365
366 let cfg = HmiConfiguration {
369 engine: HmiEngine::Pocket,
370 app: Some("hero-main".into()),
371 project_dir: project.clone(),
372 dist_dir: dist.clone(),
373 command: HmiBuildCommand::Custom(vec![
374 "sh".into(),
375 "-c".into(),
376 "mkdir -p dist && : > dist/hero-main.js && : > dist/hero-main.pak".into(),
377 ]),
378 };
379
380 let out = HmiCompiler::build(cfg)
381 .await
382 .expect("pocket build succeeds");
383 assert_eq!(out, dist);
384
385 let _ = std::fs::remove_dir_all(&project);
386 }
387
388 #[tokio::test]
389 async fn pocket_build_errors_when_pak_missing() {
390 let project = tmp_dir("pocket-nopak");
391 let dist = project.join("dist");
392
393 let cfg = HmiConfiguration {
395 engine: HmiEngine::Pocket,
396 app: Some("hero-main".into()),
397 project_dir: project.clone(),
398 dist_dir: dist.clone(),
399 command: HmiBuildCommand::Custom(vec![
400 "sh".into(),
401 "-c".into(),
402 "mkdir -p dist && : > dist/hero-main.js".into(),
403 ]),
404 };
405
406 let err = HmiCompiler::build(cfg)
407 .await
408 .expect_err("missing pak is an error");
409 assert!(matches!(
410 err,
411 HmiCompileError::PocketArtifactNotFound(path) if path.ends_with("hero-main.pak")
412 ));
413
414 let _ = std::fs::remove_dir_all(&project);
415 }
416}