#![cfg_attr(coverage_nightly, coverage(off))]
use super::{AnalyzeComplexityContract, AnalyzeSatdContract, BaseAnalysisContract, ContractError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ContractVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl ContractVersion {
#[must_use]
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
#[must_use]
pub fn current() -> Self {
Self::new(1, 0, 0) }
#[must_use]
pub fn is_compatible(&self, other: &Self) -> bool {
self.major == other.major && self.minor >= other.minor
}
#[must_use]
pub fn requires_migration(&self, other: &Self) -> bool {
self.major != other.major
}
}
impl std::fmt::Display for ContractVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VersionedContract<T> {
pub version: ContractVersion,
pub contract: T,
pub metadata: ContractMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContractMetadata {
pub created_at: u64,
pub created_by: String,
pub description: Option<String>,
pub deprecated: bool,
pub migration_notes: Option<String>,
}
impl ContractMetadata {
#[must_use]
pub fn new(created_by: &str) -> Self {
Self {
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
.as_secs(),
created_by: created_by.to_string(),
description: None,
deprecated: false,
migration_notes: None,
}
}
#[must_use]
pub fn with_description(mut self, description: &str) -> Self {
self.description = Some(description.to_string());
self
}
#[must_use]
pub fn deprecated(mut self, migration_notes: &str) -> Self {
self.deprecated = true;
self.migration_notes = Some(migration_notes.to_string());
self
}
}
pub struct ContractRegistry {
contracts: HashMap<String, Vec<VersionedContract<serde_json::Value>>>,
migrations: HashMap<(ContractVersion, ContractVersion), Box<dyn ContractMigration>>,
}
impl Default for ContractRegistry {
fn default() -> Self {
Self::new()
}
}
impl ContractRegistry {
#[must_use]
pub fn new() -> Self {
Self {
contracts: HashMap::new(),
migrations: HashMap::new(),
}
}
pub fn register<T>(
&mut self,
name: &str,
contract: VersionedContract<T>,
) -> Result<(), ContractError>
where
T: Serialize,
{
let json_contract = VersionedContract {
version: contract.version,
contract: serde_json::to_value(contract.contract)
.map_err(|e| ContractError::InvalidValue(e.to_string()))?,
metadata: contract.metadata,
};
self.contracts
.entry(name.to_string())
.or_default()
.push(json_contract);
Ok(())
}
#[must_use]
pub fn get_latest(&self, name: &str) -> Option<&VersionedContract<serde_json::Value>> {
self.contracts
.get(name)?
.iter()
.max_by_key(|c| (&c.version.major, &c.version.minor, &c.version.patch))
}
#[must_use]
pub fn get_version(
&self,
name: &str,
version: &ContractVersion,
) -> Option<&VersionedContract<serde_json::Value>> {
self.contracts
.get(name)?
.iter()
.find(|c| &c.version == version)
}
#[must_use]
pub fn get_all_versions(
&self,
name: &str,
) -> Option<&Vec<VersionedContract<serde_json::Value>>> {
self.contracts.get(name)
}
pub fn register_migration(
&mut self,
from: ContractVersion,
to: ContractVersion,
migration: Box<dyn ContractMigration>,
) {
self.migrations.insert((from, to), migration);
}
pub fn migrate(
&self,
name: &str,
from_version: &ContractVersion,
to_version: &ContractVersion,
contract: serde_json::Value,
) -> Result<serde_json::Value, ContractError> {
if let Some(migration) = self
.migrations
.get(&(from_version.clone(), to_version.clone()))
{
migration.migrate(contract)
} else {
Err(ContractError::InvalidValue(format!(
"No migration available from {from_version} to {to_version} for contract {name}"
)))
}
}
#[must_use]
pub fn is_deprecated(&self, name: &str, version: &ContractVersion) -> bool {
if let Some(contract) = self.get_version(name, version) {
contract.metadata.deprecated
} else {
false
}
}
#[must_use]
pub fn get_deprecation_info(&self, name: &str, version: &ContractVersion) -> Option<String> {
if let Some(contract) = self.get_version(name, version) {
if contract.metadata.deprecated {
contract.metadata.migration_notes.clone()
} else {
None
}
} else {
None
}
}
}
pub trait ContractMigration: Send + Sync {
fn migrate(&self, contract: serde_json::Value) -> Result<serde_json::Value, ContractError>;
}
pub struct ParameterRenameMigration {
pub old_name: String,
pub new_name: String,
}
impl ContractMigration for ParameterRenameMapping {
fn migrate(&self, mut contract: serde_json::Value) -> Result<serde_json::Value, ContractError> {
if let Some(obj) = contract.as_object_mut() {
for (old, new) in &self.mappings {
if let Some(value) = obj.remove(old) {
obj.insert(new.clone(), value);
}
}
}
Ok(contract)
}
}
pub struct ParameterRenameMapping {
pub mappings: HashMap<String, String>,
}
impl Default for ParameterRenameMapping {
fn default() -> Self {
Self::new()
}
}
impl ParameterRenameMapping {
#[must_use]
pub fn new() -> Self {
Self {
mappings: HashMap::new(),
}
}
#[must_use]
pub fn add_mapping(mut self, old_name: &str, new_name: &str) -> Self {
self.mappings
.insert(old_name.to_string(), new_name.to_string());
self
}
#[must_use]
pub fn project_path_to_path() -> Self {
Self::new().add_mapping("project_path", "path")
}
#[must_use]
pub fn file_to_files() -> Box<dyn ContractMigration> {
Box::new(FileToFilesMigration)
}
}
pub struct FileToFilesMigration;
impl ContractMigration for FileToFilesMigration {
fn migrate(&self, mut contract: serde_json::Value) -> Result<serde_json::Value, ContractError> {
if let Some(obj) = contract.as_object_mut() {
if let Some(file) = obj.remove("file") {
if !obj.contains_key("files") {
obj.insert("files".to_string(), serde_json::json!([file]));
}
}
}
Ok(contract)
}
}
pub struct ContractBuilder<T> {
version: ContractVersion,
contract: T,
metadata: ContractMetadata,
}
impl<T> ContractBuilder<T> {
pub fn new(contract: T, created_by: &str) -> Self {
Self {
version: ContractVersion::current(),
contract,
metadata: ContractMetadata::new(created_by),
}
}
pub fn version(mut self, major: u32, minor: u32, patch: u32) -> Self {
self.version = ContractVersion::new(major, minor, patch);
self
}
pub fn description(mut self, description: &str) -> Self {
self.metadata = self.metadata.with_description(description);
self
}
pub fn deprecated(mut self, migration_notes: &str) -> Self {
self.metadata = self.metadata.deprecated(migration_notes);
self
}
pub fn build(self) -> VersionedContract<T> {
VersionedContract {
version: self.version,
contract: self.contract,
metadata: self.metadata,
}
}
}
pub fn initialize_registry() -> Result<ContractRegistry, ContractError> {
let mut registry = ContractRegistry::new();
let complexity_contract = ContractBuilder::new(
AnalyzeComplexityContract {
base: BaseAnalysisContract::default(),
max_cyclomatic: None,
max_cognitive: None,
max_halstead: None,
},
"pmat-system",
)
.description("Analyze code complexity with uniform parameters")
.build();
registry
.register("analyze_complexity", complexity_contract)
.map_err(|e| ContractError::InvalidValue(e.to_string()))?;
let satd_contract = ContractBuilder::new(
AnalyzeSatdContract {
base: BaseAnalysisContract::default(),
severity: None,
critical_only: false,
strict: false,
fail_on_violation: false,
},
"pmat-system",
)
.description("Analyze Self-Admitted Technical Debt")
.build();
registry
.register("analyze_satd", satd_contract)
.map_err(|e| ContractError::InvalidValue(e.to_string()))?;
registry.register_migration(
ContractVersion::new(0, 1, 0), ContractVersion::new(1, 0, 0), Box::new(ParameterRenameMapping::project_path_to_path()),
);
registry.register_migration(
ContractVersion::new(0, 1, 0),
ContractVersion::new(1, 0, 0),
ParameterRenameMapping::file_to_files(),
);
Ok(registry)
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_version_compatibility() {
let v1_0_0 = ContractVersion::new(1, 0, 0);
let v1_1_0 = ContractVersion::new(1, 1, 0);
let v2_0_0 = ContractVersion::new(2, 0, 0);
assert!(v1_1_0.is_compatible(&v1_0_0));
assert!(!v1_0_0.is_compatible(&v1_1_0));
assert!(!v2_0_0.is_compatible(&v1_0_0));
assert!(v2_0_0.requires_migration(&v1_0_0));
}
#[test]
fn test_contract_registry() {
let mut registry = ContractRegistry::new();
let contract = ContractBuilder::new(json!({"path": ".", "format": "json"}), "test").build();
registry.register("test_contract", contract).unwrap();
let latest = registry.get_latest("test_contract").unwrap();
assert_eq!(latest.version, ContractVersion::current());
assert_eq!(latest.contract["path"], ".");
}
#[test]
fn test_parameter_migration() {
let migration = ParameterRenameMapping::project_path_to_path();
let old_contract = json!({
"project_path": "/src",
"format": "json"
});
let new_contract = migration.migrate(old_contract).unwrap();
assert_eq!(new_contract["path"], "/src");
assert!(new_contract["project_path"].is_null());
assert_eq!(new_contract["format"], "json");
}
#[test]
fn test_file_to_files_migration() {
let migration = FileToFilesMigration;
let old_contract = json!({
"path": ".",
"file": "main.rs",
"format": "json"
});
let new_contract = migration.migrate(old_contract).unwrap();
assert_eq!(new_contract["files"], json!(["main.rs"]));
assert!(new_contract["file"].is_null());
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}