use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use cargo_metadata::MetadataCommand;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestRaw {
#[serde(default)]
pub ios: Option<IosSectionRaw>,
#[serde(default)]
pub android: Option<AndroidSectionRaw>,
#[serde(default)]
pub plugins: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IosSectionRaw {
#[serde(default)]
pub swift_sources: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AndroidSectionRaw {
#[serde(default)]
pub kotlin_sources: Vec<String>,
#[serde(default)]
pub jni_sources: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedModule {
pub package: String,
pub manifest_dir: PathBuf,
pub ios_swift_sources: Vec<PathBuf>,
pub android_kotlin_sources: Vec<PathBuf>,
pub android_jni_sources: Vec<PathBuf>,
}
pub fn discover(manifest_path: &Path, app_package: &str) -> Result<Vec<ResolvedModule>> {
let metadata = MetadataCommand::new()
.manifest_path(manifest_path)
.exec()
.with_context(|| {
format!(
"cargo metadata failed for {} (package: {app_package})",
manifest_path.display(),
)
})?;
let resolve = metadata
.resolve
.as_ref()
.ok_or_else(|| anyhow!("cargo metadata returned no resolve graph"))?;
let root_id = resolve
.root
.as_ref()
.filter(|id| {
metadata
.packages
.iter()
.any(|p| &p.id == *id && p.name == app_package)
})
.cloned()
.or_else(|| {
metadata
.packages
.iter()
.find(|p| p.name == app_package)
.map(|p| p.id.clone())
})
.ok_or_else(|| anyhow!("cargo package `{app_package}` not found in the workspace"))?;
let mut visit: Vec<&cargo_metadata::PackageId> = vec![&root_id];
let mut seen: std::collections::HashSet<&cargo_metadata::PackageId> = Default::default();
let mut module_pkg_ids: Vec<cargo_metadata::PackageId> = Vec::new();
while let Some(pkg_id) = visit.pop() {
if !seen.insert(pkg_id) {
continue;
}
if let Some(node) = resolve.nodes.iter().find(|n| &n.id == pkg_id) {
for dep in &node.deps {
visit.push(&dep.pkg);
}
}
if pkg_id != &root_id {
module_pkg_ids.push(pkg_id.clone());
}
}
let mut resolved: Vec<ResolvedModule> = Vec::new();
for id in module_pkg_ids {
let pkg = metadata
.packages
.iter()
.find(|p| p.id == id)
.expect("dep id came from `resolve.nodes`; must exist in metadata.packages");
let manifest_dir = pkg
.manifest_path
.parent()
.map(|p| PathBuf::from(p.as_str()))
.ok_or_else(|| {
anyhow!(
"dep `{}` manifest_path has no parent: {}",
pkg.name,
pkg.manifest_path,
)
})?;
let Some(whisker_meta) = pkg.metadata.get("whisker") else {
continue;
};
let manifest: ManifestRaw =
serde_json::from_value(whisker_meta.clone()).with_context(|| {
format!("parse [package.metadata.whisker] in {}", pkg.manifest_path,)
})?;
let mut ios_swift: Vec<PathBuf> = Vec::new();
if let Some(ios) = manifest.ios {
for raw_path in ios.swift_sources {
let resolved_path = manifest_dir.join(&raw_path);
let canonical = resolved_path.canonicalize().with_context(|| {
format!(
"module `{}` declares metadata.whisker.ios.swift_sources = \
[..., {raw_path:?}] but {} does not exist",
pkg.name,
resolved_path.display(),
)
})?;
ios_swift.push(canonical);
}
}
let mut android_kotlin: Vec<PathBuf> = Vec::new();
let mut android_jni: Vec<PathBuf> = Vec::new();
if let Some(android) = manifest.android {
for raw_path in android.kotlin_sources {
let resolved_path = manifest_dir.join(&raw_path);
let canonical = resolved_path.canonicalize().with_context(|| {
format!(
"module `{}` declares metadata.whisker.android.kotlin_sources = \
[..., {raw_path:?}] but {} does not exist",
pkg.name,
resolved_path.display(),
)
})?;
android_kotlin.push(canonical);
}
for raw_path in android.jni_sources {
let resolved_path = manifest_dir.join(&raw_path);
let canonical = resolved_path.canonicalize().with_context(|| {
format!(
"module `{}` declares metadata.whisker.android.jni_sources = \
[..., {raw_path:?}] but {} does not exist",
pkg.name,
resolved_path.display(),
)
})?;
android_jni.push(canonical);
}
}
resolved.push(ResolvedModule {
package: pkg.name.clone(),
manifest_dir,
ios_swift_sources: ios_swift,
android_kotlin_sources: android_kotlin,
android_jni_sources: android_jni,
});
}
resolved.sort_by(|a, b| a.package.cmp(&b.package));
Ok(resolved)
}
pub fn android_kotlin_sources_env_value(modules: &[ResolvedModule]) -> String {
let mut paths: Vec<String> = Vec::new();
for m in modules {
for p in &m.android_kotlin_sources {
paths.push(p.to_string_lossy().into_owned());
}
}
paths.join(":")
}
pub fn android_jni_sources_env_value(modules: &[ResolvedModule]) -> String {
let mut paths: Vec<String> = Vec::new();
for m in modules {
for p in &m.android_jni_sources {
paths.push(p.to_string_lossy().into_owned());
}
}
paths.join(":")
}
#[derive(Debug, Clone, Serialize)]
pub struct ModulesReportModule {
pub crate_name: String,
pub manifest_dir: PathBuf,
pub android: Option<AndroidModuleReport>,
pub ios: Option<IosModuleReport>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AndroidModuleReport {
pub subproject_dir: PathBuf,
pub behaviors_class: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct IosModuleReport {
pub swift_module: Option<String>,
pub swift_sources: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModulesReport {
pub cargo_lock_sha256: String,
pub user_package: String,
pub modules: Vec<ModulesReportModule>,
}
pub fn build_modules_report(workspace_root: &Path, user_package: &str) -> Result<ModulesReport> {
let manifest_path = workspace_root.join("Cargo.toml");
let resolved = discover(&manifest_path, user_package)
.with_context(|| format!("discover modules for `{user_package}`"))?;
let lock_path = workspace_root.join("Cargo.lock");
let cargo_lock_sha256 =
sha256_file(&lock_path).with_context(|| format!("hash {}", lock_path.display()))?;
let modules: Vec<ModulesReportModule> = resolved
.into_iter()
.map(|m| {
let android = if m.manifest_dir.join("build.gradle.kts").is_file() {
Some(AndroidModuleReport {
subproject_dir: m.manifest_dir.clone(),
behaviors_class: crate_to_behaviors_class(&m.package),
})
} else {
None
};
let swift_pkg = m.manifest_dir.join("Package.swift");
let has_ios = !m.ios_swift_sources.is_empty() || swift_pkg.is_file();
let ios = if has_ios {
Some(IosModuleReport {
swift_module: if swift_pkg.is_file() {
Some(crate_to_swift_module(&m.package))
} else {
None
},
swift_sources: m.ios_swift_sources,
})
} else {
None
};
ModulesReportModule {
crate_name: m.package,
manifest_dir: m.manifest_dir,
android,
ios,
}
})
.collect();
Ok(ModulesReport {
cargo_lock_sha256,
user_package: user_package.to_string(),
modules,
})
}
pub fn crate_to_behaviors_class(crate_name: &str) -> String {
let mut out = pascal_case(crate_name);
out.push_str("Behaviors");
out
}
pub fn crate_to_swift_module(crate_name: &str) -> String {
pascal_case(crate_name)
}
fn pascal_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '-' || ch == '_' {
next_upper = true;
continue;
}
if next_upper {
out.extend(ch.to_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hex(&hasher.finalize()))
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}