use crate::cargo::multi_target_metadata::MultiTargetMetadata;
use crate::cargo::resolution::{
CargoConfigSnapshot, ResolutionInputs, ResolutionRequest, ResolutionView, ResolutionViews,
capture_target_identities,
};
use crate::compiler::cfg_eval::TargetCfgSet;
use crate::config::RailConfig;
use crate::error::{GitError, RailError, RailResult};
use crate::git::SystemGit;
use crate::graph::WorkspaceGraph;
use crate::source::{
ChangeSet, ContentDigest, GitWorktreeCapture, PlanningSourceCapture, RepositoryPath, SourceEntryKind,
SourceExclusions, SourceSnapshot, changes_between_objects,
};
use crate::workspace::snapshot::{CapturedLockfile, CapturedRailConfig, DerivedViews, WorkspaceSnapshot};
use cargo_metadata::{Metadata, MetadataCommand, Package};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct CargoState {
metadata: Arc<Metadata>,
workspace_root: PathBuf,
package_index: std::collections::HashMap<String, usize>,
proc_macro_crates: std::collections::HashSet<String>,
}
impl CargoState {
fn load(workspace_root: &Path) -> RailResult<Self> {
crate::instrumentation::record_cargo_metadata_load(false);
let metadata = MetadataCommand::new()
.manifest_path(workspace_root.join("Cargo.toml"))
.exec()?;
Ok(Self::from_metadata(Arc::new(super::capture_metadata_paths(metadata)?)))
}
fn load_fresh(
workspace_root: &Path,
cargo_current_dir: &Path,
cargo_program: &std::ffi::OsStr,
credential_sensitive: bool,
) -> RailResult<Self> {
crate::instrumentation::record_cargo_metadata_load(false);
let mut command = MetadataCommand::new();
command
.cargo_path(PathBuf::from(cargo_program))
.current_dir(cargo_current_dir)
.manifest_path(workspace_root.join("Cargo.toml"));
if workspace_root.join("Cargo.lock").is_file() {
command.other_options(vec!["--locked".to_string()]);
}
let metadata = command.exec().map_err(|error| {
if credential_sensitive {
RailError::with_help(
"Cargo metadata failed while credential capabilities were active",
"run cargo metadata directly for provider diagnostics; cargo-rail suppresses credential-provider output",
)
} else {
error.into()
}
})?;
Ok(Self::from_metadata(Arc::new(super::capture_metadata_paths(metadata)?)))
}
fn load_planning(workspace_root: &Path, cargo_current_dir: &Path, require_existing_lock: bool) -> RailResult<Self> {
crate::instrumentation::record_cargo_metadata_load(false);
let mut command = MetadataCommand::new();
command
.current_dir(cargo_current_dir)
.manifest_path(workspace_root.join("Cargo.toml"));
if require_existing_lock || workspace_root.join("Cargo.lock").is_file() {
command.other_options(vec!["--locked".to_string()]);
}
let metadata = command.exec()?;
Ok(Self::from_metadata(Arc::new(super::capture_metadata_paths(metadata)?)))
}
fn from_metadata(metadata: Arc<Metadata>) -> Self {
let workspace_root = metadata.workspace_root.as_std_path().to_path_buf();
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(crate) fn shared_metadata(&self) -> Arc<Metadata> {
Arc::clone(&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 package_allows_registry(package: &Package, registry: &str) -> bool {
package
.publish
.as_ref()
.is_none_or(|registries| registries.iter().any(|candidate| candidate == registry))
}
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
}
}
#[derive(Debug, 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()
}
}
#[derive(Debug)]
pub struct WorkspaceContext {
pub workspace_root: PathBuf,
git: Option<Arc<GitState>>,
cargo: Arc<CargoState>,
workspace_prefix: Option<PathBuf>,
source_capture: Option<Arc<GitWorktreeCapture>>,
planning_source_capture: Option<Arc<PlanningSourceCapture>>,
graph: Arc<WorkspaceGraph>,
derived_views: Arc<DerivedViews>,
snapshot: Option<WorkspaceSnapshot>,
config: Option<Arc<RailConfig>>,
config_path: Option<PathBuf>,
authority_source_root: PathBuf,
historical_planning: Option<HistoricalPlanningAuthority>,
}
#[derive(Debug)]
struct HistoricalPlanningAuthority {
_root: tempfile::TempDir,
to: String,
capture: String,
}
impl WorkspaceContext {
pub fn build(workspace_root: &Path) -> RailResult<Self> {
Self::build_inner(workspace_root, ContextCapture::None, None)
}
#[doc(hidden)]
pub fn build_with_source_capture(workspace_root: &Path, capture_source: bool) -> RailResult<Self> {
let capture = if capture_source {
ContextCapture::Worktree
} else {
ContextCapture::None
};
Self::build_inner(workspace_root, capture, None)
}
pub(crate) fn build_with_source_capture_and_config(
workspace_root: &Path,
capture_source: bool,
config_override: Option<&Path>,
) -> RailResult<Self> {
let capture = if capture_source {
ContextCapture::Worktree
} else {
ContextCapture::None
};
Self::build_inner(workspace_root, capture, config_override)
}
pub(crate) fn build_with_planning_capture_and_config(
workspace_root: &Path,
config_override: Option<&Path>,
) -> RailResult<Self> {
Self::build_inner(workspace_root, ContextCapture::Planning, config_override)
}
pub(crate) fn build_with_planning_verification_and_config(
workspace_root: &Path,
config_override: Option<&Path>,
) -> RailResult<Self> {
Self::build_inner(workspace_root, ContextCapture::PlanningVerification, config_override)
}
pub(crate) fn build_historical_planning_with_config(
workspace_root: &Path,
from: &str,
to: &str,
config_override: Option<&Path>,
) -> RailResult<Self> {
let source_git = Arc::new(GitState::open(workspace_root)?);
let requested_root = crate::utils::canonicalize_existing(workspace_root).map_err(|error| {
RailError::message(format!(
"failed to resolve requested historical workspace root '{}': {error}",
workspace_root.display()
))
})?;
let requested_relative =
crate::utils::path_relative_to(source_git.repo_root(), &requested_root).map_err(|error| {
RailError::message(format!(
"historical workspace root '{}' is outside Git worktree '{}': {error}",
requested_root.display(),
source_git.repo_root().display()
))
})?;
let root = tempfile::TempDir::new()
.map_err(|error| RailError::message(format!("failed to create historical planning root: {error}")))?;
let materialized_source_root = root.path().join("source");
fs::create_dir(&materialized_source_root)?;
let materialized_source_root =
crate::utils::canonicalize_existing(&materialized_source_root).map_err(|error| {
RailError::message(format!(
"failed to resolve historical planning root '{}': {error}",
materialized_source_root.display()
))
})?;
source_git
.git()
.materialize_tree(to, &materialized_source_root)
.map_err(|error| error.context(format!("failed to materialize historical planning tree '{to}'")))?;
let materialized_requested_root = materialized_source_root.join(requested_relative);
let mapped_config = config_override.map(|path| {
if !path.is_absolute() {
return path.to_path_buf();
}
path.strip_prefix(source_git.repo_root()).map_or_else(
|_| path.to_path_buf(),
|relative| materialized_source_root.join(relative),
)
});
let mut context = Self::build_inner_at(
&materialized_requested_root,
ContextCapture::Planning,
mapped_config.as_deref(),
Some(&materialized_requested_root),
)
.map_err(|error| {
error.context(format!(
"historical tree '{to}' cannot form a supported Cargo workspace or declared planning policy"
))
})?;
let workspace_prefix = crate::utils::path_relative_to(&materialized_source_root, context.workspace_root())
.map_err(|error| {
RailError::message(format!(
"historical Cargo workspace '{}' escapes materialized tree '{}': {error}",
context.workspace_root().display(),
materialized_source_root.display()
))
})?;
let capture_input = format!("historical-planning-v1\0{from}\0{to}");
context.workspace_prefix = (!workspace_prefix.as_os_str().is_empty()).then_some(workspace_prefix);
context.git = Some(source_git);
context.authority_source_root = materialized_source_root;
context.historical_planning = Some(HistoricalPlanningAuthority {
_root: root,
to: to.to_string(),
capture: format!("v1-sha256-{}", ContentDigest::sha256(capture_input.as_bytes())),
});
Ok(context)
}
pub fn build_with_snapshot(workspace_root: &Path) -> RailResult<Self> {
Self::build_inner(workspace_root, ContextCapture::Snapshot, None)
}
pub(crate) fn build_with_snapshot_and_config(
workspace_root: &Path,
config_override: Option<&Path>,
) -> RailResult<Self> {
Self::build_inner(workspace_root, ContextCapture::Snapshot, config_override)
}
fn build_inner(workspace_root: &Path, capture: ContextCapture, config_override: Option<&Path>) -> RailResult<Self> {
Self::build_inner_at(workspace_root, capture, config_override, None)
}
fn build_inner_at(
workspace_root: &Path,
capture: ContextCapture,
config_override: Option<&Path>,
cargo_current_dir: Option<&Path>,
) -> RailResult<Self> {
let process_current_dir = std::env::current_dir().map_err(|error| {
RailError::message(format!("failed to determine Cargo metadata current directory: {error}"))
})?;
let requested_workspace_root = if workspace_root.is_absolute() {
workspace_root.to_path_buf()
} else {
process_current_dir.join(workspace_root)
};
let canonical_workspace_root =
crate::utils::canonicalize_existing(&requested_workspace_root).map_err(|error| {
RailError::message(format!(
"failed to resolve workspace root '{}': {error}",
requested_workspace_root.display()
))
})?;
let workspace_root = canonical_workspace_root.as_path();
let cargo_current_dir = cargo_current_dir.map_or(process_current_dir, Path::to_path_buf);
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 initial_generated_roots = [super::cargo_rail_state_root(workspace_root)];
let mut source_capture = if matches!(capture, ContextCapture::Worktree | ContextCapture::Snapshot) {
git.as_ref()
.map(|state| GitWorktreeCapture::capture_excluding(state.git(), &initial_generated_roots))
.transpose()?
} else {
None
};
let mut planning_source_capture = if capture.is_planning() {
git.as_ref()
.map(|state| PlanningSourceCapture::capture_excluding(state.git(), &initial_generated_roots))
.transpose()?
} else {
None
};
let planning_cargo_config = if capture == ContextCapture::Planning {
Some(Arc::new(CargoConfigSnapshot::capture(&cargo_current_dir)?))
} else {
None
};
let resolution_inputs = if capture == ContextCapture::Snapshot {
Some(ResolutionViews::capture_inputs(&cargo_current_dir)?)
} else {
None
};
let cargo = Arc::new(if let Some(inputs) = &resolution_inputs {
CargoState::load_fresh(
workspace_root,
&cargo_current_dir,
inputs.toolchain.cargo_program(),
inputs.cargo_config.has_credential_capability(),
)?
} else if capture.is_planning() {
CargoState::load_planning(
workspace_root,
&cargo_current_dir,
capture == ContextCapture::PlanningVerification,
)?
} else {
CargoState::load(workspace_root)?
});
let workspace_root = cargo.workspace_root().to_path_buf();
let workspace_prefix = git
.as_ref()
.map(|git| {
crate::utils::path_relative_to(git.repo_root(), &workspace_root).map_err(|error| {
RailError::with_help(
format!(
"Cargo workspace root '{}' is outside Git worktree '{}': {}",
workspace_root.display(),
git.repo_root().display(),
error
),
"select a Cargo workspace contained by this repository, or run outside the nested Git worktree",
)
})
})
.transpose()?
.filter(|prefix| !prefix.as_os_str().is_empty());
let generated_lockfile = if let (Some(capture), Some(git)) = (source_capture.as_mut(), git.as_ref()) {
let generated_roots = validated_generated_source_roots(git.repo_root(), &workspace_root, &cargo)?;
capture.exclude_generated_roots(git.git(), &generated_roots)?;
stabilize_cargo_generated_lockfile(capture, git, &workspace_root, &generated_roots)?
} else {
None
};
let planning_generated_lockfile =
if let (Some(capture), Some(git)) = (planning_source_capture.as_mut(), git.as_ref()) {
let generated_roots = validated_generated_source_roots(git.repo_root(), &workspace_root, &cargo)?;
capture.exclude_generated_roots(git.git(), &generated_roots)?;
stabilize_planning_generated_lockfile(capture, git, &workspace_root)?
} else {
None
};
let graph = Arc::new(WorkspaceGraph::from_metadata(cargo.metadata())?);
let resolution_views = Arc::new(if let Some(inputs) = resolution_inputs.clone() {
ResolutionViews::new_with_inputs(
workspace_root.clone(),
cargo_current_dir,
cargo.shared_metadata(),
Arc::clone(&graph),
inputs,
)
} else if let Some(cargo_config) = planning_cargo_config.clone() {
ResolutionViews::new_with_cargo_config(
workspace_root.clone(),
cargo_current_dir,
cargo.shared_metadata(),
Arc::clone(&graph),
cargo_config,
)
} else {
ResolutionViews::new(
workspace_root.clone(),
cargo_current_dir,
cargo.shared_metadata(),
Arc::clone(&graph),
)
});
let config_path = config_override.map_or_else(
|| RailConfig::find_config_path(&workspace_root),
|path| {
Some(if path.is_absolute() {
path.to_path_buf()
} else {
requested_workspace_root.join(path)
})
},
);
let (config, rail_config, rail_config_discovery_root, captured_config_path) = match config_path {
Some(path) => {
let (decoded, bytes) = crate::config::load_decoded(&path)?;
let parsed = decoded.config;
let parsed = Arc::new(parsed);
(
Some(Arc::clone(&parsed)),
Some(CapturedRailConfig::new(path.clone(), bytes, parsed)),
None,
Some(path),
)
}
None => (None, None, Some(workspace_root.clone()), None),
};
if let Some(ref cfg) = config {
let workspace_members = cargo
.workspace_members()
.into_iter()
.map(|package| package.name.to_string())
.collect::<Vec<_>>();
cfg.validate(&workspace_root, Some(&workspace_members))?;
}
let mut targets = config.as_ref().map(|c| c.targets.clone()).unwrap_or_default();
targets.sort();
targets.dedup();
let derived_views = Arc::new(DerivedViews::new(
workspace_root.clone(),
targets.clone(),
Arc::clone(&resolution_views),
));
let snapshot = if let Some(inputs) = resolution_inputs {
let generated_lock_marker = generated_lockfile
.as_ref()
.map(|lockfile| (lockfile.path().to_path_buf(), ContentDigest::sha256(lockfile.bytes())));
let captured_lockfile = generated_lockfile;
let source = match (&source_capture, &git) {
(Some(capture), Some(_)) => capture.shared_snapshot(),
(None, None) => Arc::new(SourceSnapshot::capture_filesystem(
&workspace_root,
&generated_source_roots(&workspace_root, &cargo),
)?),
_ => {
return Err(RailError::message(
"workspace snapshot source backend is inconsistent with repository state",
));
}
};
let source_root = git.as_ref().map_or(workspace_root.as_path(), |git| git.repo_root());
let target_identities = capture_target_identities(derived_views.cargo_current_dir(), &targets, &inputs)?;
let snapshot = WorkspaceSnapshot::capture(super::snapshot::WorkspaceSnapshotCapture {
source_root,
source,
cargo: Arc::clone(&cargo),
rail_config,
rail_config_discovery_root,
generated_lockfile: captured_lockfile,
resolution_inputs: inputs.clone(),
targets: target_identities,
derived: Arc::clone(&derived_views),
})?;
if let (Some(capture), Some(git)) = (&source_capture, &git) {
capture.validate_unchanged(git.git())?;
}
snapshot.validate_authoritative_files_unchanged()?;
let current_inputs = ResolutionViews::capture_inputs(resolution_views.cargo_current_dir())?;
validate_resolution_inputs_unchanged(&inputs, ¤t_inputs)?;
snapshot.validate_external_targets_unchanged()?;
if let Some((path, digest)) = generated_lock_marker {
write_generated_lock_marker(&workspace_root, &path, digest)?;
}
Some(snapshot)
} else {
None
};
if let (Some(capture), Some(git)) = (&planning_source_capture, &git) {
capture.validate_unchanged(git.git())?;
}
if let Some(initial) = planning_cargo_config {
let current = CargoConfigSnapshot::capture(resolution_views.cargo_current_dir())?;
if current != *initial {
return Err(RailError::with_help(
"Cargo configuration changed while constructing the planning context",
"retry after Cargo configuration and environment changes have stopped",
));
}
}
if capture == ContextCapture::Planning
&& let Some(lockfile) = planning_generated_lockfile
{
write_generated_lock_marker(
&workspace_root,
lockfile.path(),
ContentDigest::sha256(lockfile.bytes()),
)?;
}
let authority_source_root = git
.as_ref()
.map_or_else(|| workspace_root.clone(), |git| git.repo_root().to_path_buf());
Ok(Self {
workspace_root,
git,
cargo,
workspace_prefix,
source_capture: source_capture.map(Arc::new),
planning_source_capture: planning_source_capture.map(Arc::new),
graph,
derived_views,
snapshot,
config,
config_path: captured_config_path,
authority_source_root,
historical_planning: None,
})
}
pub fn require_config(&self) -> RailResult<&Arc<RailConfig>> {
self.config().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 config(&self) -> Option<&Arc<RailConfig>> {
self.snapshot
.as_ref()
.and_then(WorkspaceSnapshot::shared_config)
.or(self.config.as_ref())
}
pub(crate) fn config_path(&self) -> Option<&Path> {
self.config_path.as_deref()
}
pub fn cargo(&self) -> &CargoState {
self.snapshot
.as_ref()
.map(WorkspaceSnapshot::cargo)
.unwrap_or(&self.cargo)
}
pub fn graph(&self) -> &WorkspaceGraph {
self.snapshot
.as_ref()
.map(|snapshot| snapshot.base_resolution().graph())
.unwrap_or(&self.graph)
}
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 source_capture(&self) -> Option<&GitWorktreeCapture> {
self.source_capture.as_deref()
}
pub(crate) fn planning_source_capture(&self) -> Option<&PlanningSourceCapture> {
self.planning_source_capture.as_deref()
}
pub(crate) fn planning_snapshot_id(&self) -> Option<String> {
self.historical_planning
.as_ref()
.map(|authority| authority.capture.clone())
.or_else(|| {
self.planning_source_capture
.as_ref()
.map(|capture| format!("v1-sha256-{}", capture.identity()))
})
}
pub(crate) fn planning_head_commit(&self) -> Option<&str> {
self.historical_planning
.as_ref()
.map(|authority| authority.to.as_str())
.or_else(|| self.planning_source_capture().map(PlanningSourceCapture::head_commit))
}
pub(crate) fn planning_cargo_configuration_identity(&self) -> RailResult<String> {
let identity = self
.derived_views
.cargo_config()?
.portable_snapshot_identity(&self.authority_source_root)?;
Ok(format!(
"cargo-configuration-v1:sha256:{}",
ContentDigest::sha256(&identity)
))
}
#[doc(hidden)]
pub fn captured_authority_id(&self) -> Option<String> {
self.snapshot_id()
.map(|snapshot| snapshot.to_string())
.or_else(|| self.planning_snapshot_id())
}
pub(crate) fn read_planning_current_files(&self, paths: &[String]) -> RailResult<Vec<Vec<u8>>> {
if self.historical_planning.is_some() {
return paths
.iter()
.map(|path| {
let path = RepositoryPath::new(Path::new(path))?;
let absolute = self.workspace_root.join(path.as_path());
let metadata = fs::symlink_metadata(&absolute).map_err(|error| {
RailError::message(format!("failed to read historical planning input '{}': {error}", path))
})?;
if !metadata.file_type().is_file() {
return Err(RailError::with_help(
format!("historical planning input '{}' is not a regular file", path),
"replace the declared input with a regular repository file",
));
}
fs::read(absolute).map_err(Into::into)
})
.collect();
}
let capture = self
.planning_source_capture()
.ok_or_else(|| RailError::message("workspace context was built without sparse planning source capture"))?;
let repository_paths = paths
.iter()
.map(|path| {
let path = self
.workspace_prefix
.as_ref()
.map_or_else(|| PathBuf::from(path), |prefix| prefix.join(path));
RepositoryPath::new(&path)
})
.collect::<RailResult<Vec<_>>>()?;
capture.read_current_files(self.git()?.git(), &repository_paths)
}
pub(crate) fn capture_worktree_source(&self) -> RailResult<GitWorktreeCapture> {
let git = self.git()?;
let generated_roots = validated_generated_source_roots(git.repo_root(), &self.workspace_root, &self.cargo)?;
GitWorktreeCapture::capture_excluding(git.git(), &generated_roots)
}
pub fn capture_filesystem_source(&self) -> RailResult<SourceSnapshot> {
if self.has_git() {
return Err(RailError::with_help(
"filesystem-backed source capture is only available without Git",
"use the Git-backed worktree capture for repositories",
));
}
SourceSnapshot::capture_filesystem(
&self.workspace_root,
&generated_source_roots(&self.workspace_root, &self.cargo),
)
}
pub(crate) fn changed_source_paths(&self) -> RailResult<Vec<PathBuf>> {
let git = self.git()?.git();
let owned_capture;
let capture = if let Some(capture) = self.source_capture() {
capture
} else {
owned_capture = self.capture_worktree_source()?;
&owned_capture
};
Ok(capture
.changes_from(git, "HEAD")?
.entries()
.iter()
.map(|change| change.path.as_path().to_path_buf())
.collect())
}
pub(crate) fn non_generated_source_paths(&self, paths: &[PathBuf]) -> RailResult<Vec<PathBuf>> {
let git = self.git()?;
let generated_roots = validated_generated_source_roots(git.repo_root(), &self.workspace_root, &self.cargo)?;
let exclusions = SourceExclusions::from_absolute_roots(git.git(), &generated_roots)?;
let mut source_paths = Vec::with_capacity(paths.len());
for path in paths {
if !exclusions.contains(&RepositoryPath::new(path)?) {
source_paths.push(path.clone());
}
}
Ok(source_paths)
}
pub(crate) fn source_paths_since(&self, base: Option<&str>) -> RailResult<Vec<PathBuf>> {
let owned_capture;
let capture = if let Some(capture) = self.source_capture() {
capture
} else {
owned_capture = self.capture_worktree_source()?;
&owned_capture
};
match base {
Some(base) => Ok(capture
.changes_from(self.git()?.git(), base)?
.entries()
.iter()
.map(|change| change.path.as_path().to_path_buf())
.collect()),
None => Ok(capture
.snapshot()
.tree()
.entries()
.iter()
.map(|entry| entry.path.as_path().to_path_buf())
.collect()),
}
}
pub(crate) fn object_source_changes(&self, base: &str, head: &str) -> RailResult<ChangeSet> {
let git = self.git()?;
let generated_roots = if self.historical_planning.is_some() {
generated_source_roots(&self.workspace_root, &self.cargo)
.into_iter()
.filter_map(|root| {
root.strip_prefix(&self.authority_source_root)
.ok()
.map(|relative| git.repo_root().join(relative))
})
.collect()
} else {
validated_generated_source_roots(git.repo_root(), &self.workspace_root, &self.cargo)?
};
let exclusions = SourceExclusions::from_absolute_roots(git.git(), &generated_roots)?;
changes_between_objects(git.git(), base, head, &exclusions)
}
pub(crate) fn planning_authority_source_root(&self) -> &Path {
&self.authority_source_root
}
pub(crate) fn repository_path_from_workspace(&self, path: &Path) -> RailResult<PathBuf> {
let relative = if path.is_absolute() {
path.strip_prefix(&self.workspace_root).map_err(|_| {
RailError::message(format!(
"workspace path '{}' is outside captured workspace '{}'",
path.display(),
self.workspace_root.display()
))
})?
} else {
path
};
Ok(self
.workspace_prefix
.as_ref()
.map_or_else(|| relative.to_path_buf(), |prefix| prefix.join(relative)))
}
pub fn multi_target_metadata(&self) -> RailResult<Arc<MultiTargetMetadata>> {
if let Some(snapshot) = &self.snapshot {
snapshot.multi_target_metadata()
} else {
self.derived_views.multi_target_metadata()
}
}
pub fn resolution_view(&self, request: ResolutionRequest) -> RailResult<Arc<ResolutionView>> {
if let Some(snapshot) = &self.snapshot {
snapshot.resolution_view(request)
} else {
self.derived_views.resolution_view(request)
}
}
pub fn snapshot(&self) -> RailResult<&WorkspaceSnapshot> {
self.snapshot.as_ref().ok_or_else(|| {
RailError::with_help(
"workspace context was built without authoritative snapshot capture",
"construct it with WorkspaceContext::build_with_snapshot",
)
})
}
pub(crate) fn recapture_after_mutation(&self) -> RailResult<Self> {
let config_path = self.snapshot()?.rail_config_path().map(Path::to_path_buf);
Self::build_with_snapshot_and_config(&self.workspace_root, config_path.as_deref())
}
#[doc(hidden)]
pub fn snapshot_id(&self) -> Option<crate::workspace::SnapshotId> {
self.snapshot.as_ref().map(WorkspaceSnapshot::id)
}
pub(crate) fn validate_snapshot_unchanged(&self) -> RailResult<()> {
self.validate_snapshot_unchanged_excluding(&[])
}
pub(crate) fn validate_snapshot_unchanged_excluding(&self, authorized_outputs: &[PathBuf]) -> RailResult<()> {
let snapshot = self.snapshot()?;
if let (Some(capture), Some(git)) = (&self.source_capture, &self.git) {
if authorized_outputs.is_empty() {
capture.validate_unchanged(git.git())?;
} else {
let mut expected = capture.as_ref().clone();
expected.exclude_generated_roots(git.git(), authorized_outputs)?;
expected.validate_unchanged(git.git())?;
}
}
snapshot.validate_live_authoritative_inputs()
}
pub(crate) fn validate_planning_source_unchanged(&self) -> RailResult<()> {
if let (Some(capture), Some(git)) = (&self.planning_source_capture, &self.git) {
capture.validate_unchanged(git.git())?;
}
Ok(())
}
pub fn target_cfg_sets(&self) -> RailResult<Arc<std::collections::HashMap<String, TargetCfgSet>>> {
if let Some(snapshot) = &self.snapshot {
snapshot.target_cfg_sets()
} else {
self.derived_views.target_cfg_sets()
}
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn workspace_prefix(&self) -> Option<PathBuf> {
self.workspace_prefix.clone()
}
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())
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ContextCapture {
None,
Worktree,
Planning,
PlanningVerification,
Snapshot,
}
impl ContextCapture {
fn is_planning(self) -> bool {
matches!(self, Self::Planning | Self::PlanningVerification)
}
}
fn validate_resolution_inputs_unchanged(initial: &ResolutionInputs, current: &ResolutionInputs) -> RailResult<()> {
if current.cargo_config != initial.cargo_config {
return Err(RailError::with_help(
"Cargo configuration changed while constructing the workspace snapshot",
"retry after Cargo configuration and environment changes have stopped",
));
}
if current.toolchain != initial.toolchain {
return Err(RailError::with_help(
"Cargo, rustc, rustdoc, or compiler-wrapper identity changed while constructing the workspace snapshot",
"retry after toolchain updates and overrides have stopped",
));
}
Ok(())
}
fn validated_generated_source_roots(
git_root: &Path,
workspace_root: &Path,
cargo: &CargoState,
) -> RailResult<Vec<PathBuf>> {
validate_cargo_output_root(
"target",
git_root,
workspace_root,
cargo.metadata().target_directory.as_std_path(),
)?;
if let Some(build_directory) = &cargo.metadata().build_directory {
validate_cargo_output_root("build", git_root, workspace_root, build_directory.as_std_path())?;
}
Ok(generated_source_roots(workspace_root, cargo))
}
fn generated_source_roots(workspace_root: &Path, cargo: &CargoState) -> Vec<PathBuf> {
let mut roots = vec![
super::cargo_rail_state_root(workspace_root),
cargo.metadata().target_directory.as_std_path().to_path_buf(),
];
if let Some(build_directory) = &cargo.metadata().build_directory {
roots.push(build_directory.as_std_path().to_path_buf());
}
roots
}
fn stabilize_cargo_generated_lockfile(
initial: &mut GitWorktreeCapture,
git: &GitState,
workspace_root: &Path,
generated_roots: &[PathBuf],
) -> RailResult<Option<CapturedLockfile>> {
let lockfile = workspace_root.join("Cargo.lock");
let lockfile_metadata = match fs::symlink_metadata(&lockfile) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !lockfile_metadata.file_type().is_file() {
return Ok(None);
}
let relative = crate::utils::path_relative_to(git.repo_root(), &lockfile)?;
let relative = RepositoryPath::new(&relative)?;
let initially_captured = initial
.snapshot()
.tree()
.entries()
.binary_search_by(|entry| entry.path.cmp(&relative))
.is_ok();
if initially_captured {
if initial.is_untracked(&relative) {
let bytes = fs::read(&lockfile)?;
let digest = ContentDigest::sha256(&bytes);
if generated_lock_marker_matches(workspace_root, digest)? {
initial.exclude_generated_roots(git.git(), std::slice::from_ref(&lockfile))?;
initial.validate_unchanged(git.git())?;
return Ok(Some(CapturedLockfile::new(lockfile, bytes)));
}
}
return Ok(None);
}
let repeated = GitWorktreeCapture::capture_excluding(git.git(), generated_roots)?;
let repeated_digest = repeated
.snapshot()
.tree()
.entries()
.binary_search_by(|entry| entry.path.cmp(&relative))
.ok()
.and_then(|index| match repeated.snapshot().tree().entries()[index].kind {
SourceEntryKind::RegularFile { digest, .. } => Some(digest),
_ => None,
});
let Some(repeated_digest) = repeated_digest else {
return Ok(None);
};
let bytes = fs::read(&lockfile)?;
if repeated_digest != ContentDigest::sha256(&bytes) {
return Err(RailError::with_help(
"Cargo-generated lockfile changed after source capture",
"retry after workspace input changes have stopped",
));
}
initial.exclude_generated_roots(git.git(), std::slice::from_ref(&lockfile))?;
Ok(Some(CapturedLockfile::new(lockfile, bytes)))
}
fn stabilize_planning_generated_lockfile(
initial: &mut PlanningSourceCapture,
git: &GitState,
workspace_root: &Path,
) -> RailResult<Option<CapturedLockfile>> {
let lockfile = workspace_root.join("Cargo.lock");
let metadata = match fs::symlink_metadata(&lockfile) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !metadata.file_type().is_file() {
return Ok(None);
}
let relative = crate::utils::path_relative_to(git.repo_root(), &lockfile)?;
let relative = RepositoryPath::new(&relative)?;
if initial.is_indexed(&relative) {
return Ok(None);
}
let bytes = fs::read(&lockfile)?;
let digest = ContentDigest::sha256(&bytes);
if initial.is_untracked(&relative) && !generated_lock_marker_matches(workspace_root, digest)? {
return Ok(None);
}
initial.exclude_generated_roots(git.git(), std::slice::from_ref(&lockfile))?;
Ok(Some(CapturedLockfile::new(lockfile, bytes)))
}
fn generated_lock_marker_path(workspace_root: &Path) -> PathBuf {
super::cargo_rail_state_root(workspace_root).join("generated-lockfile-v1")
}
fn generated_lock_marker_value(workspace_root: &Path, digest: ContentDigest) -> RailResult<Vec<u8>> {
let workspace = workspace_root
.to_str()
.ok_or_else(|| RailError::message("workspace root is not valid UTF-8 for generated lockfile provenance"))?;
let mut marker = Vec::from(&b"cargo-rail-generated-lockfile-v1\0"[..]);
marker.extend_from_slice(&(workspace.len() as u64).to_le_bytes());
marker.extend_from_slice(workspace.as_bytes());
marker.extend_from_slice(digest.as_bytes());
Ok(marker)
}
fn generated_lock_marker_matches(workspace_root: &Path, digest: ContentDigest) -> RailResult<bool> {
let marker = generated_lock_marker_path(workspace_root);
let metadata = match fs::symlink_metadata(&marker) {
Ok(metadata) if metadata.file_type().is_file() => metadata,
Ok(_) => return Ok(false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
let expected = generated_lock_marker_value(workspace_root, digest)?;
let expected_len = u64::try_from(expected.len())
.map_err(|_| RailError::message("generated lockfile marker length exceeds the supported range"))?;
if metadata.len() != expected_len {
return Ok(false);
}
let mut actual = Vec::with_capacity(expected.len());
fs::File::open(marker)?
.take(expected_len.saturating_add(1))
.read_to_end(&mut actual)?;
Ok(actual == expected)
}
fn write_generated_lock_marker(workspace_root: &Path, lockfile: &Path, digest: ContentDigest) -> RailResult<()> {
if lockfile != workspace_root.join("Cargo.lock") {
return Err(RailError::message(format!(
"refusing to mark unexpected generated lockfile '{}'",
lockfile.display()
)));
}
let marker = generated_lock_marker_path(workspace_root);
if fs::symlink_metadata(&marker).is_ok_and(|metadata| !metadata.file_type().is_file()) {
return Err(RailError::message(format!(
"generated lockfile marker '{}' is not a regular file",
marker.display()
)));
}
let parent = marker
.parent()
.ok_or_else(|| RailError::message("generated lockfile marker has no parent directory"))?;
fs::create_dir_all(parent)?;
fs::write(&marker, generated_lock_marker_value(workspace_root, digest)?).map_err(|error| {
RailError::message(format!(
"failed to write generated lockfile marker '{}': {error}",
marker.display()
))
})
}
fn validate_cargo_output_root(
kind: &str,
git_root: &Path,
workspace_root: &Path,
output_root: &Path,
) -> RailResult<()> {
let git_root = crate::utils::canonicalize_existing(git_root)?;
let workspace_root = crate::utils::canonicalize_existing(workspace_root)?;
let output_root = crate::utils::canonicalize_allow_missing(output_root)?;
if output_root.starts_with(&git_root) && workspace_root.starts_with(&output_root) {
return Err(RailError::with_help(
format!(
"Cargo {} directory '{}' contains Cargo workspace root '{}'",
kind,
output_root.display(),
workspace_root.display()
),
"configure Cargo build output in a dedicated directory below or outside the repository",
));
}
Ok(())
}
#[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();
}
#[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
);
}
}
}