ark-cli 0.1.2

Architectural boundary enforcer for .NET solutions
use miette::Result;
use rayon::prelude::*;
use std::path::Path;

use crate::config::load_config;
use crate::config::model::ArchitectureConfig;
use crate::parser::{ProjectFile, discover_projects};
use crate::rules::{is_ignored, resolve_layer};

pub fn run(root: &str, config_path: &str, project: &str) -> Result<()> {
    let root_path = Path::new(root);
    let config = load_config(Path::new(config_path))?;

    let project_paths = discover_projects(root_path)?;
    let projects: Vec<ProjectFile> = project_paths
        .par_iter()
        .filter_map(|p| ProjectFile::parse(p).ok())
        .collect();

    let known: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
    if !known.contains(&project) {
        eprintln!(
            "Warning: '{}' not found among discovered .csproj files",
            project
        );
    }

    print!("{}", describe_project(project, &config, &known));
    Ok(())
}

pub fn describe_project(
    project: &str,
    config: &ArchitectureConfig,
    known_projects: &[&str],
) -> String {
    let mut out = String::new();

    if is_ignored(project, &config.ignore_patterns) {
        out.push_str(&format!("Project: {project}\n"));
        out.push_str("Layer:   (ignored — matches an ignorePattern)\n");
        return out;
    }

    let Some(layer) = resolve_layer(project, &config.layers) else {
        out.push_str(&format!("Project: {project}\n"));
        out.push_str("Layer:   (unmatched — no layer pattern matches this project name)\n");
        return out;
    };

    out.push_str(&format!("Project: {project}\n"));
    out.push_str(&format!("Layer:   {}\n\n", layer.name));
    out.push_str("Dependency rules:\n");

    for other in &config.layers {
        if other.name == layer.name {
            continue;
        }
        let rule = config
            .dependency_rules
            .iter()
            .find(|r| r.from == layer.name && r.to == other.name);
        let (status, tag) = match rule {
            Some(r) if r.allowed => ("allowed  ", "[explicit]"),
            Some(_) => ("forbidden", "[explicit]"),
            None => ("forbidden", "[default]"),
        };
        out.push_str(&format!("  → {:<24} {}  {:<10}\n", other.name, status, tag));
    }

    let siblings: Vec<&str> = known_projects
        .iter()
        .copied()
        .filter(|&p| p != project)
        .filter(|p| {
            resolve_layer(p, &config.layers)
                .map(|l| l.name == layer.name)
                .unwrap_or(false)
        })
        .collect();

    if !siblings.is_empty() {
        out.push_str("\nOther projects in this layer:\n");
        for s in siblings {
            out.push_str(&format!("  {s}\n"));
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::model::{ArchitectureConfig, DependencyRule, Layer};

    fn cfg(layers: &[(&str, &[&str])], rules: &[(&str, &str, bool)]) -> ArchitectureConfig {
        ArchitectureConfig {
            layers: layers
                .iter()
                .map(|(name, pats)| Layer {
                    name: name.to_string(),
                    patterns: pats.iter().map(|s| s.to_string()).collect(),
                    namespace_patterns: vec![],
                })
                .collect(),
            dependency_rules: rules
                .iter()
                .map(|(from, to, allowed)| DependencyRule {
                    from: from.to_string(),
                    to: to.to_string(),
                    allowed: *allowed,
                })
                .collect(),
            package_policies: vec![],
            ignore_patterns: vec![],
        }
    }

    #[test]
    fn unmatched_project_says_unmatched() {
        let config = cfg(&[("Domain", &["*.Domain"])], &[]);
        let out = describe_project("MyApp.Unknown", &config, &[]);
        assert!(out.contains("unmatched"));
    }

    #[test]
    fn matched_project_shows_layer() {
        let config = cfg(
            &[
                ("Domain", &["*.Domain"]),
                ("Application", &["*.Application"]),
            ],
            &[("Application", "Domain", true)],
        );
        let out = describe_project("MyApp.Domain", &config, &[]);
        assert!(out.contains("Domain"));
        assert!(out.contains("Application"));
        assert!(out.contains("allowed") || out.contains("forbidden"));
    }

    #[test]
    fn ignored_project_says_ignored() {
        let mut config = cfg(&[("Domain", &["*.Domain"])], &[]);
        config.ignore_patterns = vec!["*.Tests".to_string()];
        let out = describe_project("MyApp.Tests", &config, &[]);
        assert!(out.contains("ignored"));
    }

    #[test]
    fn siblings_listed_when_present() {
        let config = cfg(&[("Domain", &["*.Domain"])], &[]);
        let out = describe_project(
            "MyApp.Domain",
            &config,
            &["MyApp.Domain", "MyApp.Core.Domain"],
        );
        assert!(out.contains("MyApp.Core.Domain"));
    }
}