use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default)]
pub struct IncludesGraph {
edges: HashMap<String, HashSet<String>>,
}
impl IncludesGraph {
#[must_use]
pub fn new() -> Self {
Self {
edges: HashMap::new(),
}
}
pub fn add_include(&mut self, from_file: &str, to_file: &str) {
if from_file == to_file {
return;
}
self.edges
.entry(from_file.to_string())
.or_default()
.insert(to_file.to_string());
}
pub fn reachable_from<'a>(&'a self, start: &'a str) -> HashSet<&'a str> {
let mut out = HashSet::new();
self.fill_reachable_from(start, &mut out);
out
}
pub fn fill_reachable_from<'a>(&'a self, start: &'a str, out: &mut HashSet<&'a str>) {
out.clear();
out.insert(start);
let mut queue: Vec<&str> = vec![start];
while let Some(current) = queue.pop() {
if let Some(neighbors) = self.edges.get(current) {
for next in neighbors {
let next: &str = next;
if out.insert(next) {
queue.push(next);
}
}
}
}
}
pub fn contains(&self, from: &str, to: &str) -> bool {
self.edges
.get(from)
.is_some_and(|neighbors| neighbors.contains(to))
}
#[must_use]
pub fn has_outgoing_edges(&self, file: &str) -> bool {
self.edges
.get(file)
.is_some_and(|neighbors| !neighbors.is_empty())
}
#[must_use]
pub fn edge_count(&self) -> usize {
self.edges.values().map(SetCount::len).sum()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.edges.values().all(|s| s.is_empty())
}
}
trait SetCount {
fn len(&self) -> usize;
}
impl<T, S> SetCount for HashSet<T, S> {
fn len(&self) -> usize {
HashSet::len(self)
}
}
pub fn resolve_include(
source_file: &str,
calling_file: &str,
all_files: &[String],
) -> Option<String> {
let path_norm = source_file.replace('\\', "/");
let calling_dir = calling_file
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
let mut same_dir: Option<&String> = None;
let mut other: Option<&String> = None;
for file in all_files {
let file_norm = file.replace('\\', "/");
if file_norm.ends_with(path_norm.as_str()) {
let prefix_len = file_norm.len() - path_norm.len();
if prefix_len == 0 || file_norm.as_bytes()[prefix_len - 1] == b'/' {
let file_dir = file_norm.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
if file_dir == calling_dir {
if same_dir.as_ref().is_none_or(|s| s.len() > file.len()) {
same_dir = Some(file);
}
} else {
if other.as_ref().is_none_or(|s| s.len() > file.len()) {
other = Some(file);
}
}
}
}
}
same_dir.or(other).cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_creates_empty_graph() {
let graph = IncludesGraph::new();
assert!(graph.is_empty());
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn default_creates_empty_graph() {
let graph = IncludesGraph::default();
assert!(graph.is_empty());
}
#[test]
fn add_include_creates_direct_edge() {
let mut graph = IncludesGraph::new();
graph.add_include("a.cpp", "b.h");
assert!(graph.contains("a.cpp", "b.h"));
}
#[test]
fn includes_graph_contains_direct() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
assert!(graph.contains("a", "b"));
assert!(
!graph.contains("b", "a"),
"edge is directed: b→a should not exist"
);
}
#[test]
fn contains_returns_false_for_missing_from() {
let graph = IncludesGraph::new();
assert!(!graph.contains("nonexistent", "b.h"));
}
#[test]
fn contains_returns_false_for_missing_to() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
assert!(!graph.contains("a", "c"));
}
#[test]
fn has_outgoing_edges_true_for_file_with_includes() {
let mut graph = IncludesGraph::new();
graph.add_include("main.cpp", "foo.h");
assert!(graph.has_outgoing_edges("main.cpp"));
}
#[test]
fn has_outgoing_edges_false_for_file_without_includes() {
let mut graph = IncludesGraph::new();
graph.add_include("main.cpp", "foo.h");
assert!(!graph.has_outgoing_edges("foo.h"));
}
#[test]
fn has_outgoing_edges_false_for_unknown_file() {
let graph = IncludesGraph::new();
assert!(!graph.has_outgoing_edges("nonexistent.cpp"));
}
#[test]
fn has_outgoing_edges_false_for_empty_graph() {
let graph = IncludesGraph::new();
assert!(!graph.has_outgoing_edges("any.cpp"));
}
#[test]
fn add_include_is_idempotent() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("a", "b");
assert_eq!(graph.edge_count(), 1, "duplicate edge should collapse");
}
#[test]
fn add_include_ignores_self_edge() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "a");
assert!(!graph.contains("a", "a"), "self-edge should be ignored");
assert!(graph.is_empty());
}
#[test]
fn add_include_multiple_targets() {
let mut graph = IncludesGraph::new();
graph.add_include("main.cpp", "foo.h");
graph.add_include("main.cpp", "bar.h");
graph.add_include("main.cpp", "baz.h");
assert_eq!(graph.edge_count(), 3);
assert!(graph.contains("main.cpp", "foo.h"));
assert!(graph.contains("main.cpp", "bar.h"));
assert!(graph.contains("main.cpp", "baz.h"));
}
#[test]
fn includes_graph_reachable_transitive() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("b", "c");
let reachable = graph.reachable_from("a");
assert!(
reachable.contains("a"),
"start node should be reachable from itself"
);
assert!(
reachable.contains("b"),
"direct neighbor should be reachable"
);
assert!(
reachable.contains("c"),
"transitive neighbor should be reachable"
);
assert_eq!(reachable.len(), 3);
}
#[test]
fn reachable_from_includes_start_itself() {
let graph = IncludesGraph::new();
let reachable = graph.reachable_from("lonely.cpp");
assert_eq!(reachable.len(), 1);
assert!(reachable.contains("lonely.cpp"));
}
#[test]
fn reachable_from_no_outgoing_edges() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
let reachable = graph.reachable_from("b");
assert_eq!(reachable.len(), 1);
assert!(reachable.contains("b"));
}
#[test]
fn reachable_from_handles_cycle() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("b", "a");
let reachable_from_a = graph.reachable_from("a");
assert!(reachable_from_a.contains("a"));
assert!(reachable_from_a.contains("b"));
assert_eq!(reachable_from_a.len(), 2);
let reachable_from_b = graph.reachable_from("b");
assert!(reachable_from_b.contains("a"));
assert!(reachable_from_b.contains("b"));
assert_eq!(reachable_from_b.len(), 2);
}
#[test]
fn reachable_from_diamond_shape() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("a", "c");
graph.add_include("b", "d");
graph.add_include("c", "d");
let reachable = graph.reachable_from("a");
assert!(reachable.contains("a"));
assert!(reachable.contains("b"));
assert!(reachable.contains("c"));
assert!(reachable.contains("d"));
assert_eq!(reachable.len(), 4, "d should appear once despite two paths");
}
#[test]
fn reachable_from_deep_chain() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("b", "c");
graph.add_include("c", "d");
graph.add_include("d", "e");
let reachable = graph.reachable_from("a");
assert_eq!(reachable.len(), 5);
for file in &["a", "b", "c", "d", "e"] {
assert!(reachable.contains(file), "{file} should be reachable");
}
}
#[test]
fn reachable_from_disconnected_components() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("c", "d");
let reachable_from_a = graph.reachable_from("a");
assert!(reachable_from_a.contains("a"));
assert!(reachable_from_a.contains("b"));
assert!(
!reachable_from_a.contains("c"),
"disconnected node should not be reachable"
);
assert!(
!reachable_from_a.contains("d"),
"disconnected node should not be reachable"
);
}
#[test]
fn fill_reachable_from_reuses_buffer() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("b", "c");
let mut buf: HashSet<&str> = HashSet::new();
graph.fill_reachable_from("a", &mut buf);
assert!(buf.contains("a"), "start node should be in buffer");
assert!(buf.contains("b"), "direct neighbor should be in buffer");
assert!(buf.contains("c"), "transitive neighbor should be in buffer");
assert_eq!(buf.len(), 3);
graph.fill_reachable_from("b", &mut buf);
assert!(buf.contains("b"), "start node should be in buffer");
assert!(buf.contains("c"), "transitive neighbor should be in buffer");
assert!(
!buf.contains("a"),
"stale entry from previous call must be cleared"
);
assert_eq!(
buf.len(),
2,
"buffer should only contain b and c after clear+fill"
);
}
#[test]
fn fill_reachable_from_matches_reachable_from() {
let mut graph = IncludesGraph::new();
graph.add_include("main.cpp", "a.h");
graph.add_include("a.h", "b.h");
graph.add_include("b.h", "c.h");
graph.add_include("main.cpp", "c.h");
let expected = graph.reachable_from("main.cpp");
let mut buf = HashSet::new();
graph.fill_reachable_from("main.cpp", &mut buf);
assert_eq!(
expected, buf,
"fill_reachable_from must match reachable_from output"
);
}
#[test]
fn edge_count_counts_all_direct_edges() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
graph.add_include("a", "c");
graph.add_include("b", "c");
assert_eq!(graph.edge_count(), 3);
}
#[test]
fn is_empty_true_for_new_graph() {
let graph = IncludesGraph::new();
assert!(graph.is_empty());
}
#[test]
fn is_empty_false_after_add_edge() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "b");
assert!(!graph.is_empty());
}
#[test]
fn is_empty_true_when_only_self_edges_attempted() {
let mut graph = IncludesGraph::new();
graph.add_include("a", "a"); assert!(graph.is_empty());
}
fn make_files(paths: &[&str]) -> Vec<String> {
paths.iter().map(|s| s.to_string()).collect()
}
#[test]
fn resolve_include_quoted_prefers_same_dir() {
let all = make_files(&["src/foo.h", "include/foo.h"]);
let result = resolve_include("foo.h", "src/main.cpp", &all);
assert_eq!(result, Some("src/foo.h".to_string()));
}
#[test]
fn resolve_include_angled_matches_anywhere() {
let all = make_files(&["include/bar.h"]);
let result = resolve_include("bar.h", "src/main.cpp", &all);
assert_eq!(result, Some("include/bar.h".to_string()));
}
#[test]
fn resolve_include_no_match_returns_none() {
let all = make_files(&["src/main.cpp", "src/foo.h"]);
let result = resolve_include("iostream", "src/main.cpp", &all);
assert_eq!(result, None);
}
#[test]
fn resolve_include_partial_path_matches() {
let all = make_files(&["src/main.cpp", "include/fmt/format.h"]);
let result = resolve_include("fmt/format.h", "src/main.cpp", &all);
assert_eq!(result, Some("include/fmt/format.h".to_string()));
}
#[test]
fn resolve_include_boundary_check_rejects_partial_filename() {
let all = make_files(&["src/xformat.h"]);
let result = resolve_include("format.h", "src/main.cpp", &all);
assert_eq!(
result, None,
"xformat.h should not match format.h (no boundary)"
);
}
#[test]
fn resolve_include_exact_match_returns_file() {
let all = make_files(&["foo.h", "src/main.cpp"]);
let result = resolve_include("foo.h", "src/main.cpp", &all);
assert_eq!(result, Some("foo.h".to_string()));
}
#[test]
fn resolve_include_same_dir_picks_shortest_path() {
let all = make_files(&["src/a/b.h", "src/b.h"]);
let result = resolve_include("b.h", "src/main.cpp", &all);
assert_eq!(result, Some("src/b.h".to_string()));
}
#[test]
fn resolve_include_other_dir_picks_shortest_path() {
let all = make_files(&["include/fmt/format.h", "vendor/deep/fmt/format.h"]);
let result = resolve_include("fmt/format.h", "src/main.cpp", &all);
assert_eq!(
result,
Some("include/fmt/format.h".to_string()),
"shorter path should win when no same-dir match"
);
}
#[test]
fn resolve_include_empty_source_returns_none() {
let all = make_files(&["src/main.cpp"]);
let result = resolve_include("", "src/main.cpp", &all);
assert_eq!(result, None, "empty source_file should not match anything");
}
#[test]
fn resolve_include_empty_all_files_returns_none() {
let all: Vec<String> = vec![];
let result = resolve_include("foo.h", "src/main.cpp", &all);
assert_eq!(result, None);
}
#[test]
fn resolve_include_handles_backslash_paths() {
let all = make_files(&["src\\foo.h"]);
let result = resolve_include("foo.h", "src\\main.cpp", &all);
assert_eq!(result, Some("src\\foo.h".to_string()));
}
#[test]
fn resolve_include_calling_file_in_root_no_dir() {
let all = make_files(&["foo.h", "src/foo.h"]);
let result = resolve_include("foo.h", "main.cpp", &all);
assert_eq!(
result,
Some("foo.h".to_string()),
"root-level file preferred when caller is in root"
);
}
}