use crate::model::{EdgeType, Graph, NodeId};
use crate::storage::capability::Storage;
use super::call_graph::CallGraphTracer;
use super::data_flow::DataFlowTracer;
use super::error::{Result, TraceError};
use super::module::TraceConfig;
use super::TraceResult;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PathFilter {
pub include_files: Option<Vec<String>>,
pub exclude_files: Option<Vec<String>>,
pub include_modules: Option<Vec<String>>,
pub symbol_pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TraceCycle {
pub nodes: Vec<String>,
pub edge_types: Vec<EdgeType>,
}
pub struct TraceEngine<'a> {
storage: &'a dyn Storage,
config: TraceConfig,
}
impl<'a> TraceEngine<'a> {
#[must_use]
pub fn new(storage: &'a dyn Storage) -> Self {
Self {
storage,
config: TraceConfig::default(),
}
}
#[must_use]
pub fn with_config(storage: &'a dyn Storage, config: TraceConfig) -> Self {
Self { storage, config }
}
#[must_use]
pub fn config(&self) -> &TraceConfig {
&self.config
}
#[must_use]
pub fn storage(&self) -> &dyn Storage {
self.storage
}
pub fn apply_path_filter(
&self,
paths: Vec<super::TracePath>,
filter: &PathFilter,
) -> Vec<super::TracePath> {
apply_path_filter(paths, filter)
}
}
fn glob_to_regex(glob: &str) -> String {
const META_CHARS: &[char] = &['.', '+', '^', '$', '(', ')', '{', '}', '|', '\\'];
let mut regex = String::with_capacity(glob.len() * 2);
for ch in glob.chars() {
match ch {
'*' => regex.push_str(".*"),
'?' => regex.push('.'),
c if META_CHARS.contains(&c) => {
regex.push('\\');
regex.push(c);
}
c => regex.push(c),
}
}
regex
}
fn matches_any_glob(value: &str, patterns: &[String]) -> bool {
patterns.iter().any(|p| {
let regex_str = glob_to_regex(p);
regex::Regex::new(®ex_str)
.map(|re| re.is_match(value))
.unwrap_or(false)
})
}
fn matches_regex(value: &str, pattern: &str) -> bool {
regex::Regex::new(pattern)
.map(|re| re.is_match(value))
.unwrap_or(false)
}
fn node_passes_filter(node: &super::TraceNode, filter: &PathFilter) -> bool {
if let Some(ref patterns) = filter.include_files {
let Some(ref file_path) = node.file_path else {
return false;
};
if !matches_any_glob(file_path, patterns) {
return false;
}
}
if let Some(ref patterns) = filter.exclude_files {
if let Some(ref file_path) = node.file_path {
if matches_any_glob(file_path, patterns) {
return false;
}
}
}
if let Some(ref modules) = filter.include_modules {
let Some(ref file_path) = node.file_path else {
return false;
};
if !modules.iter().any(|m| file_path.contains(m.as_str())) {
return false;
}
}
if let Some(ref pattern) = filter.symbol_pattern {
if !matches_regex(&node.name, pattern) {
return false;
}
}
true
}
pub fn apply_path_filter(
paths: Vec<super::TracePath>,
filter: &PathFilter,
) -> Vec<super::TracePath> {
paths
.into_iter()
.filter_map(|path| {
let keep_indices: Vec<usize> = path
.nodes
.iter()
.enumerate()
.filter(|(_, n)| node_passes_filter(n, filter))
.map(|(i, _)| i)
.collect();
if keep_indices.is_empty() {
return None;
}
let nodes: Vec<super::TraceNode> = keep_indices
.iter()
.map(|&i| path.nodes[i].clone())
.collect();
let mut edges = Vec::new();
for window in keep_indices.windows(2) {
if window[1] == window[0] + 1 {
edges.push(path.edges[window[0]].clone());
}
}
let depth = edges.len();
Some(super::TracePath {
nodes,
edges,
depth,
})
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TraceType {
Calls,
DataFlow,
All,
}
impl fmt::Display for TraceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_cli_str())
}
}
impl FromStr for TraceType {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"" => Err("trace type is empty".to_string()),
"calls" => Ok(Self::Calls),
"dataflow" | "data_flow" | "data-flow" => Ok(Self::DataFlow),
"all" => Ok(Self::All),
other => Err(format!("unknown trace type: {other}")),
}
}
}
impl TraceType {
#[must_use]
pub fn from_cli_str(s: &str) -> Option<Self> {
Self::from_str(s).ok()
}
#[must_use]
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Calls => "calls",
Self::DataFlow => "dataflow",
Self::All => "all",
}
}
}
pub struct TraceFacade<'a> {
graph: &'a Graph,
}
impl<'a> TraceFacade<'a> {
#[must_use]
pub fn new(graph: &'a Graph) -> Self {
Self { graph }
}
pub fn trace(&self, symbol: &str, trace_type: TraceType, depth: usize) -> Result<TraceResult> {
if depth == 0 {
return Err(TraceError::InvalidDepth(depth));
}
let start_id = self.resolve_symbol(symbol)?;
self.trace_by_id(&start_id, symbol, trace_type, depth)
}
pub fn trace_by_id(
&self,
start_id: &NodeId,
symbol_label: &str,
trace_type: TraceType,
depth: usize,
) -> Result<TraceResult> {
if depth == 0 {
return Err(TraceError::InvalidDepth(depth));
}
let paths = match trace_type {
TraceType::Calls => CallGraphTracer::new(self.graph).trace(start_id, depth),
TraceType::DataFlow => DataFlowTracer::new(self.graph).trace(start_id, depth),
TraceType::All => {
let mut combined = CallGraphTracer::new(self.graph).trace(start_id, depth);
combined.extend(DataFlowTracer::new(self.graph).trace(start_id, depth));
combined
}
};
Ok(TraceResult {
symbol: symbol_label.to_string(),
paths,
cycles: Vec::new(),
})
}
fn resolve_symbol(&self, symbol: &str) -> Result<NodeId> {
let by_name: Vec<&crate::model::Node> = self
.graph
.nodes
.values()
.filter(|n| n.name == symbol)
.collect();
if by_name.len() == 1 {
return Ok(by_name[0].id.clone());
}
if by_name.len() > 1 {
let candidates: Vec<String> =
by_name.iter().map(|n| n.qualified_name.clone()).collect();
return Err(TraceError::AmbiguousSymbol {
symbol: symbol.to_string(),
candidates,
});
}
let by_qn: Vec<&crate::model::Node> = self
.graph
.nodes
.values()
.filter(|n| n.qualified_name == symbol)
.collect();
if by_qn.len() == 1 {
return Ok(by_qn[0].id.clone());
}
if by_qn.len() > 1 {
let candidates: Vec<String> = by_qn.iter().map(|n| n.qualified_name.clone()).collect();
return Err(TraceError::AmbiguousSymbol {
symbol: symbol.to_string(),
candidates,
});
}
Err(TraceError::SymbolNotFound(symbol.to_string()))
}
}
#[cfg(test)]
fn path_node_names(path: &super::TracePath) -> Vec<&str> {
path.nodes.iter().map(|n| n.name.as_str()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Edge, EdgeType, Node, NodeLabel};
fn make_func(id: &str, name: &str) -> Node {
Node::builder(NodeLabel::Function, name, format!("proj.{name}"))
.id(id)
.project("proj")
.file_path(format!("src/{name}.rs"))
.start_line(10)
.build()
}
fn make_var(id: &str, name: &str) -> Node {
Node::builder(NodeLabel::Variable, name, format!("proj.{name}"))
.id(id)
.project("proj")
.build()
}
fn graph_a_calls_b_and_dataflow() -> Graph {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_var("v", "v"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "v", EdgeType::Reads, "proj"));
g
}
#[test]
fn trace_type_from_cli_str_parses_known() {
assert_eq!(TraceType::from_cli_str("calls"), Some(TraceType::Calls));
assert_eq!(
TraceType::from_cli_str("dataflow"),
Some(TraceType::DataFlow)
);
assert_eq!(TraceType::from_cli_str("all"), Some(TraceType::All));
}
#[test]
fn trace_type_from_cli_str_case_insensitive() {
assert_eq!(TraceType::from_cli_str("CALLS"), Some(TraceType::Calls));
assert_eq!(
TraceType::from_cli_str("DataFlow"),
Some(TraceType::DataFlow)
);
assert_eq!(TraceType::from_cli_str("ALL"), Some(TraceType::All));
}
#[test]
fn trace_type_from_cli_str_rejects_unknown() {
assert!(TraceType::from_cli_str("unknown").is_none());
assert!(TraceType::from_cli_str("").is_none());
}
#[test]
fn trace_type_as_cli_str_roundtrips() {
for t in [TraceType::Calls, TraceType::DataFlow, TraceType::All] {
let s = t.as_cli_str();
assert_eq!(TraceType::from_cli_str(s), Some(t));
}
}
#[test]
fn facade_trace_calls_returns_call_path() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 3).unwrap();
assert_eq!(result.symbol, "a");
assert_eq!(result.paths.len(), 1);
assert_eq!(path_node_names(&result.paths[0]), vec!["a", "b"]);
assert_eq!(result.paths[0].edges[0].edge_type, "CALLS");
}
#[test]
fn facade_trace_calls_does_not_include_dataflow() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 3).unwrap();
for p in &result.paths {
for e in &p.edges {
assert_eq!(e.edge_type, "CALLS");
}
}
}
#[test]
fn facade_trace_dataflow_returns_dataflow_path() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::DataFlow, 3).unwrap();
assert_eq!(result.paths.len(), 1);
assert_eq!(path_node_names(&result.paths[0]), vec!["a", "v"]);
assert_eq!(result.paths[0].edges[0].edge_type, "READS");
}
#[test]
fn facade_trace_dataflow_does_not_include_calls() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::DataFlow, 3).unwrap();
for p in &result.paths {
for e in &p.edges {
assert_ne!(e.edge_type, "CALLS");
}
}
}
#[test]
fn facade_trace_all_returns_both_call_and_dataflow_paths() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::All, 3).unwrap();
assert_eq!(result.paths.len(), 2);
let edge_types: Vec<String> = result
.paths
.iter()
.filter(|p| p.depth == 1)
.map(|p| p.edges[0].edge_type.clone())
.collect();
assert!(edge_types.contains(&"CALLS".to_string()));
assert!(edge_types.contains(&"READS".to_string()));
}
#[test]
fn facade_trace_symbol_not_found_returns_error() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("missing", TraceType::Calls, 3);
assert!(matches!(result, Err(TraceError::SymbolNotFound(_))));
if let Err(TraceError::SymbolNotFound(s)) = result {
assert_eq!(s, "missing");
}
}
#[test]
fn facade_trace_ambiguous_symbol_returns_error_with_candidates() {
let mut g = Graph::new();
g.add_node(make_func("a1", "a"));
g.add_node(make_func("a2", "a"));
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 3);
match result {
Err(TraceError::AmbiguousSymbol { symbol, candidates }) => {
assert_eq!(symbol, "a");
assert_eq!(
candidates.len(),
2,
"expected 2 candidate FQNs, got {candidates:?}"
);
assert!(
candidates.iter().any(|q| q == "proj.a"),
"candidates should contain proj.a: {candidates:?}"
);
}
other => panic!("expected AmbiguousSymbol with candidates, got {other:?}"),
}
}
#[test]
fn facade_trace_ambiguous_symbol_by_qualified_name_returns_candidates() {
let mut g = Graph::new();
let n1 = Node::builder(NodeLabel::Function, "first", "proj.dup".to_string())
.id("dup1")
.project("proj")
.build();
let n2 = Node::builder(NodeLabel::Function, "second", "proj.dup".to_string())
.id("dup2")
.project("proj")
.build();
g.add_node(n1);
g.add_node(n2);
let facade = TraceFacade::new(&g);
let result = facade.trace("proj.dup", TraceType::Calls, 3);
match result {
Err(TraceError::AmbiguousSymbol { symbol, candidates }) => {
assert_eq!(symbol, "proj.dup");
assert_eq!(candidates.len(), 2, "got {candidates:?}");
assert!(candidates.iter().all(|q| q == "proj.dup"));
}
other => panic!("expected AmbiguousSymbol, got {other:?}"),
}
}
#[test]
fn facade_trace_invalid_depth_returns_error() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 0);
assert!(matches!(result, Err(TraceError::InvalidDepth(0))));
}
#[test]
fn facade_trace_respects_depth_limit() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 2).unwrap();
for p in &result.paths {
assert!(p.depth <= 2);
}
}
#[test]
fn facade_trace_depth_1_returns_only_direct_edges() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 1).unwrap();
assert_eq!(result.paths.len(), 1);
assert_eq!(result.paths[0].depth, 1);
}
#[test]
fn facade_trace_resolves_by_qualified_name() {
let mut g = Graph::new();
let node = Node::builder(NodeLabel::Function, "foo", "proj.src.foo")
.id("foo-id")
.project("proj")
.build();
g.add_node(node);
g.add_node(make_func("bar", "bar"));
g.add_edge(Edge::new("foo-id", "bar", EdgeType::Calls, "proj"));
let facade = TraceFacade::new(&g);
let result = facade.trace("proj.src.foo", TraceType::Calls, 3).unwrap();
assert_eq!(result.paths.len(), 1);
assert_eq!(path_node_names(&result.paths[0]), vec!["foo", "bar"]);
}
#[test]
fn facade_trace_result_symbol_matches_input() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::All, 3).unwrap();
assert_eq!(result.symbol, "a");
}
#[test]
fn facade_trace_empty_graph_symbol_not_found() {
let g = Graph::new();
let facade = TraceFacade::new(&g);
let result = facade.trace("anything", TraceType::Calls, 3);
assert!(matches!(result, Err(TraceError::SymbolNotFound(_))));
}
#[test]
fn facade_trace_no_outgoing_edges_returns_empty_paths() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
let facade = TraceFacade::new(&g);
let result = facade.trace("a", TraceType::Calls, 3).unwrap();
assert!(result.paths.is_empty());
}
#[test]
fn facade_trace_all_with_only_dataflow_edges() {
let mut g = Graph::new();
g.add_node(make_var("x", "x"));
g.add_node(make_var("y", "y"));
g.add_edge(Edge::new("x", "y", EdgeType::DataFlows, "proj"));
let facade = TraceFacade::new(&g);
let result = facade.trace("x", TraceType::All, 3).unwrap();
assert_eq!(result.paths.len(), 1);
assert_eq!(result.paths[0].edges[0].edge_type, "DATAFLOWS");
}
fn build_storage() -> std::sync::Arc<dyn Storage> {
use crate::kit::StorageModule;
use crate::storage::StorageConfig;
StorageModule::build_cap(&StorageConfig::in_memory()).expect("StorageModule::build_cap")
}
#[test]
fn path_filter_default_is_all_none() {
let pf = PathFilter::default();
assert!(pf.include_files.is_none());
assert!(pf.exclude_files.is_none());
assert!(pf.include_modules.is_none());
assert!(pf.symbol_pattern.is_none());
}
#[test]
fn path_filter_serializes_to_json() {
let pf = PathFilter {
include_files: Some(vec!["/src/a.rs".to_string()]),
exclude_files: None,
include_modules: Some(vec!["mod_a".to_string()]),
symbol_pattern: Some("handler.*".to_string()),
};
let json = serde_json::to_string(&pf).expect("serialize PathFilter");
assert!(json.contains("\"include_files\""));
assert!(json.contains("/src/a.rs"));
assert!(json.contains("\"include_modules\""));
assert!(json.contains("handler.*"));
assert!(json.contains("\"exclude_files\":null"));
}
#[test]
fn path_filter_round_trips_through_json() {
let pf = PathFilter {
include_files: Some(vec!["/src/*.rs".to_string()]),
exclude_files: Some(vec!["/target/*".to_string()]),
include_modules: None,
symbol_pattern: Some("test_.*".to_string()),
};
let json = serde_json::to_string(&pf).expect("serialize");
let deserialized: PathFilter = serde_json::from_str(&json).expect("deserialize");
assert_eq!(pf, deserialized);
}
#[test]
fn trace_cycle_serializes_with_edge_types() {
let cycle = TraceCycle {
nodes: vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"a".to_string(),
],
edge_types: vec![EdgeType::Calls, EdgeType::Calls, EdgeType::Calls],
};
let json = serde_json::to_string(&cycle).expect("serialize TraceCycle");
assert!(json.contains("\"nodes\""));
assert!(json.contains("\"edge_types\""));
assert!(json.contains("\"Calls\""));
let de: TraceCycle = serde_json::from_str(&json).expect("deserialize");
assert_eq!(cycle, de);
}
#[test]
fn trace_config_default_has_expected_values() {
let config = super::TraceConfig::default();
assert_eq!(config.max_depth, 5);
assert_eq!(config.edge_types, vec![EdgeType::Calls]);
assert!(config.path_filter.is_none());
assert!(!config.detect_cycles);
assert!(!config.cross_service);
}
#[test]
fn trace_config_clamped_depth_respects_limit() {
let mut config = super::TraceConfig {
max_depth: 50,
..Default::default()
};
assert_eq!(config.clamped_depth(), 10);
config.max_depth = 3;
assert_eq!(config.clamped_depth(), 3);
}
#[test]
fn trace_config_serializes_to_json() {
let config = super::TraceConfig {
db_path: std::path::PathBuf::from(":memory:"),
max_depth: 7,
edge_types: vec![EdgeType::Calls, EdgeType::HttpCalls],
path_filter: Some(PathFilter {
include_files: Some(vec!["/src/*.rs".to_string()]),
..Default::default()
}),
detect_cycles: true,
cross_service: true,
};
let json = serde_json::to_string(&config).expect("serialize TraceConfig");
assert!(json.contains("\"max_depth\":7"));
assert!(json.contains("\"detect_cycles\":true"));
assert!(json.contains("\"cross_service\":true"));
assert!(json.contains("\"HttpCalls\""));
}
#[test]
fn trace_engine_new_uses_default_config() {
let storage = build_storage();
let engine = TraceEngine::new(storage.as_ref());
assert_eq!(engine.config().max_depth, 5);
assert!(!engine.config().detect_cycles);
assert!(!engine.config().cross_service);
}
#[test]
fn trace_engine_with_config_applies_custom_config() {
let storage = build_storage();
let config = super::TraceConfig {
db_path: std::path::PathBuf::from(":memory:"),
max_depth: 10,
edge_types: vec![EdgeType::Calls, EdgeType::HttpCalls],
path_filter: None,
detect_cycles: true,
cross_service: true,
};
let engine = TraceEngine::with_config(storage.as_ref(), config);
assert_eq!(engine.config().max_depth, 10);
assert!(engine.config().detect_cycles);
assert!(engine.config().cross_service);
assert_eq!(engine.config().edge_types.len(), 2);
}
#[test]
fn trace_result_serializes_with_cycles_field() {
let result = super::TraceResult {
symbol: "a".to_string(),
paths: Vec::new(),
cycles: vec![TraceCycle {
nodes: vec!["a".to_string(), "b".to_string(), "a".to_string()],
edge_types: vec![EdgeType::Calls, EdgeType::Calls],
}],
};
let json = serde_json::to_string(&result).expect("serialize TraceResult");
assert!(json.contains("\"symbol\":\"a\""));
assert!(json.contains("\"paths\":[]"));
assert!(json.contains("\"cycles\""));
assert!(json.contains("\"Calls\""));
}
use crate::trace::{TraceEdge, TraceNode, TracePath};
fn make_node_with_path(name: &str, file_path: &str) -> TraceNode {
TraceNode {
name: name.to_string(),
label: "Function".to_string(),
file_path: Some(file_path.to_string()),
start_line: Some(1),
}
}
fn make_3node_path() -> TracePath {
TracePath {
nodes: vec![
make_node_with_path("a", "/src/a.rs"),
make_node_with_path("b", "/src/b.rs"),
make_node_with_path("c", "/src/c.rs"),
],
edges: vec![
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
],
depth: 2,
}
}
#[test]
fn apply_path_filter_include_files_keeps_only_matching() {
let paths = vec![make_3node_path()];
let filter = PathFilter {
include_files: Some(vec!["/src/a.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
}
#[test]
fn apply_path_filter_include_files_glob_matches_multiple() {
let paths = vec![make_3node_path()];
let filter = PathFilter {
include_files: Some(vec!["/src/[ab].rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 2);
assert_eq!(result[0].nodes[0].name, "a");
assert_eq!(result[0].nodes[1].name, "b");
assert_eq!(result[0].edges.len(), 1);
}
#[test]
fn apply_path_filter_exclude_files_drops_matching() {
let paths = vec![make_3node_path()];
let filter = PathFilter {
exclude_files: Some(vec!["/src/b.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 2);
assert_eq!(result[0].nodes[0].name, "a");
assert_eq!(result[0].nodes[1].name, "c");
assert_eq!(result[0].edges.len(), 0);
}
#[test]
fn apply_path_filter_include_modules_keeps_matching() {
let paths = vec![TracePath {
nodes: vec![
make_node_with_path("a", "/src/module_a/foo.rs"),
make_node_with_path("b", "/src/module_b/bar.rs"),
],
edges: vec![TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
}],
depth: 1,
}];
let filter = PathFilter {
include_modules: Some(vec!["module_a".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
}
#[test]
fn apply_path_filter_symbol_pattern_keeps_matching() {
let paths = vec![TracePath {
nodes: vec![
make_node_with_path("handler_create", "/src/a.rs"),
make_node_with_path("do_work", "/src/b.rs"),
make_node_with_path("handler_delete", "/src/c.rs"),
],
edges: vec![
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
],
depth: 2,
}];
let filter = PathFilter {
symbol_pattern: Some("handler.*".to_string()),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 2);
assert_eq!(result[0].nodes[0].name, "handler_create");
assert_eq!(result[0].nodes[1].name, "handler_delete");
}
#[test]
fn apply_path_filter_no_match_removes_path() {
let paths = vec![make_3node_path()];
let filter = PathFilter {
include_files: Some(vec!["/nonexistent.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert!(result.is_empty());
}
#[test]
fn apply_path_filter_no_filter_returns_all_paths() {
let paths = vec![make_3node_path()];
let filter = PathFilter::default();
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 3);
}
#[test]
fn apply_path_filter_multiple_paths() {
let paths = vec![
make_3node_path(),
TracePath {
nodes: vec![
make_node_with_path("x", "/src/a.rs"),
make_node_with_path("y", "/src/d.rs"),
],
edges: vec![TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
}],
depth: 1,
},
];
let filter = PathFilter {
include_files: Some(vec!["/src/a.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 2);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
assert_eq!(result[1].nodes.len(), 1);
assert_eq!(result[1].nodes[0].name, "x");
}
#[test]
fn apply_path_filter_wildcard_glob_matches_any_file() {
let paths = vec![make_3node_path()];
let filter = PathFilter {
include_files: Some(vec!["/src/*.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 3);
}
#[test]
fn apply_path_filter_node_without_file_path_excluded_by_include() {
let paths = vec![TracePath {
nodes: vec![
TraceNode {
name: "no_file".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
},
make_node_with_path("a", "/src/a.rs"),
],
edges: vec![TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
}],
depth: 1,
}];
let filter = PathFilter {
include_files: Some(vec!["/src/a.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
}
#[test]
fn apply_path_filter_exclude_files_keeps_node_without_file_path() {
let paths = vec![TracePath {
nodes: vec![
TraceNode {
name: "no_file".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
},
make_node_with_path("a", "/src/a.rs"),
],
edges: vec![TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
}],
depth: 1,
}];
let filter = PathFilter {
exclude_files: Some(vec!["/src/a.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "no_file");
}
#[test]
fn trace_engine_storage_returns_storage_ref() {
let storage = build_storage();
let engine = TraceEngine::new(storage.as_ref());
let returned = engine.storage();
let query_result = returned.execute("RETURN 1 AS v;");
assert!(query_result.is_ok(), "storage ref should be usable");
}
#[test]
fn trace_engine_apply_path_filter_delegates_to_standalone() {
let storage = build_storage();
let engine = TraceEngine::new(storage.as_ref());
let paths = vec![make_3node_path()];
let filter = PathFilter {
include_files: Some(vec!["/src/a.rs".to_string()]),
..Default::default()
};
let result = engine.apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
}
#[test]
fn apply_path_filter_question_mark_glob_matches_single_char() {
let paths = vec![TracePath {
nodes: vec![
make_node_with_path("a", "/src/a.rs"),
make_node_with_path("b", "/src/ab.rs"),
make_node_with_path("c", "/src/abc.rs"),
],
edges: vec![
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
},
],
depth: 2,
}];
let filter = PathFilter {
include_files: Some(vec!["/src/a?.rs".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "b");
}
#[test]
fn apply_path_filter_include_modules_drops_node_without_file_path() {
let paths = vec![TracePath {
nodes: vec![
TraceNode {
name: "no_file".to_string(),
label: "Function".to_string(),
file_path: None,
start_line: None,
},
make_node_with_path("a", "/src/module_a/foo.rs"),
],
edges: vec![TraceEdge {
edge_type: "CALLS".to_string(),
reason: None,
confidence: 1.0,
}],
depth: 1,
}];
let filter = PathFilter {
include_modules: Some(vec!["module_a".to_string()]),
..Default::default()
};
let result = apply_path_filter(paths, &filter);
assert_eq!(result.len(), 1);
assert_eq!(result[0].nodes.len(), 1);
assert_eq!(result[0].nodes[0].name, "a");
}
#[test]
fn facade_trace_by_id_invalid_depth_returns_error() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade.trace_by_id(&"a".to_string(), "a", TraceType::Calls, 0);
assert!(matches!(result, Err(TraceError::InvalidDepth(0))));
}
#[test]
fn facade_trace_by_id_resolves_without_symbol_lookup() {
let g = graph_a_calls_b_and_dataflow();
let facade = TraceFacade::new(&g);
let result = facade
.trace_by_id(&"a".to_string(), "label_a", TraceType::Calls, 3)
.unwrap();
assert_eq!(result.symbol, "label_a");
assert_eq!(result.paths.len(), 1);
assert_eq!(path_node_names(&result.paths[0]), vec!["a", "b"]);
}
#[test]
fn trace_type_display_canonical_strings() {
assert_eq!(TraceType::Calls.to_string(), "calls");
assert_eq!(TraceType::DataFlow.to_string(), "dataflow");
assert_eq!(TraceType::All.to_string(), "all");
}
#[test]
fn trace_type_from_str_parses_canonical_forms() {
assert_eq!(TraceType::from_str("calls").unwrap(), TraceType::Calls);
assert_eq!(
TraceType::from_str("dataflow").unwrap(),
TraceType::DataFlow
);
assert_eq!(TraceType::from_str("all").unwrap(), TraceType::All);
}
#[test]
fn trace_type_from_str_parses_aliases() {
assert_eq!(
TraceType::from_str("data_flow").unwrap(),
TraceType::DataFlow
);
assert_eq!(
TraceType::from_str("data-flow").unwrap(),
TraceType::DataFlow
);
}
#[test]
fn trace_type_from_str_is_case_insensitive() {
assert_eq!(TraceType::from_str("CALLS").unwrap(), TraceType::Calls);
assert_eq!(
TraceType::from_str("DataFlow").unwrap(),
TraceType::DataFlow
);
assert_eq!(TraceType::from_str("ALL").unwrap(), TraceType::All);
}
#[test]
fn trace_type_from_str_trims_whitespace() {
assert_eq!(TraceType::from_str(" calls ").unwrap(), TraceType::Calls);
assert_eq!(
TraceType::from_str("\tdataflow\n").unwrap(),
TraceType::DataFlow
);
}
#[test]
fn trace_type_from_str_rejects_empty() {
assert!(TraceType::from_str("").is_err());
assert!(TraceType::from_str(" ").is_err());
}
#[test]
fn trace_type_from_str_rejects_unknown() {
assert!(TraceType::from_str("bogus").is_err());
assert!(TraceType::from_str("trace").is_err());
}
#[test]
fn trace_type_display_fromstr_roundtrip() {
for variant in [TraceType::Calls, TraceType::DataFlow, TraceType::All] {
let s = variant.to_string();
assert_eq!(TraceType::from_str(&s).unwrap(), variant);
}
}
}