use std::path::{Path, PathBuf};
use consortium_cfg::Session;
use consortium_cfg_common::config::Party;
use tokio::process::Command;
use super::application::acore_linux_triple;
use super::firmware::remote_coprocessors;
use super::{PipelineError, core_rust_triple, visible_core_of};
use crate::compile::optee::OPTEE_TARGET;
use crate::compile::utils::run_command;
use crate::compile::web::HmiBuildCommand;
use crate::progress::BuildReporter;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CargoFetch {
pub label: String,
pub path: PathBuf,
pub target: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HmiFetch {
pub project_dir: PathBuf,
pub command: HmiBuildCommand,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchPlan {
pub cargo: Vec<CargoFetch>,
pub hmi: Option<HmiFetch>,
}
impl FetchPlan {
pub fn rust_targets(&self) -> Vec<String> {
let mut targets: Vec<String> = Vec::new();
for entry in &self.cargo {
if !targets.contains(&entry.target) {
targets.push(entry.target.clone());
}
}
targets
}
}
pub struct FetchConfig {
pub manifest_path: PathBuf,
pub hmi: Option<HmiFetch>,
pub rust_targets: bool,
}
pub fn plan_fetch(
session: &Session,
manifest_dir: &Path,
hmi: Option<HmiFetch>,
) -> Result<FetchPlan, PipelineError> {
let mut cargo = Vec::new();
for cp in remote_coprocessors(&session.database) {
let core = visible_core_of(cp)?;
let Some((_, root)) = session
.config
.endpoints
.iter()
.find(|(party, _)| party.core() == core)
else {
continue;
};
cargo.push(CargoFetch {
label: core.to_string(),
path: manifest_dir.join(root),
target: core_rust_triple(core),
});
}
if session.config.tee.is_some() {
let root = session
.config
.endpoints
.get(&Party::AcoreSecure)
.ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreSecure.to_string()))?;
cargo.push(CargoFetch {
label: "ta".to_string(),
path: manifest_dir.join(root),
target: OPTEE_TARGET.to_string(),
});
}
let root = session
.config
.endpoints
.get(&Party::AcoreNonSecure)
.ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreNonSecure.to_string()))?;
cargo.push(CargoFetch {
label: "app".to_string(),
path: manifest_dir.join(root),
target: acore_linux_triple(&session.database.ap)?,
});
Ok(FetchPlan { cargo, hmi })
}
pub fn derive_hmi_fetch(
session: &Session,
manifest_dir: &Path,
) -> Result<Option<HmiFetch>, PipelineError> {
use consortium_cfg_common::config::HmiEngine;
let Some(hmi) = &session.config.hmi else {
return Ok(None);
};
hmi.validate().map_err(PipelineError::Codegen)?;
let command = match hmi.engine {
HmiEngine::Cog => crate::compile::utils::resolve_hmi_fetch_command(),
HmiEngine::Pocket => crate::compile::utils::resolve_pocket_fetch_command(),
}
.map_err(PipelineError::Codegen)?;
Ok(Some(HmiFetch {
project_dir: manifest_dir.join(&hmi.source),
command,
}))
}
pub async fn run(config: FetchConfig) -> Result<FetchPlan, PipelineError> {
run_inner(config, None).await
}
pub async fn run_with_reporter(
config: FetchConfig,
reporter: &dyn BuildReporter,
) -> Result<FetchPlan, PipelineError> {
run_inner(config, Some(reporter)).await
}
async fn run_inner(
config: FetchConfig,
reporter: Option<&dyn BuildReporter>,
) -> Result<FetchPlan, PipelineError> {
let session = super::load_session(&config.manifest_path).await?;
let manifest_dir = config
.manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let plan = plan_fetch(&session, &manifest_dir, config.hmi)?;
let targets = plan.rust_targets();
let install_targets = config.rust_targets && !targets.is_empty();
let total = usize::from(install_targets) + plan.cargo.len() + usize::from(plan.hmi.is_some());
let mut steps = super::StepRunner::new(reporter, total);
if install_targets {
let detail = targets.join(" ");
steps
.run("Install Rust targets", Some(&detail), async {
install_rust_targets(&targets, reporter).await
})
.await?;
}
for entry in &plan.cargo {
let detail = format!("{} ({})", entry.label, entry.target);
steps
.run("Fetch dependencies", Some(&detail), async {
cargo_fetch(entry, reporter).await
})
.await?;
}
if let Some(hmi) = &plan.hmi {
let detail = hmi.project_dir.display().to_string();
steps
.run("Install HMI dependencies", Some(&detail), async {
hmi_install(hmi, reporter).await
})
.await?;
}
Ok(plan)
}
async fn cargo_fetch(
entry: &CargoFetch,
reporter: Option<&dyn BuildReporter>,
) -> Result<(), PipelineError> {
let cargo = crate::compile::utils::resolve_cargo()
.await
.map_err(|message| PipelineError::Fetch {
label: entry.label.clone(),
target: entry.target.clone(),
message,
})?;
let mut cmd = Command::new(&cargo);
cmd.arg("fetch")
.arg("--target")
.arg(&entry.target)
.current_dir(&entry.path);
let status = run_command(&mut cmd, reporter)
.await
.map_err(|e| PipelineError::Fetch {
label: entry.label.clone(),
target: entry.target.clone(),
message: e.to_string(),
})?;
if !status.success() {
return Err(PipelineError::Fetch {
label: entry.label.clone(),
target: entry.target.clone(),
message: format!("`cargo fetch` exited with status: {status}"),
});
}
Ok(())
}
async fn install_rust_targets(
targets: &[String],
reporter: Option<&dyn BuildReporter>,
) -> Result<(), PipelineError> {
let mut cmd = Command::new("rustup");
cmd.args(["target", "add", "--toolchain", "stable"])
.args(targets);
let status = match run_command(&mut cmd, reporter).await {
Ok(status) => status,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!(
"note: rustup not found; skipping `rustup target add {}`. Ensure the \
standard library for these targets is installed by other means.",
targets.join(" ")
);
return Ok(());
}
Err(e) => {
return Err(PipelineError::RustTarget {
targets: targets.join(" "),
message: e.to_string(),
});
}
};
if !status.success() {
return Err(PipelineError::RustTarget {
targets: targets.join(" "),
message: format!("`rustup target add` exited with status: {status}"),
});
}
Ok(())
}
async fn hmi_install(
hmi: &HmiFetch,
reporter: Option<&dyn BuildReporter>,
) -> Result<(), PipelineError> {
let dir = hmi.project_dir.display().to_string();
let (program, args) = hmi.command.program_args();
if program.is_empty() {
return Err(PipelineError::HmiFetch {
dir,
message: "HMI install command is empty".to_string(),
});
}
match tokio::fs::metadata(&hmi.project_dir).await {
Ok(meta) if meta.is_dir() => {}
_ => {
return Err(PipelineError::HmiFetch {
dir,
message: "project directory not found".to_string(),
});
}
}
let mut cmd = Command::new(program);
cmd.args(&args).current_dir(&hmi.project_dir);
let status = run_command(&mut cmd, reporter)
.await
.map_err(|e| PipelineError::HmiFetch {
dir: dir.clone(),
message: format!("failed to launch `{program}`: {e}"),
})?;
if !status.success() {
return Err(PipelineError::HmiFetch {
dir,
message: format!("`{program}` exited with status: {status}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
fn session() -> Session {
Session::from_toml(STM32).expect("example manifest loads")
}
#[test]
fn plans_one_fetch_per_cargo_project() {
let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
let entries: Vec<(&str, &str)> = plan
.cargo
.iter()
.map(|e| (e.label.as_str(), e.target.as_str()))
.collect();
assert_eq!(
entries,
vec![
("cm33", "thumbv8m.main-none-eabihf"),
("ta", "aarch64-unknown-linux-gnu"),
("app", "aarch64-unknown-linux-gnu"),
]
);
assert_eq!(plan.cargo[0].path, Path::new("/proj/./firmware"));
assert_eq!(plan.cargo[1].path, Path::new("/proj/./ta"));
assert_eq!(plan.cargo[2].path, Path::new("/proj/./app"));
assert!(plan.hmi.is_none());
}
#[test]
fn rust_targets_are_deduplicated_in_first_use_order() {
let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
assert_eq!(
plan.rust_targets(),
vec!["thumbv8m.main-none-eabihf", "aarch64-unknown-linux-gnu"]
);
}
#[test]
fn plan_carries_the_hmi_install_through() {
let hmi = HmiFetch {
project_dir: PathBuf::from("/proj/website"),
command: HmiBuildCommand::Custom(vec!["pnpm".into(), "install".into()]),
};
let plan =
plan_fetch(&session(), Path::new("/proj"), Some(hmi.clone())).expect("plan succeeds");
assert_eq!(plan.hmi, Some(hmi));
}
#[test]
fn plan_omits_the_ta_without_a_tee_section() {
let manifest = "\
[profile]\n\
chip = \"stm32mp257fak3\"\n\
shared = \"./shared\"\n\
deploy_root = \"/opt/consortium\"\n\
\n\
[endpoints]\n\
linux = \"./app\"\n";
let session = Session::from_toml(manifest).expect("manifest loads");
let plan = plan_fetch(&session, Path::new("/proj"), None).expect("plan succeeds");
let labels: Vec<&str> = plan.cargo.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, vec!["app"]);
}
#[test]
fn plan_errors_without_an_application_endpoint() {
let manifest = "\
[profile]\n\
chip = \"stm32mp257fak3\"\n\
shared = \"./shared\"\n\
deploy_root = \"/opt/consortium\"\n";
let session = Session::from_toml(manifest).expect("manifest loads");
let err = plan_fetch(&session, Path::new("/proj"), None)
.expect_err("a build always needs the Linux application");
assert!(matches!(err, PipelineError::MissingEndpoint(_)));
}
#[tokio::test]
async fn hmi_install_runs_the_command_in_the_project_dir() {
let dir = std::env::var("SCRATCH_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(format!("consortium-fetch-hmi-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
let hmi = HmiFetch {
project_dir: dir.clone(),
command: HmiBuildCommand::Custom(vec![
"sh".into(),
"-c".into(),
"printf x > installed".into(),
]),
};
hmi_install(&hmi, None).await.expect("install succeeds");
assert!(dir.join("installed").exists());
let failing = HmiFetch {
project_dir: dir.clone(),
command: HmiBuildCommand::Custom(vec!["false".into()]),
};
let err = hmi_install(&failing, None)
.await
.expect_err("non-zero exit is an error");
assert!(matches!(err, PipelineError::HmiFetch { .. }));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn hmi_install_errors_when_project_dir_missing() {
let hmi = HmiFetch {
project_dir: PathBuf::from("/nonexistent/consortium-website"),
command: HmiBuildCommand::Custom(vec!["true".into()]),
};
let err = hmi_install(&hmi, None)
.await
.expect_err("missing project dir is an error");
assert!(matches!(err, PipelineError::HmiFetch { .. }));
}
}