use std::collections::{BTreeMap, BTreeSet, VecDeque};
pub(super) fn architecture_layer(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
let segments = path_segments(&lower);
if has_segment(&segments, &["test", "tests"]) || lower.contains("_test.") {
"tests"
} else if has_segment(&segments, &["docs"]) || lower.ends_with(".md") {
"docs"
} else if has_segment(&segments, &["interface", "interfaces", "cli", "web"]) {
"interfaces"
} else if has_segment(&segments, &["application", "service", "services"]) {
"application"
} else if has_segment(&segments, &["domain", "model", "models"]) {
"domain"
} else if has_segment(&segments, &["storage", "repository", "repositories"]) {
"storage"
} else if has_segment(&segments, &["net", "network", "http"]) {
"network"
} else if lower.contains("config") || lower.ends_with(".toml") || lower.ends_with(".yaml") {
"configuration"
} else {
"source"
}
}
pub(super) fn layer_confidence(layer: &str) -> f64 {
match layer {
"source" => 0.52,
"docs" | "configuration" => 0.64,
_ => 0.78,
}
}
pub(super) fn route_domain(url: &str) -> Option<String> {
url.trim_matches('/')
.split('/')
.find(|segment| {
let lower = segment.to_ascii_lowercase();
!segment.is_empty()
&& !is_route_placeholder_segment(segment)
&& !is_route_domain_prefix(&lower)
})
.map(normalize_label)
}
pub(super) fn path_domain(path: &str) -> Option<String> {
let parts = path
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let start = parts
.iter()
.position(|segment| {
let lower = segment.to_ascii_lowercase();
!is_source_root_segment(&lower) && !is_workspace_container_segment(&lower)
})
.unwrap_or(0);
let start = path_domain_scan_start(&parts, start);
parts
.iter()
.enumerate()
.skip(start)
.find_map(|(index, segment)| {
let lower = segment.to_ascii_lowercase();
(is_domain_segment(&lower) && !is_path_domain_prefix(&lower))
.then(|| domain_segment_label(segment, index + 1 == parts.len()))
})
}
pub(super) fn normalized_view_paths(paths: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for path in paths {
let path = normalize_view_path(path);
if path != "." && !path.is_empty() && !normalized.iter().any(|existing| existing == &path) {
normalized.push(path);
}
}
normalized
}
pub(super) fn domain_token(value: &str) -> Option<String> {
feature_flag_domain_parts(value)
.into_iter()
.find(|part| {
let lower = part.to_ascii_lowercase();
lower.len() > 2 && !is_feature_flag_domain_prefix(&lower)
})
.map(|part| normalize_label(&part))
}
pub(super) fn domain_confidence(evidence_count: usize) -> f64 {
(0.45 + (evidence_count.min(5) as f64 * 0.08)).min(0.85)
}
pub(super) fn module_key(path: &str) -> String {
let parts = path
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
match parts.as_slice() {
[] => "root".to_owned(),
[single] if is_entrypoint_or_metadata_file(&single.to_ascii_lowercase()) => {
"root".to_owned()
}
[single] => (*single).to_owned(),
[workspace, package, ..] if is_workspace_container_segment(workspace) => {
normalize_label(package)
}
[first, package, domain, ..]
if is_source_root_segment(first) && is_src_layout_package_segment(package, domain) =>
{
normalize_label(domain)
}
[first, _crate_dir, third, ..]
if matches!(*first, "src" | "app" | "lib") && is_module_boundary(third) =>
{
(*third).to_owned()
}
[first, second]
if matches!(*first, "src" | "app" | "lib")
&& is_entrypoint_or_metadata_file(&second.to_ascii_lowercase()) =>
{
"root".to_owned()
}
["src", second, ..] | ["app", second, ..] | ["lib", second, ..] => (*second).to_owned(),
[first, second, ..] if matches!(*first, "src" | "app" | "lib") => (*second).to_owned(),
[first, ..] => (*first).to_owned(),
}
}
pub(super) fn affected_candidate_matches_changed_path(
changed_path: &str,
candidate_path: &str,
) -> bool {
let changed_module = module_key(changed_path);
let candidate_module = module_key(candidate_path);
if changed_module == "root" && candidate_module == "root" {
return same_changed_file_parent(changed_path, candidate_path);
}
changed_module == candidate_module || same_changed_file_parent(changed_path, candidate_path)
}
fn path_domain_scan_start(parts: &[&str], start: usize) -> usize {
if start > 0
&& is_source_root_segment(parts[start - 1])
&& parts
.get(start + 1)
.is_some_and(|next_segment| is_src_layout_package_segment(parts[start], next_segment))
{
return start + 1;
}
if parts
.get(start + 1)
.is_some_and(|segment| is_module_boundary(segment))
{
return start + 1;
}
start
}
fn is_module_boundary(segment: &str) -> bool {
matches!(
segment,
"api"
| "application"
| "code"
| "domain"
| "env"
| "evaluation"
| "indexing"
| "interfaces"
| "interface"
| "net"
| "observability"
| "paths"
| "project"
| "retrieval"
| "storage"
| "watcher"
)
}
fn is_src_layout_package_segment(segment: &str, next_segment: &str) -> bool {
let lower = segment.to_ascii_lowercase();
!is_module_boundary(&lower)
&& !is_source_root_segment(&lower)
&& !is_entrypoint_or_metadata_file(&lower)
&& !next_segment.contains('.')
&& is_domain_segment(next_segment)
}
fn is_route_domain_prefix(segment: &str) -> bool {
segment == "api" || is_version_segment(segment)
}
fn is_route_placeholder_segment(segment: &str) -> bool {
segment.starts_with(':')
|| segment.starts_with('{')
|| segment.starts_with('[') && segment.ends_with(']')
|| segment.starts_with('*')
}
fn is_path_domain_prefix(segment: &str) -> bool {
matches!(
segment,
"api"
| "controller"
| "controllers"
| "endpoint"
| "endpoints"
| "graphql"
| "handler"
| "handlers"
| "http"
| "rest"
| "route"
| "routes"
| "rpc"
| "web"
) || is_version_segment(segment)
}
fn is_feature_flag_domain_prefix(segment: &str) -> bool {
matches!(
segment,
"allow"
| "allowed"
| "enable"
| "enabled"
| "disable"
| "disabled"
| "flag"
| "has"
| "is"
| "rollout"
| "should"
| "toggle"
| "use"
| "uses"
)
}
fn is_version_segment(segment: &str) -> bool {
segment.len() > 1
&& segment.starts_with('v')
&& segment[1..]
.chars()
.all(|character| character.is_ascii_digit())
}
fn is_source_root_segment(segment: &str) -> bool {
matches!(
segment,
"src" | "lib" | "app" | "tests" | "test" | "docs" | "bin"
)
}
fn is_workspace_container_segment(segment: &str) -> bool {
matches!(
segment,
"apps" | "crates" | "modules" | "packages" | "services" | "workspaces"
)
}
fn is_domain_segment(segment: &str) -> bool {
let lower = segment.to_ascii_lowercase();
!is_entrypoint_or_metadata_file(&lower) && !is_source_root_segment(&lower) && lower.len() > 2
}
fn is_entrypoint_or_metadata_file(segment: &str) -> bool {
matches!(
segment,
"__init__.py"
| "cargo.toml"
| "go.mod"
| "index.js"
| "index.jsx"
| "index.ts"
| "index.tsx"
| "lib.rs"
| "main.rs"
| "mod.rs"
| "package.json"
| "pom.xml"
| "pyproject.toml"
| "readme.md"
| "setup.py"
)
}
fn path_segments(path: &str) -> Vec<&str> {
path.split(['/', '\\'])
.filter(|segment| !segment.is_empty())
.collect()
}
fn has_segment(segments: &[&str], candidates: &[&str]) -> bool {
segments
.iter()
.any(|segment| candidates.iter().any(|candidate| segment == candidate))
}
fn domain_segment_label(segment: &str, terminal: bool) -> String {
if terminal
&& let Some((stem, _extension)) = segment.rsplit_once('.')
&& !stem.is_empty()
{
return normalize_label(stem);
}
normalize_label(segment)
}
fn feature_flag_domain_parts(value: &str) -> Vec<String> {
let mut parts = Vec::new();
for part in value.split(|character: char| !character.is_ascii_alphanumeric()) {
push_camel_case_parts(part, &mut parts);
}
parts
}
fn push_camel_case_parts(value: &str, parts: &mut Vec<String>) {
let mut current = String::new();
let mut previous_was_lower_or_digit = false;
for character in value.chars() {
if !character.is_ascii_alphanumeric() {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
previous_was_lower_or_digit = false;
continue;
}
if character.is_ascii_uppercase() && previous_was_lower_or_digit && !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
previous_was_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
current.push(character);
}
if !current.is_empty() {
parts.push(current);
}
}
pub(super) fn topological_tour(
modules: &BTreeSet<String>,
graph: &BTreeMap<String, BTreeSet<String>>,
) -> (Vec<String>, bool) {
let mut indegree = modules
.iter()
.map(|module| (module.clone(), 0usize))
.collect::<BTreeMap<_, _>>();
for targets in graph.values() {
for target in targets {
*indegree.entry(target.clone()).or_default() += 1;
}
}
let mut queue = indegree
.iter()
.filter_map(|(module, degree)| (*degree == 0).then_some(module.clone()))
.collect::<VecDeque<_>>();
let mut order = Vec::new();
let mut graph = graph.clone();
while let Some(module) = queue.pop_front() {
order.push(module.clone());
if let Some(targets) = graph.remove(&module) {
for target in targets {
if let Some(degree) = indegree.get_mut(&target) {
*degree = degree.saturating_sub(1);
if *degree == 0 {
queue.push_back(target);
}
}
}
}
}
let cycle = order.len() < modules.len();
if cycle {
for module in modules {
if !order.contains(module) {
order.push(module.clone());
}
}
}
(order, cycle)
}
pub(super) fn is_test_config_or_doc(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.contains("test")
|| lower.contains("spec")
|| lower.contains("config")
|| lower.ends_with(".md")
|| lower.ends_with(".yaml")
|| lower.ends_with(".toml")
}
fn normalize_label(value: &str) -> String {
value
.trim_matches(|character: char| !character.is_ascii_alphanumeric())
.to_ascii_lowercase()
}
fn same_changed_file_parent(changed_path: &str, candidate_path: &str) -> bool {
let (changed_parent, changed_name) =
changed_path.rsplit_once('/').unwrap_or(("", changed_path));
let (candidate_parent, _) = candidate_path
.rsplit_once('/')
.unwrap_or(("", candidate_path));
let changed_is_file =
changed_name.contains('.') || changed_name.bytes().any(|byte| byte.is_ascii_uppercase());
changed_is_file && changed_parent == candidate_parent
}
fn normalize_view_path(path: &str) -> String {
let mut path = path.replace('\\', "/");
while path.ends_with('/') {
path.pop();
}
while path.starts_with("./") {
path.drain(..2);
}
path
}
#[cfg(test)]
#[path = "rules_tests.rs"]
mod tests;