use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use monochange_core::AnalyzedFileChange;
use monochange_core::DetectionLevel;
use monochange_core::Ecosystem;
use monochange_core::MonochangeResult;
use monochange_core::PackageAnalysisContext;
use monochange_core::PackageAnalysisResult;
use monochange_core::PackageRecord;
use monochange_core::PackageSnapshot;
use monochange_core::PackageSnapshotFile;
use monochange_core::SemanticAnalyzer;
use monochange_core::SemanticChange;
use monochange_core::SemanticChangeCategory;
use monochange_core::SemanticChangeKind;
use quote::ToTokens;
use toml::Value;
use crate::CARGO_MANIFEST_FILE;
#[derive(Debug, Clone, Copy, Default)]
pub struct CargoSemanticAnalyzer;
#[must_use]
pub const fn semantic_analyzer() -> CargoSemanticAnalyzer {
CargoSemanticAnalyzer
}
impl SemanticAnalyzer for CargoSemanticAnalyzer {
fn analyzer_id(&self) -> &'static str {
"cargo/public-api"
}
fn applies_to(&self, package: &PackageRecord) -> bool {
package.ecosystem == Ecosystem::Cargo
}
fn analyze_package(
&self,
context: &PackageAnalysisContext<'_>,
) -> MonochangeResult<PackageAnalysisResult> {
let mut warnings = Vec::new();
let mut semantic_changes = Vec::new();
let (before_symbols, mut before_warnings) = snapshot_public_symbols(
context.before_snapshot,
context.changed_files,
context.detection_level,
);
let (after_symbols, mut after_warnings) = snapshot_public_symbols(
context.after_snapshot,
context.changed_files,
context.detection_level,
);
warnings.append(&mut before_warnings);
warnings.append(&mut after_warnings);
semantic_changes.extend(diff_public_symbols(&before_symbols, &after_symbols));
if let Some(manifest_change) = context
.changed_files
.iter()
.find(|change| change.package_path == Path::new(CARGO_MANIFEST_FILE))
{
semantic_changes.extend(analyze_manifest_change(manifest_change, &mut warnings));
}
semantic_changes.sort_by(|left, right| {
(
left.category,
left.kind,
left.item_kind.as_str(),
left.item_path.as_str(),
)
.cmp(&(
right.category,
right.kind,
right.item_kind.as_str(),
right.item_path.as_str(),
))
});
Ok(PackageAnalysisResult {
analyzer_id: self.analyzer_id().to_string(),
package_id: display_package_id(context.package),
ecosystem: context.package.ecosystem,
changed_files: context
.changed_files
.iter()
.map(|file| file.package_path.clone())
.collect(),
semantic_changes,
warnings,
})
}
}
fn display_package_id(package: &PackageRecord) -> String {
package
.metadata
.get("config_id")
.cloned()
.unwrap_or_else(|| package.id.clone())
}
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
struct PublicSymbol {
item_kind: String,
item_path: String,
signature: String,
file_path: PathBuf,
}
fn snapshot_public_symbols(
snapshot: Option<&PackageSnapshot>,
changed_files: &[AnalyzedFileChange],
_detection_level: DetectionLevel,
) -> (BTreeMap<(String, String), PublicSymbol>, Vec<String>) {
let mut warnings = Vec::new();
let mut symbols = BTreeMap::new();
if let Some(snapshot) = snapshot {
for file in &snapshot.files {
if !is_rust_source_file(file) {
continue;
}
match collect_public_symbols(file) {
Ok(file_symbols) => {
for symbol in file_symbols {
symbols
.insert((symbol.item_kind.clone(), symbol.item_path.clone()), symbol);
}
}
Err(error) => warnings.push(error),
}
}
return (symbols, warnings);
}
for change in changed_files {
let contents = change
.after_contents
.as_deref()
.or(change.before_contents.as_deref());
let Some(contents) = contents else {
continue;
};
let file = PackageSnapshotFile {
path: change.package_path.clone(),
contents: contents.to_string(),
};
if !is_rust_source_file(&file) {
continue;
}
match collect_public_symbols(&file) {
Ok(file_symbols) => {
for symbol in file_symbols {
symbols.insert((symbol.item_kind.clone(), symbol.item_path.clone()), symbol);
}
}
Err(error) => warnings.push(error),
}
}
(symbols, warnings)
}
fn is_rust_source_file(file: &PackageSnapshotFile) -> bool {
file.path.extension().and_then(|ext| ext.to_str()) == Some("rs") && file.path.starts_with("src")
}
fn collect_public_symbols(file: &PackageSnapshotFile) -> Result<Vec<PublicSymbol>, String> {
let module_prefix = module_prefix_for_file(&file.path);
let parsed = syn::parse_file(&file.contents)
.map_err(|error| format!("failed to parse {}: {error}", file.path.display()))?;
let mut symbols = Vec::new();
collect_public_symbols_from_items(&parsed.items, &module_prefix, &file.path, &mut symbols);
Ok(symbols)
}
fn collect_public_symbols_from_items(
items: &[syn::Item],
module_prefix: &[String],
file_path: &Path,
output: &mut Vec<PublicSymbol>,
) {
for item in items {
match item {
syn::Item::Const(item) if is_public(&item.vis) => {
push_symbol(
output,
"constant",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Enum(item) if is_public(&item.vis) => {
push_symbol(
output,
"enum",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Fn(item) if is_public(&item.vis) => {
push_symbol(
output,
"function",
module_prefix,
item.sig.ident.to_string(),
render_signature(&item.sig),
file_path,
);
}
syn::Item::Mod(item) if is_public(&item.vis) => {
push_symbol(
output,
"module",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
if let Some((_, nested_items)) = &item.content {
let mut nested_prefix = module_prefix.to_vec();
nested_prefix.push(item.ident.to_string());
collect_public_symbols_from_items(
nested_items,
&nested_prefix,
file_path,
output,
);
}
}
syn::Item::Static(item) if is_public(&item.vis) => {
push_symbol(
output,
"static",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Struct(item) if is_public(&item.vis) => {
push_symbol(
output,
"struct",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Trait(item) if is_public(&item.vis) => {
push_symbol(
output,
"trait",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Type(item) if is_public(&item.vis) => {
push_symbol(
output,
"type_alias",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Union(item) if is_public(&item.vis) => {
push_symbol(
output,
"union",
module_prefix,
item.ident.to_string(),
render_signature(item),
file_path,
);
}
syn::Item::Use(item) if is_public(&item.vis) => {
let use_tree = render_signature(&item.tree);
push_symbol(
output,
"reexport",
module_prefix,
use_tree.clone(),
format!("pub use {use_tree};"),
file_path,
);
}
_ => {}
}
}
}
#[allow(clippy::needless_pass_by_value)]
fn push_symbol(
output: &mut Vec<PublicSymbol>,
item_kind: &str,
module_prefix: &[String],
item_name: String,
signature: String,
file_path: &Path,
) {
let item_path = if module_prefix.is_empty() {
item_name.clone()
} else {
format!("{}::{item_name}", module_prefix.join("::"))
};
output.push(PublicSymbol {
item_kind: item_kind.to_string(),
item_path,
signature,
file_path: file_path.to_path_buf(),
});
}
fn is_public(visibility: &syn::Visibility) -> bool {
matches!(visibility, syn::Visibility::Public(_))
}
fn render_signature(value: &impl ToTokens) -> String {
value.to_token_stream().to_string()
}
fn module_prefix_for_file(path: &Path) -> Vec<String> {
let mut components = path
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>();
if components
.first()
.is_some_and(|component| component == "src")
{
components.remove(0);
}
let Some(last) = components.pop() else {
return Vec::new();
};
let stem = last.strip_suffix(".rs").unwrap_or(&last);
if stem != "lib" && stem != "main" && stem != "mod" {
components.push(stem.to_string());
}
components
}
fn diff_public_symbols(
before: &BTreeMap<(String, String), PublicSymbol>,
after: &BTreeMap<(String, String), PublicSymbol>,
) -> Vec<SemanticChange> {
let mut changes = Vec::new();
for (key, after_symbol) in after {
match before.get(key) {
None => {
changes.push(build_symbol_change(
SemanticChangeKind::Added,
after_symbol,
None,
Some(after_symbol.signature.clone()),
));
}
Some(before_symbol) if before_symbol.signature != after_symbol.signature => {
changes.push(build_symbol_change(
SemanticChangeKind::Modified,
after_symbol,
Some(before_symbol.signature.clone()),
Some(after_symbol.signature.clone()),
));
}
Some(_) => {}
}
}
for (key, before_symbol) in before {
if after.contains_key(key) {
continue;
}
changes.push(build_symbol_change(
SemanticChangeKind::Removed,
before_symbol,
Some(before_symbol.signature.clone()),
None,
));
}
changes
}
fn build_symbol_change(
kind: SemanticChangeKind,
symbol: &PublicSymbol,
before_signature: Option<String>,
after_signature: Option<String>,
) -> SemanticChange {
let verb = if kind == SemanticChangeKind::Added {
"added"
} else if kind == SemanticChangeKind::Removed {
"removed"
} else {
"modified"
};
SemanticChange {
category: SemanticChangeCategory::PublicApi,
kind,
item_kind: symbol.item_kind.clone(),
item_path: symbol.item_path.clone(),
summary: format!("{} `{}` {verb}", symbol.item_kind, symbol.item_path),
file_path: symbol.file_path.clone(),
before_signature,
after_signature,
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct ManifestEntry {
item_kind: String,
value: String,
}
fn analyze_manifest_change(
change: &AnalyzedFileChange,
warnings: &mut Vec<String>,
) -> Vec<SemanticChange> {
let mut changes = Vec::new();
let before_manifest = parse_manifest(
change.before_contents.as_deref(),
&change.package_path,
warnings,
);
let after_manifest = parse_manifest(
change.after_contents.as_deref(),
&change.package_path,
warnings,
);
let before_dependencies = before_manifest
.as_ref()
.map(extract_dependency_entries)
.unwrap_or_default();
let after_dependencies = after_manifest
.as_ref()
.map(extract_dependency_entries)
.unwrap_or_default();
changes.extend(compare_manifest_entries(
SemanticChangeCategory::Dependency,
&change.package_path,
&before_dependencies,
&after_dependencies,
));
let before_metadata = before_manifest
.as_ref()
.map(extract_metadata_entries)
.unwrap_or_default();
let after_metadata = after_manifest
.as_ref()
.map(extract_metadata_entries)
.unwrap_or_default();
changes.extend(compare_manifest_entries(
SemanticChangeCategory::Metadata,
&change.package_path,
&before_metadata,
&after_metadata,
));
changes
}
fn parse_manifest(
contents: Option<&str>,
path: &Path,
warnings: &mut Vec<String>,
) -> Option<Value> {
let contents = contents?;
match toml::from_str::<Value>(contents) {
Ok(value) => Some(value),
Err(error) => {
warnings.push(format!("failed to parse {}: {error}", path.display()));
None
}
}
}
fn extract_dependency_entries(value: &Value) -> BTreeMap<String, ManifestEntry> {
let mut entries = BTreeMap::new();
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(table) = value.get(section).and_then(Value::as_table) else {
continue;
};
for (name, dependency) in table {
entries.insert(
name.clone(),
ManifestEntry {
item_kind: "dependency".to_string(),
value: format!("[{section}] {}", describe_manifest_value(dependency)),
},
);
}
}
entries
}
fn extract_metadata_entries(value: &Value) -> BTreeMap<String, ManifestEntry> {
let mut entries = BTreeMap::new();
for field in ["edition", "rust-version", "publish"] {
if let Some(field_value) = value
.get("package")
.and_then(Value::as_table)
.and_then(|table| table.get(field))
{
entries.insert(
format!("package.{field}"),
ManifestEntry {
item_kind: "manifest_field".to_string(),
value: describe_manifest_value(field_value),
},
);
}
}
if let Some(features) = value.get("features").and_then(Value::as_table) {
for (name, feature) in features {
entries.insert(
format!("feature.{name}"),
ManifestEntry {
item_kind: "feature".to_string(),
value: describe_manifest_value(feature),
},
);
}
}
entries
}
fn compare_manifest_entries(
category: SemanticChangeCategory,
file_path: &Path,
before: &BTreeMap<String, ManifestEntry>,
after: &BTreeMap<String, ManifestEntry>,
) -> Vec<SemanticChange> {
let mut changes = Vec::new();
for (name, after_entry) in after {
match before.get(name) {
None => {
changes.push(build_manifest_change(
category,
SemanticChangeKind::Added,
file_path,
name,
after_entry,
None,
Some(after_entry.value.clone()),
));
}
Some(before_entry) if before_entry != after_entry => {
changes.push(build_manifest_change(
category,
SemanticChangeKind::Modified,
file_path,
name,
after_entry,
Some(before_entry.value.clone()),
Some(after_entry.value.clone()),
));
}
Some(_) => {}
}
}
for (name, before_entry) in before {
if after.contains_key(name) {
continue;
}
changes.push(build_manifest_change(
category,
SemanticChangeKind::Removed,
file_path,
name,
before_entry,
Some(before_entry.value.clone()),
None,
));
}
changes
}
fn build_manifest_change(
category: SemanticChangeCategory,
kind: SemanticChangeKind,
file_path: &Path,
item_path: &str,
entry: &ManifestEntry,
before_signature: Option<String>,
after_signature: Option<String>,
) -> SemanticChange {
let verb = if kind == SemanticChangeKind::Added {
"added"
} else if kind == SemanticChangeKind::Removed {
"removed"
} else {
"modified"
};
SemanticChange {
category,
kind,
item_kind: entry.item_kind.clone(),
item_path: item_path.to_string(),
summary: format!("{} `{}` {verb}", entry.item_kind, item_path),
file_path: file_path.to_path_buf(),
before_signature,
after_signature,
}
}
fn describe_manifest_value(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Array(items) => {
items
.iter()
.map(describe_manifest_value)
.collect::<Vec<_>>()
.join(", ")
}
Value::Table(table) => {
table
.iter()
.map(|(key, value)| format!("{key}={}", describe_manifest_value(value)))
.collect::<Vec<_>>()
.join(", ")
}
other => other.to_string(),
}
}
#[cfg(test)]
#[path = "__tests__/analysis_tests.rs"]
mod tests;