use std::path::{Path, PathBuf};
use anyhow::{ensure, Context, Result};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
use crate::build::linkage::{detect_dep_artifacts, DepArtifacts};
use crate::commands::build::LinkType;
use crate::config::Linkage;
pub fn build_as_for_hint(_consumer: LinkType, hint: Option<Linkage>) -> LinkType {
match hint {
Some(Linkage::SharedExternal) => LinkType::Shared,
Some(Linkage::StaticEmbedded) | Some(Linkage::StaticExternal) => LinkType::Static,
None => LinkType::Both,
}
}
pub fn compute_source_fingerprint(dep_root: &Path, build_as: LinkType) -> Result<String> {
ensure!(
dep_root.exists(),
"dep root does not exist: {} (was `ccgo fetch` run?)",
dep_root.display()
);
let mut hasher = Sha256::new();
hasher.update(format!("build_as={build_as}\n").as_bytes());
let toml_path = dep_root.join("CCGO.toml");
if toml_path.exists() {
let content = std::fs::read(&toml_path)
.with_context(|| format!("failed to read {}", toml_path.display()))?;
hasher.update(b"toml=");
hasher.update(&content);
hasher.update(b"\n");
}
let src = dep_root.join("src");
if src.is_dir() {
let mut entries: Vec<(PathBuf, u128, u64)> = WalkDir::new(&src)
.follow_links(false)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.filter_map(|e| {
let meta = e.metadata().ok()?;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
Some((e.path().to_path_buf(), mtime, meta.len()))
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (path, mtime, size) in entries {
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(b"\0");
hasher.update(mtime.to_le_bytes());
hasher.update(size.to_le_bytes());
hasher.update(b"\n");
}
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn fingerprint_path(dep_root: &Path, platform: &str, build_as: LinkType) -> PathBuf {
dep_root.join(format!(
".ccgo_materialize_{platform}_{build_as}.fingerprint"
))
}
pub fn read_fingerprint(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn write_fingerprint(path: &Path, value: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(path, value)
.with_context(|| format!("failed to write {}", path.display()))
}
pub fn materialize_source_deps_inner(
project_root: &Path,
platform: &str,
archs: &[String],
release: bool,
dep_hints: &[(String, Option<Linkage>)],
ccgo_bin: &str,
) -> Result<()> {
let platform_lc = platform.to_lowercase();
let consumer_link_type = LinkType::Both;
for (dep_name, hint) in dep_hints {
let dep_root = project_root.join(".ccgo/deps").join(dep_name);
if !dep_root.exists() {
continue;
}
if !dep_root.join("src").is_dir() || !dep_root.join("CCGO.toml").is_file() {
continue;
}
let build_as = build_as_for_hint(consumer_link_type.clone(), *hint);
let fp_path = fingerprint_path(&dep_root, &platform_lc, build_as.clone());
let fp_now = compute_source_fingerprint(&dep_root, build_as.clone())?;
let fp_prev = read_fingerprint(&fp_path)?;
let arts = detect_dep_artifacts(&dep_root, &platform_lc);
let artifacts_present = matches!(
arts,
DepArtifacts::Both | DepArtifacts::OnlyStatic | DepArtifacts::OnlyShared
);
let fp_matches = Some(&fp_now) == fp_prev.as_ref();
if artifacts_present && fp_matches {
continue;
}
if artifacts_present && fp_prev.is_none() {
write_fingerprint(&fp_path, &fp_now)?;
continue;
}
eprintln!(
"📦 Materializing source-only dep '{}' for {} (--build-as {})",
dep_name, platform_lc, build_as
);
let mut cmd = std::process::Command::new(ccgo_bin);
cmd.arg("build").arg(&platform_lc).current_dir(&dep_root);
if release {
cmd.arg("--release");
}
if !archs.is_empty() {
cmd.arg("--arch").arg(archs.join(","));
}
cmd.arg("--build-as").arg(build_as.to_string());
let status = cmd.status().with_context(|| {
format!(
"failed to spawn `ccgo build` for source-only dep '{}'",
dep_name
)
})?;
if !status.success() {
anyhow::bail!(
"recursive `ccgo build` for source-only dep '{}' (--build-as {}) \
failed with exit code {:?}. The dep at {} could not be \
compiled — check its CCGO.toml and try `ccgo build {} \
--build-as {}` inside that directory to reproduce.",
dep_name,
build_as,
status.code(),
dep_root.display(),
platform_lc,
build_as,
);
}
bridge_cmake_build_to_lib(&dep_root, &platform_lc, release)?;
write_fingerprint(&fp_path, &fp_now)?;
}
Ok(())
}
fn bridge_cmake_build_to_lib(dep_root: &Path, platform: &str, release: bool) -> Result<()> {
let profile = if release { "release" } else { "debug" };
let cmake_build_platform = dep_root.join("cmake_build").join(profile).join(platform);
if !cmake_build_platform.is_dir() {
return Ok(());
}
let lib_platform = dep_root.join("lib").join(platform);
for link_type in ["shared", "static"] {
let cmake_link_dir = cmake_build_platform.join(link_type);
if !cmake_link_dir.is_dir() {
continue;
}
let lib_link_dir = lib_platform.join(link_type);
if lib_link_dir.exists() {
continue;
}
let xcfw_intermediate = cmake_link_dir.join("xcframework");
if xcfw_intermediate.is_dir() {
std::fs::create_dir_all(&lib_link_dir).with_context(|| {
format!("failed to create {}", lib_link_dir.display())
})?;
for entry in std::fs::read_dir(&xcfw_intermediate).with_context(|| {
format!("failed to read {}", xcfw_intermediate.display())
})? {
let entry = entry?;
if entry
.file_name()
.to_string_lossy()
.ends_with(".xcframework")
{
create_dir_link(&entry.path(), &lib_link_dir.join(entry.file_name()))?;
}
}
} else {
std::fs::create_dir_all(&lib_platform).with_context(|| {
format!("failed to create {}", lib_platform.display())
})?;
create_dir_link(&cmake_link_dir, &lib_link_dir)?;
}
}
Ok(())
}
fn create_dir_link(source: &Path, target: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(source, target).with_context(|| {
format!(
"failed to symlink {} -> {}",
target.display(),
source.display()
)
})
}
#[cfg(windows)]
{
let status = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(target)
.arg(source)
.status()
.with_context(|| {
format!(
"failed to spawn `cmd /c mklink /J` for {} -> {}",
target.display(),
source.display()
)
})?;
if !status.success() {
anyhow::bail!(
"`mklink /J {} {}` exited with code {:?}",
target.display(),
source.display(),
status.code()
);
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
{
let _ = (source, target);
anyhow::bail!("create_dir_link is not implemented for this OS");
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_CONSUMERS: [LinkType; 3] = [LinkType::Shared, LinkType::Static, LinkType::Both];
#[test]
fn no_hint_means_build_both_for_every_consumer() {
for consumer in ALL_CONSUMERS {
assert_eq!(
build_as_for_hint(consumer.clone(), None),
LinkType::Both,
"consumer={consumer:?}"
);
}
}
#[test]
fn shared_external_hint_picks_shared_for_every_consumer() {
for consumer in ALL_CONSUMERS {
assert_eq!(
build_as_for_hint(consumer.clone(), Some(Linkage::SharedExternal)),
LinkType::Shared,
"consumer={consumer:?}"
);
}
}
#[test]
fn static_embedded_hint_picks_static_for_every_consumer() {
for consumer in ALL_CONSUMERS {
assert_eq!(
build_as_for_hint(consumer.clone(), Some(Linkage::StaticEmbedded)),
LinkType::Static,
"consumer={consumer:?}"
);
}
}
#[test]
fn static_external_hint_picks_static_for_every_consumer() {
for consumer in ALL_CONSUMERS {
assert_eq!(
build_as_for_hint(consumer.clone(), Some(Linkage::StaticExternal)),
LinkType::Static,
"consumer={consumer:?}"
);
}
}
#[test]
fn fingerprint_changes_when_source_file_is_modified() {
use std::fs;
let tmp = tempfile::TempDir::new().unwrap();
let dep = tmp.path().join("leaf");
fs::create_dir_all(dep.join("src")).unwrap();
fs::write(dep.join("CCGO.toml"), "[project]\nname=\"leaf\"\n").unwrap();
fs::write(dep.join("src/leaf.cc"), "int x() { return 1; }\n").unwrap();
let fp1 = compute_source_fingerprint(&dep, LinkType::Both).unwrap();
fs::write(dep.join("src/leaf.cc"), "int x() { return 42; }\n").unwrap();
let fp2 = compute_source_fingerprint(&dep, LinkType::Both).unwrap();
assert_ne!(fp1, fp2, "fingerprint should change when src content changes");
}
#[test]
fn fingerprint_changes_when_build_as_differs() {
use std::fs;
let tmp = tempfile::TempDir::new().unwrap();
let dep = tmp.path().join("leaf");
fs::create_dir_all(dep.join("src")).unwrap();
fs::write(dep.join("CCGO.toml"), "[project]\nname=\"leaf\"\n").unwrap();
fs::write(dep.join("src/leaf.cc"), "int x() {}\n").unwrap();
let fp_static = compute_source_fingerprint(&dep, LinkType::Static).unwrap();
let fp_shared = compute_source_fingerprint(&dep, LinkType::Shared).unwrap();
assert_ne!(
fp_static, fp_shared,
"different --build-as must yield different fingerprints"
);
}
#[test]
fn fingerprint_errors_when_dep_root_does_not_exist() {
let tmp = tempfile::TempDir::new().unwrap();
let nonexistent = tmp.path().join("never-fetched");
let err = compute_source_fingerprint(&nonexistent, LinkType::Both).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not exist"),
"expected `does not exist` in error, got: {msg}"
);
assert!(
msg.contains("ccgo fetch"),
"expected pointer to `ccgo fetch` in error, got: {msg}"
);
}
#[test]
fn fingerprint_persists_to_disk_and_reads_back() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join(".ccgo_materialize_macos.fingerprint");
write_fingerprint(&path, "abc123").unwrap();
let read = read_fingerprint(&path).unwrap();
assert_eq!(read, Some("abc123".to_string()));
}
#[test]
fn missing_fingerprint_file_reads_as_none() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("nonexistent.fingerprint");
assert_eq!(read_fingerprint(&path).unwrap(), None);
}
#[test]
#[cfg(target_os = "macos")]
fn materialize_rebuilds_source_only_dep() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let consumer = manifest_dir.join("tests/fixtures/source_only/consumer");
let leaf = manifest_dir.join("tests/fixtures/source_only/leaf");
let _ = std::fs::remove_dir_all(consumer.join(".ccgo"));
let _ = std::fs::remove_dir_all(leaf.join("cmake_build"));
let _ = std::fs::remove_dir_all(leaf.join("lib"));
for build_as in &["both", "shared", "static"] {
let _ = std::fs::remove_file(
leaf.join(format!(".ccgo_materialize_macos_{build_as}.fingerprint")),
);
}
let deps_dir = consumer.join(".ccgo/deps");
std::fs::create_dir_all(&deps_dir).unwrap();
std::os::unix::fs::symlink(&leaf, deps_dir.join("leaf")).unwrap();
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
let ccgo_bin = manifest_dir.join("target").join(profile).join("ccgo");
assert!(
ccgo_bin.exists(),
"ccgo binary not found at {} — run `cargo build --bin ccgo`",
ccgo_bin.display()
);
let result = materialize_source_deps_inner(
&consumer,
"macos",
&[],
false,
&[("leaf".to_string(), None)],
ccgo_bin.to_str().unwrap(),
);
assert!(
result.is_ok(),
"materialize_source_deps_inner failed: {result:?}"
);
assert!(
leaf.join("cmake_build").exists(),
"leaf should have produced cmake_build/ after materialize"
);
assert!(
leaf.join(".ccgo_materialize_macos_both.fingerprint").exists(),
"fingerprint sidecar (Both variant) should be persisted after a successful spawn"
);
}
#[cfg(test)]
fn make_synthetic_dep(parent: &Path, name: &str) -> PathBuf {
let dep = parent.join(".ccgo/deps").join(name);
std::fs::create_dir_all(dep.join("src")).unwrap();
std::fs::create_dir_all(dep.join("lib/macos/static")).unwrap();
std::fs::write(dep.join("CCGO.toml"), "[project]\nname=\"x\"\n").unwrap();
std::fs::write(dep.join("src/x.cc"), "int x() { return 0; }\n").unwrap();
std::fs::write(
dep.join(format!("lib/macos/static/lib{name}.a")),
b"!<arch>\n",
)
.unwrap();
dep
}
#[test]
fn cache_hit_skips_spawn_when_artifacts_present_and_fingerprint_matches() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let dep_root = make_synthetic_dep(&project, "leaf");
let build_as = build_as_for_hint(LinkType::Both, None);
let fp = compute_source_fingerprint(&dep_root, build_as.clone()).unwrap();
write_fingerprint(&fingerprint_path(&dep_root, "macos", build_as.clone()), &fp).unwrap();
let sentinel = tmp.path().join("does-not-exist-and-must-not-be-spawned");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("leaf".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_ok(),
"cache hit should skip spawn entirely; got error: {result:?}"
);
}
#[test]
fn cache_miss_attempts_spawn_when_source_changed() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let dep_root = make_synthetic_dep(&project, "leaf");
write_fingerprint(
&fingerprint_path(&dep_root, "macos", LinkType::Both),
"stale-fingerprint-0000",
)
.unwrap();
let sentinel = tmp.path().join("does-not-exist-and-spawn-must-error");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("leaf".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_err(),
"cache miss must attempt spawn (which fails on the sentinel)"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("failed to spawn") || msg.contains("'leaf'"),
"expected spawn-attempt error mentioning the dep, got: {msg}"
);
}
#[test]
fn first_run_with_prebuilt_artifacts_trusts_them_and_records_fingerprint() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let dep_root = make_synthetic_dep(&project, "leaf");
let fp_path = fingerprint_path(&dep_root, "macos", LinkType::Both);
assert!(
!fp_path.exists(),
"test setup invariant: no fingerprint sidecar yet"
);
let sentinel = tmp.path().join("does-not-exist-and-must-not-be-spawned");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("leaf".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_ok(),
"first run with prebuilt artifacts should skip spawn; got: {result:?}"
);
assert!(
fp_path.exists(),
"fingerprint sidecar should be persisted on first-run trust path"
);
}
#[test]
fn cache_miss_when_artifacts_deleted_even_if_fingerprint_matches() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let dep_root = make_synthetic_dep(&project, "leaf");
let build_as = build_as_for_hint(LinkType::Both, None);
let fp = compute_source_fingerprint(&dep_root, build_as.clone()).unwrap();
write_fingerprint(&fingerprint_path(&dep_root, "macos", build_as.clone()), &fp).unwrap();
std::fs::remove_dir_all(dep_root.join("lib")).unwrap();
let sentinel = tmp.path().join("does-not-exist");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("leaf".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_err(),
"missing artifacts must trigger spawn even when fingerprint matches"
);
}
#[test]
fn binary_only_dep_is_skipped() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
let dep = project.join(".ccgo/deps/binarydep");
std::fs::create_dir_all(dep.join("lib/macos/shared")).unwrap();
std::fs::write(
dep.join("lib/macos/shared/libbinarydep.dylib"),
b"fake-dylib",
)
.unwrap();
let sentinel = tmp.path().join("does-not-exist");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("binarydep".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_ok(),
"binary-only deps must be skipped without attempting spawn; got: {result:?}"
);
}
#[test]
fn missing_dep_root_is_skipped() {
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(project.join(".ccgo/deps")).unwrap();
let sentinel = tmp.path().join("does-not-exist");
let result = materialize_source_deps_inner(
&project,
"macos",
&[],
false,
&[("ghost".to_string(), None)],
sentinel.to_str().unwrap(),
);
assert!(
result.is_ok(),
"missing dep_root must be skipped without spawn; got: {result:?}"
);
}
}