use crate::cargo::multi_target_metadata::MultiTargetMetadata;
use crate::config::{ConfigLoadResult, RailConfig};
use crate::error::{ConfigError, GitError, RailError, RailResult};
use crate::git::SystemGit;
use crate::graph::WorkspaceGraph;
use crate::progress;
use cargo_metadata::{Metadata, MetadataCommand, Package};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct CargoState {
metadata: Metadata,
workspace_root: PathBuf,
package_index: std::collections::HashMap<String, usize>,
proc_macro_crates: std::collections::HashSet<String>,
}
const CACHE_VERSION: u32 = 2;
#[derive(Serialize, Deserialize)]
struct MetadataCache {
#[serde(default)]
version: u32,
hash: u64,
metadata: Metadata,
}
impl CargoState {
fn load(workspace_root: &Path) -> RailResult<Self> {
let cache_dir = workspace_root.join("target").join("cargo-rail");
let cache_file = cache_dir.join("metadata.json");
if let Some(cache) = fs::read_to_string(&cache_file)
.ok()
.and_then(|s| serde_json::from_str::<MetadataCache>(&s).ok())
&& cache.version == CACHE_VERSION
{
let cached_root = cache.metadata.workspace_root.as_std_path();
let current_root_canonical = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let cached_root_canonical = cached_root.canonicalize().unwrap_or_else(|_| cached_root.to_path_buf());
if current_root_canonical == cached_root_canonical {
let current_hash = compute_workspace_hash_with_members(workspace_root, &cache.metadata);
if cache.hash == current_hash {
return Ok(Self::from_metadata(cache.metadata));
}
}
}
let metadata = MetadataCommand::new()
.manifest_path(workspace_root.join("Cargo.toml"))
.exec()?;
if let Ok(()) = fs::create_dir_all(&cache_dir) {
let current_hash = compute_workspace_hash_with_members(workspace_root, &metadata);
let cache = MetadataCache {
version: CACHE_VERSION,
hash: current_hash,
metadata: metadata.clone(),
};
match serde_json::to_string(&cache) {
Ok(json) => {
if let Err(e) = fs::write(&cache_file, json) {
crate::warn!("failed to write metadata cache {}: {}", cache_file.display(), e);
}
}
Err(e) => {
crate::warn!(
"failed to serialize metadata cache {}: {} (proceeding without cache)",
cache_file.display(),
e
);
}
}
}
Ok(Self::from_metadata(metadata))
}
fn from_metadata(metadata: Metadata) -> Self {
let workspace_root: PathBuf = metadata.workspace_root.as_std_path().components().collect();
let workspace_member_ids: std::collections::HashSet<_> = metadata.workspace_members.iter().collect();
let workspace_packages: Vec<_> = metadata
.packages
.iter()
.enumerate()
.filter(|(_, pkg)| workspace_member_ids.contains(&pkg.id))
.collect();
let package_index = workspace_packages
.iter()
.map(|(idx, pkg)| (pkg.name.to_string(), *idx))
.collect();
let proc_macro_crates = workspace_packages
.iter()
.filter(|(_, pkg)| {
pkg.targets.iter().any(|t| {
t.kind
.iter()
.any(|k| matches!(k, cargo_metadata::TargetKind::ProcMacro))
})
})
.map(|(_, pkg)| pkg.name.to_string())
.collect();
Self {
metadata,
workspace_root,
package_index,
proc_macro_crates,
}
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn workspace_members(&self) -> Vec<&Package> {
self.metadata.workspace_packages()
}
pub fn get_package(&self, name: &str) -> Option<&Package> {
self.package_index.get(name).map(|&idx| &self.metadata.packages[idx])
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
pub fn is_proc_macro(&self, crate_name: &str) -> bool {
self.proc_macro_crates.contains(crate_name)
}
pub fn proc_macro_crates(&self) -> &std::collections::HashSet<String> {
&self.proc_macro_crates
}
pub fn is_package_publishable(package: &Package) -> bool {
package.publish.as_ref().map(|p| !p.is_empty()).unwrap_or(true)
}
pub fn is_binary_only(&self, crate_name: &str) -> bool {
let Some(pkg) = self.get_package(crate_name) else {
return false;
};
let mut has_bin = false;
let mut has_lib_like = false;
for target in &pkg.targets {
for kind in &target.kind {
match kind {
cargo_metadata::TargetKind::Bin => has_bin = true,
cargo_metadata::TargetKind::Lib | cargo_metadata::TargetKind::ProcMacro => has_lib_like = true,
_ => {}
}
}
}
has_bin && !has_lib_like
}
}
fn compute_workspace_hash_with_members(workspace_root: &Path, metadata: &Metadata) -> u64 {
const FNV_PRIME: u64 = 0x100000001b3;
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
let mut hash = FNV_OFFSET_BASIS;
fn hash_file(hash: &mut u64, path: &Path, fnv_prime: u64) {
let Ok(mut file) = fs::File::open(path) else {
*hash ^= 0xff;
*hash = hash.wrapping_mul(fnv_prime);
return;
};
let mut buffer = [0; 8192];
while let Ok(n) = file.read(&mut buffer) {
if n == 0 {
break;
}
for byte in &buffer[..n] {
*hash ^= *byte as u64;
*hash = hash.wrapping_mul(fnv_prime);
}
}
}
hash_file(&mut hash, &workspace_root.join("Cargo.toml"), FNV_PRIME);
hash_file(&mut hash, &workspace_root.join("Cargo.lock"), FNV_PRIME);
let mut member_manifests: Vec<PathBuf> = metadata
.workspace_packages()
.iter()
.map(|p| p.manifest_path.as_std_path().to_path_buf())
.collect();
member_manifests.sort_unstable_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
for manifest_path in member_manifests {
hash_file(&mut hash, &manifest_path, FNV_PRIME);
}
hash
}
#[derive(Clone)]
pub struct GitState {
git: SystemGit,
repo_root: PathBuf,
}
impl GitState {
fn open(path: &Path) -> RailResult<Self> {
let git = SystemGit::open(path)?;
let repo_root = git.worktree_root.clone();
Ok(Self { git, repo_root })
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
pub fn git(&self) -> &SystemGit {
&self.git
}
pub fn current_branch(&self) -> RailResult<String> {
self.git.current_branch()
}
pub fn is_detached_head(&self) -> RailResult<bool> {
self.git.is_detached_head()
}
pub fn default_branch(&self) -> RailResult<Option<String>> {
self.git.default_branch()
}
}
pub struct WorkspaceContext {
pub workspace_root: PathBuf,
git: Option<Arc<GitState>>,
pub cargo: Arc<CargoState>,
pub graph: Arc<WorkspaceGraph>,
pub config: Option<Arc<RailConfig>>,
targets: Vec<String>,
multi_target_metadata: Mutex<Option<Arc<MultiTargetMetadata>>>,
}
impl WorkspaceContext {
pub fn build(workspace_root: &Path) -> RailResult<Self> {
let git = match GitState::open(workspace_root) {
Ok(git) => Some(Arc::new(git)),
Err(RailError::Git(GitError::RepoNotFound { .. })) => None,
Err(err) => return Err(err),
};
let cargo = Arc::new(CargoState::load(workspace_root)?);
let workspace_root = cargo.workspace_root().to_path_buf();
if let Some(git) = git.as_ref() {
let git_root_canonical = git
.repo_root()
.canonicalize()
.unwrap_or_else(|_| git.repo_root().to_path_buf());
let workspace_root_canonical = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.clone());
if git_root_canonical != workspace_root_canonical {
crate::warn!(
"git repo root ({}) differs from Cargo workspace root ({})",
git.repo_root().display(),
workspace_root.display()
);
}
}
let graph = Arc::new(WorkspaceGraph::from_metadata(cargo.metadata())?);
let config = match RailConfig::try_load(&workspace_root) {
ConfigLoadResult::Loaded(cfg) => Some(Arc::new(*cfg)),
ConfigLoadResult::NotFound => None,
ConfigLoadResult::ParseError { path, message } => {
return Err(RailError::Config(ConfigError::ParseError { path, message }));
}
};
if let Some(ref cfg) = config
&& !cfg.targets.is_empty()
{
crate::targets::validate_targets(&cfg.targets)?;
}
if let Some(ref cfg) = config {
cfg.change_detection.validate().map_err(RailError::Config)?;
cfg.unify.validate(&workspace_root).map_err(RailError::Config)?;
cfg.run.validate().map_err(RailError::Config)?;
}
let targets = config.as_ref().map(|c| c.targets.clone()).unwrap_or_default();
Ok(Self {
workspace_root,
git,
cargo,
graph,
config,
targets,
multi_target_metadata: Mutex::new(None),
})
}
pub fn require_config(&self) -> RailResult<&Arc<RailConfig>> {
self.config.as_ref().ok_or_else(|| {
crate::error::RailError::message(format!(
"No rail.toml found in: {}\nSearched: rail.toml, .rail.toml, .cargo/rail.toml, .config/rail.toml\nRun 'cargo rail init' to create one.",
self.workspace_root.display()
))
})
}
pub fn git(&self) -> RailResult<&GitState> {
self.git.as_deref().ok_or_else(|| {
RailError::Git(GitError::RepoNotFound {
path: self.workspace_root.clone(),
})
})
}
pub fn has_git(&self) -> bool {
self.git.is_some()
}
pub fn multi_target_metadata(&self) -> RailResult<Arc<MultiTargetMetadata>> {
let mut guard = self
.multi_target_metadata
.lock()
.map_err(|_| RailError::message("Lock poisoned".to_string()))?;
if let Some(ref cached) = *guard {
return Ok(Arc::clone(cached));
}
if !self.targets.is_empty() {
progress!("Loading metadata for {} target(s)...", self.targets.len());
}
let metadata = Arc::new(MultiTargetMetadata::load_parallel(&self.workspace_root, &self.targets)?);
*guard = Some(Arc::clone(&metadata));
Ok(metadata)
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn workspace_prefix(&self) -> Option<PathBuf> {
let git_root = self.git.as_ref()?.repo_root();
let git_root_canonical = git_root.canonicalize().unwrap_or_else(|_| git_root.to_path_buf());
let workspace_canonical = self
.workspace_root
.canonicalize()
.unwrap_or_else(|_| self.workspace_root.clone());
if let Ok(prefix) = workspace_canonical.strip_prefix(&git_root_canonical) {
if prefix.as_os_str().is_empty() {
None } else {
Some(prefix.to_path_buf())
}
} else {
None
}
}
pub fn to_workspace_path(&self, git_path: &Path) -> Option<PathBuf> {
if let Some(prefix) = self.workspace_prefix() {
let normalized = git_path.components().collect::<PathBuf>();
normalized.strip_prefix(&prefix).ok().map(|p| p.to_path_buf())
} else {
Some(git_path.to_path_buf())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workspace_context_build() {
let current_dir = std::env::current_dir().unwrap();
let ctx = WorkspaceContext::build(¤t_dir);
assert!(ctx.is_ok(), "Should successfully build workspace context");
let ctx = ctx.unwrap();
assert!(ctx.git().unwrap().repo_root().exists(), "Repo root should exist");
assert!(ctx.workspace_root.exists(), "Workspace root should exist");
let _ = ctx.git().unwrap().repo_root();
assert_eq!(
ctx.cargo.workspace_root(),
&ctx.workspace_root,
"Cargo workspace root should match"
);
let packages = ctx.cargo.workspace_members();
assert!(!packages.is_empty(), "Should have workspace packages");
assert!(
packages.iter().any(|p| p.name == "cargo-rail"),
"Should find cargo-rail package"
);
let members = ctx.graph.workspace_members();
assert!(!members.is_empty(), "Graph should have workspace members");
assert!(
members.contains(&"cargo-rail".to_string()),
"Graph should contain cargo-rail"
);
let _ = ctx.config.as_ref();
}
#[test]
fn test_git_state_wrapper() {
let current_dir = std::env::current_dir().unwrap();
let ctx = WorkspaceContext::build(¤t_dir).unwrap();
let head = ctx.git().unwrap().git().head_commit();
assert!(head.is_ok(), "Should get HEAD commit");
let head_sha = head.unwrap();
assert_eq!(head_sha.len(), 40, "HEAD SHA should be 40 characters");
let branch = ctx.git().unwrap().git().current_branch();
assert!(branch.is_ok(), "Should get current branch");
}
#[test]
fn test_cargo_state_wrapper() {
let current_dir = std::env::current_dir().unwrap();
let ctx = WorkspaceContext::build(¤t_dir).unwrap();
let metadata = ctx.cargo.metadata();
let packages = metadata.workspace_packages();
assert!(!packages.is_empty(), "Should have packages");
let cargo_rail = ctx.cargo.get_package("cargo-rail");
assert!(cargo_rail.is_some(), "Should find cargo-rail package");
let pkg = cargo_rail.unwrap();
assert_eq!(pkg.name.as_str(), "cargo-rail");
}
#[test]
fn test_graph_integration() {
let current_dir = std::env::current_dir().unwrap();
let ctx = WorkspaceContext::build(¤t_dir).unwrap();
let graph_members = ctx.graph.workspace_members();
let cargo_packages: Vec<_> = ctx.cargo.workspace_members().iter().map(|p| p.name.as_str()).collect();
for member in graph_members {
assert!(
cargo_packages.contains(&member.as_str()),
"Graph member {} should be in cargo packages",
member
);
}
}
}