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, MemoryRegion, 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/// Shared-memory region layout for an IPC channel.
74///
75/// Injected as compile-time environment variables by the builder before invoking
76/// `cargo build`. The physical base address is **not** carried here: the IPC
77/// transport never resolves its region from the environment. Controller firmware
78/// gets the base baked into its generated init code (via the lowered `Address`),
79/// and the Linux application reaches the *same* memory through the UIO device
80/// named by `uio`; so no side needs a `CONSORTIUM_IPC_BASE`.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields, rename_all = "snake_case")]
83pub struct SharedMemConfig {
84 /// Length of the region in bytes.
85 pub length: u64,
86 /// UIO device index the Linux application mmaps to reach the same region.
87 pub uio: u32,
88 /// Maximum transfer unit (payload bytes per slot) for the channel.
89 pub mtu: u32,
90}
91
92impl SharedMemConfig {
93 /// Builds a resolved config from a `[[ipc.channel]]` shared-memory region
94 /// plus the UIO index and MTU the lowering resolved.
95 ///
96 /// Only the region's `length` is carried; the physical base is resolved by
97 /// the generated code, not the environment (see the type docs). `uio` is the
98 /// AP-side UIO device index the Linux application mmaps (from `LoweredIr`'s
99 /// resolved `ApAddress`), and `mtu` is the channel MTU.
100 pub fn from_region(region: MemoryRegion, uio: u32, mtu: u32) -> Self {
101 Self {
102 length: u64::from(region.length),
103 uio,
104 mtu,
105 }
106 }
107
108 /// Environment variable suffixes used when the builder injects this config.
109 ///
110 /// The builder emits `CONSORTIUM_<PREFIX>_LENGTH`, `_UIO`, and `_MTU`, where
111 /// `<PREFIX>` is `IPC`. Consuming crates read these with `option_env!` and
112 /// parse them in a `const` context. Numeric values are emitted as decimal
113 /// strings even when the source manifest used hexadecimal TOML literals.
114 pub fn env_pairs(&self, prefix: &str) -> [(String, String); 3] {
115 [
116 (
117 format!("CONSORTIUM_{prefix}_LENGTH"),
118 self.length.to_string(),
119 ),
120 (format!("CONSORTIUM_{prefix}_UIO"), self.uio.to_string()),
121 (format!("CONSORTIUM_{prefix}_MTU"), self.mtu.to_string()),
122 ]
123 }
124}
125
126/// Debug-ring layout injected as `CONSORTIUM_DBG_*` compile-time variables.
127///
128/// Unlike IPC, the defmt logger *does* read its region from the environment:
129/// `consortium-dbg`'s `config` module resolves `CONSORTIUM_DBG_BASE` and
130/// `CONSORTIUM_DBG_LENGTH` with `option_env!` in a `const` context (and treats
131/// the presence of `BASE` as "debug configured"). So the debug carrier keeps the
132/// physical base the IPC carrier drops.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields, rename_all = "snake_case")]
135pub struct DbgConfig {
136 /// Physical base of the debug ring, as seen by the logging core (raw memory).
137 pub base: u64,
138 /// Length of the ring in bytes.
139 pub length: u64,
140}
141
142impl DbgConfig {
143 /// Builds a debug carrier from a resolved ring base and length.
144 pub fn new(base: u64, length: u64) -> Self {
145 Self { base, length }
146 }
147
148 /// The `CONSORTIUM_DBG_BASE`/`_LENGTH` pairs `consortium-dbg` reads.
149 /// Numeric values are emitted as decimal strings.
150 pub fn env_pairs(&self) -> [(String, String); 2] {
151 [
152 ("CONSORTIUM_DBG_BASE".to_string(), self.base.to_string()),
153 ("CONSORTIUM_DBG_LENGTH".to_string(), self.length.to_string()),
154 ]
155 }
156}
157
158/// On-target install subdirectory (beneath `[profile] deploy_root`) for each
159/// engine's assets: a served tree for Cog, a bundle/pak pair for PocketJS.
160const WWW_SUBDIR: &str = "www";
161const POCKET_SUBDIR: &str = "hmi";
162
163/// Compile-time HMI carrier the builder injects as environment variables before
164/// invoking the application `cargo build`. The consuming runtime crate reads
165/// them with `option_env!` at compile time, so the *values* are on-target paths
166/// resolved beneath `[profile] deploy_root` — the build host never dereferences
167/// them.
168///
169/// The engine tag selects which variables are emitted:
170/// - [`Cog`](Self::Cog): `consortium-hmi-webkit` serves a dist directory.
171/// - [`Pocket`](Self::Pocket): `consortium-hmi-pocket` loads a bundle + pak.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case", tag = "engine")]
174pub enum HmiConfig {
175 /// Cog/WPE WebKit shell serving a static dist directory via `csti://`.
176 Cog {
177 /// On-target directory the deployed application serves assets from;
178 /// becomes `CONSORTIUM_HMI_DIST`.
179 dist: String,
180 /// Dev-server URL loaded directly in debug builds; when absent, debug
181 /// builds fall back to the `csti://` static handler.
182 #[serde(skip_serializing_if = "Option::is_none")]
183 debug_url: Option<String>,
184 },
185 /// Native PocketJS shell loading a built bundle + pak from on-target paths.
186 Pocket {
187 /// On-target path of the PocketJS `<app>.js` bundle; becomes
188 /// `CONSORTIUM_HMI_POCKET_BUNDLE`.
189 bundle: String,
190 /// On-target path of the PocketJS `<app>.pak`; becomes
191 /// `CONSORTIUM_HMI_POCKET_PAK`.
192 pak: String,
193 /// Logical PocketJS viewport width in pixels.
194 logical_width: u32,
195 /// Logical PocketJS viewport height in pixels.
196 logical_height: u32,
197 },
198}
199
200impl HmiConfig {
201 /// Builds the compile-time HMI carrier from the parsed `[hmi]` section.
202 ///
203 /// Because the build host and the target are different machines, every value
204 /// is an *on-target* path a downstream packaging recipe installs assets to.
205 /// Relative install locations are resolved beneath `deploy_root` (the
206 /// manifest's `[profile] deploy_root` convention): Cog serves
207 /// `{deploy_root}/www`, and PocketJS loads `{deploy_root}/hmi/<app>.{js,pak}`.
208 ///
209 /// Callers must have validated `hmi` (see [`Hmi::validate`]); a PocketJS
210 /// section without `app` falls back to an empty name here.
211 pub fn from_hmi(hmi: &Hmi, deploy_root: &str) -> Self {
212 match hmi.engine {
213 HmiEngine::Cog => Self::Cog {
214 dist: on_target(deploy_root, WWW_SUBDIR)
215 .to_string_lossy()
216 .into_owned(),
217 debug_url: hmi.debug.clone(),
218 },
219 HmiEngine::Pocket => {
220 let app = hmi.app.as_deref().unwrap_or_default();
221 let base = on_target(deploy_root, POCKET_SUBDIR);
222 Self::Pocket {
223 bundle: base
224 .join(format!("{app}.js"))
225 .to_string_lossy()
226 .into_owned(),
227 pak: base
228 .join(format!("{app}.pak"))
229 .to_string_lossy()
230 .into_owned(),
231 logical_width: hmi.logical.width,
232 logical_height: hmi.logical.height,
233 }
234 }
235 }
236 }
237
238 /// Environment variables the builder injects for this config.
239 ///
240 /// Consuming crates read these with `option_env!` at compile time. For Cog,
241 /// `CONSORTIUM_HMI_DEBUG_URL` is emitted only when set, so its absence lets
242 /// debug builds fall back to the static `csti://` handler.
243 pub fn env_pairs(&self) -> Vec<(String, String)> {
244 match self {
245 Self::Cog { dist, debug_url } => {
246 let mut pairs = vec![("CONSORTIUM_HMI_DIST".to_string(), dist.clone())];
247 if let Some(url) = debug_url {
248 pairs.push(("CONSORTIUM_HMI_DEBUG_URL".to_string(), url.clone()));
249 }
250 pairs
251 }
252 Self::Pocket {
253 bundle,
254 pak,
255 logical_width,
256 logical_height,
257 } => vec![
258 ("CONSORTIUM_HMI_POCKET_BUNDLE".to_string(), bundle.clone()),
259 ("CONSORTIUM_HMI_POCKET_PAK".to_string(), pak.clone()),
260 (
261 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
262 logical_width.to_string(),
263 ),
264 (
265 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
266 logical_height.to_string(),
267 ),
268 ],
269 }
270 }
271}
272
273/// Resolves an on-target subdirectory beneath `deploy_root`. An absolute
274/// `deploy_root` yields an absolute path; the subdir is always relative.
275fn on_target(deploy_root: &str, subdir: &str) -> PathBuf {
276 Path::new(deploy_root).join(subdir)
277}
278
279/// Cortex-M core a firmware crate targets; selects the Rust target triple.
280#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
281#[serde(rename_all = "kebab-case")]
282pub enum CortexMcuCore {
283 CortexM0p,
284 CortexM33,
285 CortexM7,
286 CortexM4,
287 CortexM4f,
288}
289
290impl CortexMcuCore {
291 /// Parses a dash-separated core name string (e.g., `"cortex-m33"`) into a variant.
292 pub fn as_key(s: &str) -> Option<Self> {
293 match s {
294 "cortex-m0p" => Some(Self::CortexM0p),
295 "cortex-m33" => Some(Self::CortexM33),
296 "cortex-m7" => Some(Self::CortexM7),
297 "cortex-m4" => Some(Self::CortexM4),
298 "cortex-m4f" => Some(Self::CortexM4f),
299 _ => None,
300 }
301 }
302
303 /// Returns the short abbreviation for this core (e.g., `"cm33"`).
304 pub fn abbr(&self) -> &'static str {
305 match self {
306 Self::CortexM0p => "cm0p",
307 Self::CortexM33 => "cm33",
308 Self::CortexM7 => "cm7",
309 Self::CortexM4 => "cm4",
310 Self::CortexM4f => "cm4f",
311 }
312 }
313
314 /// Parses a short abbreviation (e.g., `"cm33"`) into a variant.
315 pub fn from_abbr(s: &str) -> Option<Self> {
316 match s {
317 "cm0p" => Some(Self::CortexM0p),
318 "cm33" => Some(Self::CortexM33),
319 "cm7" => Some(Self::CortexM7),
320 "cm4" => Some(Self::CortexM4),
321 "cm4f" => Some(Self::CortexM4f),
322 _ => None,
323 }
324 }
325
326 /// Returns the Rust target triple for cross-compiling to this core.
327 pub fn to_rust_target(&self) -> &'static str {
328 match self {
329 Self::CortexM0p => "thumbv6m-none-eabi",
330 Self::CortexM33 => "thumbv8m.main-none-eabihf",
331 Self::CortexM7 | Self::CortexM4f => "thumbv7em-none-eabihf",
332 Self::CortexM4 => "thumbv7em-none-eabi",
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 const SAMPLE: SharedMemConfig = SharedMemConfig {
342 length: 0x1000,
343 uio: 0,
344 mtu: 64,
345 };
346
347 #[test]
348 fn env_pairs_emit_the_ipc_layout_the_app_reads() {
349 // The IPC carrier no longer carries a base: nothing reads
350 // `CONSORTIUM_IPC_BASE` (the transport resolves its region from generated
351 // code / UIO). Numeric env-var values are decimal strings.
352 let pairs = SAMPLE.env_pairs("IPC");
353 assert_eq!(
354 pairs,
355 [
356 ("CONSORTIUM_IPC_LENGTH".to_string(), "4096".to_string()),
357 ("CONSORTIUM_IPC_UIO".to_string(), "0".to_string()),
358 ("CONSORTIUM_IPC_MTU".to_string(), "64".to_string()),
359 ]
360 );
361 // No base is emitted for IPC.
362 assert!(!pairs.iter().any(|(k, _)| k == "CONSORTIUM_IPC_BASE"));
363 }
364
365 #[test]
366 fn dbg_config_emits_the_base_and_length_the_logger_reads() {
367 // The debug carrier keeps base + length, matching exactly what
368 // `consortium-dbg`'s `config` module reads with `option_env!`.
369 let dbg = DbgConfig::new(0x2040_8000, 0x2000);
370 assert_eq!(
371 dbg.env_pairs(),
372 [
373 ("CONSORTIUM_DBG_BASE".to_string(), "541097984".to_string()),
374 ("CONSORTIUM_DBG_LENGTH".to_string(), "8192".to_string()),
375 ]
376 );
377 }
378
379 #[test]
380 fn hmi_env_pairs_emit_dist_and_optional_debug_url() {
381 let with_url = HmiConfig::Cog {
382 dist: "ui/dist".to_string(),
383 debug_url: Some("http://localhost:5173".to_string()),
384 };
385 assert_eq!(
386 with_url.env_pairs(),
387 [
388 ("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string()),
389 (
390 "CONSORTIUM_HMI_DEBUG_URL".to_string(),
391 "http://localhost:5173".to_string()
392 ),
393 ]
394 );
395
396 let no_url = HmiConfig::Cog {
397 dist: "ui/dist".to_string(),
398 debug_url: None,
399 };
400 assert_eq!(
401 no_url.env_pairs(),
402 [("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string())]
403 );
404 }
405
406 #[test]
407 fn pocket_env_pairs_emit_bundle_and_pak_paths() {
408 let cfg = HmiConfig::Pocket {
409 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
410 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
411 logical_width: 800,
412 logical_height: 480,
413 };
414 assert_eq!(
415 cfg.env_pairs(),
416 [
417 (
418 "CONSORTIUM_HMI_POCKET_BUNDLE".to_string(),
419 "/opt/consortium/hmi/hero-main.js".to_string()
420 ),
421 (
422 "CONSORTIUM_HMI_POCKET_PAK".to_string(),
423 "/opt/consortium/hmi/hero-main.pak".to_string()
424 ),
425 (
426 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
427 "800".to_string()
428 ),
429 (
430 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
431 "480".to_string()
432 ),
433 ]
434 );
435 }
436
437 #[test]
438 fn from_region_widens_schema_region_into_injectable_config() {
439 // A resolved `[[ipc.channel]]` region plus the lowered UIO index and MTU
440 // produce the config the builder injects; the region's base is dropped
441 // (the IPC carrier has no base).
442 let region = MemoryRegion {
443 base: 0x2040_0000,
444 length: 0x4000,
445 };
446 let cfg = SharedMemConfig::from_region(region, 0, 128);
447 assert_eq!(
448 cfg,
449 SharedMemConfig {
450 length: 0x4000,
451 uio: 0,
452 mtu: 128,
453 }
454 );
455 // The widened length round-trips through the env injection.
456 let pairs = cfg.env_pairs("IPC");
457 assert_eq!(
458 pairs[0],
459 ("CONSORTIUM_IPC_LENGTH".to_string(), "16384".to_string())
460 );
461 }
462
463 #[test]
464 fn from_hmi_places_cog_dist_under_deploy_root() {
465 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
466
467 let hmi = Hmi {
468 engine: HmiEngine::Cog,
469 backend: HmiBackend::Wayland,
470 debug: Some("http://127.0.0.1:5173/".to_string()),
471 source: "website".to_string(),
472 build: "website/dist".to_string(),
473 app: None,
474 logical: Default::default(),
475 };
476
477 // The Cog engine serves `{deploy_root}/www`, and the debug URL carries
478 // through for dev builds.
479 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
480 assert_eq!(
481 cfg,
482 HmiConfig::Cog {
483 dist: "/opt/consortium/www".to_string(),
484 debug_url: Some("http://127.0.0.1:5173/".to_string()),
485 }
486 );
487 }
488
489 #[test]
490 fn from_hmi_places_pocket_bundle_and_pak_under_deploy_root() {
491 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
492
493 let hmi = Hmi {
494 engine: HmiEngine::Pocket,
495 backend: HmiBackend::Wayland,
496 debug: None,
497 source: "submodules/pocketjs".to_string(),
498 build: "dist".to_string(),
499 app: Some("hero-main".to_string()),
500 logical: consortium_cfg_common::config::HmiLogical {
501 width: 800,
502 height: 480,
503 },
504 };
505
506 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
507 assert_eq!(
508 cfg,
509 HmiConfig::Pocket {
510 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
511 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
512 logical_width: 800,
513 logical_height: 480,
514 }
515 );
516 }
517
518 #[test]
519 fn mcu_core_resolves_rust_target_triples() {
520 assert_eq!(
521 CortexMcuCore::CortexM33.to_rust_target(),
522 "thumbv8m.main-none-eabihf"
523 );
524 assert_eq!(
525 CortexMcuCore::CortexM7.to_rust_target(),
526 "thumbv7em-none-eabihf"
527 );
528 assert_eq!(
529 CortexMcuCore::as_key("cortex-m33"),
530 Some(CortexMcuCore::CortexM33)
531 );
532 assert_eq!(
533 CortexMcuCore::from_abbr("cm0p"),
534 Some(CortexMcuCore::CortexM0p)
535 );
536 assert_eq!(CortexMcuCore::CortexM4f.abbr(), "cm4f");
537 }
538}