use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use crate::BumpSeverity;
use crate::Ecosystem;
use crate::MonochangeResult;
use crate::PackageRecord;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DetectionLevel {
Basic,
Signature,
Semantic,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FileChangeKind {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AnalyzedFileChange {
pub path: PathBuf,
pub package_path: PathBuf,
pub kind: FileChangeKind,
pub before_contents: Option<String>,
pub after_contents: Option<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackageSnapshotFile {
pub path: PathBuf,
pub contents: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackageSnapshot {
pub label: String,
pub files: Vec<PackageSnapshotFile>,
}
impl PackageSnapshot {
#[must_use]
pub fn file(&self, path: &Path) -> Option<&PackageSnapshotFile> {
self.files.iter().find(|file| file.path == path)
}
}
pub const API_SNAPSHOT_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiSnapshot {
pub schema_version: u16,
pub package_id: String,
pub package_name: String,
pub ecosystem: Ecosystem,
pub analyzer_id: String,
pub items: Vec<ApiItem>,
pub warnings: Vec<String>,
}
impl ApiSnapshot {
#[must_use]
pub fn new(
package_id: impl Into<String>,
package_name: impl Into<String>,
ecosystem: Ecosystem,
analyzer_id: impl Into<String>,
items: Vec<ApiItem>,
warnings: Vec<String>,
) -> Self {
let mut snapshot = Self {
schema_version: API_SNAPSHOT_SCHEMA_VERSION,
package_id: package_id.into(),
package_name: package_name.into(),
ecosystem,
analyzer_id: analyzer_id.into(),
items,
warnings,
};
snapshot.sort_items();
snapshot
}
pub fn sort_items(&mut self) {
self.items.sort_by(|left, right| left.id.cmp(&right.id));
}
#[must_use]
pub fn diff(&self, after: &Self) -> ApiDiff {
diff_api_snapshots(self, after)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiItem {
pub id: String,
pub kind: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, String>,
}
impl ApiItem {
#[must_use]
pub fn new(
kind: impl Into<String>,
path: impl Into<String>,
signature: Option<String>,
) -> Self {
let kind = kind.into();
let path = path.into();
Self {
id: format!("{kind}:{path}"),
kind,
path,
signature,
source_path: None,
metadata: BTreeMap::new(),
}
}
#[must_use]
pub fn with_source_path(mut self, source_path: impl Into<PathBuf>) -> Self {
self.source_path = Some(source_path.into());
self
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ApiConfidence {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ApiChangeKind {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiChange {
pub kind: ApiChangeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<ApiItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<ApiItem>,
pub suggested_bump: BumpSeverity,
pub confidence: ApiConfidence,
pub summary: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiDiff {
pub package_id: String,
pub package_name: String,
pub ecosystem: Ecosystem,
pub analyzer_id: String,
pub suggested_bump: BumpSeverity,
pub changes: Vec<ApiChange>,
pub warnings: Vec<String>,
}
impl ApiDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
}
#[must_use]
pub fn diff_api_snapshots(before: &ApiSnapshot, after: &ApiSnapshot) -> ApiDiff {
let before_items: BTreeMap<_, _> = before.items.iter().map(|item| (&item.id, item)).collect();
let after_items: BTreeMap<_, _> = after.items.iter().map(|item| (&item.id, item)).collect();
let mut changes = Vec::new();
for (id, before_item) in &before_items {
match after_items.get(id) {
Some(after_item)
if before_item.signature != after_item.signature
|| before_item.metadata != after_item.metadata =>
{
changes.push(api_change_modified(before_item, after_item));
}
Some(_) => {}
None => changes.push(api_change_removed(before_item)),
}
}
for (id, after_item) in &after_items {
if !before_items.contains_key(id) {
changes.push(api_change_added(after_item));
}
}
changes.sort_by(|left, right| left.summary.cmp(&right.summary));
let suggested_bump = changes
.iter()
.map(|change| change.suggested_bump)
.max()
.unwrap_or(BumpSeverity::None);
let warnings = before
.warnings
.iter()
.chain(after.warnings.iter())
.cloned()
.collect();
ApiDiff {
package_id: after.package_id.clone(),
package_name: after.package_name.clone(),
ecosystem: after.ecosystem,
analyzer_id: after.analyzer_id.clone(),
suggested_bump,
changes,
warnings,
}
}
fn api_change_added(item: &ApiItem) -> ApiChange {
ApiChange {
kind: ApiChangeKind::Added,
before: None,
after: Some(item.clone()),
suggested_bump: BumpSeverity::Minor,
confidence: ApiConfidence::High,
summary: format!("added public {} `{}`", item.kind, item.path),
}
}
fn api_change_removed(item: &ApiItem) -> ApiChange {
ApiChange {
kind: ApiChangeKind::Removed,
before: Some(item.clone()),
after: None,
suggested_bump: BumpSeverity::Major,
confidence: ApiConfidence::High,
summary: format!("removed public {} `{}`", item.kind, item.path),
}
}
fn api_change_modified(before: &ApiItem, after: &ApiItem) -> ApiChange {
ApiChange {
kind: ApiChangeKind::Modified,
before: Some(before.clone()),
after: Some(after.clone()),
suggested_bump: BumpSeverity::Major,
confidence: ApiConfidence::High,
summary: format!("changed public {} `{}`", after.kind, after.path),
}
}
#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SemanticChangeCategory {
PublicApi,
Export,
Dependency,
Metadata,
}
#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SemanticChangeKind {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SemanticChange {
pub category: SemanticChangeCategory,
pub kind: SemanticChangeKind,
pub item_kind: String,
pub item_path: String,
pub summary: String,
pub file_path: PathBuf,
pub before_signature: Option<String>,
pub after_signature: Option<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackageAnalysisResult {
pub analyzer_id: String,
pub package_id: String,
pub ecosystem: Ecosystem,
pub changed_files: Vec<PathBuf>,
pub semantic_changes: Vec<SemanticChange>,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub struct PackageAnalysisContext<'a> {
pub repo_root: &'a Path,
pub package: &'a PackageRecord,
pub detection_level: DetectionLevel,
pub changed_files: &'a [AnalyzedFileChange],
pub before_snapshot: Option<&'a PackageSnapshot>,
pub after_snapshot: Option<&'a PackageSnapshot>,
}
impl PackageAnalysisContext<'_> {
#[must_use]
pub fn package_root(&self) -> &Path {
self.package
.manifest_path
.parent()
.unwrap_or(&self.package.workspace_root)
}
}
pub trait SemanticAnalyzer: Send + Sync {
fn analyzer_id(&self) -> &'static str;
fn applies_to(&self, package: &PackageRecord) -> bool;
fn analyze_package(
&self,
context: &PackageAnalysisContext<'_>,
) -> MonochangeResult<PackageAnalysisResult>;
}
#[cfg(test)]
#[path = "__tests__/analysis_tests.rs"]
mod tests;