use std::collections::BTreeSet;
use std::path::Path;
use crate::core::NormalizedPath;
use prost::Message;
use serde::{Deserialize, Serialize};
use super::proto::{plan_from_proto, plan_to_proto, rust_plan_proto};
pub const RUST_ARTIFACT_PLAN_SCHEMA_VERSION: u32 = 1;
pub const SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS: &[u32] = &[1, 2];
pub const RUST_ARTIFACT_CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum RustPlanError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("protobuf encode error: {0}")]
ProtobufEncode(#[from] prost::EncodeError),
#[error("protobuf decode error: {0}")]
ProtobufDecode(#[from] prost::DecodeError),
#[error(
"unsupported Rust artifact plan schema version {found}; supported version is {supported}"
)]
UnsupportedSchemaVersion { found: u32, supported: u32 },
#[error(
"unsupported Rust artifact cache schema version {found}; supported version is {supported}"
)]
UnsupportedCacheSchemaVersion { found: u32, supported: u32 },
#[error("invalid Rust artifact plan: {0}")]
InvalidPlan(String),
#[error("Rust artifact bundle is missing: {0}")]
BundleMissing(NormalizedPath),
#[error("invalid Rust artifact bundle manifest: {0}")]
InvalidManifest(String),
#[error("unsafe relative artifact path in bundle: {0}")]
UnsafeRelativePath(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustPlanMode {
Thin,
Full,
}
impl std::fmt::Display for RustPlanMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Thin => write!(f, "thin"),
Self::Full => write!(f, "full"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustArtifactClass {
Rlib,
Rmeta,
DepInfo,
ProcMacro,
SharedLib,
CargoFingerprint,
CargoFingerprintMeta,
CargoFingerprintOutputs,
BuildScriptMetadata,
BuildScriptOutput,
BuildScriptBuild,
Incremental,
Dwo,
Pdb,
Dsym,
FullTarget,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustToolchainIdentity {
pub rustc: String,
pub cargo: String,
pub channel: String,
pub host: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustPlanInputs {
pub features_hash: String,
pub rustflags_hash: String,
pub env_hash: String,
pub lockfile_hash: String,
pub cargo_config_hash: String,
#[serde(default)]
pub manifest_hashes: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustPlanPackages {
#[serde(default)]
pub selected_package_ids: Vec<String>,
#[serde(default)]
pub workspace_package_ids: Vec<String>,
#[serde(default)]
pub excluded_path_package_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RustArtifactPlanV1 {
pub schema_version: u32,
pub mode: RustPlanMode,
pub workspace_root: NormalizedPath,
pub target_dir: NormalizedPath,
pub toolchain: RustToolchainIdentity,
pub target_triple: String,
pub profile: String,
pub inputs: RustPlanInputs,
pub packages: RustPlanPackages,
#[serde(default)]
pub allowed_artifact_classes: Vec<RustArtifactClass>,
pub cache_schema_version: u32,
#[serde(default)]
pub journal_log_path: Option<NormalizedPath>,
#[serde(default)]
pub cache_profile: Option<String>,
#[serde(default)]
pub dropped_artifact_classes: Vec<RustArtifactClass>,
}
impl RustArtifactPlanV1 {
pub fn load(path: &Path) -> Result<Self, RustPlanError> {
let raw = std::fs::read(path)?;
if is_probably_json_plan(&raw) {
let raw = std::str::from_utf8(&raw).map_err(|err| {
RustPlanError::InvalidPlan(format!("JSON plan is not valid UTF-8: {err}"))
})?;
return Self::from_json_str(raw);
}
Self::from_proto_bytes(&raw)
}
pub fn from_json_str(raw: &str) -> Result<Self, RustPlanError> {
let value: serde_json::Value = serde_json::from_str(raw.trim_start_matches('\u{feff}'))?;
Self::from_json_value(value)
}
pub fn from_proto_bytes(raw: &[u8]) -> Result<Self, RustPlanError> {
let proto = rust_plan_proto::RustArtifactPlanV1::decode(raw)?;
plan_from_proto(proto)
}
pub fn to_proto_bytes(&self) -> Result<Vec<u8>, RustPlanError> {
let mut bytes = Vec::new();
plan_to_proto(self).encode(&mut bytes)?;
Ok(bytes)
}
pub fn from_json_value(value: serde_json::Value) -> Result<Self, RustPlanError> {
let schema_version = json_u32_field(&value, "schema_version")?;
if schema_version != RUST_ARTIFACT_PLAN_SCHEMA_VERSION {
return Err(RustPlanError::UnsupportedSchemaVersion {
found: schema_version,
supported: RUST_ARTIFACT_PLAN_SCHEMA_VERSION,
});
}
let cache_schema_version = json_u32_field(&value, "cache_schema_version")?;
ensure_supported_cache_schema_version(cache_schema_version)?;
let plan: Self = serde_json::from_value(value)?;
plan.validate()?;
Ok(plan)
}
pub fn validate(&self) -> Result<(), RustPlanError> {
let mut errors = Vec::new();
if self.profile.trim().is_empty() {
errors.push("profile must not be empty");
}
if self.target_triple.trim().is_empty() {
errors.push("target_triple must not be empty");
}
if self.toolchain.rustc.trim().is_empty() {
errors.push("toolchain.rustc must not be empty");
}
if self.toolchain.cargo.trim().is_empty() {
errors.push("toolchain.cargo must not be empty");
}
if self.toolchain.channel.trim().is_empty() {
errors.push("toolchain.channel must not be empty");
}
if self.toolchain.host.trim().is_empty() {
errors.push("toolchain.host must not be empty");
}
if self.workspace_root.as_os_str().is_empty() {
errors.push("workspace_root must not be empty");
}
if self.target_dir.as_os_str().is_empty() {
errors.push("target_dir must not be empty");
}
if errors.is_empty() {
Ok(())
} else {
Err(RustPlanError::InvalidPlan(errors.join("; ")))
}
}
#[must_use]
pub fn effective_allowed_classes(&self) -> BTreeSet<RustArtifactClass> {
if self.allowed_artifact_classes.is_empty() {
default_thin_classes()
} else {
self.allowed_artifact_classes.iter().copied().collect()
}
}
}
pub(super) fn is_probably_json_plan(raw: &[u8]) -> bool {
let without_bom = raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(raw);
without_bom
.iter()
.copied()
.find(|byte| !byte.is_ascii_whitespace())
.is_some_and(|byte| byte == b'{')
}
pub(super) fn default_thin_classes() -> BTreeSet<RustArtifactClass> {
[
RustArtifactClass::Rlib,
RustArtifactClass::Rmeta,
RustArtifactClass::DepInfo,
RustArtifactClass::ProcMacro,
RustArtifactClass::SharedLib,
RustArtifactClass::CargoFingerprint,
RustArtifactClass::BuildScriptMetadata,
RustArtifactClass::BuildScriptOutput,
]
.into_iter()
.collect()
}
pub(super) fn json_u32_field(
value: &serde_json::Value,
field: &'static str,
) -> Result<u32, RustPlanError> {
let Some(raw) = value.get(field) else {
return Err(RustPlanError::InvalidPlan(format!("{field} is required")));
};
let Some(n) = raw.as_u64() else {
return Err(RustPlanError::InvalidPlan(format!(
"{field} must be an unsigned integer"
)));
};
u32::try_from(n).map_err(|_| RustPlanError::InvalidPlan(format!("{field} is too large")))
}
pub(super) fn ensure_supported_cache_schema_version(
cache_schema_version: u32,
) -> Result<(), RustPlanError> {
if SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS.contains(&cache_schema_version) {
Ok(())
} else {
let supported = SUPPORTED_RUST_ARTIFACT_CACHE_SCHEMA_VERSIONS
.iter()
.copied()
.max()
.unwrap_or(RUST_ARTIFACT_CACHE_SCHEMA_VERSION);
Err(RustPlanError::UnsupportedCacheSchemaVersion {
found: cache_schema_version,
supported,
})
}
}