use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use cargo_metadata::{Package, PackageId};
use serde_json::{Map, Value};
use crate::error::RailResult;
use crate::workspace::WorkspaceContext;
#[derive(Debug, Clone)]
pub(crate) struct SemanticFileChange {
pub(crate) scope: SemanticScope,
pub(crate) input: &'static str,
pub(crate) fallback: Option<&'static str>,
}
#[derive(Debug, Clone)]
pub(crate) enum SemanticScope {
None,
Packages(BTreeSet<PackageId>),
Workspace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SemanticCargoInput {
PackageManifest,
WorkspaceManifest,
Lockfile,
}
#[derive(Debug)]
struct MemberManifestInput {
package: PackageId,
before: Vec<u8>,
after: Vec<u8>,
}
pub(crate) fn analyze<'a>(
ctx: &WorkspaceContext,
changed_files: impl IntoIterator<Item = &'a str>,
base_ref: &str,
head_ref: Option<&str>,
) -> RailResult<BTreeMap<String, SemanticFileChange>> {
let semantic_paths = changed_files
.into_iter()
.filter_map(|path| semantic_cargo_input(path).map(|input| (path, input)))
.collect::<Vec<_>>();
if semantic_paths.is_empty() {
return Ok(BTreeMap::new());
}
let absolute_paths = semantic_paths
.iter()
.map(|(path, _)| ctx.workspace_root().join(path))
.collect::<Vec<_>>();
let repository_paths = absolute_paths
.iter()
.map(|path| ctx.repository_path_from_workspace(path))
.collect::<RailResult<Vec<_>>>()?;
let base_items = repository_paths
.iter()
.map(|path| (base_ref, path.as_path()))
.collect::<Vec<_>>();
let base = ctx.git()?.git().read_files_bulk(&base_items)?;
let head = if let Some(head_ref) = head_ref {
let head_items = repository_paths
.iter()
.map(|path| (head_ref, path.as_path()))
.collect::<Vec<_>>();
ctx.git()?.git().read_files_bulk(&head_items)?
} else if ctx.planning_source_capture().is_some() {
let paths = semantic_paths
.iter()
.map(|(path, _)| (*path).to_string())
.collect::<Vec<_>>();
ctx.read_planning_current_files(&paths)?
} else {
absolute_paths
.iter()
.map(|path| match fs::read(path) {
Ok(bytes) => Ok(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error),
})
.collect::<Result<Vec<_>, _>>()?
};
let member_manifests = semantic_paths
.iter()
.any(|(_, input)| *input == SemanticCargoInput::WorkspaceManifest)
.then(|| member_manifest_inputs(ctx, base_ref, head_ref))
.transpose()?;
Ok(semantic_paths
.into_iter()
.zip(base)
.zip(head)
.map(|(((path, input), before), after)| {
let change = match input {
SemanticCargoInput::WorkspaceManifest => {
workspace_manifest_change(ctx, &before, &after, member_manifests.as_deref().unwrap_or_default())
}
SemanticCargoInput::PackageManifest => package_manifest_change(ctx, path, &before, &after),
SemanticCargoInput::Lockfile => lockfile_change(ctx, &before, &after),
};
(path.to_string(), change)
})
.collect())
}
fn semantic_cargo_input(path: &str) -> Option<SemanticCargoInput> {
match path {
"Cargo.lock" => Some(SemanticCargoInput::Lockfile),
"Cargo.toml" => Some(SemanticCargoInput::WorkspaceManifest),
path if path.ends_with("Cargo.toml") => Some(SemanticCargoInput::PackageManifest),
_ => None,
}
}
fn member_manifest_inputs(
ctx: &WorkspaceContext,
base_ref: &str,
head_ref: Option<&str>,
) -> RailResult<Vec<MemberManifestInput>> {
let packages = ctx.cargo().metadata().workspace_packages();
let absolute_paths = packages
.iter()
.map(|package| package.manifest_path.as_std_path())
.collect::<Vec<_>>();
let repository_paths = absolute_paths
.iter()
.map(|path| ctx.repository_path_from_workspace(path))
.collect::<RailResult<Vec<_>>>()?;
let base_items = repository_paths
.iter()
.map(|path| (base_ref, path.as_path()))
.collect::<Vec<_>>();
let before = ctx.git()?.git().read_files_bulk(&base_items)?;
let after = if let Some(head_ref) = head_ref {
let head_items = repository_paths
.iter()
.map(|path| (head_ref, path.as_path()))
.collect::<Vec<_>>();
ctx.git()?.git().read_files_bulk(&head_items)?
} else if ctx.planning_source_capture().is_some() {
let paths = absolute_paths
.iter()
.map(|path| {
path.strip_prefix(ctx.workspace_root())
.map(crate::utils::path_to_git_format)
.map_err(|_| {
crate::error::RailError::message(format!(
"workspace member manifest '{}' is outside the captured Cargo workspace",
path.display()
))
})
})
.collect::<RailResult<Vec<_>>>()?;
ctx.read_planning_current_files(&paths)?
} else {
absolute_paths
.iter()
.map(|path| match fs::read(path) {
Ok(bytes) => Ok(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error),
})
.collect::<Result<Vec<_>, _>>()?
};
Ok(packages
.into_iter()
.zip(before)
.zip(after)
.map(|((package, before), after)| MemberManifestInput {
package: package.id.clone(),
before,
after,
})
.collect())
}
fn package_manifest_change(ctx: &WorkspaceContext, path: &str, before: &[u8], after: &[u8]) -> SemanticFileChange {
if before.is_empty()
&& parse_toml(after).is_some()
&& let Some(package) = ctx.graph().file_to_package(std::path::Path::new(path))
{
return SemanticFileChange {
scope: SemanticScope::Packages(BTreeSet::from([package.id.clone()])),
input: "manifest",
fallback: None,
};
}
let (Some(mut before), Some(mut after)) = (parse_toml(before), parse_toml(after)) else {
return fallback("manifest", "manifest_parse_unknown");
};
strip_package_metadata(&mut before);
strip_package_metadata(&mut after);
if before == after {
return unchanged("manifest");
}
let Some(package) = ctx.graph().file_to_package(std::path::Path::new(path)) else {
return fallback("manifest", "manifest_owner_unknown");
};
SemanticFileChange {
scope: SemanticScope::Packages(BTreeSet::from([package.id.clone()])),
input: "manifest",
fallback: None,
}
}
fn workspace_manifest_change(
ctx: &WorkspaceContext,
before: &[u8],
after: &[u8],
member_manifests: &[MemberManifestInput],
) -> SemanticFileChange {
let (Some(before), Some(after)) = (parse_toml(before), parse_toml(after)) else {
return fallback("workspace_manifest", "manifest_parse_unknown");
};
if before == after {
return unchanged("workspace_manifest");
}
let (Some(before_root), Some(after_root)) = (before.as_object(), after.as_object()) else {
return fallback("workspace_manifest", "manifest_shape_unknown");
};
let mut before_other = before_root.clone();
let mut after_other = after_root.clone();
let before_workspace = before_other.remove("workspace");
let after_workspace = after_other.remove("workspace");
let package_manifest_keys = [
"package",
"dependencies",
"dev-dependencies",
"build-dependencies",
"target",
"features",
"lib",
"bin",
"example",
"test",
"bench",
"lints",
"badges",
];
if let Some(package) = before_other.get_mut("package").and_then(Value::as_object_mut) {
package.remove("metadata");
}
if let Some(package) = after_other.get_mut("package").and_then(Value::as_object_mut) {
package.remove("metadata");
}
let root_package_changed = package_manifest_keys
.iter()
.any(|key| before_other.get(*key) != after_other.get(*key));
for key in package_manifest_keys {
before_other.remove(key);
after_other.remove(key);
}
if before_other != after_other {
return fallback("workspace_manifest", "resolver_or_source_change");
}
let (mut before_workspace, mut after_workspace) = match (before_workspace, after_workspace) {
(None, None) => (Map::new(), Map::new()),
(Some(before), Some(after)) => {
let (Some(before), Some(after)) = (before.as_object().cloned(), after.as_object().cloned()) else {
return fallback("workspace_manifest", "workspace_shape_unknown");
};
(before, after)
}
(None, Some(_)) | (Some(_), None) => return fallback("workspace_manifest", "workspace_shape_unknown"),
};
let dependency_keys = changed_table_keys(
before_workspace.remove("dependencies").as_ref(),
after_workspace.remove("dependencies").as_ref(),
);
let package_keys = changed_table_keys(
before_workspace.remove("package").as_ref(),
after_workspace.remove("package").as_ref(),
);
let lints_changed = before_workspace.remove("lints") != after_workspace.remove("lints");
before_workspace.remove("metadata");
after_workspace.remove("metadata");
if before_workspace != after_workspace {
return fallback("workspace_manifest", "resolver_or_membership_change");
}
let mut packages = BTreeSet::new();
if root_package_changed {
let Some(package) = ctx.graph().file_to_package(std::path::Path::new("Cargo.toml")) else {
return fallback("workspace_manifest", "manifest_owner_unknown");
};
packages.insert(package.id.clone());
}
for member in member_manifests {
let (Some(before_document), Some(after_document)) = (parse_toml(&member.before), parse_toml(&member.after))
else {
return fallback("workspace_manifest", "member_manifest_parse_unknown");
};
if [before_document, after_document].iter().any(|document| {
consumes_workspace_dependency(document, &dependency_keys)
|| inherits_workspace_package_key(document, &package_keys)
|| (lints_changed && inherits_workspace_lints(document))
}) {
packages.insert(member.package.clone());
}
}
SemanticFileChange {
scope: if packages.is_empty() {
SemanticScope::None
} else {
SemanticScope::Packages(packages)
},
input: "workspace_manifest",
fallback: None,
}
}
fn lockfile_change(ctx: &WorkspaceContext, before: &[u8], after: &[u8]) -> SemanticFileChange {
let (Some(before), Some(after)) = (parse_toml(before), parse_toml(after)) else {
return fallback("lockfile", "lockfile_parse_unknown");
};
if before.get("version") != after.get("version") {
return fallback("lockfile", "lockfile_format_changed");
}
let (Some(before_packages), Some(after_packages)) = (lock_packages(&before), lock_packages(&after)) else {
return fallback("lockfile", "lockfile_package_shape_unknown");
};
if before_packages == after_packages {
return unchanged("lockfile");
}
let changed = before_packages
.keys()
.chain(after_packages.keys())
.filter(|key| before_packages.get(*key) != after_packages.get(*key))
.cloned()
.collect::<BTreeSet<_>>();
let current_names = after_packages
.keys()
.map(|key| key.name.as_str())
.collect::<BTreeSet<_>>();
if changed
.iter()
.any(|key| !after_packages.contains_key(key) && !current_names.contains(key.name.as_str()))
{
return fallback("lockfile", "removed_resolution_unknown");
}
let mut package_ids = BTreeSet::new();
for key in changed.iter().filter(|key| after_packages.contains_key(*key)) {
let matches = ctx
.cargo()
.metadata()
.packages
.iter()
.filter(|package| lock_key_matches_package(key, package))
.map(|package| package.id.clone())
.collect::<Vec<_>>();
if matches.is_empty() {
return fallback("lockfile", "resolved_package_unknown");
}
package_ids.extend(matches);
}
SemanticFileChange {
scope: SemanticScope::Packages(package_ids),
input: "lockfile",
fallback: None,
}
}
fn parse_toml(bytes: &[u8]) -> Option<Value> {
std::str::from_utf8(bytes)
.ok()
.and_then(|text| toml_edit::de::from_str(text).ok())
}
fn strip_package_metadata(document: &mut Value) {
if let Some(package) = document.get_mut("package").and_then(Value::as_object_mut) {
package.remove("metadata");
}
}
fn changed_table_keys(before: Option<&Value>, after: Option<&Value>) -> BTreeSet<String> {
let empty = Map::new();
let before = before.and_then(Value::as_object).unwrap_or(&empty);
let after = after.and_then(Value::as_object).unwrap_or(&empty);
before
.keys()
.chain(after.keys())
.filter(|key| before.get(*key) != after.get(*key))
.cloned()
.collect()
}
fn consumes_workspace_dependency(document: &Value, changed: &BTreeSet<String>) -> bool {
if changed.is_empty() {
return false;
}
dependency_tables(document).any(|table| {
changed.iter().any(|alias| {
table
.get(alias)
.and_then(Value::as_object)
.and_then(|dependency| dependency.get("workspace"))
.and_then(Value::as_bool)
== Some(true)
})
})
}
fn dependency_tables(document: &Value) -> impl Iterator<Item = &Map<String, Value>> {
let root = ["dependencies", "dev-dependencies", "build-dependencies"]
.into_iter()
.filter_map(|key| document.get(key).and_then(Value::as_object));
let targets = document
.get("target")
.and_then(Value::as_object)
.into_iter()
.flat_map(|targets| targets.values())
.filter_map(Value::as_object)
.flat_map(|target| {
["dependencies", "dev-dependencies", "build-dependencies"]
.into_iter()
.filter_map(|key| target.get(key).and_then(Value::as_object))
});
root.chain(targets)
}
fn inherits_workspace_package_key(document: &Value, changed: &BTreeSet<String>) -> bool {
document
.get("package")
.and_then(Value::as_object)
.is_some_and(|package| {
changed.iter().any(|key| {
package
.get(key)
.and_then(Value::as_object)
.and_then(|value| value.get("workspace"))
.and_then(Value::as_bool)
== Some(true)
})
})
}
fn inherits_workspace_lints(document: &Value) -> bool {
document
.get("lints")
.and_then(Value::as_object)
.and_then(|lints| lints.get("workspace"))
.and_then(Value::as_bool)
== Some(true)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct LockKey {
name: String,
version: String,
source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LockNode {
checksum: Option<String>,
dependencies: BTreeSet<String>,
}
fn lock_packages(document: &Value) -> Option<BTreeMap<LockKey, LockNode>> {
let packages = document.get("package")?.as_array()?;
let mut nodes = BTreeMap::new();
for package in packages {
let package = package.as_object()?;
if package.keys().any(|key| {
!matches!(
key.as_str(),
"name" | "version" | "source" | "checksum" | "dependencies"
)
}) {
return None;
}
let key = LockKey {
name: package.get("name")?.as_str()?.to_string(),
version: package.get("version")?.as_str()?.to_string(),
source: package.get("source").and_then(Value::as_str).map(ToString::to_string),
};
let node = LockNode {
checksum: package.get("checksum").and_then(Value::as_str).map(ToString::to_string),
dependencies: package
.get("dependencies")
.map(|dependencies| {
dependencies
.as_array()?
.iter()
.map(|dependency| dependency.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_else(|| Some(BTreeSet::new()))?,
};
if nodes.insert(key, node).is_some() {
return None;
}
}
Some(nodes)
}
fn lock_key_matches_package(key: &LockKey, package: &Package) -> bool {
key.name == package.name.as_str()
&& key.version == package.version.to_string()
&& key.source.as_deref() == package.source.as_ref().map(|source| source.repr.as_str())
}
fn unchanged(input: &'static str) -> SemanticFileChange {
SemanticFileChange {
scope: SemanticScope::None,
input,
fallback: None,
}
}
fn fallback(input: &'static str, reason: &'static str) -> SemanticFileChange {
SemanticFileChange {
scope: SemanticScope::Workspace,
input,
fallback: Some(reason),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_semantically_analyzed_cargo_inputs() {
assert_eq!(
semantic_cargo_input("Cargo.toml"),
Some(SemanticCargoInput::WorkspaceManifest)
);
assert_eq!(
semantic_cargo_input("crates/demo/Cargo.toml"),
Some(SemanticCargoInput::PackageManifest)
);
assert_eq!(semantic_cargo_input("Cargo.lock"), Some(SemanticCargoInput::Lockfile));
assert_eq!(semantic_cargo_input("crates/demo/Cargo.lock"), None);
assert_eq!(semantic_cargo_input(".cargo/config.toml"), None);
assert_eq!(semantic_cargo_input("scripts/generate.py"), None);
}
#[test]
fn lock_checksum_diff_maps_to_exact_resolved_package_id() {
let ctx = WorkspaceContext::build(&std::path::PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"),
))
.expect("workspace context should load");
let package = ctx
.cargo()
.metadata()
.packages
.iter()
.find(|package| package.name.as_str() == "serde" && package.source.is_some())
.expect("workspace resolution should contain registry serde");
let source = package.source.as_ref().expect("serde should have a registry source");
let lock = |checksum: &str| {
format!(
"version = 4\n\n[[package]]\nname = \"{}\"\nversion = \"{}\"\nsource = \"{}\"\nchecksum = \"{}\"\n",
package.name, package.version, source.repr, checksum
)
};
let change = lockfile_change(&ctx, lock("a").as_bytes(), lock("b").as_bytes());
let SemanticScope::Packages(packages) = change.scope else {
panic!("checksum-only change should localize to resolved packages");
};
assert_eq!(packages, BTreeSet::from([package.id.clone()]));
assert_eq!(change.fallback, None);
}
#[test]
fn lock_dependency_diff_maps_to_exact_resolved_package_id() {
let ctx = WorkspaceContext::build(&std::path::PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"),
))
.expect("workspace context should load");
let workspace_packages = ctx.cargo().metadata().workspace_packages();
let package = workspace_packages
.iter()
.find(|package| package.name.as_str() == "cargo-rail")
.expect("workspace resolution should contain cargo-rail");
let lock = |dependency: &str| {
format!(
"version = 4\n\n[[package]]\nname = \"{}\"\nversion = \"{}\"\ndependencies = [\"{}\"]\n",
package.name, package.version, dependency
)
};
let change = lockfile_change(&ctx, lock("serde").as_bytes(), lock("serde_json").as_bytes());
let SemanticScope::Packages(packages) = change.scope else {
panic!("dependency-edge change should localize to the resolved package");
};
assert_eq!(packages, BTreeSet::from([package.id.clone()]));
assert_eq!(change.fallback, None);
}
}