use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use super::{EdgeType, NodeId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum ConfidenceTier {
SameFile,
ImportScoped,
#[default]
Global,
}
impl ConfidenceTier {
#[must_use]
pub fn default_score(&self) -> f32 {
match self {
Self::SameFile => 0.95,
Self::ImportScoped => 0.90,
Self::Global => 0.50,
}
}
}
impl ConfidenceTier {
#[must_use]
pub fn as_db_type(&self) -> &'static str {
match self {
Self::SameFile => "SAME_FILE",
Self::ImportScoped => "IMPORT_SCOPED",
Self::Global => "GLOBAL",
}
}
}
impl fmt::Display for ConfidenceTier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_db_type())
}
}
impl FromStr for ConfidenceTier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"SAME_FILE" => Ok(Self::SameFile),
"IMPORT_SCOPED" => Ok(Self::ImportScoped),
"GLOBAL" => Ok(Self::Global),
other => Err(format!("unknown ConfidenceTier: {other}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
pub source: NodeId,
pub target: NodeId,
pub edge_type: EdgeType,
pub confidence: f32,
#[serde(default)]
pub confidence_tier: ConfidenceTier,
pub reason: Option<String>,
pub start_line: Option<u32>,
pub project: String,
}
impl Edge {
#[must_use]
pub fn new(
source: impl Into<String>,
target: impl Into<String>,
edge_type: EdgeType,
project: impl Into<String>,
) -> Self {
Edge {
source: source.into(),
target: target.into(),
edge_type,
confidence: 1.0,
confidence_tier: ConfidenceTier::Global,
reason: None,
start_line: None,
project: project.into(),
}
}
pub fn builder(
source: impl Into<String>,
target: impl Into<String>,
edge_type: EdgeType,
project: impl Into<String>,
) -> EdgeBuilder {
EdgeBuilder {
edge: Edge::new(source, target, edge_type, project),
}
}
}
#[must_use]
#[derive(Debug, Clone)]
pub struct EdgeBuilder {
edge: Edge,
}
impl EdgeBuilder {
pub fn source(mut self, source: impl Into<String>) -> Self {
self.edge.source = source.into();
self
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.edge.target = target.into();
self
}
pub fn edge_type(mut self, edge_type: EdgeType) -> Self {
self.edge.edge_type = edge_type;
self
}
pub fn confidence(mut self, confidence: f32) -> Self {
self.edge.confidence = confidence;
self
}
pub fn confidence_tier(mut self, tier: ConfidenceTier) -> Self {
self.edge.confidence_tier = tier;
self
}
pub fn reason(mut self, reason: impl Into<String>) -> Self {
self.edge.reason = Some(reason.into());
self
}
pub fn start_line(mut self, start_line: u32) -> Self {
self.edge.start_line = Some(start_line);
self
}
pub fn project(mut self, project: impl Into<String>) -> Self {
self.edge.project = project.into();
self
}
#[must_use]
pub fn build(self) -> Edge {
self.edge
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_sets_required_fields_and_default_confidence() {
let edge = Edge::new("s", "t", EdgeType::Calls, "proj");
assert_eq!(edge.source, "s");
assert_eq!(edge.target, "t");
assert_eq!(edge.edge_type, EdgeType::Calls);
assert_eq!(edge.confidence, 1.0);
assert_eq!(edge.reason, None);
assert_eq!(edge.start_line, None);
assert_eq!(edge.project, "proj");
}
#[test]
fn new_accepts_string_and_str() {
let s = String::from("src");
let t = String::from("tgt");
let p = String::from("proj");
let edge = Edge::new(s, t, EdgeType::Reads, p);
assert_eq!(edge.source, "src");
assert_eq!(edge.target, "tgt");
assert_eq!(edge.project, "proj");
}
#[test]
fn builder_sets_required_fields() {
let edge = Edge::builder("s", "t", EdgeType::Calls, "proj").build();
assert_eq!(edge.source, "s");
assert_eq!(edge.target, "t");
assert_eq!(edge.edge_type, EdgeType::Calls);
assert_eq!(edge.project, "proj");
assert_eq!(edge.confidence, 1.0);
}
#[test]
fn builder_fluent_setters() {
let edge = Edge::builder("s", "t", EdgeType::Calls, "proj")
.source("s2")
.target("t2")
.edge_type(EdgeType::FfiCalls)
.confidence(0.85)
.reason("extern \"C\" declaration match")
.start_line(42)
.project("proj2")
.build();
assert_eq!(edge.source, "s2");
assert_eq!(edge.target, "t2");
assert_eq!(edge.edge_type, EdgeType::FfiCalls);
assert!((edge.confidence - 0.85).abs() < f32::EPSILON);
assert_eq!(
edge.reason.as_deref(),
Some("extern \"C\" declaration match")
);
assert_eq!(edge.start_line, Some(42));
assert_eq!(edge.project, "proj2");
}
#[test]
fn builder_confidence_override() {
let edge = Edge::builder("s", "t", EdgeType::Calls, "proj")
.confidence(0.5)
.build();
assert!((edge.confidence - 0.5).abs() < f32::EPSILON);
}
#[test]
fn serde_roundtrip() {
let edge = Edge::builder("s", "t", EdgeType::Calls, "proj")
.confidence(0.9)
.reason("test")
.start_line(7)
.build();
let json = serde_json::to_string(&edge).unwrap();
let parsed: Edge = serde_json::from_str(&json).unwrap();
assert_eq!(edge, parsed);
}
#[test]
fn serde_roundtrip_minimal() {
let edge = Edge::new("s", "t", EdgeType::Contains, "p");
let json = serde_json::to_string(&edge).unwrap();
let parsed: Edge = serde_json::from_str(&json).unwrap();
assert_eq!(edge, parsed);
}
#[test]
fn clone_is_equal() {
let edge = Edge::new("s", "t", EdgeType::Calls, "proj");
let cloned = edge.clone();
assert_eq!(edge, cloned);
}
#[test]
fn debug_is_non_empty() {
let edge = Edge::new("s", "t", EdgeType::Calls, "proj");
let debug = format!("{edge:?}");
assert!(debug.contains("Edge"));
assert!(debug.contains("Calls"));
}
#[test]
fn equality() {
let a = Edge::new("s", "t", EdgeType::Calls, "proj");
let b = Edge::new("s", "t", EdgeType::Calls, "proj");
assert_eq!(a, b);
let c = Edge::new("s", "t", EdgeType::Reads, "proj");
assert_ne!(a, c);
let d = Edge::new("s", "t", EdgeType::Calls, "other");
assert_ne!(a, d);
}
#[test]
fn confidence_tier_default_score_returns_tier_specific_values() {
assert!(
(ConfidenceTier::SameFile.default_score() - 0.95).abs() < f32::EPSILON,
"SameFile → 0.95"
);
assert!(
(ConfidenceTier::ImportScoped.default_score() - 0.90).abs() < f32::EPSILON,
"ImportScoped → 0.90"
);
assert!(
(ConfidenceTier::Global.default_score() - 0.50).abs() < f32::EPSILON,
"Global → 0.50"
);
}
#[test]
fn confidence_tier_as_db_type_returns_upper_snake_case_for_all_variants() {
assert_eq!(ConfidenceTier::SameFile.as_db_type(), "SAME_FILE");
assert_eq!(ConfidenceTier::ImportScoped.as_db_type(), "IMPORT_SCOPED");
assert_eq!(ConfidenceTier::Global.as_db_type(), "GLOBAL");
}
#[test]
fn confidence_tier_display_outputs_db_type_string() {
assert_eq!(format!("{}", ConfidenceTier::SameFile), "SAME_FILE");
assert_eq!(format!("{}", ConfidenceTier::ImportScoped), "IMPORT_SCOPED");
assert_eq!(format!("{}", ConfidenceTier::Global), "GLOBAL");
}
#[test]
fn confidence_tier_from_str_parses_all_valid_db_type_strings() {
assert_eq!(
"SAME_FILE".parse::<ConfidenceTier>().unwrap(),
ConfidenceTier::SameFile
);
assert_eq!(
"IMPORT_SCOPED".parse::<ConfidenceTier>().unwrap(),
ConfidenceTier::ImportScoped
);
assert_eq!(
"GLOBAL".parse::<ConfidenceTier>().unwrap(),
ConfidenceTier::Global
);
}
#[test]
fn confidence_tier_from_str_rejects_unknown_string_with_descriptive_error() {
let result: Result<ConfidenceTier, String> = "BOGUS_TIER".parse();
assert!(result.is_err(), "unknown tier string should error");
let err = result.unwrap_err();
assert!(
err.contains("unknown ConfidenceTier"),
"error should mention the type: {err}"
);
assert!(
err.contains("BOGUS_TIER"),
"error should include the rejected input: {err}"
);
}
}