use anyhow::{Context, Result};
use rustbridge_bundle::Platform;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use yansi::{Condition, Paint, Style};
static WARN: Style = Style::new()
.yellow()
.bold()
.whenever(Condition::STDERR_IS_TTY);
#[derive(Deserialize)]
struct CargoToml {
package: Option<PackageInfo>,
lib: Option<LibInfo>,
}
#[derive(Deserialize)]
struct PackageInfo {
name: Option<String>,
version: Option<toml::Value>,
metadata: Option<PackageMetadata>,
}
#[derive(Deserialize)]
struct PackageMetadata {
rustbridge: Option<RustbridgeMetadata>,
}
#[derive(Deserialize, Default)]
struct RustbridgeMetadata {
#[serde(rename = "schema-source")]
schema_source: Option<String>,
#[serde(rename = "header-source")]
header_source: Option<String>,
}
#[derive(Deserialize)]
struct LibInfo {
name: Option<String>,
#[serde(rename = "crate-type")]
crate_type: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct WorkspaceCargoToml {
workspace: Option<WorkspaceInfo>,
}
#[derive(Deserialize)]
struct WorkspaceInfo {
package: Option<WorkspacePackageInfo>,
}
#[derive(Deserialize)]
struct WorkspacePackageInfo {
version: Option<String>,
}
#[derive(Debug)]
pub struct PluginProject {
pub name: String,
pub version: String,
pub lib_name: String,
pub schema_source: Option<String>,
pub header_source: Option<String>,
}
impl PluginProject {
pub fn detect(project_dir: &Path) -> Result<Self> {
let cargo_toml_path = project_dir.join("Cargo.toml");
if !cargo_toml_path.exists() {
anyhow::bail!(
"No Cargo.toml found in {}. Run this command from a plugin project directory.",
project_dir.display()
);
}
let contents = std::fs::read_to_string(&cargo_toml_path)
.with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?;
let cargo_toml: CargoToml =
toml::from_str(&contents).context("Failed to parse Cargo.toml")?;
let lib_info = cargo_toml.lib.as_ref();
let has_cdylib = lib_info
.and_then(|l| l.crate_type.as_ref())
.is_some_and(|types| types.iter().any(|t| t == "cdylib"));
if !has_cdylib {
anyhow::bail!(
"Expected [lib] crate-type to include \"cdylib\" in {}. \
This command only works with plugin projects.",
cargo_toml_path.display()
);
}
let package = cargo_toml
.package
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing [package] section in Cargo.toml"))?;
let name = package
.name
.clone()
.ok_or_else(|| anyhow::anyhow!("Missing package.name in Cargo.toml"))?;
let version = resolve_version(package, project_dir)?;
let lib_name = lib_info
.and_then(|l| l.name.clone())
.unwrap_or_else(|| name.replace('-', "_"));
let rb_meta = package
.metadata
.as_ref()
.and_then(|m| m.rustbridge.as_ref());
let schema_source = rb_meta.and_then(|r| r.schema_source.clone());
let header_source = rb_meta.and_then(|r| r.header_source.clone());
Ok(Self {
name,
version,
lib_name,
schema_source,
header_source,
})
}
}
fn resolve_version(package: &PackageInfo, project_dir: &Path) -> Result<String> {
let version_value = package
.version
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing package.version in Cargo.toml"))?;
match version_value {
toml::Value::String(v) => Ok(v.clone()),
toml::Value::Table(t) => {
if t.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
find_workspace_version(project_dir)
} else {
anyhow::bail!("Unsupported package.version format in Cargo.toml")
}
}
_ => anyhow::bail!("Unsupported package.version format in Cargo.toml"),
}
}
fn find_workspace_version(start_dir: &Path) -> Result<String> {
let mut dir = start_dir.to_path_buf();
loop {
if !dir.pop() {
anyhow::bail!(
"Could not find workspace root with [workspace.package] version. \
Searched from {}",
start_dir.display()
);
}
let candidate = dir.join("Cargo.toml");
if !candidate.exists() {
continue;
}
let contents = std::fs::read_to_string(&candidate)
.with_context(|| format!("Failed to read {}", candidate.display()))?;
let workspace_toml: WorkspaceCargoToml = match toml::from_str(&contents) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if let Some(workspace) = workspace_toml.workspace
&& let Some(pkg) = workspace.package
&& let Some(version) = pkg.version
{
return Ok(version);
}
}
}
fn default_signing_key_path() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("Could not determine home directory")?;
Ok(PathBuf::from(home).join(".rustbridge").join("signing.key"))
}
fn derive_pack_output(project_dir: &Path, name: &str, version: &str, dev: bool) -> PathBuf {
let suffix = if dev { "-dev" } else { "" };
project_dir
.join("target")
.join("bundle")
.join(format!("{name}-{version}{suffix}.rbp"))
}
fn find_target_dir(project_dir: &Path) -> PathBuf {
let mut dir = project_dir.to_path_buf();
loop {
if !dir.pop() {
return project_dir.join("target");
}
let candidate = dir.join("Cargo.toml");
if !candidate.exists() {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&candidate)
&& let Ok(parsed) = toml::from_str::<WorkspaceCargoToml>(&contents)
&& parsed.workspace.is_some()
{
return dir.join("target");
}
}
}
fn format_system_time(time: SystemTime) -> String {
let duration = time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = {
let z = days as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m as u32, d as u32)
};
format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02} UTC")
}
fn check_library_staleness(project_dir: &Path, lib_path: &Path, variant_label: &str) {
let lib_mtime = match lib_path.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => return,
};
let mut newest_source: Option<(PathBuf, SystemTime)> = None;
let mut consider = |path: PathBuf| {
if let Ok(meta) = path.metadata()
&& let Ok(mtime) = meta.modified()
&& newest_source.as_ref().is_none_or(|(_, prev)| mtime > *prev)
{
newest_source = Some((path, mtime));
}
};
let cargo_toml = project_dir.join("Cargo.toml");
if cargo_toml.exists() {
consider(cargo_toml);
}
let build_rs = project_dir.join("build.rs");
if build_rs.exists() {
consider(build_rs);
}
let src_dir = project_dir.join("src");
if src_dir.is_dir() {
for entry in walkdir::WalkDir::new(&src_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.into_path();
if path.extension().is_some_and(|ext| ext == "rs") {
consider(path);
}
}
}
if let Some((newest_path, newest_mtime)) = newest_source
&& newest_mtime > lib_mtime
{
let lib_display = lib_path.display();
let src_display = newest_path
.strip_prefix(project_dir)
.unwrap_or(&newest_path)
.display();
let lib_time_str = format_system_time(lib_mtime);
let newest_time_str = format_system_time(newest_mtime);
let build_hint = if variant_label == "Release" {
"cargo build --release"
} else {
"cargo build"
};
let header = format!("Warning: {variant_label} library appears out of date.");
eprintln!();
eprintln!("{}", header.paint(WARN));
eprintln!(" Library: {lib_display} ({lib_time_str})");
eprintln!(" Newest source: {src_display} ({newest_time_str})");
eprintln!(" Run: {build_hint}");
eprintln!();
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_pack(
dev: bool,
sign_key: Option<String>,
no_sign: bool,
schema_source: Option<String>,
header_source: Option<String>,
) -> Result<()> {
if sign_key.is_some() && no_sign {
anyhow::bail!("Conflicting flags: --sign-key and --no-sign cannot be used together");
}
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let project = PluginProject::detect(&cwd)?;
let effective_schema = schema_source.or_else(|| {
if let Some(ref s) = project.schema_source {
println!(" Schema source (from Cargo.toml): {s}");
}
project.schema_source.clone()
});
let effective_header = header_source.or_else(|| {
if let Some(ref h) = project.header_source {
println!(" Header source (from Cargo.toml): {h}");
}
project.header_source.clone()
});
let platform = Platform::current().ok_or_else(|| {
anyhow::anyhow!(
"Unsupported platform: {}-{}",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let platform_str = platform.to_string();
println!("Packing plugin: {} v{}", project.name, project.version);
println!(" Platform: {platform_str}");
println!(
" Library name: {}",
platform.library_name(&project.lib_name)
);
let target_dir = find_target_dir(&cwd);
let lib_filename = platform.library_name(&project.lib_name);
let mut libraries: Vec<(String, String, String)> = Vec::new();
let release_lib = target_dir.join("release").join(&lib_filename);
if !release_lib.exists() {
anyhow::bail!(
"Release library not found: {}\n\
Run: cargo build --release",
release_lib.display()
);
}
check_library_staleness(&cwd, &release_lib, "Release");
libraries.push((
platform_str.clone(),
"release".to_string(),
release_lib.to_string_lossy().to_string(),
));
println!(" Release library: {}", release_lib.display());
if dev {
let debug_lib = target_dir.join("debug").join(&lib_filename);
if !debug_lib.exists() {
anyhow::bail!(
"Debug library not found: {}\n\
Run: cargo build",
debug_lib.display()
);
}
check_library_staleness(&cwd, &debug_lib, "Debug");
libraries.push((
platform_str,
"debug".to_string(),
debug_lib.to_string_lossy().to_string(),
));
println!(" Debug library: {}", debug_lib.display());
}
let mut sbom_files: Vec<(String, String)> = Vec::new();
for sbom_name in &["sbom.cdx.json", "sbom.spdx.json"] {
let sbom_path = cwd.join(sbom_name);
if sbom_path.exists() {
println!(" SBOM: {sbom_name}");
sbom_files.push((
sbom_path.to_string_lossy().to_string(),
(*sbom_name).to_string(),
));
}
}
let license_path = find_license_file(&cwd);
if let Some(ref lp) = license_path {
println!(" License: {}", lp.display());
}
let resolved_sign_key = if dev || no_sign {
None
} else {
match sign_key {
Some(path) => Some(path),
None => {
let default_key = default_signing_key_path()?;
if default_key.exists() {
println!(" Signing with: {}", default_key.display());
Some(default_key.to_string_lossy().to_string())
} else {
eprintln!(
"{} No signing key found at {}. Bundle will not be signed. \
Use 'rustbridge keygen' to generate a key.",
"Warning:".paint(WARN),
default_key.display()
);
None
}
}
}
};
let output = derive_pack_output(&cwd, &project.name, &project.version, dev);
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
println!(" Output: {}", output.display());
crate::bundle::create(
&project.name,
&project.version,
&libraries,
Some(output.to_string_lossy().to_string()),
&[], resolved_sign_key,
effective_header, effective_schema, None, license_path.map(|p| p.to_string_lossy().to_string()),
false, &sbom_files,
&[], )?;
Ok(())
}
fn find_license_file(project_dir: &Path) -> Option<PathBuf> {
for name in &[
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"LICENSE-MIT",
"LICENSE-APACHE",
] {
let path = project_dir.join(name);
if path.exists() {
return Some(path);
}
}
None
}
#[cfg(test)]
mod tests {
#![allow(non_snake_case)]
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_cargo_toml(dir: &Path, content: &str) {
fs::write(dir.join("Cargo.toml"), content).unwrap();
}
#[test]
fn detect___standalone_project___extracts_name_and_version() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-plugin"
version = "2.1.0"
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert_eq!(project.name, "my-plugin");
assert_eq!(project.version, "2.1.0");
}
#[test]
fn detect___custom_lib_name___uses_lib_name() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-plugin"
version = "1.0.0"
[lib]
name = "custom_name"
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert_eq!(project.lib_name, "custom_name");
}
#[test]
fn detect___workspace_version___resolves_from_workspace_root() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[workspace]
members = ["crates/my-plugin"]
[workspace.package]
version = "3.0.0"
"#,
);
let member_dir = temp.path().join("crates").join("my-plugin");
fs::create_dir_all(&member_dir).unwrap();
write_cargo_toml(
&member_dir,
r#"
[package]
name = "my-plugin"
version.workspace = true
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(&member_dir).unwrap();
assert_eq!(project.version, "3.0.0");
}
#[test]
fn detect___no_cargo_toml___returns_error() {
let temp = TempDir::new().unwrap();
let result = PluginProject::detect(temp.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("No Cargo.toml"), "Error was: {err}");
}
#[test]
fn detect___no_cdylib_crate_type___returns_error() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-lib"
version = "1.0.0"
[lib]
crate-type = ["rlib"]
"#,
);
let result = PluginProject::detect(temp.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("cdylib"), "Error was: {err}");
}
#[test]
fn detect___hyphenated_name___converts_to_underscores() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-cool-plugin"
version = "1.0.0"
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert_eq!(project.lib_name, "my_cool_plugin");
}
#[test]
fn output_path___release_mode___no_dev_suffix() {
let dir = Path::new("/project");
let path = derive_pack_output(dir, "my-plugin", "1.0.0", false);
assert_eq!(
path,
PathBuf::from("/project/target/bundle/my-plugin-1.0.0.rbp")
);
}
#[test]
fn output_path___dev_mode___has_dev_suffix() {
let dir = Path::new("/project");
let path = derive_pack_output(dir, "my-plugin", "1.0.0", true);
assert_eq!(
path,
PathBuf::from("/project/target/bundle/my-plugin-1.0.0-dev.rbp")
);
}
#[test]
fn detect___metadata_schema_source___extracts_value() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-plugin"
version = "1.0.0"
[package.metadata.rustbridge]
schema-source = "src/lib.rs:schema.json"
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert_eq!(
project.schema_source.as_deref(),
Some("src/lib.rs:schema.json")
);
}
#[test]
fn detect___metadata_header_source___extracts_value() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-plugin"
version = "1.0.0"
[package.metadata.rustbridge]
header-source = "src/binary_messages.rs:messages.h"
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert_eq!(
project.header_source.as_deref(),
Some("src/binary_messages.rs:messages.h")
);
}
#[test]
fn detect___no_metadata___fields_are_none() {
let temp = TempDir::new().unwrap();
write_cargo_toml(
temp.path(),
r#"
[package]
name = "my-plugin"
version = "1.0.0"
[lib]
crate-type = ["cdylib"]
"#,
);
let project = PluginProject::detect(temp.path()).unwrap();
assert!(project.schema_source.is_none());
assert!(project.header_source.is_none());
}
#[test]
fn staleness___library_older_than_source___prints_warning() {
use filetime::FileTime;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let src_dir = temp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "// source").unwrap();
let lib_path = temp.path().join("libx.so");
fs::write(&lib_path, "fake lib").unwrap();
let old_time = FileTime::from_unix_time(1_577_836_800, 0); let new_time = FileTime::from_unix_time(1_737_849_600, 0);
filetime::set_file_mtime(&lib_path, old_time).unwrap();
filetime::set_file_mtime(src_dir.join("lib.rs"), new_time).unwrap();
let lib_mtime = fs::metadata(&lib_path).unwrap().modified().unwrap();
let src_mtime = fs::metadata(src_dir.join("lib.rs"))
.unwrap()
.modified()
.unwrap();
assert!(src_mtime > lib_mtime, "Source should be newer than library");
check_library_staleness(temp.path(), &lib_path, "Release");
}
#[test]
fn staleness___library_newer_than_source___no_warning() {
use filetime::FileTime;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let src_dir = temp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "// source").unwrap();
let lib_path = temp.path().join("libx.so");
fs::write(&lib_path, "fake lib").unwrap();
let old_time = FileTime::from_unix_time(1_577_836_800, 0); let new_time = FileTime::from_unix_time(1_737_849_600, 0);
filetime::set_file_mtime(src_dir.join("lib.rs"), old_time).unwrap();
filetime::set_file_mtime(temp.path().join("Cargo.toml"), old_time).unwrap();
filetime::set_file_mtime(&lib_path, new_time).unwrap();
let lib_mtime = fs::metadata(&lib_path).unwrap().modified().unwrap();
let src_mtime = fs::metadata(src_dir.join("lib.rs"))
.unwrap()
.modified()
.unwrap();
assert!(lib_mtime > src_mtime, "Library should be newer than source");
check_library_staleness(temp.path(), &lib_path, "Release");
}
#[test]
fn staleness___no_src_directory___no_warning() {
use filetime::FileTime;
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let lib_path = temp.path().join("libx.so");
fs::write(&lib_path, "fake lib").unwrap();
let old_time = FileTime::from_unix_time(1_577_836_800, 0);
filetime::set_file_mtime(&lib_path, old_time).unwrap();
check_library_staleness(temp.path(), &lib_path, "Release");
}
}