use crate::audit::issue::{AuditIssue, IssueCategory, IssueSeverity};
use crate::config::PackageToolsConfig;
use crate::error::AuditResult;
use crate::types::PackageInfo;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyCategorization {
pub internal_packages: Vec<InternalPackage>,
pub external_packages: Vec<ExternalPackage>,
pub workspace_links: Vec<WorkspaceLink>,
pub local_links: Vec<LocalLink>,
pub stats: CategorizationStats,
}
impl DependencyCategorization {
#[must_use]
pub fn empty() -> Self {
Self {
internal_packages: Vec::new(),
external_packages: Vec::new(),
workspace_links: Vec::new(),
local_links: Vec::new(),
stats: CategorizationStats::default(),
}
}
#[must_use]
pub fn has_internal_packages(&self) -> bool {
!self.internal_packages.is_empty()
}
#[must_use]
pub fn has_external_packages(&self) -> bool {
!self.external_packages.is_empty()
}
#[must_use]
pub fn has_workspace_links(&self) -> bool {
!self.workspace_links.is_empty()
}
#[must_use]
pub fn has_local_links(&self) -> bool {
!self.local_links.is_empty()
}
#[must_use]
pub fn internal_percentage(&self) -> f64 {
let total = self.stats.internal_packages + self.stats.external_packages;
if total == 0 { 0.0 } else { (self.stats.internal_packages as f64 / total as f64) * 100.0 }
}
#[must_use]
pub fn external_percentage(&self) -> f64 {
let total = self.stats.internal_packages + self.stats.external_packages;
if total == 0 { 0.0 } else { (self.stats.external_packages as f64 / total as f64) * 100.0 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalPackage {
pub name: String,
pub path: PathBuf,
pub version: Option<String>,
pub used_by: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalPackage {
pub name: String,
pub version_spec: String,
pub used_by: Vec<String>,
pub is_deprecated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceLink {
pub package_name: String,
pub dependency_name: String,
pub version_spec: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalLink {
pub package_name: String,
pub dependency_name: String,
pub link_type: LocalLinkType,
pub path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LocalLinkType {
File,
Link,
Portal,
}
impl LocalLinkType {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::File => "file",
Self::Link => "link",
Self::Portal => "portal",
}
}
#[must_use]
pub fn protocol_prefix(&self) -> &'static str {
match self {
Self::File => "file:",
Self::Link => "link:",
Self::Portal => "portal:",
}
}
#[must_use]
pub fn from_version_spec(version_spec: &str) -> Option<Self> {
if version_spec.starts_with("file:") {
Some(Self::File)
} else if version_spec.starts_with("link:") {
Some(Self::Link)
} else if version_spec.starts_with("portal:") {
Some(Self::Portal)
} else {
None
}
}
}
impl std::fmt::Display for LocalLinkType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CategorizationStats {
pub total_packages: usize,
pub internal_packages: usize,
pub external_packages: usize,
pub workspace_links: usize,
pub local_links: usize,
}
pub async fn categorize_dependencies(
packages: &[PackageInfo],
_config: &PackageToolsConfig,
) -> AuditResult<DependencyCategorization> {
let workspace_packages: HashSet<String> =
packages.iter().map(|p| p.name().to_string()).collect();
let mut internal_map: HashMap<String, InternalPackage> = HashMap::new();
let mut external_map: HashMap<String, ExternalPackage> = HashMap::new();
let mut workspace_links: Vec<WorkspaceLink> = Vec::new();
let mut local_links: Vec<LocalLink> = Vec::new();
for package in packages {
let package_name = package.name().to_string();
let package_json = package.package_json();
let mut all_raw_deps = Vec::new();
if let Some(deps) = &package_json.dependencies {
for (name, version) in deps {
all_raw_deps.push((name.clone(), version.clone()));
}
}
if let Some(deps) = &package_json.dev_dependencies {
for (name, version) in deps {
all_raw_deps.push((name.clone(), version.clone()));
}
}
if let Some(deps) = &package_json.peer_dependencies {
for (name, version) in deps {
all_raw_deps.push((name.clone(), version.clone()));
}
}
if let Some(deps) = &package_json.optional_dependencies {
for (name, version) in deps {
all_raw_deps.push((name.clone(), version.clone()));
}
}
for (dep_name, version_spec) in all_raw_deps {
if version_spec.starts_with("workspace:") {
workspace_links.push(WorkspaceLink {
package_name: package_name.clone(),
dependency_name: dep_name.clone(),
version_spec: version_spec.clone(),
});
continue;
}
if let Some(link_type) = LocalLinkType::from_version_spec(&version_spec) {
let path = version_spec
.strip_prefix(link_type.protocol_prefix())
.unwrap_or(&version_spec)
.to_string();
local_links.push(LocalLink {
package_name: package_name.clone(),
dependency_name: dep_name.clone(),
link_type,
path,
});
continue;
}
if workspace_packages.contains(&dep_name) {
internal_map
.entry(dep_name.clone())
.and_modify(|pkg| {
if !pkg.used_by.contains(&package_name) {
pkg.used_by.push(package_name.clone());
}
})
.or_insert_with(|| {
let dep_package = packages.iter().find(|p| p.name() == dep_name);
InternalPackage {
name: dep_name.clone(),
path: dep_package.map(|p| p.path().to_path_buf()).unwrap_or_default(),
version: dep_package.map(|p| format!("{}", p.version())),
used_by: vec![package_name.clone()],
}
});
} else {
external_map
.entry(dep_name.clone())
.and_modify(|pkg| {
if !pkg.used_by.contains(&package_name) {
pkg.used_by.push(package_name.clone());
}
})
.or_insert_with(|| ExternalPackage {
name: dep_name.clone(),
version_spec: version_spec.clone(),
used_by: vec![package_name.clone()],
is_deprecated: false,
});
}
}
}
let mut internal_packages: Vec<InternalPackage> = internal_map.into_values().collect();
internal_packages.sort_by(|a, b| a.name.cmp(&b.name));
let mut external_packages: Vec<ExternalPackage> = external_map.into_values().collect();
external_packages.sort_by(|a, b| a.name.cmp(&b.name));
workspace_links.sort_by(|a, b| {
a.package_name.cmp(&b.package_name).then(a.dependency_name.cmp(&b.dependency_name))
});
local_links.sort_by(|a, b| {
a.package_name.cmp(&b.package_name).then(a.dependency_name.cmp(&b.dependency_name))
});
let stats = CategorizationStats {
total_packages: packages.len(),
internal_packages: internal_packages.len(),
external_packages: external_packages.len(),
workspace_links: workspace_links.len(),
local_links: local_links.len(),
};
Ok(DependencyCategorization {
internal_packages,
external_packages,
workspace_links,
local_links,
stats,
})
}
#[must_use]
pub fn generate_categorization_issues(
categorization: &DependencyCategorization,
) -> Vec<AuditIssue> {
let mut issues = Vec::new();
let highly_used_threshold = 5;
for internal_pkg in &categorization.internal_packages {
if internal_pkg.used_by.len() >= highly_used_threshold {
issues.push(AuditIssue {
severity: IssueSeverity::Info,
category: IssueCategory::Dependencies,
title: format!("Highly-used internal package: {}", internal_pkg.name),
description: format!(
"Package '{}' is used by {} packages: {}. This indicates it's a core dependency.",
internal_pkg.name,
internal_pkg.used_by.len(),
internal_pkg.used_by.join(", ")
),
affected_packages: vec![internal_pkg.name.clone()],
suggestion: Some(format!(
"Consider carefully managing changes to '{}' as it impacts {} packages. \
Ensure proper versioning and changelog maintenance.",
internal_pkg.name,
internal_pkg.used_by.len()
)),
metadata: {
let mut meta = HashMap::new();
meta.insert("used_by_count".to_string(), internal_pkg.used_by.len().to_string());
meta.insert("used_by".to_string(), internal_pkg.used_by.join(", "));
meta
},
});
}
}
if !categorization.workspace_links.is_empty() {
issues.push(AuditIssue {
severity: IssueSeverity::Info,
category: IssueCategory::Dependencies,
title: format!(
"Workspace protocol in use ({} links)",
categorization.workspace_links.len()
),
description: format!(
"Found {} dependencies using workspace: protocol. This ensures packages \
always use the workspace version of internal dependencies.",
categorization.workspace_links.len()
),
affected_packages: categorization
.workspace_links
.iter()
.map(|link| link.package_name.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
suggestion: Some(
"Workspace protocol is recommended for internal dependencies in monorepos. \
It ensures consistency and prevents version mismatches."
.to_string(),
),
metadata: {
let mut meta = HashMap::new();
meta.insert("count".to_string(), categorization.workspace_links.len().to_string());
meta
},
});
}
if !categorization.local_links.is_empty() {
let file_count = categorization
.local_links
.iter()
.filter(|link| link.link_type == LocalLinkType::File)
.count();
let link_count = categorization
.local_links
.iter()
.filter(|link| link.link_type == LocalLinkType::Link)
.count();
let portal_count = categorization
.local_links
.iter()
.filter(|link| link.link_type == LocalLinkType::Portal)
.count();
issues.push(AuditIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::Dependencies,
title: format!(
"Local filesystem protocols in use ({} links)",
categorization.local_links.len()
),
description: format!(
"Found {} dependencies using local filesystem protocols (file: {}, link: {}, portal: {}). \
These dependencies reference packages via filesystem paths.",
categorization.local_links.len(),
file_count,
link_count,
portal_count
),
affected_packages: categorization
.local_links
.iter()
.map(|link| link.package_name.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
suggestion: Some(
"Consider using workspace: protocol for internal dependencies instead of file:, link:, or portal: \
protocols. Local protocols can cause issues with portability and package managers."
.to_string(),
),
metadata: {
let mut meta = HashMap::new();
meta.insert("file_count".to_string(), file_count.to_string());
meta.insert("link_count".to_string(), link_count.to_string());
meta.insert("portal_count".to_string(), portal_count.to_string());
meta.insert("total".to_string(), categorization.local_links.len().to_string());
meta
},
});
}
issues.push(AuditIssue {
severity: IssueSeverity::Info,
category: IssueCategory::Dependencies,
title: "Dependency categorization summary".to_string(),
description: format!(
"Workspace contains {} packages with {} unique internal packages and {} unique external packages. \
Internal/External ratio: {:.1}%/{:.1}%.",
categorization.stats.total_packages,
categorization.stats.internal_packages,
categorization.stats.external_packages,
categorization.internal_percentage(),
categorization.external_percentage()
),
affected_packages: Vec::new(),
suggestion: None,
metadata: {
let mut meta = HashMap::new();
meta.insert("total_packages".to_string(), categorization.stats.total_packages.to_string());
meta.insert("internal_packages".to_string(), categorization.stats.internal_packages.to_string());
meta.insert("external_packages".to_string(), categorization.stats.external_packages.to_string());
meta.insert("workspace_links".to_string(), categorization.stats.workspace_links.to_string());
meta.insert("local_links".to_string(), categorization.stats.local_links.to_string());
meta.insert("internal_percentage".to_string(), format!("{:.1}", categorization.internal_percentage()));
meta.insert("external_percentage".to_string(), format!("{:.1}", categorization.external_percentage()));
meta
},
});
issues
}