use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportResolutionStats {
pub unresolved_internal_by_source: BTreeMap<PathBuf, Vec<String>>,
}
impl ImportResolutionStats {
pub fn record(&mut self, source: &Path, raw_import: &str) {
self.unresolved_internal_by_source
.entry(source.to_path_buf())
.or_default()
.push(raw_import.to_string());
}
pub fn is_empty(&self) -> bool {
self.unresolved_internal_by_source.is_empty()
}
pub fn total(&self) -> usize {
self.unresolved_internal_by_source
.values()
.map(Vec::len)
.sum()
}
pub fn could_target_stem(&self, stem: &str) -> bool {
if stem.is_empty() {
return false;
}
self.unresolved_internal_by_source
.values()
.flatten()
.any(|import| {
import_target_stems(import)
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(stem))
})
}
}
fn import_target_stems(raw_import: &str) -> Vec<String> {
let trimmed = raw_import.trim().trim_end_matches('/');
let last_path = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
let mut stems = Vec::new();
if let Some(head) = last_path.split('.').next()
&& !head.is_empty()
&& head != ".."
{
stems.push(head.to_string());
}
if let Some(tail) = last_path.rsplit('.').next()
&& !tail.is_empty()
&& tail != ".."
&& !stems.iter().any(|s| s == tail)
{
stems.push(tail.to_string());
}
stems
}
pub(crate) fn is_relative_import(import: &str) -> bool {
import.starts_with("./") || import.starts_with("../")
}
pub(crate) fn is_unresolved_internal_import(import: &str, repo_dirs: &HashSet<String>) -> bool {
let import = import.trim();
if import.is_empty() {
return false;
}
if is_relative_import(import) {
return true;
}
if import.starts_with("@/") || import.starts_with("~/") || import == "~" {
return true;
}
leading_segment(import).is_some_and(|segment| repo_dirs.contains(segment))
}
fn leading_segment(import: &str) -> Option<&str> {
import
.split(['/', '\\', '.', ':'])
.find(|segment| !segment.is_empty())
}
pub(crate) fn repo_directory_names<'a, I>(paths: I) -> HashSet<String>
where
I: IntoIterator<Item = &'a Path>,
{
let mut dirs = HashSet::new();
for path in paths {
let mut components: Vec<&str> = path
.components()
.filter_map(|component| component.as_os_str().to_str())
.collect();
components.pop(); for component in components {
dirs.insert(component.to_string());
}
}
dirs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_and_counts_unresolved_imports_per_source() {
let mut stats = ImportResolutionStats::default();
assert!(stats.is_empty());
stats.record(Path::new("src/a.ts"), "./missing");
stats.record(Path::new("src/a.ts"), "../gone/helper");
stats.record(Path::new("src/b.ts"), "./missing");
assert!(!stats.is_empty());
assert_eq!(stats.total(), 3);
assert_eq!(stats.unresolved_internal_by_source.len(), 2);
}
#[test]
fn could_target_stem_matches_final_segment_without_extension() {
let mut stats = ImportResolutionStats::default();
stats.record(Path::new("src/a.ts"), "../legacy/Utils.js");
assert!(stats.could_target_stem("utils"));
assert!(stats.could_target_stem("Utils"));
assert!(!stats.could_target_stem("legacy"));
assert!(!stats.could_target_stem(""));
}
#[test]
fn could_target_stem_matches_last_dotted_module_segment() {
let mut stats = ImportResolutionStats::default();
stats.record(Path::new("apps/web/main.py"), "app.services.foo");
assert!(stats.could_target_stem("foo"));
assert!(stats.could_target_stem("app"));
assert!(!stats.could_target_stem("services"));
}
#[test]
fn relative_import_detection_matches_dot_prefixes_only() {
assert!(is_relative_import("./a"));
assert!(is_relative_import("../a"));
assert!(!is_relative_import("react"));
assert!(!is_relative_import("@scope/pkg"));
}
#[test]
fn internal_import_classifier_separates_workspace_from_third_party() {
let repo_dirs: HashSet<String> = ["app", "components", "ml"]
.into_iter()
.map(String::from)
.collect();
assert!(is_unresolved_internal_import("./helper", &repo_dirs));
assert!(is_unresolved_internal_import(
"@/components/Button",
&repo_dirs
));
assert!(is_unresolved_internal_import("~/lib/util", &repo_dirs));
assert!(is_unresolved_internal_import("app.ml.train", &repo_dirs));
assert!(is_unresolved_internal_import(
"components/Button",
&repo_dirs
));
assert!(!is_unresolved_internal_import("react", &repo_dirs));
assert!(!is_unresolved_internal_import("@angular/core", &repo_dirs));
assert!(!is_unresolved_internal_import("numpy", &repo_dirs));
assert!(!is_unresolved_internal_import(
"django.db.models",
&repo_dirs
));
}
#[test]
fn repo_directory_names_collects_parent_segments_only() {
let paths = [
Path::new("apps/ml/app/train.py"),
Path::new("apps/web/src/index.ts"),
];
let dirs = repo_directory_names(paths);
assert!(dirs.contains("apps"));
assert!(dirs.contains("ml"));
assert!(dirs.contains("app"));
assert!(dirs.contains("web"));
assert!(dirs.contains("src"));
assert!(!dirs.contains("train"));
assert!(!dirs.contains("index"));
}
}