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};
const SOURCE_ROOTS: &[&str] = &[
"internal", "src", "app", "lib", "packages", "apps", "services", "pkg",
];
const NOT_COMPONENTS: &[&str] = &[
"node_modules",
"vendor",
"target",
"dist",
"build",
"out",
"test",
"tests",
"spec",
"docs",
"doc",
"examples",
"scripts",
"tools",
"migrations",
];
#[derive(Debug, PartialEq, Eq)]
pub struct Found {
pub name: String,
pub pattern: String,
}
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()
}
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")
}
}
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
}
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"));
}
}