easement 0.1.1

Architecture quality gate for polyglot codebases
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use anyhow::{Result, bail};

use crate::components::ComponentMap;
use crate::config::{Config, DEFAULT_CONFIG};
use crate::{check, lang, scan};

/// Directories whose children are the components, in order of preference.
const SOURCE_ROOTS: &[&str] = &[
    "internal", "src", "app", "lib", "packages", "apps", "services", "pkg",
];

/// Top-level directories that are never components of their own.
const NOT_COMPONENTS: &[&str] = &[
    "node_modules",
    "vendor",
    "target",
    "dist",
    "build",
    "out",
    "test",
    "tests",
    "spec",
    "docs",
    "doc",
    "examples",
    "scripts",
    "tools",
    "migrations",
];

/// A component found in the layout of the project: its name and `in` pattern.
#[derive(Debug, PartialEq, Eq)]
pub struct Found {
    pub name: String,
    pub pattern: String,
}

/// Components from the layout of `files` (relative, `/`-separated): the
/// subdirectories of the first conventional source root that has at least two,
/// otherwise the top-level directories.
pub fn discover(files: &[String]) -> Vec<Found> {
    let dirs_under = |base: &str| -> BTreeSet<String> {
        files
            .iter()
            .filter_map(|f| {
                let rest = if base.is_empty() {
                    f.as_str()
                } else {
                    f.strip_prefix(base)?.strip_prefix('/')?
                };
                let (dir, _) = rest.split_once('/')?;
                Some(dir.to_string())
            })
            .collect()
    };
    for base in SOURCE_ROOTS {
        let dirs = dirs_under(base);
        if dirs.len() >= 2 {
            return dirs
                .into_iter()
                .map(|d| Found {
                    pattern: format!("{base}/{d}"),
                    name: d,
                })
                .collect();
        }
    }
    dirs_under("")
        .into_iter()
        .filter(|d| !d.starts_with('.') && !NOT_COMPONENTS.contains(&d.as_str()))
        .map(|d| Found {
            pattern: d.clone(),
            name: d,
        })
        .collect()
}

/// A YAML key or scalar, quoted when plain style would be read as something else.
fn scalar(text: &str) -> String {
    let plain = text
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
        && text
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.')
        && !matches!(
            text.to_ascii_lowercase().as_str(),
            "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "y" | "n"
        );
    if plain {
        text.to_string()
    } else {
        serde_json::to_string(text).expect("a string serializes")
    }
}

/// The config text: every dependency between components found in the code is allowed.
pub fn render(found: &[Found], deps: &BTreeMap<String, BTreeSet<String>>) -> String {
    let mut out = String::from(
        "# yaml-language-server: $schema=https://raw.githubusercontent.com/mokevnin/easement/main/easement.schema.json\n\
         # Generated by `easement init` from the code as it is: every dependency found\n\
         # between components is allowed. Delete the ones that should not exist and add\n\
         # `because:` reasons, then run `easement check`.\n\
         version: 1\n\ncomponents:\n",
    );
    for f in found {
        out.push_str(&format!(
            "  {}: {{ in: {} }}\n",
            scalar(&f.name),
            scalar(&f.pattern)
        ));
    }
    if !deps.is_empty() {
        out.push_str("\nrules:\n");
        for (from, to) in deps {
            let list: Vec<String> = to.iter().map(|t| scalar(t)).collect();
            out.push_str(&format!(
                "  {}: {{ may_depend_on: [{}] }}\n",
                scalar(from),
                list.join(", ")
            ));
        }
    }
    out.push_str("\n# no_cycles: true\n# strict: true\n");
    out
}

/// Writes a starting `easement.yml` into `root` and says what it contains.
pub fn run(root: &Path, force: bool) -> Result<String> {
    let path = root.join(DEFAULT_CONFIG);
    if path.exists() && !force {
        bail!(
            "{} already exists (use --force to overwrite)",
            path.display()
        );
    }
    let probe: Config =
        serde_norway::from_str("version: 1\ncomponents:\n  probe: { in: probe }\n")?;
    let adapters = lang::adapters(root, &probe)?;
    let files: Vec<String> = scan::walker(root)
        .build()
        .flatten()
        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
        .filter(|e| adapters.iter().any(|a| a.handles(e.path())))
        .filter_map(|e| lang::relative(root, e.path()))
        .map(|p| scan::slash(&p))
        .collect();
    let found = discover(&files);
    if found.is_empty() {
        bail!("found no source directories to turn into components; write easement.yml by hand");
    }
    let draft = render(&found, &BTreeMap::new());
    let config: Config = serde_norway::from_str(&draft)?;
    let scanned = scan::scan(root, &config, &mut scan::Cache::default())?;
    let map = ComponentMap::new(&config)?;
    let (_, edges, _) = check::evaluate(&config, &map, &scanned.deps);
    let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for e in &edges {
        deps.entry(e.from.clone()).or_default().insert(e.to.clone());
    }
    std::fs::write(&path, render(&found, &deps))?;
    Ok(format!(
        "easement: wrote {} with {} components and {} allowed dependencies; \
         remove the dependencies that should not exist, then run `easement check`",
        path.display(),
        found.len(),
        deps.values().map(BTreeSet::len).sum::<usize>()
    ))
}

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

    fn files(paths: &[&str]) -> Vec<String> {
        paths.iter().map(|p| p.to_string()).collect()
    }

    fn names(found: &[Found]) -> Vec<(&str, &str)> {
        found
            .iter()
            .map(|f| (f.name.as_str(), f.pattern.as_str()))
            .collect()
    }

    #[test]
    fn prefers_a_source_root_with_several_children() {
        let found = discover(&files(&[
            "cmd/app/main.go",
            "internal/handlers/a.go",
            "internal/services/b.go",
            "internal/main.go",
            "src/only/c.go",
        ]));
        assert_eq!(
            names(&found),
            [
                ("handlers", "internal/handlers"),
                ("services", "internal/services")
            ]
        );
    }

    #[test]
    fn falls_back_to_top_level_directories() {
        let found = discover(&files(&[
            "api/a.py",
            "billing/b.py",
            "tests/t.py",
            ".venv/x.py",
            "setup.py",
        ]));
        assert_eq!(names(&found), [("api", "api"), ("billing", "billing")]);
        assert!(discover(&files(&["main.go"])).is_empty());
    }

    #[test]
    fn renders_valid_config_with_quoting() {
        let found = [
            Found {
                name: "web".into(),
                pattern: "src/web".into(),
            },
            Found {
                name: "no".into(),
                pattern: "src/no".into(),
            },
        ];
        let deps = BTreeMap::from([("web".to_string(), BTreeSet::from(["no".to_string()]))]);
        let text = render(&found, &deps);
        assert!(text.contains("  \"no\": { in: src/no }"));
        let config: Config = serde_norway::from_str(&text).unwrap();
        assert!(config.allows("web", "no"));
        assert!(!config.allows("no", "web"));
    }
}