* Copyright(c) 2024-2026 China Telecom Cloud Technologies Co., Ltd. All rights
* reserved. ctscat is licensed under Mulan PSL v2. You can use this software
* according to the terms and conditions of the Mulan PSL V2. You may obtain a
* copy of Mulan PSL v2 at: http://license.coscl.org.cn/MulanPSL2.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
* KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. See the Mulan PSL v2 for
* more details.
*/
use crate::snapshot::types::{CommitEntry, FileEntry, RepositorySnapshot};
use crate::utils::spec::{SpecComparison, SpecParser};
use crate::utils::version::VersionParser;
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct L1Snapshot {
pub package_name: String,
pub version: String,
pub spec_content: String,
pub spec_sha256: String,
pub patches: Vec<PatchFile>,
pub source_files: Vec<SourceFile>,
pub commits: Vec<CommitEntry>,
pub snapshot_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct L2Snapshot {
pub package_name: String,
pub version: String,
pub spec_content: String,
pub spec_sha256: String,
pub patches: Vec<PatchFile>,
pub source_files: Vec<SourceFile>,
pub customizations: Vec<Customization>,
pub commits: Vec<CommitEntry>,
pub snapshot_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PatchFile {
pub filename: String,
pub path: String,
pub content_hash: String,
pub size: u64,
pub applied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceFile {
pub filename: String,
pub path: String,
pub content_hash: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Customization {
pub customization_type: CustomizationType,
pub description: String,
pub affected_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CustomizationType {
VersionChange,
FeatureModification,
ConfigurationChange,
SecurityHardening,
PerformanceOptimization,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitDiff {
pub l1_commits_count: usize,
pub l2_commits_count: usize,
pub behind_commits: Vec<CommitEntry>,
pub base_commit: Option<CommitEntry>,
pub base_version_release: Option<(String, Option<String>)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct L2VsL1Report {
pub id: Option<i64>,
pub package_name: String,
pub spec_diff: SpecDiff,
pub patch_diff: PatchDiff,
pub source_diff: SourceDiff,
pub customization_analysis: CustomizationAnalysis,
pub sync_recommendations: Vec<SyncRecommendation>,
pub conflicts: Vec<MergeConflict>,
pub commit_diff: CommitDiff,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecDiff {
pub version_diff: Option<VersionDiff>,
pub content_identical: bool,
pub diff_summary: String,
pub key_changes: Vec<String>,
pub detailed_comparison: Option<SpecComparison>,
pub build_requires_added: Vec<String>,
pub build_requires_removed: Vec<String>,
pub configure_options_added: Vec<String>,
pub configure_options_removed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionDiff {
pub l1_version: String,
pub l2_version: String,
pub relationship: VersionRelationship,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum VersionRelationship {
L2Newer,
L2Older,
Same,
Incomparable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchDiff {
pub l1_total: usize,
pub l2_total: usize,
pub l2_added: Vec<PatchFile>,
pub l2_removed: Vec<PatchFile>,
pub l2_modified: Vec<PatchModification>,
pub identical: Vec<PatchFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchModification {
pub filename: String,
pub l1_hash: String,
pub l2_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceDiff {
pub l1_total: usize,
pub l2_total: usize,
pub l2_added: Vec<SourceFile>,
pub l2_removed: Vec<SourceFile>,
pub l2_modified: Vec<SourceModification>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceModification {
pub filename: String,
pub l1_hash: String,
pub l2_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomizationAnalysis {
pub total_customizations: usize,
pub by_type: HashMap<String, Vec<Customization>>,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncRecommendation {
pub priority: SyncPriority,
pub recommendation_type: SyncType,
pub description: String,
pub affected_files: Vec<String>,
pub estimated_effort: EffortLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SyncPriority {
Critical,
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SyncType {
VersionUpgrade,
SecurityPatch,
BugFix,
NewFeature,
ConfigUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EffortLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeConflict {
pub conflict_type: ConflictType,
pub description: String,
pub files: Vec<String>,
pub resolution_hint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ConflictType {
VersionConflict,
PatchConflict,
FileModificationConflict,
ConfigurationConflict,
}
pub struct L2VsL1Comparator;
impl L2VsL1Comparator {
pub fn new() -> Self {
Self
}
pub fn create_l1_snapshot(
package_name: String,
snapshot: &RepositorySnapshot,
) -> Result<L1Snapshot> {
let spec = match snapshot.spec.as_ref() {
Some(s) => s,
None => {
tracing::error!(
tracking_id = snapshot.tracking_id,
origin = ?snapshot.origin,
files_count = snapshot.files.len(),
"创建 L1 快照失败:缺少 spec 文件"
);
return Err(anyhow!("L1 快照缺少 spec 文件"));
}
};
let version = match spec.version.clone() {
Some(v) => v,
None => {
tracing::error!(
tracking_id = snapshot.tracking_id,
spec_path = %spec.path,
spec_sha256 = %spec.sha256,
"创建 L1 快照失败:无法从 spec 文件提取版本号"
);
return Err(anyhow!("无法从 spec 文件提取版本号"));
}
};
use base64::Engine;
let decoded = match base64::engine::general_purpose::STANDARD.decode(&spec.content_base64) {
Ok(bytes) => bytes,
Err(e) => {
tracing::error!(
tracking_id = snapshot.tracking_id,
spec_path = %spec.path,
spec_sha256 = %spec.sha256,
base64_len = spec.content_base64.len(),
error = %e,
"创建 L1 快照失败:解码 spec 内容失败"
);
return Err(anyhow!("解码 spec 内容失败: {}", e));
}
};
let spec_content = match String::from_utf8(decoded) {
Ok(s) => s,
Err(e) => {
tracing::error!(
tracking_id = snapshot.tracking_id,
spec_path = %spec.path,
spec_sha256 = %spec.sha256,
error = %e,
"创建 L1 快照失败:spec 内容不是有效的 UTF-8"
);
return Err(anyhow!("spec 内容不是有效的 UTF-8: {}", e));
}
};
let patches = match Self::extract_patches(&snapshot.files) {
Ok(p) => p,
Err(e) => {
tracing::error!(
tracking_id = snapshot.tracking_id,
files_count = snapshot.files.len(),
error = %e,
"创建 L1 快照失败:提取 patch 文件出错"
);
return Err(e);
}
};
let source_files = match Self::extract_source_files(&snapshot.files) {
Ok(s) => s,
Err(e) => {
tracing::error!(
tracking_id = snapshot.tracking_id,
files_count = snapshot.files.len(),
error = %e,
"创建 L1 快照失败:提取源文件出错"
);
return Err(e);
}
};
Ok(L1Snapshot {
package_name,
version,
spec_content,
spec_sha256: spec.sha256.clone(),
patches,
source_files,
commits: snapshot.commits.clone(),
snapshot_at: snapshot.generated_at,
})
}
pub fn create_l2_snapshot(
package_name: String,
snapshot: &RepositorySnapshot,
) -> Result<L2Snapshot> {
let spec = snapshot
.spec
.as_ref()
.ok_or_else(|| anyhow!("L2 快照缺少 spec 文件"))?;
let version = spec
.version
.clone()
.ok_or_else(|| anyhow!("无法从 spec 文件提取版本号"))?;
use base64::Engine;
let spec_content = String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(&spec.content_base64)
.map_err(|e| anyhow!("解码 spec 内容失败: {}", e))?,
)
.map_err(|e| anyhow!("spec 内容不是有效的 UTF-8: {}", e))?;
let patches = Self::extract_patches(&snapshot.files)?;
let source_files = Self::extract_source_files(&snapshot.files)?;
let customizations = Self::analyze_customizations(&spec_content, &patches)?;
Ok(L2Snapshot {
package_name,
version,
spec_content,
spec_sha256: spec.sha256.clone(),
patches,
source_files,
customizations,
commits: snapshot.commits.clone(),
snapshot_at: snapshot.generated_at,
})
}
fn extract_patches(files: &[FileEntry]) -> Result<Vec<PatchFile>> {
let patches = files
.iter()
.filter(|f| f.path.ends_with(".patch") || f.path.ends_with(".diff"))
.map(|f| PatchFile {
filename: f.path.split('/').next_back().unwrap_or(&f.path).to_string(),
path: f.path.clone(),
content_hash: f.sha256.clone(),
size: f.size,
applied: true,
})
.collect();
Ok(patches)
}
fn extract_source_files(files: &[FileEntry]) -> Result<Vec<SourceFile>> {
let source_files = files
.iter()
.filter(|f| {
!f.path.ends_with(".patch")
&& !f.path.ends_with(".diff")
&& !f.path.ends_with(".spec")
})
.map(|f| SourceFile {
filename: f.path.split('/').next_back().unwrap_or(&f.path).to_string(),
path: f.path.clone(),
content_hash: f.sha256.clone(),
size: f.size,
})
.collect();
Ok(source_files)
}
fn analyze_customizations(
spec_content: &str,
patches: &[PatchFile],
) -> Result<Vec<Customization>> {
let mut customizations = Vec::new();
customizations.extend(Self::analyze_spec_customizations(spec_content)?);
customizations.extend(Self::analyze_patch_customizations(patches)?);
Ok(customizations)
}
fn analyze_spec_customizations(spec_content: &str) -> Result<Vec<Customization>> {
let mut customizations = Vec::new();
if spec_content.contains("# Custom:") || spec_content.contains("# Enterprise:") {
customizations.push(Customization {
customization_type: CustomizationType::Other,
description: "spec 文件包含定制标记".to_string(),
affected_files: vec!["*.spec".to_string()],
});
}
if spec_content.contains("# Version modified") || spec_content.contains("# Custom version")
{
customizations.push(Customization {
customization_type: CustomizationType::VersionChange,
description: "spec 文件包含版本变更标记".to_string(),
affected_files: vec!["*.spec".to_string()],
});
}
let config_keywords = [
"# Config:",
"# Configuration:",
"--with-",
"--enable-",
"--disable-",
];
for keyword in &config_keywords {
if spec_content.contains(keyword) {
let config_lines: Vec<&str> = spec_content
.lines()
.filter(|line| line.contains(keyword))
.take(3)
.collect();
if !config_lines.is_empty() {
customizations.push(Customization {
customization_type: CustomizationType::ConfigurationChange,
description: format!("spec 文件包含配置修改: {}", config_lines.join("; ")),
affected_files: vec!["*.spec".to_string()],
});
break;
}
}
}
let perf_keywords = [
"# Performance:",
"# Optimization:",
"# Optimize",
"-O2",
"-O3",
"-march=",
];
for keyword in &perf_keywords {
if spec_content.contains(keyword) {
customizations.push(Customization {
customization_type: CustomizationType::PerformanceOptimization,
description: format!("spec 文件包含性能优化标记: {}", keyword),
affected_files: vec!["*.spec".to_string()],
});
break;
}
}
let security_keywords = [
"# Security:",
"# Hardening:",
"-fstack-protector",
"-D_FORTIFY_SOURCE",
"--enable-security",
];
for keyword in &security_keywords {
if spec_content.contains(keyword) {
customizations.push(Customization {
customization_type: CustomizationType::SecurityHardening,
description: format!("spec 文件包含安全加固标记: {}", keyword),
affected_files: vec!["*.spec".to_string()],
});
break;
}
}
Ok(customizations)
}
fn analyze_patch_customizations(patches: &[PatchFile]) -> Result<Vec<Customization>> {
let mut customizations = Vec::new();
for patch in patches {
let filename_lower = patch.filename.to_lowercase();
if filename_lower.contains("custom")
|| filename_lower.contains("enterprise")
|| filename_lower.contains("internal")
|| filename_lower.contains("proprietary")
{
customizations.push(Customization {
customization_type: CustomizationType::FeatureModification,
description: format!("定制功能补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
continue;
}
if filename_lower.contains("security")
|| filename_lower.contains("hardening")
|| filename_lower.contains("cve-")
|| filename_lower.contains("vulnerability")
{
customizations.push(Customization {
customization_type: CustomizationType::SecurityHardening,
description: format!("安全加固补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
continue;
}
if filename_lower.contains("config")
|| filename_lower.contains("configure")
|| filename_lower.contains("settings")
|| filename_lower.contains("options")
{
customizations.push(Customization {
customization_type: CustomizationType::ConfigurationChange,
description: format!("配置修改补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
continue;
}
if filename_lower.contains("performance")
|| filename_lower.contains("optimize")
|| filename_lower.contains("optimization")
|| filename_lower.contains("perf")
|| filename_lower.contains("speed")
{
customizations.push(Customization {
customization_type: CustomizationType::PerformanceOptimization,
description: format!("性能优化补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
continue;
}
if filename_lower.contains("version")
|| filename_lower.contains("upgrade")
|| filename_lower.contains("downgrade")
{
customizations.push(Customization {
customization_type: CustomizationType::VersionChange,
description: format!("版本变更补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
continue;
}
let feature_keywords = [
"feature",
"add-",
"enable-",
"disable-",
"support-",
"implement",
];
if feature_keywords
.iter()
.any(|kw| filename_lower.contains(kw))
{
customizations.push(Customization {
customization_type: CustomizationType::FeatureModification,
description: format!("功能修改补丁: {}", patch.filename),
affected_files: vec![patch.path.clone()],
});
}
}
Ok(customizations)
}
pub async fn compare(
&self,
l1_snapshot: &L1Snapshot,
l2_snapshot: &L2Snapshot,
db: &DatabaseConnection,
tracking_id: i32,
) -> Result<L2VsL1Report> {
let spec_diff = self.compare_spec(l1_snapshot, l2_snapshot)?;
tracing::info!(
"对比 {} 个 L1 patch 文件和 {} 个 L2 patch 文件",
l1_snapshot.patches.len(),
l2_snapshot.patches.len()
);
let patch_diff = self.compare_patches(&l1_snapshot.patches, &l2_snapshot.patches)?;
let source_diff =
self.compare_source_files(&l1_snapshot.source_files, &l2_snapshot.source_files)?;
let customization_analysis =
self.analyze_customization_impact(&l2_snapshot.customizations)?;
let sync_recommendations = self.generate_sync_recommendations(
&spec_diff,
&patch_diff,
&source_diff,
&customization_analysis,
)?;
let conflicts = self.detect_conflicts(&spec_diff, &patch_diff, &source_diff)?;
let commit_diff = self
.compare_commit_db(l1_snapshot, l2_snapshot, db, tracking_id)
.await?;
Ok(L2VsL1Report {
id: None,
package_name: l1_snapshot.package_name.clone(),
spec_diff,
patch_diff,
source_diff,
customization_analysis,
sync_recommendations,
conflicts,
commit_diff,
created_at: Utc::now(),
})
}
fn compare_spec(&self, l1: &L1Snapshot, l2: &L2Snapshot) -> Result<SpecDiff> {
let content_identical = l1.spec_sha256 == l2.spec_sha256;
let l1_spec = SpecParser::parse(&l1.spec_content)?;
let l2_spec = SpecParser::parse(&l2.spec_content)?;
let detailed_comparison = SpecParser::compare(&l1_spec, &l2_spec);
let version_diff = if l1.version != l2.version {
let relationship = self.compare_version_relationship(&l1.version, &l2.version)?;
Some(VersionDiff {
l1_version: l1.version.clone(),
l2_version: l2.version.clone(),
relationship,
})
} else {
None
};
let diff_summary = if content_identical {
"spec 文件内容完全相同".to_string()
} else {
detailed_comparison.summary()
};
let mut key_changes = Vec::new();
if detailed_comparison.version_changed {
if let Some((old_ver, new_ver)) = &detailed_comparison.version_diff {
key_changes.push(format!("版本从 {} 变更为 {}", old_ver, new_ver));
}
}
if !detailed_comparison.build_requires_added.is_empty() {
key_changes.push(format!(
"新增 BuildRequires: {}",
detailed_comparison.build_requires_added.join(", ")
));
}
if !detailed_comparison.build_requires_removed.is_empty() {
key_changes.push(format!(
"删除 BuildRequires: {}",
detailed_comparison.build_requires_removed.join(", ")
));
}
if !detailed_comparison.configure_options_added.is_empty() {
key_changes.push(format!(
"新增 configure 选项: {}",
detailed_comparison.configure_options_added.join(" ")
));
}
if !detailed_comparison.configure_options_removed.is_empty() {
key_changes.push(format!(
"删除 configure 选项: {}",
detailed_comparison.configure_options_removed.join(" ")
));
}
if detailed_comparison.sources_changed {
key_changes.push("Source 文件列表变化".to_string());
}
if detailed_comparison.patches_changed {
key_changes.push("Patch 文件列表变化".to_string());
}
Ok(SpecDiff {
version_diff,
content_identical,
diff_summary,
key_changes,
detailed_comparison: Some(detailed_comparison.clone()),
build_requires_added: detailed_comparison.build_requires_added.clone(),
build_requires_removed: detailed_comparison.build_requires_removed.clone(),
configure_options_added: detailed_comparison.configure_options_added.clone(),
configure_options_removed: detailed_comparison.configure_options_removed.clone(),
})
}
fn compare_version_relationship(
&self,
l1_version: &str,
l2_version: &str,
) -> Result<VersionRelationship> {
match (
VersionParser::parse(l1_version),
VersionParser::parse(l2_version),
) {
(Ok(v1), Ok(v2)) => {
if v2.is_newer_than(&v1) {
Ok(VersionRelationship::L2Newer)
} else if v1.is_newer_than(&v2) {
Ok(VersionRelationship::L2Older)
} else {
Ok(VersionRelationship::Same)
}
}
_ => Ok(VersionRelationship::Incomparable),
}
}
fn compare_patches(
&self,
l1_patches: &[PatchFile],
l2_patches: &[PatchFile],
) -> Result<PatchDiff> {
let l1_map: HashMap<String, &PatchFile> =
l1_patches.iter().map(|p| (p.filename.clone(), p)).collect();
let l2_map: HashMap<String, &PatchFile> =
l2_patches.iter().map(|p| (p.filename.clone(), p)).collect();
let mut l2_added = Vec::new();
let mut l2_removed = Vec::new();
let mut l2_modified = Vec::new();
let mut identical = Vec::new();
for l2_patch in l2_patches {
if let Some(l1_patch) = l1_map.get(&l2_patch.filename) {
if l1_patch.content_hash == l2_patch.content_hash {
tracing::info!("patch {} 内容 identical", l2_patch.filename);
identical.push(l2_patch.clone());
} else {
tracing::info!("patch {} 内容不同", l2_patch.filename);
l2_modified.push(PatchModification {
filename: l2_patch.filename.clone(),
l1_hash: l1_patch.content_hash.clone(),
l2_hash: l2_patch.content_hash.clone(),
});
}
} else {
tracing::info!("patch {} 新增", l2_patch.filename);
l2_added.push(l2_patch.clone());
}
}
for l1_patch in l1_patches {
if !l2_map.contains_key(&l1_patch.filename) {
tracing::info!("patch {} 已删除", l1_patch.filename);
l2_removed.push(l1_patch.clone());
}
}
Ok(PatchDiff {
l1_total: l1_patches.len(),
l2_total: l2_patches.len(),
l2_added,
l2_removed,
l2_modified,
identical,
})
}
fn compare_source_files(
&self,
l1_sources: &[SourceFile],
l2_sources: &[SourceFile],
) -> Result<SourceDiff> {
let l1_map: HashMap<String, &SourceFile> =
l1_sources.iter().map(|s| (s.filename.clone(), s)).collect();
let l2_map: HashMap<String, &SourceFile> =
l2_sources.iter().map(|s| (s.filename.clone(), s)).collect();
let mut l2_added = Vec::new();
let mut l2_removed = Vec::new();
let mut l2_modified = Vec::new();
for l2_source in l2_sources {
if let Some(l1_source) = l1_map.get(&l2_source.filename) {
if l1_source.content_hash != l2_source.content_hash {
l2_modified.push(SourceModification {
filename: l2_source.filename.clone(),
l1_hash: l1_source.content_hash.clone(),
l2_hash: l2_source.content_hash.clone(),
});
}
} else {
l2_added.push(l2_source.clone());
}
}
for l1_source in l1_sources {
if !l2_map.contains_key(&l1_source.filename) {
l2_removed.push(l1_source.clone());
}
}
Ok(SourceDiff {
l1_total: l1_sources.len(),
l2_total: l2_sources.len(),
l2_added,
l2_removed,
l2_modified,
})
}
fn analyze_customization_impact(
&self,
customizations: &[Customization],
) -> Result<CustomizationAnalysis> {
let mut by_type: HashMap<String, Vec<Customization>> = HashMap::new();
for custom in customizations {
let type_name = format!("{:?}", custom.customization_type);
by_type.entry(type_name).or_default().push(custom.clone());
}
let summary = if customizations.is_empty() {
"未检测到定制内容".to_string()
} else {
let mut summary_parts = Vec::new();
summary_parts.push(format!(
"检测到 {} 项定制内容,包括 {} 种类型",
customizations.len(),
by_type.len()
));
let type_order = [
("SecurityHardening", "安全加固"),
("VersionChange", "版本变更"),
("FeatureModification", "功能修改"),
("ConfigurationChange", "配置修改"),
("PerformanceOptimization", "性能优化"),
("Other", "其他"),
];
for (type_key, type_name_cn) in &type_order {
if let Some(items) = by_type.get(*type_key) {
summary_parts.push(format!("- {}: {} 项", type_name_cn, items.len()));
}
}
let mut highlights = Vec::new();
if let Some(security_items) = by_type.get("SecurityHardening") {
if !security_items.is_empty() {
highlights.push(format!(
"包含 {} 项安全加固,需要在同步时特别注意保留",
security_items.len()
));
}
}
if let Some(version_items) = by_type.get("VersionChange") {
if !version_items.is_empty() {
highlights.push(format!(
"包含 {} 项版本变更,可能影响升级策略",
version_items.len()
));
}
}
if let Some(feature_items) = by_type.get("FeatureModification") {
if feature_items.len() >= 3 {
highlights.push(format!(
"包含 {} 项功能修改,同步时可能需要重新适配",
feature_items.len()
));
}
}
if !highlights.is_empty() {
summary_parts.push("\n重点关注:".to_string());
summary_parts.extend(highlights.into_iter().map(|h| format!(" {}", h)));
}
summary_parts.join("\n")
};
Ok(CustomizationAnalysis {
total_customizations: customizations.len(),
by_type,
summary,
})
}
fn generate_sync_recommendations(
&self,
spec_diff: &SpecDiff,
patch_diff: &PatchDiff,
source_diff: &SourceDiff,
customization_analysis: &CustomizationAnalysis,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
recommendations.extend(self.generate_security_recommendations(patch_diff)?);
recommendations.extend(self.generate_version_recommendations(spec_diff)?);
recommendations.extend(self.generate_bugfix_recommendations(patch_diff)?);
recommendations.extend(self.generate_feature_recommendations(patch_diff)?);
recommendations.extend(self.generate_config_recommendations(spec_diff)?);
recommendations.extend(self.generate_source_recommendations(source_diff)?);
recommendations
.extend(self.generate_customization_recommendations(customization_analysis)?);
recommendations.extend(self.generate_dependency_recommendations(spec_diff)?);
recommendations.sort_by(|a, b| a.priority.cmp(&b.priority));
recommendations = Self::deduplicate_recommendations(recommendations);
recommendations.sort_by(|a, b| a.priority.cmp(&b.priority));
Ok(recommendations)
}
fn generate_security_recommendations(
&self,
patch_diff: &PatchDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
let security_patches: Vec<_> = patch_diff
.l2_removed
.iter()
.filter(|p| {
let filename_lower = p.filename.to_lowercase();
filename_lower.contains("cve-")
|| filename_lower.contains("security")
|| filename_lower.contains("vulnerability")
|| filename_lower.contains("exploit")
})
.collect();
if !security_patches.is_empty() {
let cve_numbers: Vec<String> = security_patches
.iter()
.filter_map(|p| Self::extract_cve_number(&p.filename))
.collect();
let description = if !cve_numbers.is_empty() {
format!(
"L1 新增了 {} 个安全补丁(包括 {}),强烈建议立即同步以修复安全漏洞",
security_patches.len(),
cve_numbers.join(", ")
)
} else {
format!(
"L1 新增了 {} 个安全相关补丁,强烈建议立即同步",
security_patches.len()
)
};
recommendations.push(SyncRecommendation {
priority: SyncPriority::Critical,
recommendation_type: SyncType::SecurityPatch,
description,
affected_files: security_patches
.iter()
.map(|p| p.filename.clone())
.collect(),
estimated_effort: EffortLevel::High,
});
}
Ok(recommendations)
}
fn extract_cve_number(filename: &str) -> Option<String> {
let filename_upper = filename.to_uppercase();
if let Some(start) = filename_upper.find("CVE-") {
let cve_part = &filename_upper[start..];
let mut end = 4;
let chars: Vec<char> = cve_part.chars().collect();
while end < chars.len() && chars[end].is_ascii_digit() {
end += 1;
}
if end < chars.len() && chars[end] == '-' {
end += 1;
}
while end < chars.len() && chars[end].is_ascii_digit() {
end += 1;
}
if end > 4 {
Some(cve_part[..end].to_string())
} else {
None
}
} else {
None
}
}
fn generate_version_recommendations(
&self,
spec_diff: &SpecDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
if let Some(version_diff) = &spec_diff.version_diff {
match version_diff.relationship {
VersionRelationship::L2Older => {
recommendations.push(SyncRecommendation {
priority: SyncPriority::High,
recommendation_type: SyncType::VersionUpgrade,
description: format!(
"L2 版本 ({}) 落后于 L1 版本 ({}),建议升级以获取最新功能和修复",
version_diff.l2_version, version_diff.l1_version
),
affected_files: vec!["*.spec".to_string()],
estimated_effort: EffortLevel::High,
});
}
VersionRelationship::L2Newer => {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Low,
recommendation_type: SyncType::VersionUpgrade,
description: format!(
"L2 版本 ({}) 领先于 L1 版本 ({}),建议评估是否需要向 L1 贡献变更",
version_diff.l2_version, version_diff.l1_version
),
affected_files: vec!["*.spec".to_string()],
estimated_effort: EffortLevel::Medium,
});
}
_ => {}
}
}
Ok(recommendations)
}
fn generate_bugfix_recommendations(
&self,
patch_diff: &PatchDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
let bugfix_patches: Vec<_> = patch_diff
.l2_removed
.iter()
.filter(|p| {
let filename_lower = p.filename.to_lowercase();
(filename_lower.contains("fix")
|| filename_lower.contains("bug")
|| filename_lower.contains("patch"))
&& !filename_lower.contains("cve-")
&& !filename_lower.contains("security")
&& !filename_lower.contains("vulnerability")
})
.collect();
if !bugfix_patches.is_empty() {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::BugFix,
description: format!(
"L1 新增了 {} 个 Bug 修复补丁,建议评估并同步到 L2",
bugfix_patches.len()
),
affected_files: bugfix_patches.iter().map(|p| p.filename.clone()).collect(),
estimated_effort: EffortLevel::Medium,
});
}
if !patch_diff.l2_modified.is_empty() {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::BugFix,
description: format!(
"L1 修改了 {} 个补丁文件,建议检查变更内容并决定是否同步",
patch_diff.l2_modified.len()
),
affected_files: patch_diff
.l2_modified
.iter()
.map(|m| m.filename.clone())
.collect(),
estimated_effort: EffortLevel::Medium,
});
}
Ok(recommendations)
}
fn generate_feature_recommendations(
&self,
patch_diff: &PatchDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
let feature_patches: Vec<_> = patch_diff
.l2_removed
.iter()
.filter(|p| {
let filename_lower = p.filename.to_lowercase();
(filename_lower.contains("feature")
|| filename_lower.contains("add-")
|| filename_lower.contains("enable-")
|| filename_lower.contains("support-")
|| filename_lower.contains("implement"))
&& !filename_lower.contains("cve-")
&& !filename_lower.contains("security")
&& !filename_lower.contains("fix")
})
.collect();
if !feature_patches.is_empty() {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::NewFeature,
description: format!(
"L1 新增了 {} 个功能补丁,建议评估这些新功能是否适用于 L2",
feature_patches.len()
),
affected_files: feature_patches.iter().map(|p| p.filename.clone()).collect(),
estimated_effort: EffortLevel::Medium,
});
}
Ok(recommendations)
}
fn generate_config_recommendations(
&self,
spec_diff: &SpecDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
if !spec_diff.build_requires_added.is_empty()
|| !spec_diff.build_requires_removed.is_empty()
{
let mut description_parts = Vec::new();
if !spec_diff.build_requires_added.is_empty() {
description_parts.push(format!(
"新增 {} 个 BuildRequires: {}",
spec_diff.build_requires_added.len(),
spec_diff.build_requires_added.join(", ")
));
}
if !spec_diff.build_requires_removed.is_empty() {
description_parts.push(format!(
"删除 {} 个 BuildRequires: {}",
spec_diff.build_requires_removed.len(),
spec_diff.build_requires_removed.join(", ")
));
}
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::ConfigUpdate,
description: format!(
"L1 的 BuildRequires 发生变更:{}。建议同步以确保构建依赖正确",
description_parts.join(";")
),
affected_files: vec!["*.spec".to_string()],
estimated_effort: EffortLevel::Low,
});
}
if !spec_diff.configure_options_added.is_empty()
|| !spec_diff.configure_options_removed.is_empty()
{
let mut description_parts = Vec::new();
if !spec_diff.configure_options_added.is_empty() {
description_parts.push(format!(
"新增选项: {}",
spec_diff.configure_options_added.join(" ")
));
}
if !spec_diff.configure_options_removed.is_empty() {
description_parts.push(format!(
"删除选项: {}",
spec_diff.configure_options_removed.join(" ")
));
}
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::ConfigUpdate,
description: format!(
"L1 的 configure 选项发生变更:{}。建议评估这些变更对 L2 的影响",
description_parts.join(";")
),
affected_files: vec!["*.spec".to_string()],
estimated_effort: EffortLevel::Medium,
});
}
Ok(recommendations)
}
fn generate_source_recommendations(
&self,
source_diff: &SourceDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
if !source_diff.l2_added.is_empty() {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Low,
recommendation_type: SyncType::NewFeature,
description: format!(
"L1 新增了 {} 个源文件,建议检查是否需要同步",
source_diff.l2_added.len()
),
affected_files: source_diff
.l2_added
.iter()
.map(|s| s.filename.clone())
.collect(),
estimated_effort: EffortLevel::Low,
});
}
if !source_diff.l2_modified.is_empty() {
recommendations.push(SyncRecommendation {
priority: SyncPriority::Low,
recommendation_type: SyncType::ConfigUpdate,
description: format!(
"L1 修改了 {} 个源文件,建议检查变更内容",
source_diff.l2_modified.len()
),
affected_files: source_diff
.l2_modified
.iter()
.map(|m| m.filename.clone())
.collect(),
estimated_effort: EffortLevel::Low,
});
}
Ok(recommendations)
}
fn generate_customization_recommendations(
&self,
customization_analysis: &CustomizationAnalysis,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
if customization_analysis.total_customizations > 0 {
let has_security = customization_analysis
.by_type
.get("SecurityHardening")
.map(|items| !items.is_empty())
.unwrap_or(false);
let priority = if has_security {
SyncPriority::High
} else {
SyncPriority::Low
};
recommendations.push(SyncRecommendation {
priority,
recommendation_type: SyncType::ConfigUpdate,
description: format!(
"L2 包含 {} 项定制内容,同步 L1 更新时需要特别注意保留这些定制。{}",
customization_analysis.total_customizations,
if has_security {
"特别注意:包含安全加固定制,必须保留"
} else {
"建议在同步前备份定制内容"
}
),
affected_files: Vec::new(),
estimated_effort: EffortLevel::Low,
});
}
Ok(recommendations)
}
fn generate_dependency_recommendations(
&self,
spec_diff: &SpecDiff,
) -> Result<Vec<SyncRecommendation>> {
let mut recommendations = Vec::new();
if let Some(detailed) = &spec_diff.detailed_comparison {
if !detailed.requires_added.is_empty() || !detailed.requires_removed.is_empty() {
let mut description_parts = Vec::new();
if !detailed.requires_added.is_empty() {
description_parts.push(format!(
"新增运行时依赖: {}",
detailed.requires_added.join(", ")
));
}
if !detailed.requires_removed.is_empty() {
description_parts.push(format!(
"删除运行时依赖: {}",
detailed.requires_removed.join(", ")
));
}
recommendations.push(SyncRecommendation {
priority: SyncPriority::Medium,
recommendation_type: SyncType::ConfigUpdate,
description: format!(
"L1 的运行时依赖发生变更:{}。建议同步以确保运行时环境正确",
description_parts.join(";")
),
affected_files: vec!["*.spec".to_string()],
estimated_effort: EffortLevel::Low,
});
}
}
Ok(recommendations)
}
fn deduplicate_recommendations(
recommendations: Vec<SyncRecommendation>,
) -> Vec<SyncRecommendation> {
use std::collections::HashSet;
let mut seen = HashSet::new();
let mut result = Vec::new();
for rec in recommendations {
let key = format!(
"{:?}:{}",
rec.recommendation_type,
rec.affected_files.join(",")
);
if !seen.contains(&key) {
seen.insert(key);
result.push(rec);
}
}
result
}
fn detect_conflicts(
&self,
spec_diff: &SpecDiff,
patch_diff: &PatchDiff,
source_diff: &SourceDiff,
) -> Result<Vec<MergeConflict>> {
let mut conflicts = Vec::new();
if let Some(version_diff) = &spec_diff.version_diff {
if version_diff.relationship == VersionRelationship::L2Newer {
conflicts.push(MergeConflict {
conflict_type: ConflictType::VersionConflict,
description: format!(
"L2 版本 ({}) 比 L1 版本 ({}) 更新,可能导致同步冲突",
version_diff.l2_version, version_diff.l1_version
),
files: vec!["*.spec".to_string()],
resolution_hint: "建议先确认 L2 的版本变更原因,再决定是否回退或保持"
.to_string(),
});
}
}
if !patch_diff.l2_modified.is_empty() {
conflicts.push(MergeConflict {
conflict_type: ConflictType::PatchConflict,
description: format!(
"{} 个补丁在 L1 和 L2 中都存在但内容不同",
patch_diff.l2_modified.len()
),
files: patch_diff
.l2_modified
.iter()
.map(|m| m.filename.clone())
.collect(),
resolution_hint: "需要人工对比补丁内容,决定保留哪个版本或合并变更".to_string(),
});
}
if !source_diff.l2_modified.is_empty() {
let critical_files: Vec<_> = source_diff
.l2_modified
.iter()
.filter(|m| {
m.filename.ends_with(".conf")
|| m.filename.ends_with(".cfg")
|| m.filename.ends_with(".ini")
})
.collect();
if !critical_files.is_empty() {
conflicts.push(MergeConflict {
conflict_type: ConflictType::FileModificationConflict,
description: format!(
"{} 个配置文件在 L1 和 L2 中都被修改",
critical_files.len()
),
files: critical_files.iter().map(|m| m.filename.clone()).collect(),
resolution_hint: "配置文件冲突可能影响系统行为,需要仔细对比并合并".to_string(),
});
}
}
if !spec_diff.content_identical && !spec_diff.key_changes.is_empty() {
conflicts.push(MergeConflict {
conflict_type: ConflictType::ConfigurationConflict,
description: "spec 文件存在关键变更,可能导致构建冲突".to_string(),
files: vec!["*.spec".to_string()],
resolution_hint: "建议对比 spec 文件的具体变更,确保构建配置兼容".to_string(),
});
}
Ok(conflicts)
}
async fn compare_commit_db(
&self,
l1_snapshot: &L1Snapshot,
l2_snapshot: &L2Snapshot,
db: &DatabaseConnection,
tracking_id: i32,
) -> Result<CommitDiff> {
use crate::entities::{l1_commit_records, l2_commit_records, prelude::*};
let l2_latest_commit = L2CommitRecords::find()
.filter(l2_commit_records::Column::TrackingId.eq(tracking_id))
.order_by_desc(l2_commit_records::Column::CommittedAt)
.one(db)
.await?;
let (l2_version, l2_release) = if let Some(commit) = &l2_latest_commit {
let version = commit
.spec_version
.clone()
.unwrap_or_else(|| l2_snapshot.version.clone());
let release = commit
.spec_release
.clone()
.or_else(|| Self::extract_release_from_spec(&l2_snapshot.spec_content));
(version, release)
} else {
let version = l2_snapshot.version.clone();
let release = Self::extract_release_from_spec(&l2_snapshot.spec_content);
(version, release)
};
let l1_models = L1CommitRecords::find()
.filter(l1_commit_records::Column::TrackingId.eq(tracking_id))
.order_by_desc(l1_commit_records::Column::CommittedAt)
.all(db)
.await?;
let l1_commits: Vec<CommitEntry> =
l1_models.iter().map(Self::model_to_commit_entry).collect();
let (base_commit, base_index) = {
let (commit, index) =
Self::find_base_commit_from_records(&l1_models, &l2_version, l2_release.as_deref());
if commit.is_some() {
(commit, index)
} else {
Self::find_base_commit(&l1_commits, &l2_version, l2_release.as_deref())
}
};
let l1_commits_count = l1_snapshot.commits.len();
let l2_commits_count = l2_snapshot.commits.len();
let behind_commits = if let Some(idx) = base_index {
l1_commits[..idx].to_vec()
} else {
l1_commits.clone()
};
let base_version_release = base_commit
.as_ref()
.map(|_| (l2_version.clone(), l2_release.clone()));
Ok(CommitDiff {
l1_commits_count,
l2_commits_count,
behind_commits,
base_commit,
base_version_release,
})
}
fn extract_release_from_spec(spec_content: &str) -> Option<String> {
for line in spec_content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Release:") {
let release_value = trimmed
.strip_prefix("Release:")
.map(|s| s.trim())
.unwrap_or("");
let cleaned = release_value
.replace("%{?dist}", "")
.replace("%{dist}", "")
.trim()
.to_string();
if !cleaned.is_empty() {
return Some(cleaned);
}
}
}
None
}
fn find_base_commit_from_records(
models: &[crate::entities::l1_commit_records::Model],
version: &str,
release: Option<&str>,
) -> (Option<CommitEntry>, Option<usize>) {
if let Some(rel) = release {
for (idx, model) in models.iter().enumerate() {
if let (Some(spec_ver), Some(spec_rel)) = (&model.spec_version, &model.spec_release)
{
if spec_ver == version && spec_rel == rel {
return (Some(Self::model_to_commit_entry(model)), Some(idx));
}
}
}
}
for (idx, model) in models.iter().enumerate() {
if let Some(spec_ver) = &model.spec_version {
if spec_ver == version {
return (Some(Self::model_to_commit_entry(model)), Some(idx));
}
}
}
(None, None)
}
fn model_to_commit_entry(model: &crate::entities::l1_commit_records::Model) -> CommitEntry {
CommitEntry {
sha: model.commit_sha.clone(),
title: model
.commit_message
.lines()
.next()
.unwrap_or("")
.to_string(),
message: model.commit_message.clone(),
author: model.author_name.clone(),
authored_at: model.committed_at,
url: Some(model.api_url.clone()),
stats: crate::snapshot::types::ChangeStats {
additions: model.additions,
deletions: model.deletions,
files_changed: model.files_changed_count,
},
primary_change_type: model.primary_change_type.clone(),
cve_list: model
.cve_list
.as_ref()
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
.unwrap_or_default(),
}
}
fn find_base_commit(
commits: &[CommitEntry],
version: &str,
release: Option<&str>,
) -> (Option<CommitEntry>, Option<usize>) {
let version_patterns = [
format!("Version: {}", version),
format!("version {}", version),
format!("v{}", version),
version.to_string(),
];
let release_patterns = release.map(|r| {
vec![
format!("Release: {}", r),
format!("release {}", r),
format!("-{}", r),
]
});
if let Some(ref rel_patterns) = release_patterns {
for (idx, commit) in commits.iter().enumerate() {
let message_lower = commit.message.to_lowercase();
let title_lower = commit.title.to_lowercase();
let has_version = version_patterns.iter().any(|pattern| {
message_lower.contains(&pattern.to_lowercase())
|| title_lower.contains(&pattern.to_lowercase())
});
let has_release = rel_patterns.iter().any(|pattern| {
message_lower.contains(&pattern.to_lowercase())
|| title_lower.contains(&pattern.to_lowercase())
});
if has_version && has_release {
tracing::info!(
"找到匹配 version={} release={:?} 的基线 commit: {} ({})",
version,
release,
commit.sha,
commit.title
);
return (Some(commit.clone()), Some(idx));
}
}
}
for (idx, commit) in commits.iter().enumerate() {
let message_lower = commit.message.to_lowercase();
let title_lower = commit.title.to_lowercase();
let has_version = version_patterns.iter().any(|pattern| {
message_lower.contains(&pattern.to_lowercase())
|| title_lower.contains(&pattern.to_lowercase())
});
if has_version {
tracing::info!(
"找到匹配 version={} 的基线 commit(无 release 匹配): {} ({})",
version,
commit.sha,
commit.title
);
return (Some(commit.clone()), Some(idx));
}
}
tracing::warn!(
"未找到匹配 version={} release={:?} 的基线 commit",
version,
release
);
(None, None)
}
}
impl Default for L2VsL1Comparator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use sea_orm::{DatabaseBackend, MockDatabase};
fn create_test_snapshot() -> RepositorySnapshot {
use crate::snapshot::types::SnapshotOrigin;
use crate::snapshot::types::SpecEntry;
use base64::Engine;
let spec_content = r#"
Name: testpkg
Version: 1.0.0
Release: 1%{?dist}
Summary: Test package
BuildRequires: gcc
%description
Test package
"#;
let spec_base64 = base64::engine::general_purpose::STANDARD.encode(spec_content);
RepositorySnapshot {
tracking_id: 1,
origin: SnapshotOrigin::L1,
spec: Some(SpecEntry {
path: "testpkg.spec".to_string(),
version: Some("1.0.0".to_string()),
release: Some("1".to_string()),
sha256: "spec_hash".to_string(),
content_base64: spec_base64,
}),
files: vec![
FileEntry {
path: "test.patch".to_string(),
sha256: "patch_hash".to_string(),
size: 100,
is_binary: false,
},
FileEntry {
path: "source.tar.gz".to_string(),
sha256: "source_hash".to_string(),
size: 1000,
is_binary: false,
},
],
commits: vec![],
generated_at: Utc::now(),
issues: vec![],
}
}
#[test]
fn test_patch_file_equality() {
let patch1 = PatchFile {
filename: "test.patch".to_string(),
path: "/path/to/test.patch".to_string(),
content_hash: "abc123".to_string(),
size: 1024,
applied: true,
};
let patch2 = patch1.clone();
assert_eq!(patch1, patch2);
}
#[test]
fn test_source_file_equality() {
let source1 = SourceFile {
filename: "test.tar.gz".to_string(),
path: "/path/to/test.tar.gz".to_string(),
content_hash: "def456".to_string(),
size: 2048,
};
let source2 = source1.clone();
assert_eq!(source1, source2);
}
#[test]
fn test_customization_type_equality() {
assert_eq!(
CustomizationType::VersionChange,
CustomizationType::VersionChange
);
assert_ne!(
CustomizationType::VersionChange,
CustomizationType::SecurityHardening
);
}
#[test]
fn test_version_relationship() {
assert_eq!(VersionRelationship::L2Newer, VersionRelationship::L2Newer);
assert_eq!(VersionRelationship::Same, VersionRelationship::Same);
assert_ne!(VersionRelationship::L2Newer, VersionRelationship::L2Older);
}
#[test]
fn test_sync_priority_ordering() {
assert!(SyncPriority::Critical < SyncPriority::High);
assert!(SyncPriority::High < SyncPriority::Medium);
assert!(SyncPriority::Medium < SyncPriority::Low);
}
#[test]
fn test_sync_type_equality() {
assert_eq!(SyncType::SecurityPatch, SyncType::SecurityPatch);
assert_ne!(SyncType::SecurityPatch, SyncType::BugFix);
}
#[test]
fn test_effort_level_equality() {
assert_eq!(EffortLevel::Low, EffortLevel::Low);
assert_ne!(EffortLevel::Low, EffortLevel::High);
}
#[test]
fn test_conflict_type_equality() {
assert_eq!(ConflictType::PatchConflict, ConflictType::PatchConflict);
assert_ne!(ConflictType::PatchConflict, ConflictType::VersionConflict);
}
#[test]
fn test_extract_cve_number_valid() {
assert_eq!(
L2VsL1Comparator::extract_cve_number("fix-CVE-2023-12345.patch"),
Some("CVE-2023-12345".to_string())
);
assert_eq!(
L2VsL1Comparator::extract_cve_number("CVE-2024-9999-security.patch"),
Some("CVE-2024-9999".to_string())
);
assert_eq!(
L2VsL1Comparator::extract_cve_number("cve-2022-1234.patch"),
Some("CVE-2022-1234".to_string())
);
}
#[test]
fn test_extract_cve_number_invalid() {
assert_eq!(L2VsL1Comparator::extract_cve_number("bugfix.patch"), None);
assert_eq!(L2VsL1Comparator::extract_cve_number("CVE-.patch"), None);
assert_eq!(L2VsL1Comparator::extract_cve_number("test.patch"), None);
}
#[test]
fn test_extract_release_from_spec() {
let spec_content = r#"
Name: mypackage
Version: 1.0.0
Release: 1%{?dist}
Summary: Test package
"#;
let release = L2VsL1Comparator::extract_release_from_spec(spec_content);
assert_eq!(release, Some("1".to_string()));
}
#[test]
fn test_extract_release_from_spec_no_release() {
let spec_content = r#"
Name: mypackage
Version: 1.0.0
Summary: Test package
"#;
let release = L2VsL1Comparator::extract_release_from_spec(spec_content);
assert_eq!(release, None);
}
#[test]
fn test_extract_release_from_spec_with_dist() {
let spec_content = "Release: 2.el8%{?dist}\n";
let release = L2VsL1Comparator::extract_release_from_spec(spec_content);
assert_eq!(release, Some("2.el8".to_string()));
}
#[test]
fn test_create_l1_snapshot() {
let snapshot = create_test_snapshot();
let result = L2VsL1Comparator::create_l1_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_ok());
let l1_snap = result.unwrap();
assert_eq!(l1_snap.package_name, "testpkg");
assert_eq!(l1_snap.version, "1.0.0");
assert_eq!(l1_snap.patches.len(), 1);
assert_eq!(l1_snap.source_files.len(), 1);
}
#[test]
fn test_create_l1_snapshot_no_spec() {
let mut snapshot = create_test_snapshot();
snapshot.spec = None;
let result = L2VsL1Comparator::create_l1_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("缺少 spec 文件"));
}
#[test]
fn test_create_l1_snapshot_no_version_in_spec() {
use crate::snapshot::types::SnapshotOrigin;
use crate::snapshot::types::SpecEntry;
use base64::Engine;
let spec_content = "Name: testpkg\nRelease: 1\nSummary: Test\n";
let spec_base64 = base64::engine::general_purpose::STANDARD.encode(spec_content);
let snapshot = RepositorySnapshot {
tracking_id: 1,
origin: SnapshotOrigin::L1,
spec: Some(SpecEntry {
path: "testpkg.spec".to_string(),
version: None,
release: Some("1".to_string()),
sha256: "spec_hash".to_string(),
content_base64: spec_base64,
}),
files: vec![],
commits: vec![],
generated_at: Utc::now(),
issues: vec![],
};
let result = L2VsL1Comparator::create_l1_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("无法从 spec 文件提取版本号"));
}
#[test]
fn test_create_l1_snapshot_invalid_base64() {
use crate::snapshot::types::SnapshotOrigin;
use crate::snapshot::types::SpecEntry;
let snapshot = RepositorySnapshot {
tracking_id: 1,
origin: SnapshotOrigin::L1,
spec: Some(SpecEntry {
path: "testpkg.spec".to_string(),
version: Some("1.0.0".to_string()),
release: Some("1".to_string()),
sha256: "spec_hash".to_string(),
content_base64: "%%%not_base64%%%".to_string(),
}),
files: vec![],
commits: vec![],
generated_at: Utc::now(),
issues: vec![],
};
let result = L2VsL1Comparator::create_l1_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("解码 spec 内容失败"));
}
#[test]
fn test_create_l1_snapshot_non_utf8_spec_content() {
use crate::snapshot::types::SnapshotOrigin;
use crate::snapshot::types::SpecEntry;
use base64::Engine;
let bytes = vec![0xffu8, 0xfeu8, 0xfdu8];
let spec_base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let snapshot = RepositorySnapshot {
tracking_id: 1,
origin: SnapshotOrigin::L1,
spec: Some(SpecEntry {
path: "testpkg.spec".to_string(),
version: Some("1.0.0".to_string()),
release: Some("1".to_string()),
sha256: "spec_hash".to_string(),
content_base64: spec_base64,
}),
files: vec![],
commits: vec![],
generated_at: Utc::now(),
issues: vec![],
};
let result = L2VsL1Comparator::create_l1_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("spec 内容不是有效的 UTF-8"));
}
#[test]
fn test_analyze_customization_impact_empty() {
let comparator = L2VsL1Comparator::new();
let analysis = comparator.analyze_customization_impact(&[]).unwrap();
assert_eq!(analysis.total_customizations, 0);
assert_eq!(analysis.by_type.len(), 0);
assert!(analysis.summary.contains("未检测到定制内容"));
}
#[test]
fn test_analyze_customization_impact_includes_highlights() {
let comparator = L2VsL1Comparator::new();
let customizations = vec![
Customization {
customization_type: CustomizationType::SecurityHardening,
description: "sec".to_string(),
affected_files: vec!["a".to_string()],
},
Customization {
customization_type: CustomizationType::VersionChange,
description: "ver".to_string(),
affected_files: vec!["b".to_string()],
},
Customization {
customization_type: CustomizationType::FeatureModification,
description: "f1".to_string(),
affected_files: vec!["c".to_string()],
},
Customization {
customization_type: CustomizationType::FeatureModification,
description: "f2".to_string(),
affected_files: vec!["d".to_string()],
},
Customization {
customization_type: CustomizationType::FeatureModification,
description: "f3".to_string(),
affected_files: vec!["e".to_string()],
},
];
let analysis = comparator
.analyze_customization_impact(&customizations)
.unwrap();
assert_eq!(analysis.total_customizations, 5);
assert!(analysis.by_type.contains_key("SecurityHardening"));
assert!(analysis.by_type.contains_key("VersionChange"));
assert!(analysis.by_type.contains_key("FeatureModification"));
assert!(analysis.summary.contains("重点关注:"));
assert!(analysis.summary.contains("安全加固"));
assert!(analysis.summary.contains("版本变更"));
assert!(analysis.summary.contains("功能修改"));
}
#[test]
fn test_generate_security_recommendations_with_cve() {
let comparator = L2VsL1Comparator::new();
let patch_diff = PatchDiff {
l1_total: 1,
l2_total: 0,
l2_added: vec![],
l2_removed: vec![PatchFile {
filename: "fix-CVE-2025-12345.patch".to_string(),
path: "fix-CVE-2025-12345.patch".to_string(),
content_hash: "h".to_string(),
size: 1,
applied: true,
}],
l2_modified: vec![],
identical: vec![],
};
let recs = comparator
.generate_security_recommendations(&patch_diff)
.unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].priority, SyncPriority::Critical);
assert_eq!(recs[0].recommendation_type, SyncType::SecurityPatch);
assert!(recs[0].description.contains("CVE-2025-12345"));
assert_eq!(recs[0].affected_files.len(), 1);
}
#[test]
fn test_generate_bugfix_recommendations_excludes_security_patches() {
let comparator = L2VsL1Comparator::new();
let patch_diff = PatchDiff {
l1_total: 3,
l2_total: 0,
l2_added: vec![],
l2_removed: vec![
PatchFile {
filename: "fix-bug.patch".to_string(),
path: "fix-bug.patch".to_string(),
content_hash: "h1".to_string(),
size: 1,
applied: true,
},
PatchFile {
filename: "security-fix.patch".to_string(),
path: "security-fix.patch".to_string(),
content_hash: "h2".to_string(),
size: 1,
applied: true,
},
PatchFile {
filename: "CVE-2025-0001.patch".to_string(),
path: "CVE-2025-0001.patch".to_string(),
content_hash: "h3".to_string(),
size: 1,
applied: true,
},
],
l2_modified: vec![],
identical: vec![],
};
let recs = comparator
.generate_bugfix_recommendations(&patch_diff)
.unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].recommendation_type, SyncType::BugFix);
assert_eq!(recs[0].affected_files, vec!["fix-bug.patch".to_string()]);
}
#[test]
fn test_generate_feature_recommendations_excludes_fix_and_security() {
let comparator = L2VsL1Comparator::new();
let patch_diff = PatchDiff {
l1_total: 3,
l2_total: 0,
l2_added: vec![],
l2_removed: vec![
PatchFile {
filename: "add-foo.patch".to_string(),
path: "add-foo.patch".to_string(),
content_hash: "h1".to_string(),
size: 1,
applied: true,
},
PatchFile {
filename: "fix-crash.patch".to_string(),
path: "fix-crash.patch".to_string(),
content_hash: "h2".to_string(),
size: 1,
applied: true,
},
PatchFile {
filename: "security-hardening.patch".to_string(),
path: "security-hardening.patch".to_string(),
content_hash: "h3".to_string(),
size: 1,
applied: true,
},
],
l2_modified: vec![],
identical: vec![],
};
let recs = comparator
.generate_feature_recommendations(&patch_diff)
.unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].recommendation_type, SyncType::NewFeature);
assert_eq!(recs[0].affected_files, vec!["add-foo.patch".to_string()]);
}
#[test]
fn test_generate_config_recommendations_for_buildrequires_and_configure() {
let comparator = L2VsL1Comparator::new();
let spec_diff = SpecDiff {
version_diff: None,
content_identical: false,
diff_summary: "x".to_string(),
key_changes: vec![],
detailed_comparison: Some(SpecComparison {
version_changed: false,
version_diff: None,
build_requires_added: vec![],
build_requires_removed: vec![],
requires_added: vec![],
requires_removed: vec![],
configure_options_added: vec![],
configure_options_removed: vec![],
sources_changed: false,
patches_changed: false,
}),
build_requires_added: vec!["gcc".to_string()],
build_requires_removed: vec!["make".to_string()],
configure_options_added: vec!["--with-foo".to_string()],
configure_options_removed: vec!["--without-bar".to_string()],
};
let recs = comparator
.generate_config_recommendations(&spec_diff)
.unwrap();
assert_eq!(recs.len(), 2);
assert!(recs.iter().all(|r| r.priority == SyncPriority::Medium));
assert!(recs.iter().any(|r| r.description.contains("BuildRequires")));
assert!(recs.iter().any(|r| r.description.contains("configure")));
}
#[test]
fn test_generate_source_recommendations_added_and_modified() {
let comparator = L2VsL1Comparator::new();
let source_diff = SourceDiff {
l1_total: 2,
l2_total: 1,
l2_added: vec![SourceFile {
filename: "new.tar.gz".to_string(),
path: "new.tar.gz".to_string(),
content_hash: "h".to_string(),
size: 1,
}],
l2_removed: vec![],
l2_modified: vec![SourceModification {
filename: "cfg.ini".to_string(),
l1_hash: "a".to_string(),
l2_hash: "b".to_string(),
}],
};
let recs = comparator
.generate_source_recommendations(&source_diff)
.unwrap();
assert_eq!(recs.len(), 2);
assert!(recs.iter().all(|r| r.priority == SyncPriority::Low));
assert!(recs
.iter()
.any(|r| r.recommendation_type == SyncType::NewFeature));
assert!(recs
.iter()
.any(|r| r.recommendation_type == SyncType::ConfigUpdate));
}
#[test]
fn test_generate_customization_recommendations_priority_changes_with_security() {
let comparator = L2VsL1Comparator::new();
let mut by_type: HashMap<String, Vec<Customization>> = HashMap::new();
by_type.insert(
"SecurityHardening".to_string(),
vec![Customization {
customization_type: CustomizationType::SecurityHardening,
description: "sec".to_string(),
affected_files: vec![],
}],
);
let analysis = CustomizationAnalysis {
total_customizations: 1,
by_type,
summary: "s".to_string(),
};
let recs = comparator
.generate_customization_recommendations(&analysis)
.unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].priority, SyncPriority::High);
assert!(recs[0].description.contains("必须保留"));
}
#[test]
fn test_generate_dependency_recommendations_requires_changes() {
let comparator = L2VsL1Comparator::new();
let spec_diff = SpecDiff {
version_diff: None,
content_identical: false,
diff_summary: "x".to_string(),
key_changes: vec![],
detailed_comparison: Some(SpecComparison {
version_changed: false,
version_diff: None,
build_requires_added: vec![],
build_requires_removed: vec![],
requires_added: vec!["zlib".to_string()],
requires_removed: vec!["glibc".to_string()],
configure_options_added: vec![],
configure_options_removed: vec![],
sources_changed: false,
patches_changed: false,
}),
build_requires_added: vec![],
build_requires_removed: vec![],
configure_options_added: vec![],
configure_options_removed: vec![],
};
let recs = comparator
.generate_dependency_recommendations(&spec_diff)
.unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].priority, SyncPriority::Medium);
assert!(recs[0].description.contains("运行时依赖"));
}
#[test]
fn test_deduplicate_recommendations_removes_duplicates_by_type_and_files() {
let file_list = vec!["a".to_string(), "b".to_string()];
let r1 = SyncRecommendation {
priority: SyncPriority::High,
recommendation_type: SyncType::BugFix,
description: "1".to_string(),
affected_files: file_list.clone(),
estimated_effort: EffortLevel::Low,
};
let r2 = SyncRecommendation {
priority: SyncPriority::Critical,
recommendation_type: SyncType::BugFix,
description: "2".to_string(),
affected_files: file_list.clone(),
estimated_effort: EffortLevel::High,
};
let deduped = L2VsL1Comparator::deduplicate_recommendations(vec![r1, r2]);
assert_eq!(deduped.len(), 1);
assert_eq!(deduped[0].description, "1");
}
#[test]
fn test_detect_conflicts_all_types() {
let comparator = L2VsL1Comparator::new();
let spec_diff = SpecDiff {
version_diff: Some(VersionDiff {
l1_version: "1.0.0".to_string(),
l2_version: "2.0.0".to_string(),
relationship: VersionRelationship::L2Newer,
}),
content_identical: false,
diff_summary: "x".to_string(),
key_changes: vec!["k".to_string()],
detailed_comparison: None,
build_requires_added: vec![],
build_requires_removed: vec![],
configure_options_added: vec![],
configure_options_removed: vec![],
};
let patch_diff = PatchDiff {
l1_total: 1,
l2_total: 1,
l2_added: vec![],
l2_removed: vec![],
l2_modified: vec![PatchModification {
filename: "p.patch".to_string(),
l1_hash: "a".to_string(),
l2_hash: "b".to_string(),
}],
identical: vec![],
};
let source_diff = SourceDiff {
l1_total: 1,
l2_total: 1,
l2_added: vec![],
l2_removed: vec![],
l2_modified: vec![SourceModification {
filename: "x.conf".to_string(),
l1_hash: "a".to_string(),
l2_hash: "b".to_string(),
}],
};
let conflicts = comparator
.detect_conflicts(&spec_diff, &patch_diff, &source_diff)
.unwrap();
assert_eq!(conflicts.len(), 4);
assert!(conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::VersionConflict));
assert!(conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::PatchConflict));
assert!(conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::FileModificationConflict));
assert!(conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::ConfigurationConflict));
}
#[test]
fn test_find_base_commit_from_records_exact_match() {
use crate::entities::l1_commit_records;
use chrono::Utc;
let now = Utc::now();
let model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "sha1".to_string(),
commit_message: "Version: 1.0.0\nRelease: 1\n".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.0.0".to_string()),
spec_release: Some("1".to_string()),
};
let (commit, idx) =
L2VsL1Comparator::find_base_commit_from_records(&[model], "1.0.0", Some("1"));
assert!(commit.is_some());
assert_eq!(idx, Some(0));
assert_eq!(commit.unwrap().sha, "sha1");
}
#[test]
fn test_find_base_commit_from_records_version_only_match() {
use crate::entities::l1_commit_records;
use chrono::Utc;
let now = Utc::now();
let model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "sha1".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.0.0".to_string()),
spec_release: None,
};
let (commit, idx) =
L2VsL1Comparator::find_base_commit_from_records(&[model], "1.0.0", Some("9"));
assert!(commit.is_some());
assert_eq!(idx, Some(0));
}
#[test]
fn test_find_base_commit_from_records_none() {
use crate::entities::l1_commit_records;
use chrono::Utc;
let now = Utc::now();
let model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "sha1".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: None,
spec_release: None,
};
let (commit, idx) =
L2VsL1Comparator::find_base_commit_from_records(&[model], "1.0.0", Some("1"));
assert!(commit.is_none());
assert!(idx.is_none());
}
#[test]
fn test_find_base_commit_with_release_match() {
let commit = CommitEntry {
sha: "s".to_string(),
title: "Version: 1.0.0 Release: 1".to_string(),
message: "Version: 1.0.0\nRelease: 1".to_string(),
author: "a".to_string(),
authored_at: Utc::now(),
url: None,
stats: crate::snapshot::types::ChangeStats {
additions: 0,
deletions: 0,
files_changed: 0,
},
primary_change_type: None,
cve_list: vec![],
};
let (base, idx) =
L2VsL1Comparator::find_base_commit(std::slice::from_ref(&commit), "1.0.0", Some("1"));
assert!(base.is_some());
assert_eq!(idx, Some(0));
assert_eq!(base.unwrap().sha, "s");
}
#[test]
fn test_find_base_commit_version_only_match() {
let commit = CommitEntry {
sha: "s".to_string(),
title: "bump v1.0.0".to_string(),
message: "bump v1.0.0".to_string(),
author: "a".to_string(),
authored_at: Utc::now(),
url: None,
stats: crate::snapshot::types::ChangeStats {
additions: 0,
deletions: 0,
files_changed: 0,
},
primary_change_type: None,
cve_list: vec![],
};
let (base, idx) =
L2VsL1Comparator::find_base_commit(std::slice::from_ref(&commit), "1.0.0", Some("9"));
assert!(base.is_some());
assert_eq!(idx, Some(0));
}
#[test]
fn test_find_base_commit_none() {
let commit = CommitEntry {
sha: "s".to_string(),
title: "no match".to_string(),
message: "no match".to_string(),
author: "a".to_string(),
authored_at: Utc::now(),
url: None,
stats: crate::snapshot::types::ChangeStats {
additions: 0,
deletions: 0,
files_changed: 0,
},
primary_change_type: None,
cve_list: vec![],
};
let (base, idx) = L2VsL1Comparator::find_base_commit(&[commit], "1.0.0", Some("1"));
assert!(base.is_none());
assert!(idx.is_none());
}
#[tokio::test]
async fn test_compare_commit_db_uses_l2_record_version_release() {
use crate::entities::{l1_commit_records, l2_commit_records};
use sea_orm::MockDatabase;
use serde_json::json;
let now = Utc::now();
let l2_model = l2_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l2sha".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.2.3".to_string()),
spec_release: Some("7".to_string()),
};
let l1_model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l1sha".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: Some(json!(["CVE-2025-1"])),
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.2.3".to_string()),
spec_release: Some("7".to_string()),
};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<l2_commit_records::Model, _, _>(vec![vec![l2_model]])
.append_query_results::<l1_commit_records::Model, _, _>(vec![vec![l1_model]])
.into_connection();
let comparator = L2VsL1Comparator::new();
let l1_snapshot = L1Snapshot {
package_name: "p".to_string(),
version: "1.2.3".to_string(),
spec_content: "Name: p\nVersion: 1.2.3\nRelease: 7\n".to_string(),
spec_sha256: "a".to_string(),
patches: vec![],
source_files: vec![],
commits: vec![],
snapshot_at: now,
};
let l2_snapshot = L2Snapshot {
package_name: "p".to_string(),
version: "1.2.3".to_string(),
spec_content: "Name: p\nVersion: 1.2.3\nRelease: 7\n".to_string(),
spec_sha256: "b".to_string(),
patches: vec![],
source_files: vec![],
customizations: vec![],
commits: vec![],
snapshot_at: now,
};
let diff = comparator
.compare_commit_db(&l1_snapshot, &l2_snapshot, &db, 1)
.await
.unwrap();
assert_eq!(diff.base_commit.as_ref().unwrap().sha, "l1sha");
assert_eq!(
diff.base_version_release,
Some(("1.2.3".to_string(), Some("7".to_string())))
);
assert_eq!(
diff.base_commit.as_ref().unwrap().cve_list,
vec!["CVE-2025-1".to_string()]
);
}
#[tokio::test]
async fn test_compare_commit_db_falls_back_to_spec_content_release() {
use crate::entities::{l1_commit_records, l2_commit_records};
use sea_orm::MockDatabase;
let now = Utc::now();
let l2_model = l2_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l2sha".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: None,
spec_release: None,
};
let l1_model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l1sha".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.2.3".to_string()),
spec_release: Some("9".to_string()),
};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<l2_commit_records::Model, _, _>(vec![vec![l2_model]])
.append_query_results::<l1_commit_records::Model, _, _>(vec![vec![l1_model]])
.into_connection();
let comparator = L2VsL1Comparator::new();
let l1_snapshot = L1Snapshot {
package_name: "p".to_string(),
version: "1.2.3".to_string(),
spec_content: "Name: p\nVersion: 1.2.3\nRelease: 9\n".to_string(),
spec_sha256: "a".to_string(),
patches: vec![],
source_files: vec![],
commits: vec![],
snapshot_at: now,
};
let l2_snapshot = L2Snapshot {
package_name: "p".to_string(),
version: "1.2.3".to_string(),
spec_content: "Name: p\nVersion: 1.2.3\nRelease: 9%{?dist}\n".to_string(),
spec_sha256: "b".to_string(),
patches: vec![],
source_files: vec![],
customizations: vec![],
commits: vec![],
snapshot_at: now,
};
let diff = comparator
.compare_commit_db(&l1_snapshot, &l2_snapshot, &db, 1)
.await
.unwrap();
assert_eq!(diff.base_commit.as_ref().unwrap().sha, "l1sha");
assert_eq!(
diff.base_version_release,
Some(("1.2.3".to_string(), Some("9".to_string())))
);
}
#[tokio::test]
async fn test_compare_commit_db_no_base_commit_means_all_behind() {
use crate::entities::{l1_commit_records, l2_commit_records};
use sea_orm::MockDatabase;
let now = Utc::now();
let l2_model = l2_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l2sha".to_string(),
commit_message: "msg".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("9.9.9".to_string()),
spec_release: Some("1".to_string()),
};
let l1_model = l1_commit_records::Model {
id: 1,
tracking_id: 1,
commit_sha: "l1sha".to_string(),
commit_message: "no match".to_string(),
author_name: "a".to_string(),
author_email: "a@a".to_string(),
committed_at: now,
change_type: None,
primary_change_type: None,
cve_list: None,
spec_changed: true,
patch_stats: None,
classification_status: "done".to_string(),
classification_notes: None,
sync_status: "idle".to_string(),
synced_to_l2_commit: None,
synced_at: None,
api_url: "http://example".to_string(),
fetched_at: now,
files_changed_count: 0,
additions: 0,
deletions: 0,
created_at: now,
updated_at: now,
spec_version: Some("1.0.0".to_string()),
spec_release: Some("1".to_string()),
};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<l2_commit_records::Model, _, _>(vec![vec![l2_model]])
.append_query_results::<l1_commit_records::Model, _, _>(vec![vec![l1_model]])
.into_connection();
let comparator = L2VsL1Comparator::new();
let l1_snapshot = L1Snapshot {
package_name: "p".to_string(),
version: "1.0.0".to_string(),
spec_content: "Name: p\nVersion: 1.0.0\nRelease: 1\n".to_string(),
spec_sha256: "a".to_string(),
patches: vec![],
source_files: vec![],
commits: vec![],
snapshot_at: now,
};
let l2_snapshot = L2Snapshot {
package_name: "p".to_string(),
version: "9.9.9".to_string(),
spec_content: "Name: p\nVersion: 9.9.9\nRelease: 1\n".to_string(),
spec_sha256: "b".to_string(),
patches: vec![],
source_files: vec![],
customizations: vec![],
commits: vec![],
snapshot_at: now,
};
let diff = comparator
.compare_commit_db(&l1_snapshot, &l2_snapshot, &db, 1)
.await
.unwrap();
assert!(diff.base_commit.is_none());
assert_eq!(diff.behind_commits.len(), 1);
assert_eq!(diff.behind_commits[0].sha, "l1sha");
}
#[tokio::test]
async fn test_compare_builds_report_without_panicking() {
let comparator = L2VsL1Comparator::new();
let now = Utc::now();
let l1_snapshot = L1Snapshot {
package_name: "p".to_string(),
version: "1.0.0".to_string(),
spec_content: "Name: p\nVersion: 1.0.0\nRelease: 1\nBuildRequires: gcc\n".to_string(),
spec_sha256: "a".to_string(),
patches: vec![PatchFile {
filename: "fix.patch".to_string(),
path: "fix.patch".to_string(),
content_hash: "h1".to_string(),
size: 1,
applied: true,
}],
source_files: vec![SourceFile {
filename: "x.conf".to_string(),
path: "x.conf".to_string(),
content_hash: "s1".to_string(),
size: 1,
}],
commits: vec![],
snapshot_at: now,
};
let l2_snapshot = L2Snapshot {
package_name: "p".to_string(),
version: "2.0.0".to_string(),
spec_content: "Name: p\nVersion: 2.0.0\nRelease: 1\nBuildRequires: gcc\n".to_string(),
spec_sha256: "b".to_string(),
patches: vec![PatchFile {
filename: "fix.patch".to_string(),
path: "fix.patch".to_string(),
content_hash: "h2".to_string(),
size: 1,
applied: true,
}],
source_files: vec![SourceFile {
filename: "x.conf".to_string(),
path: "x.conf".to_string(),
content_hash: "s2".to_string(),
size: 1,
}],
customizations: vec![Customization {
customization_type: CustomizationType::SecurityHardening,
description: "sec".to_string(),
affected_files: vec!["x.conf".to_string()],
}],
commits: vec![],
snapshot_at: now,
};
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<crate::entities::l2_commit_records::Model, _, _>(vec![vec![]])
.append_query_results::<crate::entities::l1_commit_records::Model, _, _>(vec![vec![]])
.into_connection();
let report = comparator
.compare(&l1_snapshot, &l2_snapshot, &db, 1)
.await
.unwrap();
assert_eq!(report.package_name, "p");
assert!(!report.sync_recommendations.is_empty());
assert!(!report.conflicts.is_empty());
}
#[test]
fn test_create_l2_snapshot() {
let snapshot = create_test_snapshot();
let result = L2VsL1Comparator::create_l2_snapshot("testpkg".to_string(), &snapshot);
assert!(result.is_ok());
let l2_snap = result.unwrap();
assert_eq!(l2_snap.package_name, "testpkg");
assert_eq!(l2_snap.version, "1.0.0");
assert_eq!(l2_snap.patches.len(), 1);
assert_eq!(l2_snap.source_files.len(), 1);
assert!(!l2_snap.customizations.is_empty() || l2_snap.customizations.is_empty());
}
#[test]
fn test_extract_patches() {
let files = vec![
FileEntry {
path: "fix.patch".to_string(),
sha256: "hash1".to_string(),
size: 100,
is_binary: false,
},
FileEntry {
path: "bugfix.diff".to_string(),
sha256: "hash2".to_string(),
size: 200,
is_binary: false,
},
FileEntry {
path: "source.tar.gz".to_string(),
sha256: "hash3".to_string(),
size: 1000,
is_binary: false,
},
];
let patches = L2VsL1Comparator::extract_patches(&files).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].filename, "fix.patch");
assert_eq!(patches[1].filename, "bugfix.diff");
}
#[test]
fn test_extract_source_files() {
let files = vec![
FileEntry {
path: "source.tar.gz".to_string(),
sha256: "hash1".to_string(),
size: 1000,
is_binary: false,
},
FileEntry {
path: "fix.patch".to_string(),
sha256: "hash2".to_string(),
size: 100,
is_binary: false,
},
FileEntry {
path: "test.spec".to_string(),
sha256: "hash3".to_string(),
size: 500,
is_binary: false,
},
];
let sources = L2VsL1Comparator::extract_source_files(&files).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].filename, "source.tar.gz");
}
#[test]
fn test_analyze_spec_customizations_with_markers() {
let spec_content = r#"
# Custom: Enterprise build
# Version modified for compatibility
Name: test
Version: 1.0.0
"#;
let customizations = L2VsL1Comparator::analyze_spec_customizations(spec_content).unwrap();
assert!(!customizations.is_empty());
assert!(customizations
.iter()
.any(|c| matches!(c.customization_type, CustomizationType::Other)));
assert!(customizations
.iter()
.any(|c| matches!(c.customization_type, CustomizationType::VersionChange)));
}
#[test]
fn test_analyze_spec_customizations_config() {
let spec_content = r#"
%configure --with-ssl --enable-shared --disable-static
"#;
let customizations = L2VsL1Comparator::analyze_spec_customizations(spec_content).unwrap();
assert!(customizations
.iter()
.any(|c| matches!(c.customization_type, CustomizationType::ConfigurationChange)));
}
#[test]
fn test_analyze_spec_customizations_security() {
let spec_content = r#"
# Security: Hardening enabled
CFLAGS="-fstack-protector-strong -D_FORTIFY_SOURCE=2"
"#;
let customizations = L2VsL1Comparator::analyze_spec_customizations(spec_content).unwrap();
assert!(customizations
.iter()
.any(|c| matches!(c.customization_type, CustomizationType::SecurityHardening)));
}
#[test]
fn test_analyze_patch_customizations_security() {
let patches = vec![PatchFile {
filename: "CVE-2023-1234-fix.patch".to_string(),
path: "/patches/CVE-2023-1234-fix.patch".to_string(),
content_hash: "hash1".to_string(),
size: 100,
applied: true,
}];
let customizations = L2VsL1Comparator::analyze_patch_customizations(&patches).unwrap();
assert_eq!(customizations.len(), 1);
assert!(matches!(
customizations[0].customization_type,
CustomizationType::SecurityHardening
));
}
#[test]
fn test_analyze_patch_customizations_custom() {
let patches = vec![PatchFile {
filename: "custom-enterprise-feature.patch".to_string(),
path: "/patches/custom-enterprise-feature.patch".to_string(),
content_hash: "hash1".to_string(),
size: 100,
applied: true,
}];
let customizations = L2VsL1Comparator::analyze_patch_customizations(&patches).unwrap();
assert_eq!(customizations.len(), 1);
assert!(matches!(
customizations[0].customization_type,
CustomizationType::FeatureModification
));
}
#[test]
fn test_analyze_patch_customizations_config() {
let patches = vec![PatchFile {
filename: "configure-options.patch".to_string(),
path: "/patches/configure-options.patch".to_string(),
content_hash: "hash1".to_string(),
size: 100,
applied: true,
}];
let customizations = L2VsL1Comparator::analyze_patch_customizations(&patches).unwrap();
assert_eq!(customizations.len(), 1);
assert!(matches!(
customizations[0].customization_type,
CustomizationType::ConfigurationChange
));
}
#[test]
fn test_analyze_patch_customizations_performance() {
let patches = vec![PatchFile {
filename: "performance-optimization.patch".to_string(),
path: "/patches/performance-optimization.patch".to_string(),
content_hash: "hash1".to_string(),
size: 100,
applied: true,
}];
let customizations = L2VsL1Comparator::analyze_patch_customizations(&patches).unwrap();
assert_eq!(customizations.len(), 1);
assert!(matches!(
customizations[0].customization_type,
CustomizationType::PerformanceOptimization
));
}
#[test]
fn test_patch_diff_structure() {
let patch = PatchFile {
filename: "test.patch".to_string(),
path: "/test.patch".to_string(),
content_hash: "hash1".to_string(),
size: 100,
applied: true,
};
let diff = PatchDiff {
l1_total: 5,
l2_total: 4,
l2_added: vec![patch.clone()],
l2_removed: vec![],
l2_modified: vec![],
identical: vec![patch],
};
assert_eq!(diff.l1_total, 5);
assert_eq!(diff.l2_total, 4);
assert_eq!(diff.l2_added.len(), 1);
}
#[test]
fn test_commit_diff_structure() {
let diff = CommitDiff {
l1_commits_count: 10,
l2_commits_count: 8,
behind_commits: vec![],
base_commit: None,
base_version_release: Some(("1.0.0".to_string(), Some("1".to_string()))),
};
assert_eq!(diff.l1_commits_count, 10);
assert_eq!(diff.l2_commits_count, 8);
assert!(diff.base_commit.is_none());
assert!(diff.base_version_release.is_some());
}
#[test]
fn test_customization_analysis_structure() {
let analysis = CustomizationAnalysis {
total_customizations: 3,
by_type: HashMap::new(),
summary: "Test summary".to_string(),
};
assert_eq!(analysis.total_customizations, 3);
assert_eq!(analysis.summary, "Test summary");
}
#[test]
fn test_sync_recommendation_structure() {
let rec = SyncRecommendation {
priority: SyncPriority::High,
recommendation_type: SyncType::SecurityPatch,
description: "Security fix needed".to_string(),
affected_files: vec!["test.patch".to_string()],
estimated_effort: EffortLevel::Medium,
};
assert_eq!(rec.priority, SyncPriority::High);
assert_eq!(rec.recommendation_type, SyncType::SecurityPatch);
assert_eq!(rec.estimated_effort, EffortLevel::Medium);
}
#[test]
fn test_merge_conflict_structure() {
let conflict = MergeConflict {
conflict_type: ConflictType::PatchConflict,
description: "Patch modified in both versions".to_string(),
files: vec!["test.patch".to_string()],
resolution_hint: "Manual merge required".to_string(),
};
assert_eq!(conflict.conflict_type, ConflictType::PatchConflict);
assert_eq!(conflict.files.len(), 1);
}
#[test]
fn test_l2_vs_l1_comparator_creation() {
let comparator = L2VsL1Comparator::new();
assert_eq!(
std::mem::size_of_val(&comparator),
std::mem::size_of::<L2VsL1Comparator>()
);
}
}