Skip to main content

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}