use std::collections::{BTreeMap, BTreeSet};
use crate::check::Edge;
use crate::components::ComponentMap;
use crate::config::Config;
use crate::lang::Target;
use crate::scan::{Dep, slash};
pub fn suggest(
config: &Config,
map: &ComponentMap,
edges: &[Edge],
deps: &[Dep],
files: &[String],
uncovered: &[String],
) -> String {
let mut unindexed: BTreeMap<&str, usize> = BTreeMap::new();
for file in uncovered {
if let Some(component) = map.component_of(file) {
*unindexed.entry(component).or_default() += 1;
}
}
let mut used: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for e in edges {
*used.entry((&e.from, &e.to)).or_default() += 1;
}
let mut out = String::new();
let allowances = unused_allowances(config, &used, &unindexed);
section(
&mut out,
"Allowed in may_depend_on but never imported (remove to tighten the rules):",
allowances
.removable
.iter()
.map(|(from, to)| format!("{from} -> {to}"))
.collect(),
);
section(
&mut out,
"Not imported among the indexed files, but some files were not indexed, so the import may be there (do not remove without checking):",
allowances.unreliable,
);
let mut missing: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for ((from, to), count) in &used {
if !config.allows(from, to) {
missing
.entry(from)
.or_default()
.push(format!("{to} ({count} import(s))"));
}
}
section(
&mut out,
"Imported but not allowed (add to may_depend_on, or fix the imports):",
missing
.into_iter()
.map(|(from, targets)| format!("{from}: {}", targets.join(", ")))
.collect(),
);
section(
&mut out,
"Instances that never import each other (candidates for `isolated: true`):",
isolation_candidates(config, map, deps, files, &unindexed),
);
if out.is_empty() {
out.push_str("Nothing to suggest: the config matches the dependency graph.\n");
}
out
}
struct Allowances {
removable: Vec<(String, String)>,
unreliable: Vec<String>,
}
fn unused_allowances(
config: &Config,
used: &BTreeMap<(&str, &str), usize>,
unindexed: &BTreeMap<&str, usize>,
) -> Allowances {
let mut found = Allowances {
removable: Vec::new(),
unreliable: Vec::new(),
};
for (from, rule) in &config.rules {
for to in rule.may_depend_on.iter().map(|l| l.name()) {
if used.contains_key(&(from.as_str(), to)) {
continue;
}
let gaps: Vec<String> = [from.as_str(), to]
.into_iter()
.filter_map(|c| Some(format!("{} file(s) of {c}", unindexed.get(c)?)))
.collect();
if gaps.is_empty() {
found.removable.push((from.clone(), to.to_string()));
} else {
found
.unreliable
.push(format!("{from} -> {to} (not indexed: {})", gaps.join(", ")));
}
}
}
found
}
pub fn removable(
config: &Config,
map: &ComponentMap,
edges: &[Edge],
uncovered: &[String],
) -> Vec<(String, String)> {
let mut unindexed: BTreeMap<&str, usize> = BTreeMap::new();
for file in uncovered {
if let Some(component) = map.component_of(file) {
*unindexed.entry(component).or_default() += 1;
}
}
let mut used: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for e in edges {
*used.entry((&e.from, &e.to)).or_default() += 1;
}
unused_allowances(config, &used, &unindexed).removable
}
fn section(out: &mut String, title: &str, items: Vec<String>) {
if items.is_empty() {
return;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(title);
out.push('\n');
for item in items {
out.push_str(&format!(" {item}\n"));
}
}
fn isolation_candidates(
config: &Config,
map: &ComponentMap,
deps: &[Dep],
files: &[String],
unindexed: &BTreeMap<&str, usize>,
) -> Vec<String> {
let mut groups: BTreeMap<(String, String), (BTreeSet<String>, bool)> = BTreeMap::new();
let instance = |file: &str, pattern: &str| {
let n = pattern.split('/').count();
file.split('/').take(n).collect::<Vec<_>>().join("/")
};
let locate = |file: &str| {
let (component, pattern) = map.component_and_pattern(file)?;
if config.components[component].isolated || !pattern.contains('*') || pattern.contains("**")
{
return None;
}
Some((
(component.to_string(), pattern.to_string()),
instance(file, pattern),
))
};
for file in files {
if let Some((key, here)) = locate(file) {
groups.entry(key).or_default().0.insert(here);
}
}
for dep in deps {
let Target::Internal(target) = &dep.target else {
continue;
};
let (file, target) = (slash(&dep.file), slash(target));
let Some((key, here)) = locate(&file) else {
continue;
};
let entry = groups.entry(key.clone()).or_default();
entry.0.insert(here.clone());
if let Some((there_key, there)) = locate(&target)
&& there_key == key
&& there != here
{
entry.1 = true;
}
}
groups
.into_iter()
.filter(|(_, (instances, crossing))| instances.len() > 1 && !crossing)
.map(|((component, pattern), (instances, _))| {
let names: Vec<_> = instances
.iter()
.map(|i| i.rsplit('/').next().unwrap_or(i).to_string())
.collect();
let note = match unindexed.get(component.as_str()) {
Some(n) => format!(" [unreliable: {n} file(s) not indexed]"),
None => String::new(),
};
format!("{component} (`{pattern}`): {}{note}", names.join(", "))
})
.collect()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn dep(file: &str, target: &str) -> Dep {
Dep {
file: PathBuf::from(file),
line: 1,
specifier: String::new(),
target: Target::Internal(PathBuf::from(target)),
}
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from: from.into(),
to: to.into(),
file: format!("{from}/x"),
line: 1,
}
}
#[test]
fn suggests_removals_additions_and_isolation() {
let config: Config = serde_norway::from_str(
"version: 1
components:
web: { in: src/web }
core: { in: src/core }
data: { in: src/data }
pages: { in: 'src/pages/*' }
rules:
web: { may_depend_on: [core, data] }
",
)
.unwrap();
let map = ComponentMap::new(&config).unwrap();
let edges = [edge("web", "core"), edge("core", "data")];
let deps = [
dep("src/pages/a/x.ts", "src/core/c.ts"),
dep("src/pages/b/y.ts", "src/pages/b/z.ts"),
];
let files = [
"src/pages/a/x.ts".to_string(),
"src/pages/b/y.ts".to_string(),
"src/pages/b/z.ts".to_string(),
"src/pages/c/leaf.ts".to_string(),
];
let out = suggest(&config, &map, &edges, &deps, &files, &[]);
assert!(out.contains(" web -> data\n"), "{out}");
assert!(!out.contains("web -> core"), "{out}");
assert!(out.contains(" core: data (1 import(s))\n"), "{out}");
assert!(out.contains(" pages (`src/pages/*`): a, b, c\n"), "{out}");
let crossing = [dep("src/pages/a/x.ts", "src/pages/b/y.ts")];
assert!(!suggest(&config, &map, &edges, &crossing, &files, &[]).contains("pages"));
}
#[test]
fn does_not_trust_a_missing_import_next_to_files_nothing_indexed() {
let config: Config = serde_norway::from_str(
"version: 1
components:
web: { in: src/web }
core: { in: src/core }
generated: { in: src/generated }
rules:
web: { may_depend_on: [core, generated] }
",
)
.unwrap();
let map = ComponentMap::new(&config).unwrap();
let edges = [edge("web", "core")];
let uncovered = ["src/generated/index.ts".to_string()];
let out = suggest(&config, &map, &edges, &[], &[], &uncovered);
assert!(
out.contains(" web -> generated (not indexed: 1 file(s) of generated)\n"),
"{out}"
);
assert!(!out.contains(" web -> generated\n"), "{out}");
let clean = suggest(&config, &map, &edges, &[], &[], &[]);
assert!(clean.contains(" web -> generated\n"), "{clean}");
}
#[test]
fn a_double_star_pattern_is_never_an_isolation_candidate() {
let config: Config = serde_norway::from_str(
"version: 1
components:
routes: { in: 'src/routes/**' }
",
)
.unwrap();
let map = ComponentMap::new(&config).unwrap();
let files = [
"src/routes/a/x.ts".to_string(),
"src/routes/b/y.ts".to_string(),
];
let out = suggest(&config, &map, &[], &[], &files, &[]);
assert!(!out.contains("isolated"), "{out}");
}
#[test]
fn isolation_candidates_with_unindexed_files_are_marked() {
let config: Config = serde_norway::from_str(
"version: 1
components:
pages: { in: 'src/pages/*' }
",
)
.unwrap();
let map = ComponentMap::new(&config).unwrap();
let files = [
"src/pages/a/x.ts".to_string(),
"src/pages/b/y.ts".to_string(),
];
let uncovered = ["src/pages/c/z.ts".to_string()];
let out = suggest(&config, &map, &[], &[], &files, &uncovered);
assert!(out.contains("[unreliable: 1 file(s) not indexed]"), "{out}");
}
}