Skip to main content

consortium_builder/compile/
web.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Compiler for the HMI (web) frontend.
18//!
19//! Unlike the firmware/application/OP-TEE compilers, this one does not invoke
20//! `cargo`: it runs the frontend project's own build command (`vp build`,
21//! `npm run build`, or an explicit override) in the project directory and then
22//! verifies the dist output landed. The resolved dist directory feeds the
23//! Linux application's [`HmiConfig`](super::env::HmiConfig) so the deployed app
24//! knows where to serve assets from.
25
26use 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/// How the HMI frontend is built.
37///
38/// Resolved by `resolve_hmi_build_command`
39/// from the `CONSORTIUM_HMI_BUILD_CMD` override, else an available `vp` or `npm`
40/// on PATH.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum HmiBuildCommand {
43    /// Vite+ unified toolchain: `vp build`.
44    VpBuild,
45    /// Node package manager: `npm run build`.
46    NpmRunBuild,
47    /// A known tool resolved to an absolute path before any later PATH changes.
48    Resolved {
49        /// Executable path.
50        program: String,
51        /// Arguments passed to the executable.
52        args: Vec<String>,
53    },
54    /// An explicit command from `CONSORTIUM_HMI_BUILD_CMD`, split on whitespace;
55    /// the first element is the program, the rest its arguments.
56    Custom(Vec<String>),
57}
58
59impl HmiBuildCommand {
60    /// The program and arguments this command runs.
61    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/// Error type for HMI frontend compilation.
78#[derive(Error, Debug)]
79pub enum HmiCompileError {
80    /// The frontend build command could not be resolved.
81    #[error("Failed to resolve HMI build command: {0}")]
82    CommandResolveError(String),
83
84    /// The frontend project directory does not exist.
85    #[error("HMI project directory not found: {0:?}")]
86    ProjectDirNotFound(PathBuf),
87
88    /// The frontend build command failed to launch or exited non-zero.
89    #[error("HMI build failed: {0}")]
90    BuildFailed(String),
91
92    /// The build succeeded but produced no dist directory.
93    #[error("HMI dist directory not found after build: {0:?}")]
94    DistNotFound(PathBuf),
95
96    /// The dist directory exists but is empty (no assets were produced).
97    #[error("HMI dist directory is empty after build: {0:?}")]
98    DistEmpty(PathBuf),
99
100    /// A PocketJS build produced no `<app>.js`/`<app>.pak` pair.
101    #[error("PocketJS artifact not found after build: {0:?}")]
102    PocketArtifactNotFound(PathBuf),
103
104    /// A PocketJS build was planned without an `[hmi].app` entry name.
105    #[error("PocketJS build requires `[hmi].app` (the entry name, e.g. \"hero-main\")")]
106    PocketAppMissing,
107
108    /// A filesystem error occurred while verifying the dist output.
109    #[error("{0}")]
110    UtilityError(String),
111}
112
113/// Build parameters for the HMI frontend.
114#[derive(Clone)]
115pub struct HmiConfiguration {
116    /// Rendering engine, selecting how the output is verified.
117    pub engine: HmiEngine,
118    /// PocketJS entry name (`[hmi].app`); `None` for other engines.
119    pub app: Option<String>,
120    /// Frontend project root (the manifest's `[hmi].source`, resolved absolute).
121    pub project_dir: PathBuf,
122    /// Dist output directory the build produces (the manifest's `[hmi].build`,
123    /// resolved absolute).
124    pub dist_dir: PathBuf,
125    /// The command that builds the frontend.
126    pub command: HmiBuildCommand,
127}
128
129/// Compiler for the HMI frontend: runs the frontend build then verifies dist.
130pub 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
188/// Verifies the dist directory exists and holds at least one entry.
189///
190/// The frontend build is opaque (no single well-known artifact like an ELF), so
191/// the strongest cheap check is that the declared dist directory materialized and
192/// is non-empty.
193async 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
212/// Verifies a PocketJS build produced both `<app>.js` and `<app>.pak`.
213///
214/// PocketJS emits exactly these two artifacts (bundle + packed assets), so
215/// unlike the opaque web dist their names are known and can be checked directly.
216async 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        // A tiny "build command" that materializes the dist tree, standing in for
276        // the real `vp build`/`npm run build`.
277        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        // The command succeeds but never creates the dist directory.
326        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        // Stand in for `bun tools/build.ts hero-main --outdir=dist`: emit the
367        // two artifacts PocketJS produces.
368        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        // Only the JS bundle lands; the pak is missing.
394        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}