Skip to main content

consortium_builder/pipeline/
mod.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//! End-to-end build pipeline: `Consortium.toml` → `dist/`.
18//!
19//! The eight stages are:
20//!
21//! 1. compile firmware for remote coprocessors ([`firmware`]);
22//! 2. build the defmt "ELF map" the app embeds ([`elf_map`]);
23//! 3. build the HMI frontend (`compile::web`);
24//! 4. compile + sign the OP-TEE TA ([`tee`]);
25//! 5. compile the Linux application ([`application`]);
26//! 6. render the systemd unit (`compile::deploy::systemd`);
27//! 7. assemble `dist/` ([`dist`]);
28//! 8. optionally hand off to bitbake ([`bitbake`]).
29//!
30//! [`plan`] resolves every stage's inputs without running any build (a dry run
31//! that threads the data flow); [`run`] plans, then executes.
32
33pub mod application;
34pub mod bitbake;
35pub mod dist;
36pub mod dtc;
37pub mod dts;
38pub mod elf_map;
39pub mod firmware;
40pub mod tee;
41
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45use consortium_cfg::Session;
46use consortium_cfg::bridge::LoweredIr;
47use consortium_cfg_common::config::{HmiEngine, VisibleCore, XcoreName};
48use consortium_cfg_common::database::Coprocessor;
49use thiserror::Error;
50
51use crate::compile::application::{ApplicationCompileError, ApplicationCompiler};
52use crate::compile::deploy::systemd::{self, SystemdRestartPolicy, SystemdUnitConfig};
53use crate::compile::env::SharedMemConfig;
54use crate::compile::firmware::{FirmwareCompileError, FirmwareCompiler};
55use crate::compile::optee::{OpteeCompileError, OpteeCompiler, OpteeConfiguration};
56use crate::compile::web::{HmiCompileError, HmiCompiler, HmiConfiguration};
57use crate::progress::{BuildReporter, BuildStep};
58
59use application::ApplicationBuildPlan;
60use bitbake::{BitbakeAction, BitbakeError, RecipeSpec};
61use dist::{DistAssemblyError, DistLayout};
62use firmware::FirmwareBuildPlan;
63use tee::TeeToolchain;
64
65/// The file name the generated per-core init module is written as inside a crate
66/// (`src/<GENERATED_MODULE>`). The crate is expected to `include!`/`mod` it.
67const GENERATED_MODULE: &str = "consortium.gen.rs";
68
69/// Aggregate error for the build pipeline.
70#[derive(Error, Debug)]
71pub enum PipelineError {
72    /// Reading the manifest file failed.
73    #[error("reading manifest {path}: {source}")]
74    ManifestRead {
75        /// The manifest path.
76        path: String,
77        /// The underlying error.
78        source: std::io::Error,
79    },
80
81    /// Parsing/validating the manifest or lowering it failed.
82    #[error(transparent)]
83    Cfg(#[from] Box<consortium_cfg::CfgError>),
84
85    /// Firmware compilation failed.
86    #[error(transparent)]
87    Firmware(#[from] FirmwareCompileError),
88
89    /// Application compilation failed.
90    #[error(transparent)]
91    Application(#[from] ApplicationCompileError),
92
93    /// OP-TEE TA compilation/signing failed.
94    #[error(transparent)]
95    Optee(#[from] OpteeCompileError),
96
97    /// HMI frontend compilation failed.
98    #[error(transparent)]
99    Hmi(#[from] HmiCompileError),
100
101    /// Assembling the `dist/` tree failed.
102    #[error(transparent)]
103    Dist(#[from] DistAssemblyError),
104
105    #[error(transparent)]
106    Dtc(#[from] dtc::DtcError),
107
108    /// The bitbake hand-off failed.
109    #[error(transparent)]
110    Bitbake(#[from] BitbakeError),
111
112    /// Writing a generated module into a crate failed.
113    #[error("writing generated module to {path}: {source}")]
114    GeneratedWrite {
115        /// The generated-module path.
116        path: String,
117        /// The underlying error.
118        source: std::io::Error,
119    },
120
121    /// A `[dbg.<core>]` core was not built in Stage 1 (typically a manager core).
122    #[error(
123        "debug core '{0}' has no compiled firmware to embed: `[dbg.{0}]` names a \
124         core that is not a remote firmware target"
125    )]
126    DbgCoreNotBuilt(String),
127
128    /// A required `[endpoints]` entry is missing.
129    #[error("manifest declares no endpoint for the required party '{0}'")]
130    MissingEndpoint(String),
131
132    /// A `[tee]` section is present but no OP-TEE toolchain was supplied.
133    #[error(
134        "manifest has a [tee] section but no OP-TEE toolchain was provided \
135         (set TA_DEV_KIT_DIR or pass a toolchain)"
136    )]
137    MissingTeeToolchain,
138
139    /// A core name could not be resolved.
140    #[error("unknown core '{0}'")]
141    UnknownCore(String),
142
143    /// Code generation produced invalid tokens or a path could not be resolved.
144    #[error("codegen error: {0}")]
145    Codegen(String),
146}
147
148impl From<consortium_cfg::CfgError> for PipelineError {
149    fn from(error: consortium_cfg::CfgError) -> Self {
150        Self::Cfg(Box::new(error))
151    }
152}
153
154/// All parameters for a pipeline run.
155pub struct PipelineConfig {
156    /// Path to the `Consortium.toml` manifest.
157    pub manifest_path: PathBuf,
158    /// Root of the `dist/` staging tree to assemble.
159    pub dist_root: PathBuf,
160    /// Build in release mode.
161    pub release: bool,
162    /// HMI frontend build parameters, or `None` to skip the HMI stage. Derive
163    /// with [`derive_hmi_configuration`].
164    pub hmi: Option<HmiConfiguration>,
165    /// OP-TEE toolchain, or `None` (an error if the manifest declares `[tee]`).
166    pub tee_toolchain: Option<TeeToolchain>,
167    /// Sign the OP-TEE TA after building it. `false` (from `--skip-tee-sign`)
168    /// stops at the unsigned ELF, so no signer script or keys are needed.
169    pub sign_tee: bool,
170    /// Bitbake hand-off actions run in order after `dist/` is assembled. Empty
171    /// skips the stage. Multiple actions compose: e.g. stage `dist/` into a
172    /// recipe's `files/` dir *and* record the deferred `bitbake` command
173    /// (dry-run) when the recipe is built later on a separate Yocto host.
174    pub bitbake: Vec<BitbakeAction>,
175}
176
177/// The fully resolved plan for a pipeline run: every stage's inputs threaded
178/// together without running any build.
179pub struct PipelinePlan {
180    /// Directory containing the manifest (all relative paths resolve against it).
181    pub manifest_dir: PathBuf,
182    /// Rust path for the shared message-type crate used by codegen.
183    pub ty_crate: syn::Path,
184    /// One plan per remote coprocessor firmware.
185    pub firmware: Vec<FirmwareBuildPlan>,
186    /// Expected build-host ELF path per firmware core.
187    pub firmware_outputs: HashMap<VisibleCore, PathBuf>,
188    /// The debug "ELF map": firmware ELF path per `[dbg.<core>]` core.
189    pub dbg_elfs: HashMap<VisibleCore, PathBuf>,
190    /// HMI build parameters, when the HMI stage runs.
191    pub hmi: Option<HmiConfiguration>,
192    /// The HMI dist directory the frontend build produces, when it runs.
193    pub hmi_dist: Option<PathBuf>,
194    /// TA build parameters, when the manifest declares `[tee]`.
195    pub ta: Option<OpteeConfiguration>,
196    /// The application build plan and its generated AP init module.
197    pub application: ApplicationBuildPlan,
198    /// The Linux device tree to write under `dist/dts/linux/`, as
199    /// `(file_name, contents)`: a merged `.dts` (default) or a `.dtso` overlay.
200    /// Empty when the manifest declares no UIO devices.
201    pub dts_files: Vec<(String, String)>,
202    /// The kernel-side files to write under `dist/kernel/`, as
203    /// `(file_name, contents)`: the UIO config fragment and, when `[profile]
204    /// kernel_recipe` is set, the `.bbappend` that merges it. Empty when the
205    /// manifest declares no UIO devices.
206    pub kernel_files: Vec<(String, String)>,
207    /// The boot-args files to write under `dist/boot/`, as
208    /// `(file_name, contents)`: the family-specific bootloader snippet(s) that
209    /// put `uio_pdrv_genirq.of_id=generic-uio` on the kernel command line, plus a
210    /// README. Empty when the manifest declares no UIO devices.
211    pub boot_files: Vec<(String, String)>,
212    /// The systemd unit to render.
213    pub systemd: SystemdUnitConfig,
214}
215
216/// Loads and validates the manifest at `manifest_path`.
217pub async fn load_session(manifest_path: &Path) -> Result<Session, PipelineError> {
218    consortium_log::trace!("loading manifest {}", manifest_path.display());
219    let toml = tokio::fs::read_to_string(manifest_path)
220        .await
221        .map_err(|source| {
222            consortium_log::trace!("manifest read failed: {}", manifest_path.display());
223            PipelineError::ManifestRead {
224                path: manifest_path.display().to_string(),
225                source,
226            }
227        })?;
228    Ok(Session::from_toml(&toml)?)
229}
230
231/// Derives HMI build parameters from the manifest's `[hmi]` section, or `None`.
232///
233/// Resolves the frontend project (`[hmi].source`) and dist (`[hmi].build`)
234/// directories against the manifest directory and the build command from the
235/// environment/PATH.
236pub fn derive_hmi_configuration(
237    session: &Session,
238    manifest_dir: &Path,
239) -> Result<Option<HmiConfiguration>, PipelineError> {
240    let Some(hmi) = &session.config.hmi else {
241        return Ok(None);
242    };
243    hmi.validate().map_err(PipelineError::Codegen)?;
244
245    let project_dir = manifest_dir.join(&hmi.source);
246    let dist_dir = manifest_dir.join(&hmi.build);
247    let command = match hmi.engine {
248        HmiEngine::Cog => {
249            crate::compile::utils::resolve_hmi_build_command().map_err(PipelineError::Codegen)?
250        }
251        HmiEngine::Pocket => {
252            // `validate` guaranteed `app` is present and non-empty.
253            let app = hmi.app.as_deref().unwrap_or_default();
254            crate::compile::utils::resolve_pocket_build_command(app, &dist_dir)
255                .map_err(PipelineError::Codegen)?
256        }
257    };
258    Ok(Some(HmiConfiguration {
259        engine: hmi.engine,
260        app: hmi.app.clone(),
261        project_dir,
262        dist_dir,
263        command,
264    }))
265}
266
267/// Resolves every stage's inputs without building (a dry run).
268pub async fn plan(
269    session: &Session,
270    config: &PipelineConfig,
271) -> Result<PipelinePlan, PipelineError> {
272    let manifest_dir = config
273        .manifest_path
274        .parent()
275        .map(Path::to_path_buf)
276        .unwrap_or_else(|| PathBuf::from("."));
277
278    let ty_crate = resolve_ty_crate(session, &manifest_dir).await?;
279
280    // Stage 1: firmware plans + expected ELF outputs.
281    let firmware =
282        firmware::plan_firmware_builds(session, &manifest_dir, config.release, &ty_crate)?;
283    let mut firmware_outputs = HashMap::new();
284    for plan in &firmware {
285        let elf = firmware::firmware_elf_path(&plan.config).await?;
286        firmware_outputs.insert(plan.core, elf);
287    }
288
289    // Stage 2: the defmt ELF map (fails fast if a dbg core was not built). The AP
290    // decodes defmt from the `*_original.elf` image (the objcopy-stripped deploy
291    // ELF has no `.defmt` section), so map each raw output to its original sibling.
292    let ir = session.lower()?;
293    let dbg_sources: HashMap<VisibleCore, PathBuf> = firmware_outputs
294        .iter()
295        .map(|(core, raw)| (*core, crate::compile::firmware::original_elf_path(raw)))
296        .collect();
297    let dbg_elfs = elf_map::resolve_dbg_elf_paths(&dbg_sources, ir.dbg.rings().keys().copied())?;
298
299    // Stage 3: HMI dist directory (built later; recorded here).
300    let hmi_dist = config.hmi.as_ref().map(|h| h.dist_dir.clone());
301
302    // Stage 4: TA plan.
303    let ta = tee::plan_ta_build(
304        session,
305        &manifest_dir,
306        config.tee_toolchain.as_ref(),
307        config.release,
308        config.sign_tee,
309    )?;
310
311    // Stage 5: application plan (AP module embeds the resolved firmware ELFs).
312    let application = application::plan_application_build(
313        session,
314        &manifest_dir,
315        hmi_dist.as_deref(),
316        &dbg_elfs,
317        config.release,
318        &ty_crate,
319    )?;
320
321    // Stage 6: the Linux device tree (merged `.dts` or overlay `.dtso`, built on
322    // the vendor base for `[profile] chip`/`devkit`) plus the kernel-side files
323    // enabling its drivers, and the boot-args files — all from the lowered IR.
324    let profile = &session.config.profile;
325    let dts_cfg = &session.config.dts;
326    // The coprocessor cores the build targets, so remoteproc fixups stay scoped.
327    let target_cores: Vec<String> = firmware.iter().map(|p| p.core.to_string()).collect();
328    let dts_files = match dts::render_linux_dts(
329        &profile.chip,
330        profile.devkit.as_deref(),
331        dts_cfg.base.as_deref(),
332        dts_cfg.mode,
333        &ir,
334        &target_cores,
335    )? {
336        Some(rendered) => {
337            consortium_log::trace!(
338                "rendered linux dts {} with {} warnings",
339                rendered.file_name,
340                rendered.warnings.len()
341            );
342            for warning in &rendered.warnings {
343                consortium_log::trace!("dts warning: {}", warning);
344                eprintln!("warning: {warning}");
345            }
346            vec![(rendered.file_name, rendered.contents)]
347        }
348        None => {
349            consortium_log::trace!("no UIO devices; no linux dts rendered");
350            Vec::new()
351        }
352    };
353
354    // The kernel config fragment plus, when the manifest names a kernel recipe, a
355    // `.bbappend` that merges it into the kernel build. Both land in `dist/kernel/`.
356    let mut kernel_files = dts::render_uio_kconfig(&ir, dts_cfg.mode, dts_cfg.configfs)
357        .map(|contents| vec![(dts::UIO_KCONFIG_NAME.to_string(), contents)])
358        .unwrap_or_default();
359    if let Some(kernel_recipe) = &session.config.profile.kernel_recipe {
360        let fragment_names: Vec<String> = kernel_files.iter().map(|(n, _)| n.clone()).collect();
361        if let Some(bbappend) = dts::render_kernel_bbappend(&fragment_names) {
362            kernel_files.push((dts::kernel_bbappend_name(kernel_recipe), bbappend));
363        }
364    }
365
366    // The family-specific boot-args files under `dist/boot/`.
367    let boot_files = dts::render_boot(&profile.chip, &ir);
368
369    // Stage 7: systemd unit.
370    let systemd = systemd_config(session, &application.config.path).await?;
371
372    Ok(PipelinePlan {
373        manifest_dir,
374        ty_crate,
375        firmware,
376        firmware_outputs,
377        dbg_elfs,
378        hmi: config.hmi.clone(),
379        hmi_dist,
380        ta,
381        application,
382        dts_files,
383        kernel_files,
384        boot_files,
385        systemd,
386    })
387}
388
389/// Runs the full pipeline, returning the assembled `dist/` root.
390pub async fn run(config: PipelineConfig) -> Result<PathBuf, PipelineError> {
391    run_inner(config, None).await
392}
393
394/// Runs the full pipeline while reporting step progress and subprocess output.
395pub async fn run_with_reporter(
396    config: PipelineConfig,
397    reporter: &dyn BuildReporter,
398) -> Result<PathBuf, PipelineError> {
399    run_inner(config, Some(reporter)).await
400}
401
402async fn run_inner(
403    config: PipelineConfig,
404    reporter: Option<&dyn BuildReporter>,
405) -> Result<PathBuf, PipelineError> {
406    consortium_log::trace!("pipeline run: manifest {}", config.manifest_path.display());
407    let session = load_session(&config.manifest_path).await?;
408    let plan = plan(&session, &config).await?;
409    consortium_log::trace!(
410        "pipeline plan: {} firmware, ta={}, hmi={}, {} bitbake",
411        plan.firmware.len(),
412        plan.ta.is_some(),
413        config.hmi.is_some(),
414        config.bitbake.len()
415    );
416
417    let total_steps = plan.firmware.len()
418        + usize::from(config.hmi.is_some())
419        + usize::from(plan.ta.is_some())
420        + 2
421        + config.bitbake.len();
422    let mut steps = StepRunner::new(reporter, total_steps);
423
424    // The deploy (objcopy-processed) ELF per core, staged into `dist/firmware/`.
425    // The AP already embedded the `*_original.elf` at plan time (Stage 2/5); the
426    // unstripped original is also staged into `dist/firmware/` so the integrator
427    // keeps its `.defmt` section for host-side defmt table parsing.
428    let mut firmware_outputs = HashMap::new();
429    let mut firmware_originals = HashMap::new();
430    for fw in &plan.firmware {
431        let detail = format!("{} ({})", fw.core, fw.config.target);
432        let (deploy, original) = steps
433            .run("Build firmware", Some(&detail), async {
434                write_generated_module(&fw.config.path, &fw.generated).await?;
435                let artifacts =
436                    FirmwareCompiler::build_with_reporter(fw.config.clone(), reporter).await?;
437                Ok((artifacts.deploy, artifacts.original))
438            })
439            .await?;
440        firmware_outputs.insert(fw.core, deploy);
441        firmware_originals.insert(fw.core, original);
442    }
443
444    let hmi_dist = match &config.hmi {
445        Some(hmi) => {
446            let detail = hmi.project_dir.display().to_string();
447            Some(
448                steps
449                    .run("Build HMI", Some(&detail), async {
450                        let dist = HmiCompiler::build_with_reporter(hmi.clone(), reporter).await?;
451                        Ok(dist)
452                    })
453                    .await?,
454            )
455        }
456        None => None,
457    };
458
459    let ta = match plan.ta {
460        Some(ta) => {
461            let detail = if ta.sign {
462                "signed trusted application"
463            } else {
464                "unsigned trusted application"
465            };
466            Some(
467                steps
468                    .run("Build TEE", Some(detail), async {
469                        let ta = OpteeCompiler::build_with_reporter(ta, reporter).await?;
470                        Ok(ta)
471                    })
472                    .await?,
473            )
474        }
475        None => None,
476    };
477
478    let app_detail = plan.application.config.target.clone();
479    let application = steps
480        .run("Build application", Some(&app_detail), async {
481            write_generated_module(&plan.application.config.path, &plan.application.generated)
482                .await?;
483            let app =
484                ApplicationCompiler::build_with_reporter(plan.application.config.clone(), reporter)
485                    .await?;
486            Ok(app)
487        })
488        .await?;
489
490    let systemd_units = vec![(
491        plan.systemd.file_name(),
492        systemd::render_unit(&plan.systemd),
493    )];
494
495    let dist_detail = config.dist_root.display().to_string();
496    let layout = DistLayout::new(&config.dist_root);
497    steps
498        .run("Assemble dist", Some(&dist_detail), async {
499            let inputs = dist::DistAssemblyInputs {
500                firmware: &firmware_outputs,
501                firmware_originals: &firmware_originals,
502                application: &application,
503                ta: ta.as_deref(),
504                hmi: config
505                    .hmi
506                    .as_ref()
507                    .zip(hmi_dist.as_deref())
508                    .map(|(cfg, dist)| dist::HmiStaging {
509                        engine: cfg.engine,
510                        app: cfg.app.as_deref(),
511                        dist,
512                    }),
513                dts_files: &plan.dts_files,
514                kconfig_fragments: &plan.kernel_files,
515                boot_files: &plan.boot_files,
516                systemd_units: &systemd_units,
517            };
518            dist::assemble(&layout, inputs).await?;
519            Ok(())
520        })
521        .await?;
522
523    // Compile the emitted device tree to a `.dtb`/`.dtbo` when `[dts] linux`
524    // points at a kernel tree (for its GPL-2.0 dt-bindings, never bundled);
525    // otherwise print how to compile it (Yocto kernel build vs. manual).
526    if let Some((dts_name, _)) = plan.dts_files.first() {
527        let arch = dtc::arch_dir(&plan.application.config.target);
528        let dts_dir = layout.dts_linux_dir();
529        match &session.config.dts.linux {
530            Some(linux) => {
531                let linux_dir = plan.manifest_dir.join(linux);
532                let profile = &session.config.profile;
533                let closure = consortium_cfg::dts::base_closure(
534                    &profile.chip,
535                    profile.devkit.as_deref(),
536                    session.config.dts.base.as_deref(),
537                )
538                .map_err(consortium_cfg::CfgError::from)?;
539                let detail = format!("dtc {dts_name}");
540                steps
541                    .run("Compile device tree", Some(&detail), async {
542                        let out = dtc::compile_device_tree(
543                            &dts_dir, dts_name, &closure, &linux_dir, arch,
544                        )
545                        .await?;
546                        Ok(out)
547                    })
548                    .await?;
549            }
550            None => {
551                let yocto =
552                    !config.bitbake.is_empty() || session.config.profile.kernel_recipe.is_some();
553                eprintln!("note: {}", dtc::compilation_hint(dts_name, arch, yocto));
554            }
555        }
556    }
557
558    for action in &config.bitbake {
559        // A dry-run invoke emits a self-contained `.bb` recipe describing how to
560        // install the assembled `dist/` tree, so a separate Yocto host can build
561        // it without re-running the Consortium pipeline.
562        let recipe_spec = match action {
563            BitbakeAction::InvokeBitbake {
564                recipe, dry_run, ..
565            } if *dry_run => Some(recipe_spec(
566                recipe,
567                &session.config.profile.deploy_root,
568                &application,
569                &firmware_outputs,
570                ta.as_deref(),
571                hmi_dist.as_deref(),
572                &plan.systemd.file_name(),
573                &plan.dts_files,
574                &plan.boot_files,
575            )?),
576            _ => None,
577        };
578
579        let (label, detail) = match action {
580            BitbakeAction::CopyIntoRecipe { recipe_files_dir } => (
581                "Stage into recipe",
582                format!("copy to {}", recipe_files_dir.display()),
583            ),
584            BitbakeAction::InvokeBitbake {
585                build_dir,
586                recipe,
587                dry_run,
588            } => {
589                let dry = if *dry_run {
590                    " (dry-run, recipe only)"
591                } else {
592                    ""
593                };
594                (
595                    "Run bitbake",
596                    format!("{recipe} in {}{dry}", build_dir.display()),
597                )
598            }
599        };
600        steps
601            .run(label, Some(&detail), async {
602                bitbake::run_with_reporter(
603                    action,
604                    &config.dist_root,
605                    recipe_spec.as_ref(),
606                    reporter,
607                )
608                .await?;
609                Ok(())
610            })
611            .await?;
612    }
613
614    Ok(config.dist_root)
615}
616
617/// Builds the install manifest for a generated `.bb` recipe from the pipeline's
618/// resolved outputs. Artifact fields are the bare file names the `dist/` stage
619/// writes under each subdirectory.
620#[allow(clippy::too_many_arguments)]
621fn recipe_spec(
622    recipe_name: &str,
623    deploy_root: &str,
624    application: &Path,
625    firmware_outputs: &HashMap<VisibleCore, PathBuf>,
626    ta: Option<&Path>,
627    hmi_dist: Option<&Path>,
628    systemd_unit: &str,
629    dts_files: &[(String, String)],
630    boot_files: &[(String, String)],
631) -> Result<RecipeSpec, PipelineError> {
632    let file_name = |path: &Path| -> Result<String, PipelineError> {
633        path.file_name()
634            .and_then(|n| n.to_str())
635            .map(str::to_string)
636            .ok_or_else(|| PipelineError::Codegen(format!("no file name for {}", path.display())))
637    };
638
639    // Firmware is staged as `firmware/<core>.elf`, matching the dist layout.
640    let firmware = firmware_outputs
641        .keys()
642        .map(|core| format!("{core}.elf"))
643        .collect();
644
645    Ok(RecipeSpec {
646        recipe_name: recipe_name.to_string(),
647        deploy_root: deploy_root.to_string(),
648        app_bin: file_name(application)?,
649        firmware,
650        ta: ta.map(file_name).transpose()?,
651        has_www: hmi_dist.is_some(),
652        systemd_unit: systemd_unit.to_string(),
653        dts_files: dts_files.iter().map(|(name, _)| name.clone()).collect(),
654        boot_files: boot_files.iter().map(|(name, _)| name.clone()).collect(),
655    })
656}
657
658struct StepRunner<'a> {
659    reporter: Option<&'a dyn BuildReporter>,
660    next: usize,
661    total: usize,
662}
663
664impl<'a> StepRunner<'a> {
665    fn new(reporter: Option<&'a dyn BuildReporter>, total: usize) -> Self {
666        Self {
667            reporter,
668            next: 0,
669            total,
670        }
671    }
672
673    async fn run<T, F>(
674        &mut self,
675        name: &str,
676        detail: Option<&str>,
677        future: F,
678    ) -> Result<T, PipelineError>
679    where
680        F: std::future::Future<Output = Result<T, PipelineError>>,
681    {
682        self.next += 1;
683        let step = BuildStep {
684            index: self.next,
685            total: self.total,
686            name,
687            detail,
688        };
689        if let Some(reporter) = self.reporter {
690            reporter.step_started(step);
691        }
692
693        let result = future.await;
694        if let Some(reporter) = self.reporter {
695            match &result {
696                Ok(_) => reporter.step_succeeded(step),
697                Err(_) => reporter.step_failed(step),
698            }
699        }
700        result
701    }
702}
703
704/// The [`VisibleCore`] a chip-database coprocessor entry names.
705pub(crate) fn visible_core_of(cp: &Coprocessor) -> Result<VisibleCore, PipelineError> {
706    cp.name
707        .parse()
708        .map_err(|_| PipelineError::UnknownCore(cp.name.clone()))
709}
710
711/// The Rust target triple for a coprocessor core (the single source of truth).
712pub(crate) fn core_rust_triple(core: VisibleCore) -> String {
713    match core {
714        VisibleCore::Mcore(name) => name.rust_triple(),
715        VisibleCore::Rcore(name) => name.rust_triple(),
716        VisibleCore::Rvcore(name) => name.rust_triple(),
717        // The AP is never a firmware target (it is absent from the chip `cp`
718        // list); the application stage resolves its Linux triple separately.
719        VisibleCore::Ap => "aarch64-unknown-linux-gnu".to_string(),
720    }
721}
722
723/// The shared-memory layout for `core`'s first IPC channel, if it has one.
724///
725/// No physical base is carried: the IPC transport resolves its region from the
726/// generated code (controllers embed the base; the AP maps through UIO), so the
727/// only AP-relevant coordinate is the UIO device index.
728pub(crate) fn ipc_config_for(ir: &LoweredIr, core: VisibleCore) -> Option<SharedMemConfig> {
729    let transceiver = ir
730        .shuhan
731        .transceivers()
732        .iter()
733        .find(|t| t.channel_primary.0 == core || t.channel_secondary.0 == core)?;
734    // The AP carries its UIO device index; controllers don't map through UIO.
735    let uio = match core {
736        VisibleCore::Ap => transceiver.region.address.ap.uio_id as u32,
737        _ => 0,
738    };
739    Some(SharedMemConfig {
740        length: u64::from(transceiver.region.length),
741        uio,
742        mtu: transceiver.mtu,
743    })
744}
745
746/// Resolves the shared message-type crate's Rust path.
747///
748/// Prefers the `[package] name` in the shared crate's `Cargo.toml`; falls back to
749/// the last component of `[profile] shared` when that file cannot be read (so the
750/// dry-run [`plan`] works without every crate present on disk).
751async fn resolve_ty_crate(
752    session: &Session,
753    manifest_dir: &Path,
754) -> Result<syn::Path, PipelineError> {
755    let shared_dir = manifest_dir.join(&session.config.profile.shared);
756    let name = match crate::compile::utils::binary_name(&shared_dir).await {
757        Ok(name) => name,
758        Err(_) => shared_dir
759            .file_name()
760            .and_then(|n| n.to_str())
761            .unwrap_or("shared")
762            .to_string(),
763    };
764    let ident = name.replace('-', "_");
765    syn::parse_str(&format!("::{ident}"))
766        .map_err(|e| PipelineError::Codegen(format!("bad shared crate name '{ident}': {e}")))
767}
768
769/// Builds the systemd unit for the application, using its binary name for
770/// `ExecStart=`/`WorkingDirectory=` under the manifest's deploy root.
771async fn systemd_config(
772    session: &Session,
773    app_path: &Path,
774) -> Result<SystemdUnitConfig, PipelineError> {
775    let binary = crate::compile::utils::binary_name(app_path)
776        .await
777        .map_err(PipelineError::Codegen)?;
778    let deploy_root = &session.config.profile.deploy_root;
779    Ok(SystemdUnitConfig {
780        unit_name: "consortium-app".to_string(),
781        exec_start: format!("{deploy_root}/app/{binary}"),
782        working_directory: format!("{deploy_root}/app"),
783        description: None,
784        restart: SystemdRestartPolicy::OnFailure,
785    })
786}
787
788/// Writes a generated init module into a crate at `src/consortium.gen.rs`.
789///
790/// The crate is expected to bring it in with `include!("consortium.gen.rs")` (or
791/// a `mod`), which is how the melt-pot examples wire generated code in.
792async fn write_generated_module(
793    project_dir: &Path,
794    source: &str,
795) -> Result<PathBuf, PipelineError> {
796    let src_dir = project_dir.join("src");
797    tokio::fs::create_dir_all(&src_dir)
798        .await
799        .map_err(|source| {
800            consortium_log::trace!("failed to create src dir {}", src_dir.display());
801            PipelineError::GeneratedWrite {
802                path: src_dir.display().to_string(),
803                source,
804            }
805        })?;
806    let path = src_dir.join(GENERATED_MODULE);
807    consortium_log::trace!(
808        "writing {} bytes of generated code to {}",
809        source.len(),
810        path.display()
811    );
812    tokio::fs::write(&path, source).await.map_err(|source| {
813        consortium_log::trace!("failed to write generated module {}", path.display());
814        PipelineError::GeneratedWrite {
815            path: path.display().to_string(),
816            source,
817        }
818    })?;
819    Ok(path)
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825
826    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
827
828    /// Fabricates a project tree on disk (manifest + per-endpoint crates with
829    /// `Cargo.toml`s, no real sources) so the dry-run [`plan`] can resolve crate
830    /// names and expected ELF paths without invoking any build.
831    fn fabricate_project(tag: &str) -> PathBuf {
832        let root =
833            std::env::temp_dir().join(format!("consortium-pipeline-{tag}-{}", std::process::id()));
834        let _ = std::fs::remove_dir_all(&root);
835        std::fs::create_dir_all(&root).unwrap();
836        std::fs::write(root.join("Consortium.toml"), STM32).unwrap();
837        for (dir, pkg) in [
838            ("app", "melt-pot-app"),
839            ("ta", "melt-pot-ta"),
840            ("firmware", "melt-pot-mcu"),
841            ("shared", "melt-pot-shared"),
842        ] {
843            let crate_dir = root.join(dir);
844            std::fs::create_dir_all(&crate_dir).unwrap();
845            std::fs::write(
846                crate_dir.join("Cargo.toml"),
847                format!("[package]\nname = \"{pkg}\"\nversion = \"0.0.0\"\n"),
848            )
849            .unwrap();
850        }
851        root
852    }
853
854    #[tokio::test]
855    async fn plan_threads_all_stages() {
856        let root = fabricate_project("plan");
857        let session = Session::from_toml(STM32).unwrap();
858        let config = PipelineConfig {
859            manifest_path: root.join("Consortium.toml"),
860            dist_root: root.join("dist"),
861            release: true,
862            hmi: Some(HmiConfiguration {
863                engine: HmiEngine::Cog,
864                app: None,
865                project_dir: root.join("website"),
866                dist_dir: root.join("website/dist"),
867                command: crate::compile::web::HmiBuildCommand::Custom(vec!["true".into()]),
868            }),
869            tee_toolchain: Some(TeeToolchain {
870                ta_dev_kit_dir: PathBuf::from("/opt/optee/export-ta_arm64"),
871                optee_signer: PathBuf::from("/opt/optee/scripts/sign_encrypt.py"),
872                linker: None,
873            }),
874            sign_tee: true,
875            bitbake: Vec::new(),
876        };
877
878        let plan = plan(&session, &config).await.expect("plan succeeds");
879
880        // ty_crate came from the shared crate's Cargo.toml package name
881        // (`melt-pot-shared` → `::melt_pot_shared`).
882        assert_eq!(
883            plan.ty_crate.segments.last().unwrap().ident.to_string(),
884            "melt_pot_shared"
885        );
886
887        // Stage 1: exactly cm33 (the remote firmware target) is planned, and its
888        // expected ELF path uses the resolved binary name + target triple.
889        assert_eq!(plan.firmware.len(), 1);
890        let cm33 = VisibleCore::Mcore("cm33".parse().unwrap());
891        // The endpoint is `./firmware`, so the resolved path keeps that prefix.
892        let expected_elf =
893            root.join("./firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu");
894        assert_eq!(plan.firmware_outputs[&cm33], expected_elf);
895
896        // Stage 2: the dbg ELF map points cm33's ring at the `*_original.elf`
897        // sibling (the defmt-bearing image), absolutized for `include_bytes!`.
898        let original_elf =
899            std::path::absolute(crate::compile::firmware::original_elf_path(&expected_elf))
900                .unwrap();
901        assert_eq!(plan.dbg_elfs[&cm33], original_elf);
902
903        // Stage 5: the AP module embeds the `*_original.elf` Stage 1 will produce,
904        // and the app targets Linux with the HMI served from the deploy root.
905        // (prettyplease may wrap a long `include_bytes!(...)` across lines, so
906        // assert on the embedded path literal rather than the whole call.)
907        assert!(plan.application.generated.contains("include_bytes!"));
908        assert!(
909            plan.application
910                .generated
911                .contains(&format!("\"{}\"", original_elf.display()))
912        );
913        assert_eq!(plan.application.config.target, "aarch64-unknown-linux-gnu");
914        assert_eq!(
915            plan.application.config.hmi.as_ref().unwrap(),
916            &crate::compile::env::HmiConfig::Cog {
917                dist: "/opt/consortium/www".to_string(),
918                debug_url: Some("http://127.0.0.1:5173/".to_string()),
919            }
920        );
921
922        // Stage 4: TA resolved from the manifest.
923        assert!(plan.ta.is_some());
924
925        // Stage 6: systemd ExecStart uses the app binary under the deploy root.
926        assert_eq!(plan.systemd.exec_start, "/opt/consortium/app/melt-pot-app");
927
928        // Stage 6: the UIO manifest yields the merged device tree (default mode,
929        // no devkit, so it builds on the generic `stm32mp257.dtsi`) plus the
930        // kernel config fragment enabling its drivers.
931        assert_eq!(
932            plan.dts_files
933                .iter()
934                .map(|(n, _)| n.as_str())
935                .collect::<Vec<_>>(),
936            vec!["consortium-stm32mp257.dts"]
937        );
938        // A merged `.dts` re-emits the vendor base plus the injected UIO nodes.
939        assert!(plan.dts_files[0].1.contains("compatible = \"generic-uio\""));
940        let kconfig = plan
941            .kernel_files
942            .iter()
943            .find(|(n, _)| n == dts::UIO_KCONFIG_NAME)
944            .expect("uio kconfig fragment planned");
945        assert!(kconfig.1.contains("CONFIG_UIO_PDRV_GENIRQ=y"));
946        assert!(kconfig.1.contains("CONFIG_UIO_DMEM_GENIRQ=y"));
947        // Merge mode needs no overlay support.
948        assert!(!kconfig.1.contains("CONFIG_OF_OVERLAY"));
949        // STM32 → an extlinux boot-args fragment lands under `dist/boot/`.
950        assert!(plan.boot_files.iter().any(|(n, _)| n == "extlinux-uio.cfg"));
951        // No `[profile] kernel_recipe` in the example, so no bbappend is emitted.
952        assert!(
953            !plan
954                .kernel_files
955                .iter()
956                .any(|(n, _)| n.ends_with(".bbappend"))
957        );
958
959        let _ = std::fs::remove_dir_all(&root);
960    }
961}