use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use crate::components::ComponentMap;
use crate::config::Config;
pub fn root_relative(root: &Path, given: &Path) -> String {
let resolved = dunce::canonicalize(given)
.ok()
.and_then(|abs| abs.strip_prefix(root).map(Path::to_path_buf).ok())
.unwrap_or_else(|| PathBuf::from(given.to_string_lossy().trim_start_matches("./")));
crate::scan::slash(&resolved)
}
pub fn explain(config: &Config, map: &ComponentMap, rel: &str) -> Result<String> {
let Some((name, pattern)) = map.component_and_pattern(rel) else {
bail!("{rel} belongs to no component, so easement does not check it");
};
let mut out = format!("{rel}\n component: {name} (matches `{pattern}`)\n");
let component = &config.components[name];
if let Some(public) = &component.public {
out.push_str(&format!(
" public API: {} (other components may import only these)\n",
public.as_vec().join(", ")
));
}
if component.priority != 0 {
out.push_str(&format!(" priority: {}\n", component.priority));
}
if let Some(except) = &component.except {
out.push_str(&format!(" except: {}\n", except.as_vec().join(", ")));
}
if component.isolated {
out.push_str(" isolated: instances of this component must not import each other\n");
if let Some(instance) = map.instance_of(rel) {
out.push_str(&format!(" instance: {instance}\n"));
}
}
if let Some(importers) = &component.allow_importers {
out.push_str(&format!(
" may be imported only from: {}\n",
importers.join(", ")
));
}
let rule = config.rules.get(name);
let mut allowed = Vec::new();
let mut forbidden = Vec::new();
let mut other = Vec::new();
for to in config.components.keys().filter(|c| c.as_str() != name) {
if config.forbids(name, to) {
forbidden.push(to.as_str());
} else if config.allows(name, to) {
let why = if rule.is_some_and(|r| r.may_depend_on.iter().any(|c| c.name() == to)) {
"may_depend_on"
} else if config.common.contains(to) {
"common"
} else {
"layers"
};
let because = config
.allow_reason(name, to)
.map(|b| format!(": {b}"))
.unwrap_or_default();
allowed.push(format!("{to} ({why}{because})"));
} else {
other.push(to.as_str());
}
}
let list = |items: &[String]| {
if items.is_empty() {
"nothing".to_string()
} else {
items.join(", ")
}
};
out.push_str(&format!(" may depend on: {}\n", list(&allowed)));
if !forbidden.is_empty() {
out.push_str(&format!(" forbidden: {}\n", forbidden.join(", ")));
}
if !other.is_empty() {
out.push_str(&format!(" not allowed: {}\n", other.join(", ")));
}
if let Some(rule) = rule {
if let Some(external) = &rule.may_use_external {
out.push_str(&format!(
" external modules: only {}\n",
list(
&external
.iter()
.map(|l| l.name().to_string())
.collect::<Vec<_>>()
)
));
}
if !rule.forbid_external.is_empty() {
out.push_str(&format!(
" forbidden external modules: {}\n",
rule.forbid_external
.iter()
.map(|l| l.name())
.collect::<Vec<_>>()
.join(", ")
));
}
let names = |items: &[crate::config::Listed]| {
items
.iter()
.map(|l| l.name())
.collect::<Vec<_>>()
.join(", ")
};
if !rule.must_depend_on.is_empty() {
out.push_str(&format!(
" every directory must import: {}\n",
names(&rule.must_depend_on)
));
}
if !rule.must_use_external.is_empty() {
out.push_str(&format!(
" every directory must use external: {}\n",
names(&rule.must_use_external)
));
}
if !rule.requires_file.is_empty() {
out.push_str(&format!(
" every directory must contain: {}\n",
names(&rule.requires_file)
));
}
if !rule.forbid_file.is_empty() {
out.push_str(&format!(
" forbidden files: {}\n",
names(&rule.forbid_file)
));
}
if !rule.requires_directive.is_empty() {
out.push_str(&format!(
" every directory must match: {}\n",
names(&rule.requires_directive)
));
}
if let Some(max) = rule.max_fan_out {
out.push_str(&format!(" max_fan_out: {max}\n"));
}
if let Some(max) = rule.max_instability {
out.push_str(&format!(" max_instability: {max}\n"));
}
if let Some(because) = &rule.because {
out.push_str(&format!(" because: {because}\n"));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explains_allowed_forbidden_and_unmapped() {
let config: Config = serde_norway::from_str(
"version: 1
components:
web: { in: src/web }
core: { in: src/core }
data: { in: src/data, public: src/data/api, allow_importers: [core], priority: 2 }
pages: { in: 'src/pages/*', isolated: true }
shared: { in: src/shared }
common: [shared]
layers: [web, core]
rules:
web:
must_not_depend_on: [data]
forbid_external: [moment]
requires_file: [README.md]
because: pages go through core
",
)
.unwrap();
let map = ComponentMap::new(&config).unwrap();
let out = explain(&config, &map, "src/web/page.ts").unwrap();
assert!(out.contains("component: web (matches `src/web`)"));
assert!(out.contains("may depend on: core (layers), shared (common)"));
assert!(out.contains("forbidden: data"));
assert!(out.contains("forbidden external modules: moment"));
assert!(out.contains("every directory must contain: README.md"));
assert!(out.contains("because: pages go through core"));
let data = explain(&config, &map, "src/data/db.ts").unwrap();
assert!(data.contains("public API: src/data/api"));
assert!(data.contains("may be imported only from: core"));
assert!(data.contains("priority: 2"));
let page = explain(&config, &map, "src/pages/home/a.ts").unwrap();
assert!(page.contains("instance: src/pages/home"));
assert!(data.contains("not allowed: core, pages, web"));
assert!(explain(&config, &map, "stray.ts").is_err());
}
}