consortium_builder/compile/env.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//! Compile-time environment-injection carriers.
18//!
19//! The builder lowers high-level build parameters into the `CONSORTIUM_*`
20//! environment variables that firmware/application/HMI crates read with
21//! `option_env!` at compile time. These types describe *what* the builder
22//! injects, not the parsed `Consortium.toml` schema (that lives in
23//! `consortium-cfg-common`); the mapping from the current schema onto these
24//! carriers is still being reconciled, so they stay local to the builder.
25
26use std::path::{Path, PathBuf};
27
28use consortium_cfg_common::config::{
29 CSTI_MANIFEST_PATH_ENV, CSTI_MCU_PATH_ENV, Hmi, HmiEngine, Workspace,
30};
31use serde::{Deserialize, Serialize};
32
33/// The build-host directories a pipeline run exposes to every endpoint `cargo`
34/// invocation, plus the `[profile] workspace` layout that decides where each
35/// endpoint crate's `target/` lives.
36///
37/// Injected as `CSTI_MANIFEST_PATH` (always) and `CSTI_MCU_PATH` (when the
38/// manifest declares exactly one controller-core firmware crate). The generated
39/// AP module names the embedded debug ELFs relative to one of them, so it carries
40/// no build-host path.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct CstiWorkspace {
43 /// `[profile] workspace`.
44 pub layout: Workspace,
45 /// Absolute directory containing `Consortium.toml`.
46 pub manifest_dir: PathBuf,
47 /// Absolute root of the controller-core firmware crate, when there is
48 /// exactly one.
49 pub mcu_dir: Option<PathBuf>,
50}
51
52impl CstiWorkspace {
53 /// The `CSTI_*` variables injected into every endpoint `cargo` invocation.
54 pub fn env_pairs(&self) -> Vec<(&'static str, &Path)> {
55 let mut pairs = vec![(CSTI_MANIFEST_PATH_ENV, self.manifest_dir.as_path())];
56 if let Some(mcu) = &self.mcu_dir {
57 pairs.push((CSTI_MCU_PATH_ENV, mcu.as_path()));
58 }
59 pairs
60 }
61
62 /// The directory whose `target/` receives the build output of the crate at
63 /// `crate_dir`: the manifest directory in a unified workspace, the crate
64 /// itself when nested.
65 pub fn target_base<'a>(&'a self, crate_dir: &'a Path) -> &'a Path {
66 match self.layout {
67 Workspace::Unified => &self.manifest_dir,
68 Workspace::Nested => crate_dir,
69 }
70 }
71}
72
73/// On-target install subdirectory (beneath `[profile] deploy_root`) for each
74/// engine's assets: a served tree for Cog, a bundle/pak pair for PocketJS.
75const WWW_SUBDIR: &str = "www";
76const POCKET_SUBDIR: &str = "hmi";
77
78/// Compile-time HMI carrier the builder injects as environment variables before
79/// invoking the application `cargo build`. The consuming runtime crate reads
80/// them with `option_env!` at compile time, so the *values* are on-target paths
81/// resolved beneath `[profile] deploy_root` — the build host never dereferences
82/// them.
83///
84/// The engine tag selects which variables are emitted:
85/// - [`Cog`](Self::Cog): `consortium-hmi-webkit` serves a dist directory.
86/// - [`Pocket`](Self::Pocket): `consortium-hmi-pocket` loads a bundle + pak.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case", tag = "engine")]
89pub enum HmiConfig {
90 /// Cog/WPE WebKit shell serving a static dist directory via `csti://`.
91 Cog {
92 /// On-target directory the deployed application serves assets from;
93 /// becomes `CONSORTIUM_HMI_DIST`.
94 dist: String,
95 /// Dev-server URL loaded directly in debug builds; when absent, debug
96 /// builds fall back to the `csti://` static handler.
97 #[serde(skip_serializing_if = "Option::is_none")]
98 debug_url: Option<String>,
99 },
100 /// Native PocketJS shell loading a built bundle + pak from on-target paths.
101 Pocket {
102 /// On-target path of the PocketJS `<app>.js` bundle; becomes
103 /// `CONSORTIUM_HMI_POCKET_BUNDLE`.
104 bundle: String,
105 /// On-target path of the PocketJS `<app>.pak`; becomes
106 /// `CONSORTIUM_HMI_POCKET_PAK`.
107 pak: String,
108 /// Logical PocketJS viewport width in pixels.
109 logical_width: u32,
110 /// Logical PocketJS viewport height in pixels.
111 logical_height: u32,
112 },
113}
114
115impl HmiConfig {
116 /// Builds the compile-time HMI carrier from the parsed `[hmi]` section.
117 ///
118 /// Because the build host and the target are different machines, every value
119 /// is an *on-target* path a downstream packaging recipe installs assets to.
120 /// Relative install locations are resolved beneath `deploy_root` (the
121 /// manifest's `[profile] deploy_root` convention): Cog serves
122 /// `{deploy_root}/www`, and PocketJS loads `{deploy_root}/hmi/<app>.{js,pak}`.
123 ///
124 /// Callers must have validated `hmi` (see [`Hmi::validate`]); a PocketJS
125 /// section without `app` falls back to an empty name here.
126 pub fn from_hmi(hmi: &Hmi, deploy_root: &str) -> Self {
127 match hmi.engine {
128 HmiEngine::Cog => Self::Cog {
129 dist: on_target(deploy_root, WWW_SUBDIR)
130 .to_string_lossy()
131 .into_owned(),
132 debug_url: hmi.debug.clone(),
133 },
134 HmiEngine::Pocket => {
135 let app = hmi.app.as_deref().unwrap_or_default();
136 let base = on_target(deploy_root, POCKET_SUBDIR);
137 Self::Pocket {
138 bundle: base
139 .join(format!("{app}.js"))
140 .to_string_lossy()
141 .into_owned(),
142 pak: base
143 .join(format!("{app}.pak"))
144 .to_string_lossy()
145 .into_owned(),
146 logical_width: hmi.logical.width,
147 logical_height: hmi.logical.height,
148 }
149 }
150 }
151 }
152
153 /// Environment variables the builder injects for this config.
154 ///
155 /// Consuming crates read these with `option_env!` at compile time. For Cog,
156 /// `CONSORTIUM_HMI_DEBUG_URL` is emitted only when set, so its absence lets
157 /// debug builds fall back to the static `csti://` handler.
158 pub fn env_pairs(&self) -> Vec<(String, String)> {
159 match self {
160 Self::Cog { dist, debug_url } => {
161 let mut pairs = vec![("CONSORTIUM_HMI_DIST".to_string(), dist.clone())];
162 if let Some(url) = debug_url {
163 pairs.push(("CONSORTIUM_HMI_DEBUG_URL".to_string(), url.clone()));
164 }
165 pairs
166 }
167 Self::Pocket {
168 bundle,
169 pak,
170 logical_width,
171 logical_height,
172 } => vec![
173 ("CONSORTIUM_HMI_POCKET_BUNDLE".to_string(), bundle.clone()),
174 ("CONSORTIUM_HMI_POCKET_PAK".to_string(), pak.clone()),
175 (
176 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
177 logical_width.to_string(),
178 ),
179 (
180 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
181 logical_height.to_string(),
182 ),
183 ],
184 }
185 }
186}
187
188/// Resolves an on-target subdirectory beneath `deploy_root`. An absolute
189/// `deploy_root` yields an absolute path; the subdir is always relative.
190fn on_target(deploy_root: &str, subdir: &str) -> PathBuf {
191 Path::new(deploy_root).join(subdir)
192}
193
194/// Cortex-M core a firmware crate targets; selects the Rust target triple.
195#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum CortexMcuCore {
198 CortexM0p,
199 CortexM33,
200 CortexM7,
201 CortexM4,
202 CortexM4f,
203}
204
205impl CortexMcuCore {
206 /// Parses a dash-separated core name string (e.g., `"cortex-m33"`) into a variant.
207 pub fn as_key(s: &str) -> Option<Self> {
208 match s {
209 "cortex-m0p" => Some(Self::CortexM0p),
210 "cortex-m33" => Some(Self::CortexM33),
211 "cortex-m7" => Some(Self::CortexM7),
212 "cortex-m4" => Some(Self::CortexM4),
213 "cortex-m4f" => Some(Self::CortexM4f),
214 _ => None,
215 }
216 }
217
218 /// Returns the short abbreviation for this core (e.g., `"cm33"`).
219 pub fn abbr(&self) -> &'static str {
220 match self {
221 Self::CortexM0p => "cm0p",
222 Self::CortexM33 => "cm33",
223 Self::CortexM7 => "cm7",
224 Self::CortexM4 => "cm4",
225 Self::CortexM4f => "cm4f",
226 }
227 }
228
229 /// Parses a short abbreviation (e.g., `"cm33"`) into a variant.
230 pub fn from_abbr(s: &str) -> Option<Self> {
231 match s {
232 "cm0p" => Some(Self::CortexM0p),
233 "cm33" => Some(Self::CortexM33),
234 "cm7" => Some(Self::CortexM7),
235 "cm4" => Some(Self::CortexM4),
236 "cm4f" => Some(Self::CortexM4f),
237 _ => None,
238 }
239 }
240
241 /// Returns the Rust target triple for cross-compiling to this core.
242 pub fn to_rust_target(&self) -> &'static str {
243 match self {
244 Self::CortexM0p => "thumbv6m-none-eabi",
245 Self::CortexM33 => "thumbv8m.main-none-eabihf",
246 Self::CortexM7 | Self::CortexM4f => "thumbv7em-none-eabihf",
247 Self::CortexM4 => "thumbv7em-none-eabi",
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn hmi_env_pairs_emit_dist_and_optional_debug_url() {
258 let with_url = HmiConfig::Cog {
259 dist: "ui/dist".to_string(),
260 debug_url: Some("http://localhost:5173".to_string()),
261 };
262 assert_eq!(
263 with_url.env_pairs(),
264 [
265 ("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string()),
266 (
267 "CONSORTIUM_HMI_DEBUG_URL".to_string(),
268 "http://localhost:5173".to_string()
269 ),
270 ]
271 );
272
273 let no_url = HmiConfig::Cog {
274 dist: "ui/dist".to_string(),
275 debug_url: None,
276 };
277 assert_eq!(
278 no_url.env_pairs(),
279 [("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string())]
280 );
281 }
282
283 #[test]
284 fn pocket_env_pairs_emit_bundle_and_pak_paths() {
285 let cfg = HmiConfig::Pocket {
286 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
287 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
288 logical_width: 800,
289 logical_height: 480,
290 };
291 assert_eq!(
292 cfg.env_pairs(),
293 [
294 (
295 "CONSORTIUM_HMI_POCKET_BUNDLE".to_string(),
296 "/opt/consortium/hmi/hero-main.js".to_string()
297 ),
298 (
299 "CONSORTIUM_HMI_POCKET_PAK".to_string(),
300 "/opt/consortium/hmi/hero-main.pak".to_string()
301 ),
302 (
303 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
304 "800".to_string()
305 ),
306 (
307 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
308 "480".to_string()
309 ),
310 ]
311 );
312 }
313
314 #[test]
315 fn from_hmi_places_cog_dist_under_deploy_root() {
316 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
317
318 let hmi = Hmi {
319 engine: HmiEngine::Cog,
320 backend: HmiBackend::Wayland,
321 debug: Some("http://127.0.0.1:5173/".to_string()),
322 source: "website".to_string(),
323 build: "website/dist".to_string(),
324 app: None,
325 logical: Default::default(),
326 };
327
328 // The Cog engine serves `{deploy_root}/www`, and the debug URL carries
329 // through for dev builds.
330 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
331 assert_eq!(
332 cfg,
333 HmiConfig::Cog {
334 dist: "/opt/consortium/www".to_string(),
335 debug_url: Some("http://127.0.0.1:5173/".to_string()),
336 }
337 );
338 }
339
340 #[test]
341 fn from_hmi_places_pocket_bundle_and_pak_under_deploy_root() {
342 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
343
344 let hmi = Hmi {
345 engine: HmiEngine::Pocket,
346 backend: HmiBackend::Wayland,
347 debug: None,
348 source: "submodules/pocketjs".to_string(),
349 build: "dist".to_string(),
350 app: Some("hero-main".to_string()),
351 logical: consortium_cfg_common::config::HmiLogical {
352 width: 800,
353 height: 480,
354 },
355 };
356
357 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
358 assert_eq!(
359 cfg,
360 HmiConfig::Pocket {
361 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
362 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
363 logical_width: 800,
364 logical_height: 480,
365 }
366 );
367 }
368
369 #[test]
370 fn mcu_core_resolves_rust_target_triples() {
371 assert_eq!(
372 CortexMcuCore::CortexM33.to_rust_target(),
373 "thumbv8m.main-none-eabihf"
374 );
375 assert_eq!(
376 CortexMcuCore::CortexM7.to_rust_target(),
377 "thumbv7em-none-eabihf"
378 );
379 assert_eq!(
380 CortexMcuCore::as_key("cortex-m33"),
381 Some(CortexMcuCore::CortexM33)
382 );
383 assert_eq!(
384 CortexMcuCore::from_abbr("cm0p"),
385 Some(CortexMcuCore::CortexM0p)
386 );
387 assert_eq!(CortexMcuCore::CortexM4f.abbr(), "cm4f");
388 }
389}