use std::path::Path;
use cargo_metadata::MetadataCommand;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::{CargoError, Result};
pub const SCHEMA_VERSION: u32 = 10;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BuildSpec {
pub version: u32,
pub workspace: WorkspaceSpec,
#[serde(default)]
pub crates: IndexMap<String, CrateSpec>,
pub root_crate: String,
#[serde(default)]
pub workspace_members: Vec<String>,
#[serde(default)]
pub flake_metadata: IndexMap<String, MemberFlakeMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_resolves: Option<CompactTargetResolves>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cargo_lock_sha256: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TargetResolve {
#[serde(default)]
pub crates: IndexMap<String, CrateTargetEdges>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompactTargetResolves {
#[serde(default)]
pub base: IndexMap<String, CrateTargetEdges>,
#[serde(default)]
pub targets: IndexMap<String, TargetOverrides>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TargetOverrides {
#[serde(default)]
pub overrides: IndexMap<String, CrateTargetEdges>,
}
impl CompactTargetResolves {
#[must_use]
pub fn from_full(full: IndexMap<String, TargetResolve>) -> Self {
let Some((_first_triple, first_resolve)) = full.iter().next() else {
return Self::default();
};
let mut base: IndexMap<String, CrateTargetEdges> = IndexMap::new();
for (key, first_edges) in &first_resolve.crates {
let present_in_all = full
.values()
.all(|resolve| resolve.crates.get(key) == Some(first_edges));
if present_in_all {
base.insert(key.clone(), first_edges.clone());
}
}
let mut targets: IndexMap<String, TargetOverrides> = IndexMap::new();
for (triple, resolve) in &full {
let mut overrides: IndexMap<String, CrateTargetEdges> = IndexMap::new();
for (key, edges) in &resolve.crates {
if !base.contains_key(key) {
overrides.insert(key.clone(), edges.clone());
}
}
targets.insert(triple.clone(), TargetOverrides { overrides });
}
Self { base, targets }
}
#[must_use]
pub fn expand(&self) -> IndexMap<String, TargetResolve> {
let mut out: IndexMap<String, TargetResolve> = IndexMap::new();
for (triple, target_overrides) in &self.targets {
let mut crates = self.base.clone();
for (key, edges) in &target_overrides.overrides {
crates.insert(key.clone(), edges.clone());
}
out.insert(triple.clone(), TargetResolve { crates });
}
out
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrateTargetEdges {
#[serde(default, skip_serializing)]
pub dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub runtime_dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub build_dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub features: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MemberFlakeMetadata {
#[serde(default)]
pub default_bin: Option<String>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module_trio: Option<ModuleTrioSpec>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModuleTrioSpec {
pub name: String,
pub description: String,
pub package_attr: String,
pub binary_name: String,
pub hm_namespace: String,
pub with_mcp: bool,
pub with_http: bool,
pub with_system_daemon: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorkspaceSpec {
pub root: String,
#[serde(default)]
pub members: Vec<WorkspaceMemberSpec>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorkspaceMemberSpec {
pub name: String,
pub relative_path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrateSpec {
pub name: String,
pub version: String,
pub edition: String,
pub source: CrateSource,
#[serde(default)]
pub features: Vec<String>,
pub proc_macro: bool,
#[serde(default)]
pub build_script: Option<String>,
#[serde(default)]
pub links: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quirks: Vec<crate::quirks::CrateQuirk>,
#[serde(default)]
pub binaries: Vec<CrateBinSpec>,
#[serde(default)]
pub lib_target: Option<LibTargetSpec>,
#[serde(default, skip_serializing)]
pub dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub runtime_dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub build_dependencies: Vec<CrateDepSpec>,
#[serde(default)]
pub crate_renames: IndexMap<String, Vec<CrateRenameRecord>>,
#[serde(default, skip_serializing_if = "BuildRustCrateArgs::is_empty")]
pub build_rust_crate_args: BuildRustCrateArgs,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BuildRustCrateArgs {
#[serde(rename = "crateName", default, skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(rename = "crateRenames", default, skip_serializing_if = "IndexMap::is_empty")]
pub crate_renames: IndexMap<String, Vec<CrateRenameRecord>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release: Option<bool>,
#[serde(rename = "procMacro", default, skip_serializing_if = "Option::is_none")]
pub proc_macro: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub links: Option<String>,
#[serde(rename = "libName", default, skip_serializing_if = "Option::is_none")]
pub lib_name: Option<String>,
#[serde(rename = "libPath", default, skip_serializing_if = "Option::is_none")]
pub lib_path: Option<String>,
#[serde(rename = "preBuild", default, skip_serializing_if = "Option::is_none")]
pub pre_build: Option<String>,
}
impl BuildRustCrateArgs {
fn is_empty(&self) -> bool {
self.crate_name.is_none()
&& self.version.is_none()
&& self.edition.is_none()
&& self.features.is_empty()
&& self.crate_renames.is_empty()
&& self.release.is_none()
&& self.proc_macro.is_none()
&& self.build.is_none()
&& self.links.is_none()
&& self.lib_name.is_none()
&& self.lib_path.is_none()
&& self.pre_build.is_none()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrateRenameRecord {
pub version: String,
pub rename: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrateBinSpec {
pub name: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LibTargetSpec {
pub name: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum CrateSource {
Registry {
url: String,
sha256: String,
name_with_ext: String,
},
Git {
url: String,
rev: String,
#[serde(default)]
sha256: Option<String>,
},
Path { relative_path: String },
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrateDepSpec {
pub name: String,
pub package_key: String,
pub kind: DepKind,
#[serde(default)]
pub features: Vec<String>,
pub uses_default_features: bool,
pub optional: bool,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub tree: BuildTree,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum BuildTree {
#[default]
Target,
Host,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum DepKind {
Normal,
Build,
Dev,
}
fn host_target_triple() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"aarch64-apple-darwin"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"x86_64-apple-darwin"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"x86_64-unknown-linux-gnu"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"aarch64-unknown-linux-gnu"
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
)))]
{
""
}
}
pub fn generate(root: &Path) -> Result<BuildSpec> {
generate_for_target(root, host_target_triple())
}
pub const FLEET_TARGETS: &[&str] = &[
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
];
pub fn generate_multi_target(root: &Path) -> Result<BuildSpec> {
use rayon::prelude::*;
let per_target_vec: Vec<(String, BuildSpec)> = FLEET_TARGETS
.par_iter()
.map(|target| {
eprintln!("gen build: resolving for {}", target);
generate_for_target(root, target)
.map(|spec| (target.to_string(), spec))
})
.collect::<Result<Vec<_>>>()?;
let mut per_target: IndexMap<String, BuildSpec> = IndexMap::new();
for (k, v) in per_target_vec {
per_target.insert(k, v);
}
let host = host_target_triple();
let (_, mut base) = per_target
.iter()
.find(|(t, _)| *t == host)
.map(|(t, s)| (t.clone(), s.clone()))
.unwrap_or_else(|| {
per_target
.iter()
.next()
.map(|(t, s)| (t.clone(), s.clone()))
.expect("FLEET_TARGETS must be non-empty")
});
for spec in per_target.values() {
for (key, crate_spec) in &spec.crates {
base.crates.entry(key.clone()).or_insert_with(|| crate_spec.clone());
}
}
for spec in per_target.values() {
for (key, src) in &spec.crates {
let Some(dst) = base.crates.get_mut(key) else {
continue;
};
for (canonical, records) in &src.crate_renames {
let entry = dst.crate_renames.entry(canonical.clone()).or_default();
for record in records {
if !entry.iter().any(|r| {
r.version == record.version && r.rename == record.rename
}) {
entry.push(record.clone());
}
}
}
for (canonical, records) in &src.build_rust_crate_args.crate_renames {
let entry = dst
.build_rust_crate_args
.crate_renames
.entry(canonical.clone())
.or_default();
for record in records {
if !entry.iter().any(|r| {
r.version == record.version && r.rename == record.rename
}) {
entry.push(record.clone());
}
}
}
}
}
let mut resolves: IndexMap<String, TargetResolve> = IndexMap::new();
for (target, spec) in &per_target {
let mut crates: IndexMap<String, CrateTargetEdges> = IndexMap::new();
for (key, crate_spec) in &spec.crates {
crates.insert(
key.clone(),
CrateTargetEdges {
dependencies: crate_spec.dependencies.clone(),
runtime_dependencies: crate_spec.runtime_dependencies.clone(),
build_dependencies: crate_spec.build_dependencies.clone(),
features: crate_spec.features.clone(),
},
);
}
resolves.insert(target.clone(), TargetResolve { crates });
}
base.target_resolves = Some(CompactTargetResolves::from_full(resolves));
Ok(base)
}
pub fn generate_for_target(root: &Path, target: &str) -> Result<BuildSpec> {
let root = std::fs::canonicalize(root).map_err(|source| CargoError::Io {
path: root.to_path_buf(),
source,
})?;
let root = root.as_path();
let manifest_path = root.join("Cargo.toml");
if !manifest_path.exists() {
return Err(CargoError::Io {
path: manifest_path,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Cargo.toml at workspace root",
),
});
}
let offline_mode = std::env::var_os("GEN_CARGO_METADATA_OFFLINE").is_some();
if offline_mode {
unsafe { std::env::set_var("GIT_TERMINAL_PROMPT", "0") };
}
let mut cmd = MetadataCommand::new();
cmd.manifest_path(&manifest_path);
let mut opts: Vec<String> = Vec::new();
if offline_mode {
opts.push("--offline".to_string());
}
if !target.is_empty() {
opts.push("--filter-platform".to_string());
opts.push(target.to_string());
}
cmd.other_options(opts);
let meta = cmd.exec().map_err(|e| CargoError::Io {
path: manifest_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
let checksums: IndexMap<(String, String), String> = {
let manifest = crate::parse(root).map_err(|e| {
eprintln!("gen lock-build: gen_cargo::parse failed: {e}");
e
})?;
manifest
.lockfile
.map(|lf| {
lf.resolved
.values()
.filter_map(|r| {
let h = r.integrity.as_ref()?;
let hex = h.strip_prefix("sha256:").unwrap_or(h);
Some(((r.id.name.clone(), r.id.version.to_string()), hex.to_string()))
})
.collect()
})
.unwrap_or_default()
};
let workspace_root_str = root.display().to_string();
let workspace_member_ids: Vec<_> = meta.workspace_members.iter().collect();
let workspace_members: Vec<WorkspaceMemberSpec> = workspace_member_ids
.iter()
.filter_map(|id| meta.packages.iter().find(|p| &p.id == *id))
.map(|p| {
let abs_dir = p.manifest_path.parent().map(|p| p.to_string()).unwrap_or_default();
let rel = pathdiff_relative(&abs_dir, &workspace_root_str)
.unwrap_or_else(|| abs_dir.clone());
WorkspaceMemberSpec {
name: p.name.to_string(),
relative_path: if rel.is_empty() { ".".to_string() } else { rel },
}
})
.collect();
let workspace_member_names: std::collections::HashSet<String> = workspace_members
.iter()
.map(|m| m.name.clone())
.collect();
let resolved_features: IndexMap<String, Vec<String>> = meta
.resolve
.as_ref()
.map(|r| {
r.nodes
.iter()
.map(|n| (n.id.repr.clone(), n.features.iter().map(String::from).collect()))
.collect()
})
.unwrap_or_default();
let by_id: IndexMap<String, &cargo_metadata::Package> = meta
.packages
.iter()
.map(|p| (p.id.repr.clone(), p))
.collect();
type DepEdge = (String, String, Vec<cargo_metadata::DepKindInfo>);
let dep_edges: IndexMap<String, Vec<DepEdge>> = meta
.resolve
.as_ref()
.map(|r| {
r.nodes
.iter()
.map(|n| {
let edges: Vec<DepEdge> = n
.deps
.iter()
.map(|d| (d.name.clone(), d.pkg.repr.clone(), d.dep_kinds.clone()))
.collect();
(n.id.repr.clone(), edges)
})
.collect()
})
.unwrap_or_default();
let mut crates: IndexMap<String, CrateSpec> = IndexMap::new();
for pkg in &meta.packages {
let key = format!("{}-{}", pkg.name, pkg.version);
let is_member = workspace_member_names.contains(pkg.name.as_str());
let edition = format!("{}", pkg.edition);
let proc_macro = pkg
.targets
.iter()
.any(|t| t.kind.iter().any(|k| k == "proc-macro"));
let manifest_dir = pkg.manifest_path.parent().map(|p| p.to_string()).unwrap_or_default();
let build_script = pkg
.targets
.iter()
.find(|t| t.kind.iter().any(|k| k == "custom-build"))
.and_then(|t| {
let abs = t.src_path.to_string();
strip_dir_prefix(&abs, &manifest_dir)
});
let links: Option<String> = pkg.links.clone();
let binaries: Vec<CrateBinSpec> = pkg
.targets
.iter()
.filter(|t| t.kind.iter().any(|k| k == "bin"))
.filter_map(|t| {
let abs = t.src_path.to_string();
strip_dir_prefix(&abs, &manifest_dir).map(|path| CrateBinSpec {
name: t.name.clone(),
path,
})
})
.collect();
let is_path_dep = pkg.source.is_none();
let lib_target = pkg
.targets
.iter()
.find(|t| {
t.kind.iter().any(|k| {
matches!(
k.as_str(),
"lib" | "rlib" | "staticlib" | "cdylib" | "dylib" | "proc-macro"
)
})
})
.and_then(|t| {
let abs = t.src_path.to_string();
let path = strip_dir_prefix(&abs, &manifest_dir)?;
let default_name = pkg.name.as_str().replace('-', "_");
let default_path = "src/lib.rs";
let is_default = t.name == default_name && path == default_path;
if is_default && !is_path_dep {
return None;
}
Some(LibTargetSpec {
name: t.name.clone(),
path,
})
});
let source = if is_path_dep {
let abs_dir = pkg.manifest_path.parent().map(|p| p.to_string()).unwrap_or_default();
let rel = pathdiff_relative(&abs_dir, &workspace_root_str)
.or_else(|| relative_path_escaping(&abs_dir, &workspace_root_str))
.unwrap_or_else(|| abs_dir.clone());
let is_external = rel.starts_with("..") || rel.starts_with('/');
if is_external {
let resolver = crate::path_resolver::GitCliResolver;
use crate::path_resolver::PathDepResolver;
let abs_path = std::path::Path::new(&abs_dir);
match resolver.resolve(abs_path) {
Some((url, rev)) => CrateSource::Git { url, rev, sha256: None },
None => {
return Err(CargoError::UnresolvableExternalPath {
name: pkg.name.to_string(),
version: pkg.version.to_string(),
abs_dir: std::path::PathBuf::from(abs_dir.clone()),
workspace_root: std::path::PathBuf::from(workspace_root_str.clone()),
reason: format!(
"directory at `{abs_dir}` either is not a git repository, has no `origin` remote, or its remote URL is not a GitHub URL (only github.com auto-resolution is implemented today)"
),
});
}
}
} else {
CrateSource::Path {
relative_path: if rel.is_empty() { ".".to_string() } else { rel },
}
}
} else if let Some(src) = &pkg.source {
let src_str = src.to_string();
if src_str.starts_with("registry+") {
let sha = checksums
.get(&(pkg.name.to_string(), pkg.version.to_string()))
.cloned()
.unwrap_or_default();
if sha.is_empty() {
eprintln!(
"gen lock-build: missing checksum for {}/{}",
pkg.name, pkg.version
);
}
CrateSource::Registry {
url: format!(
"https://static.crates.io/crates/{}/{}-{}.crate",
pkg.name, pkg.name, pkg.version
),
sha256: sha,
name_with_ext: format!("{}-{}.tar.gz", pkg.name, pkg.version),
}
} else if src_str.starts_with("git+") {
let trimmed = src_str.trim_start_matches("git+");
let (raw_url, rev) = trimmed
.rsplit_once('#')
.map(|(u, f)| (u.to_string(), f.to_string()))
.unwrap_or_else(|| (trimmed.to_string(), String::new()));
let url = raw_url.split('?').next().unwrap_or(&raw_url).to_string();
CrateSource::Git {
url,
rev,
sha256: None,
}
} else {
let abs_dir = pkg.manifest_path.parent().map(|p| p.to_string()).unwrap_or_default();
let rel = pathdiff_relative(&abs_dir, &workspace_root_str)
.unwrap_or_else(|| abs_dir.clone());
CrateSource::Path { relative_path: rel }
}
} else {
CrateSource::Path {
relative_path: ".".to_string(),
}
};
let features = resolved_features
.get(&pkg.id.repr)
.cloned()
.unwrap_or_default();
let edges_for_pkg = dep_edges.get(&pkg.id.repr).cloned().unwrap_or_default();
let mut dependencies: Vec<CrateDepSpec> = Vec::new();
for (local_name, dep_pkg_id, dep_kinds) in &edges_for_pkg {
let Some(dep_pkg) = by_id.get(dep_pkg_id) else { continue; };
let package_key = match dep_pkg.source.as_ref().map(|s| s.to_string()) {
Some(s) => match git_rev_of_source(&s) {
Some(rev) if !rev.is_empty() => {
format!("{}-{}-{}", dep_pkg.name, dep_pkg.version, rev)
}
_ => format!("{}-{}", dep_pkg.name, dep_pkg.version),
},
None => format!("{}-{}", dep_pkg.name, dep_pkg.version),
};
let kinds_to_emit: Vec<_> = dep_kinds
.iter()
.filter(|k| !matches!(k.kind, cargo_metadata::DependencyKind::Development))
.collect();
if kinds_to_emit.is_empty() { continue; }
for graph_kind in &kinds_to_emit {
let kind = match graph_kind.kind {
cargo_metadata::DependencyKind::Normal => DepKind::Normal,
cargo_metadata::DependencyKind::Build => DepKind::Build,
cargo_metadata::DependencyKind::Development => DepKind::Dev,
_ => DepKind::Normal,
};
let target = graph_kind.target.as_ref().map(|p| p.to_string());
let declared = pkg.dependencies.iter().find(|d| {
let consumer_name = d
.rename
.clone()
.unwrap_or_else(|| d.name.clone())
.replace('-', "_");
consumer_name == local_name.replace('-', "_")
&& d.kind == graph_kind.kind
});
let (features, uses_default_features, optional) = match declared {
Some(d) => (
d.features.iter().map(String::from).collect(),
d.uses_default_features,
d.optional,
),
None => (Vec::new(), true, false),
};
if optional {
let consuming_feats = resolved_features
.get(&pkg.id.repr)
.map(|f| f.as_slice())
.unwrap_or(&[]);
let implicit_feat = declared
.map(|d| d.rename.clone().unwrap_or_else(|| d.name.clone()))
.unwrap_or_else(|| local_name.clone());
if !optional_dep_activated(consuming_feats, &implicit_feat) {
continue;
}
}
let dep_is_proc_macro = dep_pkg
.targets
.iter()
.any(|t| t.kind.iter().any(|k| k == "proc-macro"));
let tree = match kind {
DepKind::Build | DepKind::Dev => BuildTree::Host,
DepKind::Normal if dep_is_proc_macro => BuildTree::Host,
DepKind::Normal => BuildTree::Target,
};
dependencies.push(CrateDepSpec {
name: local_name.clone(),
package_key: package_key.clone(),
kind,
features,
uses_default_features,
optional,
target,
tree,
});
}
}
let runtime_dependencies: Vec<CrateDepSpec> = dependencies
.iter()
.filter(|d| matches!(d.kind, DepKind::Normal))
.cloned()
.collect();
let build_dependencies: Vec<CrateDepSpec> = dependencies
.iter()
.filter(|d| matches!(d.kind, DepKind::Build))
.cloned()
.collect();
let crate_renames =
synthesize_crate_renames(&runtime_dependencies, &build_dependencies, &by_id);
let rustc_crate_name = lib_target
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_else(|| pkg.name.replace('-', "_"));
let pre_build = format!("export CARGO_CRATE_NAME={};", rustc_crate_name);
let build_rust_crate_args = BuildRustCrateArgs {
crate_name: Some(pkg.name.to_string()),
version: Some(pkg.version.to_string()),
edition: Some(edition.clone()),
features: features.clone(),
crate_renames: crate_renames.clone(),
release: Some(true),
proc_macro: if proc_macro { Some(true) } else { None },
build: build_script.clone(),
links: links.clone(),
lib_name: lib_target.as_ref().map(|t| t.name.clone()),
lib_path: lib_target.as_ref().map(|t| t.path.clone()),
pre_build: Some(pre_build),
};
let quirks = crate::quirks::for_crate(pkg.name.as_str());
let new_entry = CrateSpec {
name: pkg.name.to_string(),
version: pkg.version.to_string(),
edition,
source,
features,
proc_macro,
build_script,
links,
quirks,
binaries,
lib_target,
dependencies,
runtime_dependencies,
build_dependencies,
crate_renames,
build_rust_crate_args,
};
let new_is_workspace = pkg.source.is_none();
let key = match &new_entry.source {
CrateSource::Git { rev, .. } if !rev.is_empty() => {
format!("{}-{}-{}", pkg.name, pkg.version, rev)
}
_ => key,
};
match crates.get(&key) {
Some(prev) => {
let prev_is_workspace = matches!(prev.source, CrateSource::Path { .. });
match (prev_is_workspace, new_is_workspace) {
(true, false) => {
}
(false, true) => {
crates.insert(key, new_entry);
}
_ if prev.features.len() > new_entry.features.len() => {
}
_ => {
crates.insert(key, new_entry);
}
}
}
None => {
crates.insert(key, new_entry);
}
}
}
let workspace_member_keys: Vec<String> = workspace_members
.iter()
.filter_map(|m| {
let pkg = meta.packages.iter().find(|p| p.name.as_str() == m.name)?;
Some(format!("{}-{}", pkg.name, pkg.version))
})
.collect();
let root_crate: String = match meta.root_package() {
Some(p) => format!("{}-{}", p.name, p.version),
None => workspace_member_keys
.first()
.cloned()
.ok_or_else(|| CargoError::Io {
path: manifest_path.clone(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
"workspace has no members; gen needs at least one buildable crate",
),
})?,
};
let mut flake_metadata: IndexMap<String, MemberFlakeMetadata> = IndexMap::new();
for m in &workspace_members {
let Some(pkg) = meta.packages.iter().find(|p| p.name.as_str() == m.name) else {
continue;
};
let key = format!("{}-{}", pkg.name, pkg.version);
let Some(c) = crates.get(&key) else { continue };
let default_bin = c.binaries.first().map(|b| b.name.clone());
let repo = pkg.repository.as_deref().and_then(parse_owner_repo);
let pleme = pkg
.metadata
.as_object()
.and_then(|o| o.get("pleme"))
.and_then(|p| p.as_object());
let module_trio = pleme.map(|p| {
let str_or = |k: &str, default: String| -> String {
p.get(k).and_then(|v| v.as_str()).map(String::from).unwrap_or(default)
};
let bool_or = |k: &str, default: bool| -> bool {
p.get(k).and_then(|v| v.as_bool()).unwrap_or(default)
};
let pkg_name = pkg.name.to_string();
let tool_id = default_bin.clone().unwrap_or_else(|| pkg_name.clone());
let name = str_or("hm-leaf", tool_id.clone());
let description = str_or(
"description",
pkg.description.clone().unwrap_or_else(|| format!("{tool_id} CLI tool")),
);
let package_attr = str_or("package-attr", tool_id.clone());
let binary_name = str_or("binary-name", tool_id.clone());
let hm_namespace = str_or("hm-namespace", "programs".to_string());
ModuleTrioSpec {
name,
description,
package_attr,
binary_name,
hm_namespace,
with_mcp: bool_or("with-mcp", false),
with_http: bool_or("with-http", false),
with_system_daemon: bool_or("with-system-daemon", false),
}
});
flake_metadata.insert(
pkg.name.to_string(),
MemberFlakeMetadata {
default_bin,
repo,
module_trio,
},
);
}
let prefetcher = crate::git_prefetcher::default_prefetcher();
for crate_spec in crates.values_mut() {
if let CrateSource::Git { url, rev, sha256, .. } = &mut crate_spec.source {
if sha256.is_none() && !url.is_empty() && !rev.is_empty() {
let hash = prefetcher.prefetch(url, rev).map_err(|e| {
CargoError::PrefetchSha256Failed {
url: url.clone(),
rev: rev.clone(),
reason: e.to_string(),
}
})?;
*sha256 = Some(hash.sri);
}
}
}
Ok(BuildSpec {
version: SCHEMA_VERSION,
workspace: WorkspaceSpec {
root: workspace_root_str,
members: workspace_members,
},
crates,
root_crate,
workspace_members: workspace_member_keys,
flake_metadata,
target_resolves: None,
cargo_lock_sha256: hash_cargo_lock(root),
})
}
fn hash_cargo_lock(root: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let lock_path = root.join("Cargo.lock");
let bytes = std::fs::read(&lock_path).ok()?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Some(format!("{:x}", hasher.finalize()))
}
fn parse_owner_repo(url: &str) -> Option<String> {
let stripped = url.trim_end_matches(".git");
let body = stripped
.strip_prefix("https://github.com/")
.or_else(|| stripped.strip_prefix("git@github.com:"))
.or_else(|| stripped.strip_prefix("ssh://git@github.com/"))?;
let mut parts = body.split('/');
let owner = parts.next()?;
let name = parts.next()?;
if owner.is_empty() || name.is_empty() {
return None;
}
Some(format!("{owner}/{name}"))
}
pub fn generate_and_write(root: &Path) -> Result<std::path::PathBuf> {
generate_for_target_and_write(root, host_target_triple())
}
#[gen_macros::fsm(label = "gen.cargo.freshness")]
pub enum Freshness {
Fresh {
spec_hash: String,
lock_hash: String,
},
Drifted {
spec_hash: String,
lock_hash: String,
},
UnhashedSpec { lock_hash: String },
MissingSpec { lock_hash: String },
MissingLock,
}
impl Freshness {
#[must_use]
pub fn needs_regen(&self) -> bool {
matches!(
self,
Freshness::Drifted { .. }
| Freshness::UnhashedSpec { .. }
| Freshness::MissingSpec { .. }
)
}
#[must_use]
pub fn summary(&self) -> &'static str {
match self {
Freshness::Fresh { .. } => "fresh",
Freshness::Drifted { .. } => "drifted",
Freshness::UnhashedSpec { .. } => "unhashed-spec",
Freshness::MissingSpec { .. } => "missing-spec",
Freshness::MissingLock => "missing-lock",
}
}
}
#[derive(Deserialize)]
struct SpecHeader {
#[serde(default)]
#[allow(dead_code)] version: Option<u32>,
#[serde(default)]
cargo_lock_sha256: Option<String>,
}
#[must_use]
pub fn check_freshness(root: &Path) -> Freshness {
let lock_hash = match hash_cargo_lock(root) {
Some(h) => h,
None => return Freshness::MissingLock,
};
let spec_path = root.join("Cargo.build-spec.json");
let spec_bytes = match std::fs::read(&spec_path) {
Ok(b) => b,
Err(_) => return Freshness::MissingSpec { lock_hash },
};
let header: SpecHeader = match serde_json::from_slice(&spec_bytes) {
Ok(h) => h,
Err(_) => return Freshness::MissingSpec { lock_hash },
};
match header.cargo_lock_sha256 {
None => Freshness::UnhashedSpec { lock_hash },
Some(spec_hash) if spec_hash == lock_hash => {
Freshness::Fresh { spec_hash, lock_hash }
}
Some(spec_hash) => Freshness::Drifted { spec_hash, lock_hash },
}
}
pub fn generate_and_write_if_stale(root: &Path) -> Result<(Freshness, std::path::PathBuf)> {
let freshness = check_freshness(root);
let spec_path = root.join("Cargo.build-spec.json");
if freshness.is_fresh() {
return Ok((freshness, spec_path));
}
let written = generate_multi_target_and_write(root)?;
let post = check_freshness(root);
Ok((post, written))
}
pub fn generate_multi_target_and_write(root: &Path) -> Result<std::path::PathBuf> {
let spec = generate_multi_target(root)?;
if let Err(violations) = crate::invariants::assert_well_formed(&spec) {
return Err(CargoError::Io {
path: root.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::Other,
format!(
"gen build (multi-target): spec violates algorithmic invariants ({} issues):\n{}",
violations.len(),
serde_json::to_string_pretty(&violations).unwrap_or_default()
),
),
});
}
let out = root.join("Cargo.build-spec.json");
let body = serde_json::to_string_pretty(&spec).map_err(|e| CargoError::Io {
path: out.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
std::fs::write(&out, body).map_err(|source| CargoError::Io {
path: out.clone(),
source,
})?;
crate::gen_delta::write_gen_delta(root, &spec).map_err(|e| CargoError::Io {
path: root.join("Cargo.gen.lock"),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
prune_and_log(root);
Ok(out)
}
pub fn generate_for_target_and_write(root: &Path, target: &str) -> Result<std::path::PathBuf> {
let spec = generate_for_target(root, target)?;
if let Err(violations) = crate::invariants::assert_well_formed(&spec) {
return Err(CargoError::Io {
path: root.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::Other,
format!(
"gen lock-build: spec violates algorithmic invariants ({} issues):\n{}",
violations.len(),
serde_json::to_string_pretty(&violations).unwrap_or_default()
),
),
});
}
let out = root.join("Cargo.build-spec.json");
let body = serde_json::to_string_pretty(&spec).map_err(|e| CargoError::Io {
path: out.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
})?;
std::fs::write(&out, body).map_err(|source| CargoError::Io {
path: out.clone(),
source,
})?;
prune_and_log(root);
Ok(out)
}
pub fn prune_deprecated_sidecars(root: &Path) -> std::io::Result<Option<std::path::PathBuf>> {
let legacy = root.join("crate-hashes.json");
if legacy.is_file() {
std::fs::remove_file(&legacy)?;
Ok(Some(legacy))
} else {
Ok(None)
}
}
fn prune_and_log(root: &Path) {
match prune_deprecated_sidecars(root) {
Ok(Some(path)) => eprintln!(
"gen: pruned deprecated {} (superseded by Cargo.build-spec.json source hashes)",
path.display()
),
Ok(None) => {}
Err(e) => eprintln!(
"gen: warning — could not prune deprecated crate-hashes.json: {e}"
),
}
}
fn git_rev_of_source(src: &str) -> Option<String> {
if !src.starts_with("git+") {
return None;
}
src.rsplit_once('#').map(|(_, rev)| rev.to_string())
}
fn optional_dep_activated(consuming_features: &[String], implicit_feature_name: &str) -> bool {
let target = implicit_feature_name.replace('-', "_");
consuming_features
.iter()
.any(|f| f.replace('-', "_") == target)
}
fn synthesize_crate_renames(
runtime: &[CrateDepSpec],
build: &[CrateDepSpec],
by_id: &IndexMap<String, &cargo_metadata::Package>,
) -> IndexMap<String, Vec<CrateRenameRecord>> {
let mut out: IndexMap<String, Vec<CrateRenameRecord>> = IndexMap::new();
for d in runtime.iter().chain(build.iter()) {
let canonical_name = {
let pkg = by_id.values().find(|p| {
let base = format!("{}-{}", p.name, p.version);
let k = match p.source.as_ref().map(|s| s.to_string()) {
Some(s) => match git_rev_of_source(&s) {
Some(rev) if !rev.is_empty() => {
format!("{}-{}-{}", p.name, p.version, rev)
}
_ => base,
},
None => base,
};
k == d.package_key
});
match pkg {
Some(p) => (p.name.to_string(), p.version.to_string()),
None => continue,
}
};
let canonical = canonical_name.0;
let canonical_version = canonical_name.1;
if d.name == canonical {
continue;
}
out.entry(canonical).or_default().push(CrateRenameRecord {
version: canonical_version,
rename: d.name.clone(),
});
}
out
}
fn strip_dir_prefix(path: &str, dir: &str) -> Option<String> {
let dir_trim = dir.trim_end_matches('/');
let prefix = format!("{dir_trim}/");
path.strip_prefix(&prefix).map(String::from)
}
fn relative_path_escaping(from: &str, base: &str) -> Option<String> {
let from_components: Vec<&str> =
from.trim_end_matches('/').split('/').filter(|c| !c.is_empty()).collect();
let base_components: Vec<&str> =
base.trim_end_matches('/').split('/').filter(|c| !c.is_empty()).collect();
let common: usize = from_components
.iter()
.zip(base_components.iter())
.take_while(|(a, b)| a == b)
.count();
if common == 0 {
return None;
}
let up_count = base_components.len() - common;
let down: Vec<&str> = from_components[common..].to_vec();
let mut parts: Vec<String> = std::iter::repeat_n("..".to_string(), up_count).collect();
parts.extend(down.into_iter().map(String::from));
if parts.is_empty() {
return Some(".".to_string());
}
Some(parts.join("/"))
}
fn pathdiff_relative(from: &str, base: &str) -> Option<String> {
let base_trim = base.trim_end_matches('/');
if from == base_trim {
return Some(String::new());
}
let with_slash = format!("{base_trim}/");
from.strip_prefix(&with_slash).map(String::from)
}
#[cfg(test)]
mod weak_dependency_tests {
use super::optional_dep_activated;
#[test]
fn weak_only_optional_dep_is_not_activated() {
let resolved = vec![
"migrate".to_string(),
"postgres".to_string(),
"sqlx-postgres".to_string(), "runtime-tokio".to_string(),
"uuid".to_string(),
"chrono".to_string(),
];
assert!(
optional_dep_activated(&resolved, "sqlx-postgres"),
"sqlx-postgres is enabled via `postgres = [\"sqlx-postgres\"]` — keep the edge"
);
assert!(
!optional_dep_activated(&resolved, "sqlx-sqlite"),
"sqlx-sqlite is reached only via the weak `sqlx-sqlite?/migrate` edge — drop it"
);
assert!(
!optional_dep_activated(&resolved, "sqlx-mysql"),
"sqlx-mysql is reached only via the weak `sqlx-mysql?/migrate` edge — drop it"
);
}
#[test]
fn activation_match_is_hyphen_underscore_insensitive() {
let resolved = vec!["sqlx_postgres".to_string()];
assert!(optional_dep_activated(&resolved, "sqlx-postgres"));
let resolved = vec!["sqlx-postgres".to_string()];
assert!(optional_dep_activated(&resolved, "sqlx_postgres"));
}
#[test]
fn non_weak_reference_activates_the_dep() {
let resolved = vec!["some-feat".to_string(), "mydep".to_string()];
assert!(
optional_dep_activated(&resolved, "mydep"),
"a non-weak dep/feature edge activates the dep — keep it"
);
}
#[test]
fn no_features_means_no_optional_activation() {
assert!(!optional_dep_activated(&[], "anything"));
}
}
#[cfg(test)]
mod path_helper_tests {
use super::{pathdiff_relative, relative_path_escaping};
#[test]
fn pathdiff_relative_returns_none_when_path_escapes_base() {
assert_eq!(
pathdiff_relative(
"/Users/me/code/gen/crates/gen-platform",
"/Users/me/code/kura"
),
None,
"pathdiff_relative cannot represent escapes — that's relative_path_escaping's job"
);
}
#[test]
fn relative_path_escaping_handles_external_path_dep() {
assert_eq!(
relative_path_escaping(
"/Users/me/code/gen/crates/gen-platform",
"/Users/me/code/kura"
),
Some("../gen/crates/gen-platform".to_string())
);
}
#[test]
fn relative_path_escaping_handles_sibling_workspaces() {
assert_eq!(
relative_path_escaping("/a/b/c", "/a/d/e"),
Some("../../b/c".to_string())
);
}
#[test]
fn relative_path_escaping_returns_none_for_disjoint_roots() {
assert_eq!(relative_path_escaping("/a/b", "/x/y"), None);
}
#[test]
fn relative_path_escaping_returns_dot_for_same_dir() {
assert_eq!(
relative_path_escaping("/a/b", "/a/b"),
Some(".".to_string())
);
}
fn freshness_tmpdir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!(
"gen-cargo-freshness-{}-{}",
std::process::id(),
n
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn check_freshness_missing_lock() {
let dir = freshness_tmpdir();
assert_eq!(
super::check_freshness(&dir).summary(),
"missing-lock"
);
}
#[test]
fn prune_removes_deprecated_crate_hashes_json() {
let dir = freshness_tmpdir();
let legacy = dir.join("crate-hashes.json");
std::fs::write(&legacy, b"{}").unwrap();
let removed = super::prune_deprecated_sidecars(&dir).unwrap();
assert_eq!(removed.as_deref(), Some(legacy.as_path()));
assert!(!legacy.exists(), "crate-hashes.json should be pruned");
assert!(super::prune_deprecated_sidecars(&dir).unwrap().is_none());
}
#[test]
fn check_freshness_missing_spec() {
let dir = freshness_tmpdir();
std::fs::write(dir.join("Cargo.lock"), b"# lock").unwrap();
assert_eq!(
super::check_freshness(&dir).summary(),
"missing-spec"
);
}
#[test]
fn check_freshness_unhashed_spec_old_schema() {
let dir = freshness_tmpdir();
std::fs::write(dir.join("Cargo.lock"), b"# lock").unwrap();
std::fs::write(
dir.join("Cargo.build-spec.json"),
br#"{"version": 5, "workspace": {"crates": {"x": {"this_field_doesnt_exist": true}}}}"#,
)
.unwrap();
assert_eq!(
super::check_freshness(&dir).summary(),
"unhashed-spec"
);
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
format!("{:x}", h.finalize())
}
#[test]
fn check_freshness_fresh_when_hashes_match() {
let dir = freshness_tmpdir();
let lock_body: &[u8] = b"# lock\nfresh test\n";
std::fs::write(dir.join("Cargo.lock"), lock_body).unwrap();
let hash = sha256_hex(lock_body);
std::fs::write(
dir.join("Cargo.build-spec.json"),
format!(r#"{{"version": 7, "cargo_lock_sha256": "{hash}"}}"#).as_bytes(),
)
.unwrap();
let f = super::check_freshness(&dir);
assert_eq!(f.summary(), "fresh");
assert!(f.is_fresh());
assert!(!f.needs_regen());
}
#[test]
fn check_freshness_drifted_when_hashes_differ() {
let dir = freshness_tmpdir();
let lock_body: &[u8] = b"# new lock contents\n";
std::fs::write(dir.join("Cargo.lock"), lock_body).unwrap();
std::fs::write(
dir.join("Cargo.build-spec.json"),
br#"{"version": 7, "cargo_lock_sha256": "deadbeef"}"#,
)
.unwrap();
let f = super::check_freshness(&dir);
assert_eq!(f.summary(), "drifted");
assert!(!f.is_fresh());
assert!(f.needs_regen());
}
#[test]
fn check_freshness_unparseable_spec_treated_as_missing() {
let dir = freshness_tmpdir();
std::fs::write(dir.join("Cargo.lock"), b"# lock").unwrap();
std::fs::write(
dir.join("Cargo.build-spec.json"),
b"this is not json at all",
)
.unwrap();
assert_eq!(
super::check_freshness(&dir).summary(),
"missing-spec"
);
}
#[test]
fn freshness_summary_matches_discriminant_across_all_variants() {
use super::Freshness;
let cases = [
Freshness::Fresh { spec_hash: "a".into(), lock_hash: "a".into() },
Freshness::Drifted { spec_hash: "a".into(), lock_hash: "b".into() },
Freshness::UnhashedSpec { lock_hash: "c".into() },
Freshness::MissingSpec { lock_hash: "d".into() },
Freshness::MissingLock,
];
for f in &cases {
assert_eq!(
f.summary(),
f.discriminant(),
"Freshness::{:?} — summary() and discriminant() must agree",
f
);
}
}
#[test]
fn freshness_is_variant_helpers_cover_every_arm() {
use super::Freshness;
let fresh = Freshness::Fresh { spec_hash: "x".into(), lock_hash: "x".into() };
let drifted = Freshness::Drifted { spec_hash: "x".into(), lock_hash: "y".into() };
let unhashed = Freshness::UnhashedSpec { lock_hash: "x".into() };
let missing_spec = Freshness::MissingSpec { lock_hash: "x".into() };
let missing_lock = Freshness::MissingLock;
assert!(fresh.is_fresh() && !drifted.is_fresh() && !unhashed.is_fresh());
assert!(drifted.is_drifted() && !fresh.is_drifted() && !missing_lock.is_drifted());
assert!(unhashed.is_unhashed_spec() && !fresh.is_unhashed_spec());
assert!(missing_spec.is_missing_spec() && !drifted.is_missing_spec());
assert!(missing_lock.is_missing_lock() && !fresh.is_missing_lock());
}
#[test]
fn freshness_registered_in_fleet_catalog() {
use gen_platform::TypedDispatcherTrait;
assert_eq!(
<super::Freshness as TypedDispatcherTrait>::variant_count(),
5,
"Freshness variant_count must stay in lockstep with \
substrate/lib/build/shared/fleet-catalog-coverage-test.nix \
(label = gen.cargo.freshness)"
);
assert_eq!(
<super::Freshness as TypedDispatcherTrait>::variant_kinds(),
vec!["fresh", "drifted", "unhashed-spec", "missing-spec", "missing-lock"],
);
}
}