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, 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}