use std::collections::{HashMap, HashSet};
use dk_core::types::{SymbolKind, Visibility};
use crate::findings::{Finding, Severity};
use super::checks::{CheckContext, SemanticCheck};
pub struct NoPublicRemoval;
impl NoPublicRemoval {
pub fn new() -> Self {
Self
}
}
impl SemanticCheck for NoPublicRemoval {
fn name(&self) -> &str {
"no-public-removal"
}
fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
let mut findings = Vec::new();
let after_names: HashSet<&str> = ctx
.after_symbols
.iter()
.map(|s| s.qualified_name.as_str())
.collect();
for before_sym in &ctx.before_symbols {
if before_sym.visibility != Visibility::Public {
continue;
}
if !after_names.contains(before_sym.qualified_name.as_str()) {
findings.push(Finding {
severity: Severity::Error,
check_name: self.name().to_string(),
message: format!(
"public {} '{}' was removed",
before_sym.kind, before_sym.qualified_name
),
file_path: Some(before_sym.file_path.to_string_lossy().to_string()),
line: None,
symbol: Some(before_sym.qualified_name.clone()),
});
}
}
findings
}
}
pub struct SignatureStable;
impl SignatureStable {
pub fn new() -> Self {
Self
}
}
impl SemanticCheck for SignatureStable {
fn name(&self) -> &str {
"signature-stable"
}
fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
let mut findings = Vec::new();
let before_sigs: HashMap<&str, Option<&str>> = ctx
.before_symbols
.iter()
.filter(|s| s.visibility == Visibility::Public)
.map(|s| (s.qualified_name.as_str(), s.signature.as_deref()))
.collect();
for after_sym in &ctx.after_symbols {
if after_sym.visibility != Visibility::Public {
continue;
}
if let Some(before_sig) = before_sigs.get(after_sym.qualified_name.as_str()) {
let after_sig = after_sym.signature.as_deref();
if *before_sig != after_sig {
findings.push(Finding {
severity: Severity::Error,
check_name: self.name().to_string(),
message: format!(
"public {} '{}' signature changed: {:?} → {:?}",
after_sym.kind,
after_sym.qualified_name,
before_sig,
after_sig
),
file_path: Some(after_sym.file_path.to_string_lossy().to_string()),
line: None,
symbol: Some(after_sym.qualified_name.clone()),
});
}
}
}
findings
}
}
pub struct TraitImplComplete;
impl TraitImplComplete {
pub fn new() -> Self {
Self
}
}
impl SemanticCheck for TraitImplComplete {
fn name(&self) -> &str {
"trait-impl-complete"
}
fn run(&self, ctx: &CheckContext) -> Vec<Finding> {
use dk_core::types::SymbolId;
let mut findings = Vec::new();
let mut before_impl_methods: HashMap<SymbolId, HashSet<&str>> = HashMap::new();
for sym in &ctx.before_symbols {
if sym.kind == SymbolKind::Function {
if let Some(parent_id) = sym.parent {
before_impl_methods
.entry(parent_id)
.or_default()
.insert(sym.qualified_name.as_str());
}
}
}
let mut after_impl_methods: HashMap<SymbolId, HashSet<&str>> = HashMap::new();
for sym in &ctx.after_symbols {
if sym.kind == SymbolKind::Function {
if let Some(parent_id) = sym.parent {
after_impl_methods
.entry(parent_id)
.or_default()
.insert(sym.qualified_name.as_str());
}
}
}
for (parent_id, before_methods) in &before_impl_methods {
let after_methods = after_impl_methods.get(parent_id);
let after_set = after_methods.cloned().unwrap_or_default();
let lost: Vec<&str> = before_methods
.difference(&after_set)
.copied()
.collect();
if !lost.is_empty() {
let parent_name = ctx
.before_symbols
.iter()
.find(|s| s.id == *parent_id)
.map(|s| s.qualified_name.as_str())
.unwrap_or("unknown");
findings.push(Finding {
severity: Severity::Warning,
check_name: self.name().to_string(),
message: format!(
"impl block '{}' lost {} method(s): {}",
parent_name,
lost.len(),
lost.join(", ")
),
file_path: None,
line: None,
symbol: Some(parent_name.to_string()),
});
}
}
findings
}
}
pub fn compat_checks() -> Vec<Box<dyn SemanticCheck>> {
vec![
Box::new(NoPublicRemoval::new()),
Box::new(SignatureStable::new()),
Box::new(TraitImplComplete::new()),
]
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::checks::CheckContext;
use dk_core::types::{Span, Symbol, SymbolKind, Visibility};
use uuid::Uuid;
fn empty_context() -> CheckContext {
CheckContext {
before_symbols: vec![],
after_symbols: vec![],
before_call_graph: vec![],
after_call_graph: vec![],
before_deps: vec![],
after_deps: vec![],
changed_files: vec![],
}
}
fn make_sym(name: &str, vis: Visibility, sig: Option<&str>, parent: Option<Uuid>) -> Symbol {
Symbol {
id: Uuid::new_v4(),
name: name.split("::").last().unwrap_or(name).into(),
qualified_name: name.into(),
kind: SymbolKind::Function,
visibility: vis,
file_path: "src/lib.rs".into(),
span: Span { start_byte: 0, end_byte: 100 },
signature: sig.map(String::from),
doc_comment: None,
parent,
last_modified_by: None,
last_modified_intent: None,
}
}
#[test]
fn test_no_public_removal_detects() {
let mut ctx = empty_context();
ctx.before_symbols.push(make_sym("crate::foo", Visibility::Public, None, None));
let check = NoPublicRemoval::new();
let findings = check.run(&ctx);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Error);
}
#[test]
fn test_no_public_removal_ignores_private() {
let mut ctx = empty_context();
ctx.before_symbols.push(make_sym("crate::internal", Visibility::Private, None, None));
let check = NoPublicRemoval::new();
assert!(check.run(&ctx).is_empty());
}
#[test]
fn test_signature_stable_detects_change() {
let mut ctx = empty_context();
ctx.before_symbols.push(make_sym(
"crate::process",
Visibility::Public,
Some("fn process(x: u32) -> u32"),
None,
));
ctx.after_symbols.push(make_sym(
"crate::process",
Visibility::Public,
Some("fn process(x: u32, y: u32) -> u32"),
None,
));
let check = SignatureStable::new();
let findings = check.run(&ctx);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Error);
}
#[test]
fn test_signature_stable_no_change() {
let mut ctx = empty_context();
let sig = "fn process(x: u32) -> u32";
ctx.before_symbols.push(make_sym("crate::process", Visibility::Public, Some(sig), None));
ctx.after_symbols.push(make_sym("crate::process", Visibility::Public, Some(sig), None));
let check = SignatureStable::new();
assert!(check.run(&ctx).is_empty());
}
#[test]
fn test_trait_impl_complete_detects_lost_method() {
let parent_id = Uuid::new_v4();
let mut ctx = empty_context();
let mut parent_sym = make_sym("crate::MyStruct", Visibility::Public, None, None);
parent_sym.id = parent_id;
parent_sym.kind = SymbolKind::Impl;
ctx.before_symbols.push(parent_sym.clone());
ctx.after_symbols.push(parent_sym);
ctx.before_symbols.push(make_sym("crate::MyStruct::method_a", Visibility::Public, None, Some(parent_id)));
ctx.before_symbols.push(make_sym("crate::MyStruct::method_b", Visibility::Public, None, Some(parent_id)));
ctx.after_symbols.push(make_sym("crate::MyStruct::method_a", Visibility::Public, None, Some(parent_id)));
let check = TraitImplComplete::new();
let findings = check.run(&ctx);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("method_b"));
}
}