astmap-core 0.0.1

Core domain types and logic for astmap
Documentation
//! Architecture layer enforcement test.
//! Parses `use crate::...` statements and verifies dependency direction.
//!
//! Layer rules:
//!   Layer 0: error      — depends on nothing (foundation)
//!   Layer 1: model      — depends on error (validation errors)
//!   Layer 2: port       — depends on model, error
//!   Layer 3: scanner    — depends on model, error, port
//!   Layer 3: diff       — depends on model, error, port
//!   Layer 3: export     — depends on model, error, port
//!   No sibling deps: scanner/diff/export do not import from each other.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

fn allowed_deps() -> HashMap<&'static str, Vec<&'static str>> {
    let mut m = HashMap::new();
    m.insert("error", vec![]);
    m.insert("model", vec!["error"]);
    m.insert("port", vec!["model", "error"]);
    m.insert("scanner", vec!["model", "error", "port"]);
    m.insert("diff", vec!["model", "error", "port"]);
    m.insert("export", vec!["model", "error", "port"]);
    m
}

#[test]
fn enforce_layer_dependencies() {
    let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
    let allowed = allowed_deps();

    for (module, allowed_deps) in &allowed {
        let module_files = collect_module_files(&src_dir, module);
        for file_path in &module_files {
            let content = fs::read_to_string(file_path).unwrap();
            for (line_no, line) in content.lines().enumerate() {
                // Skip test code — test modules may use lib.rs re-exports freely
                if is_in_test_block(&content, line_no) {
                    continue;
                }
                if let Some(dep) = parse_crate_dep(line) {
                    assert!(
                        allowed_deps.contains(&dep.as_str()),
                        "LAYER VIOLATION: {module}/{} line {} imports crate::{dep} \
                         (allowed: {allowed_deps:?})",
                        file_path.file_name().unwrap().to_str().unwrap(),
                        line_no + 1,
                    );
                }
            }
        }
    }
}

fn collect_module_files(src_dir: &Path, module: &str) -> Vec<PathBuf> {
    let mut files = vec![];
    let single = src_dir.join(format!("{}.rs", module));
    if single.exists() {
        files.push(single);
    }
    let dir = src_dir.join(module);
    if dir.is_dir() {
        for entry in fs::read_dir(&dir).unwrap() {
            let path = entry.unwrap().path();
            if path.extension().is_some_and(|e| e == "rs") {
                files.push(path);
            }
        }
    }
    files
}

/// Extract the first module name from a `use crate::...` statement.
fn parse_crate_dep(line: &str) -> Option<String> {
    let trimmed = line.trim();
    if !trimmed.starts_with("use crate::") {
        return None;
    }
    let after = trimmed.strip_prefix("use crate::")?;
    let module = after.split([':', '{', ';']).next()?.trim();
    if module.is_empty() {
        return None;
    }
    Some(module.to_string())
}

/// Rough heuristic: check if a line is inside a `#[cfg(test)]` block.
fn is_in_test_block(content: &str, target_line: usize) -> bool {
    let lines: Vec<&str> = content.lines().collect();
    // Walk backward to find enclosing #[cfg(test)]
    for i in (0..target_line).rev() {
        let trimmed = lines[i].trim();
        if trimmed == "#[cfg(test)]" {
            return true;
        }
        // If we hit a module-level item before finding #[cfg(test)], stop
        if trimmed.starts_with("pub fn ")
            || trimmed.starts_with("fn ")
            || trimmed.starts_with("pub struct ")
            || trimmed.starts_with("pub enum ")
            || trimmed.starts_with("pub trait ")
        {
            return false;
        }
    }
    false
}

#[cfg(test)]
mod unit_tests {
    use super::*;

    #[test]
    fn test_parse_crate_dep_simple() {
        assert_eq!(
            parse_crate_dep("use crate::model::Symbol;"),
            Some("model".to_string())
        );
    }

    #[test]
    fn test_parse_crate_dep_braces() {
        assert_eq!(
            parse_crate_dep("use crate::error::{DbError, DiffError};"),
            Some("error".to_string())
        );
    }

    #[test]
    fn test_parse_crate_dep_non_crate() {
        assert_eq!(parse_crate_dep("use std::path::Path;"), None);
    }

    #[test]
    fn test_parse_crate_dep_empty() {
        assert_eq!(parse_crate_dep("use crate::{DbError};"), None);
    }
}