rvtest 0.3.0

A Next Level Testing Library for Rust — BDD specs, property-based testing, parametrized tests, rich reporting, and code coverage. Just a library, not a framework.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::arch::checker::collect_rs_files;

pub(crate) struct DependencyGraph {
    pub(crate) edges: HashMap<String, HashSet<String>>,
}

impl DependencyGraph {
    pub(crate) fn from_dir(dir: &Path) -> Result<Self, String> {
        if !dir.is_dir() {
            return Err(format!("directory not found: {:?}", dir));
        }

        let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
        let mut files: Vec<PathBuf> = Vec::new();
        collect_rs_files(dir, &mut files, dir);

        for file in &files {
            let rel = file.strip_prefix(dir).map_err(|e| format!("path: {e}"))?;
            let module = path_to_module(rel);
            let content = std::fs::read_to_string(file)
                .map_err(|e| format!("read {:?}: {e}", file))?;
            let deps = parse_deps(&content);

            let entry: &mut HashSet<String> = edges.entry(module).or_default();
            for dep in deps {
                if dep.starts_with("crate::") {
                    entry.insert(dep.trim_start_matches("crate::").to_owned());
                }
            }
        }

        Ok(DependencyGraph { edges })
    }

    pub(crate) fn dependencies_of(&self, module: &str) -> HashSet<String> {
        self.edges.get(module).cloned().unwrap_or_default()
    }

    pub(crate) fn find_cycles(&self) -> Vec<Vec<String>> {
        let nodes: Vec<&String> = self.edges.keys().collect();
        let mut visited: HashSet<&String> = HashSet::new();
        let mut in_stack: HashSet<&String> = HashSet::new();
        let mut stack: Vec<&String> = Vec::new();
        let mut cycles: Vec<Vec<String>> = Vec::new();

        fn dfs<'a>(
            node: &'a String,
            graph: &'a HashMap<String, HashSet<String>>,
            visited: &mut HashSet<&'a String>,
            in_stack: &mut HashSet<&'a String>,
            stack: &mut Vec<&'a String>,
            cycles: &mut Vec<Vec<String>>,
        ) {
            if !visited.insert(node) {
                return;
            }
            in_stack.insert(node);
            stack.push(node);

            if let Some(deps) = graph.get(node) {
                for dep in deps {
                    if in_stack.contains(dep) {
                        let pos = stack.iter().position(|n| *n == dep).unwrap();
                        let cycle: Vec<String> = stack[pos..]
                            .iter()
                            .map(|s| (*s).clone())
                            .collect();
                        cycles.push(cycle);
                    } else {
                        dfs(dep, graph, visited, in_stack, stack, cycles);
                    }
                }
            }

            stack.pop();
            in_stack.remove(node);
        }

        for node in &nodes {
            dfs(node, &self.edges, &mut visited, &mut in_stack, &mut stack, &mut cycles);
        }

        cycles
    }
}

pub(crate) fn path_to_module(rel: &Path) -> String {
    let s = rel.to_string_lossy();
    let stem = s.strip_suffix(".rs").unwrap_or(&s);
    if stem.ends_with("/mod") || stem == "mod" {
        let parent = rel.parent().and_then(|p| p.to_str()).unwrap_or("");
        return if parent.is_empty() { "crate_root".into() } else { parent.replace('/', "::") };
    }
    stem.replace('/', "::")
}

pub(crate) fn parse_deps(content: &str) -> HashSet<String> {
    let mut deps = HashSet::new();
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.is_empty() {
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("use crate::") {
            let path = rest.trim_end_matches(';');
            let top = path.split("::").next().unwrap_or(path);
            if !top.is_empty() {
                deps.insert(top.to_owned());
            }
        }
        if let Some(rest) = trimmed.strip_prefix("pub mod ") {
            let name = rest.split(';').next().unwrap_or(rest).trim();
            deps.insert(name.to_owned());
        } else if let Some(rest) = trimmed.strip_prefix("mod ") {
            let name = rest.split(';').next().unwrap_or(rest).trim();
            deps.insert(name.to_owned());
        }
    }
    deps
}