use crate::manifest::{Manifest, ResourceDependency};
use colored::Colorize;
use std::collections::{HashMap, HashSet};
use std::fmt;
#[derive(Debug, Clone)]
pub struct ResourceUsage {
pub resource_name: String,
pub source_file: String,
pub version: Option<String>,
}
impl fmt::Display for ResourceUsage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"'{}' uses version {}",
self.resource_name,
self.version.as_deref().unwrap_or("latest")
)
}
}
#[derive(Debug)]
pub struct Redundancy {
pub source_file: String,
pub usages: Vec<ResourceUsage>,
}
impl fmt::Display for Redundancy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"{} Multiple versions of '{}' will be installed:",
"⚠".yellow(),
self.source_file
)?;
for usage in &self.usages {
writeln!(f, " - {usage}")?;
}
Ok(())
}
}
pub struct RedundancyDetector {
usages: HashMap<String, Vec<ResourceUsage>>,
}
impl Default for RedundancyDetector {
fn default() -> Self {
Self::new()
}
}
impl RedundancyDetector {
#[must_use]
pub fn new() -> Self {
Self {
usages: HashMap::new(),
}
}
pub fn add_usage(&mut self, resource_name: String, dep: &ResourceDependency) {
if dep.is_local() {
return;
}
if let Some(source) = dep.get_source() {
let source_file = format!("{}:{}", source, dep.get_path());
let usage = ResourceUsage {
resource_name,
source_file: source_file.clone(),
version: dep.get_version().map(std::string::ToString::to_string),
};
self.usages.entry(source_file).or_default().push(usage);
}
}
pub fn analyze_manifest(&mut self, manifest: &Manifest) {
for (name, dep) in manifest.all_dependencies() {
self.add_usage(name.to_string(), dep);
}
}
#[must_use]
pub fn detect_redundancies(&self) -> Vec<Redundancy> {
let mut redundancies = Vec::new();
for (source_file, uses) in &self.usages {
if uses.len() <= 1 {
continue;
}
let versions: HashSet<Option<String>> =
uses.iter().map(|u| u.version.clone()).collect();
if versions.len() > 1 {
redundancies.push(Redundancy {
source_file: source_file.clone(),
usages: uses.clone(),
});
}
}
redundancies
}
#[must_use]
pub fn can_consolidate(&self, redundancy: &Redundancy) -> bool {
let versions: HashSet<_> = redundancy.usages.iter().map(|u| &u.version).collect();
versions.len() == 1
}
#[must_use]
pub fn generate_redundancy_warning(&self, redundancies: &[Redundancy]) -> String {
if redundancies.is_empty() {
return String::new();
}
let mut message =
format!("\n{} Redundant dependencies detected\n\n", "Warning:".yellow().bold());
for redundancy in redundancies {
message.push_str(&format!("{redundancy}\n"));
}
message.push_str(&format!(
"\n{} This is not an error, but you may want to consider:\n",
"Note:".blue()
));
message.push_str(" • Using the same version for consistency\n");
message.push_str(" • These resources will be installed to different files\n");
message.push_str(" • Each will work independently\n");
for redundancy in redundancies {
let has_latest = redundancy.usages.iter().any(|u| u.version.is_none());
let has_specific = redundancy.usages.iter().any(|u| u.version.is_some());
if has_latest && has_specific {
message.push_str(&format!(
" • Consider aligning versions for '{}' across all resources\n",
redundancy.source_file
));
}
}
message
}
#[must_use]
pub const fn check_transitive_redundancies(&self) -> Vec<Redundancy> {
Vec::new()
}
#[must_use]
pub fn suggest_consolidation(&self, redundancy: &Redundancy) -> Vec<String> {
let mut suggestions = Vec::new();
let versions: Vec<_> =
redundancy.usages.iter().filter_map(|u| u.version.as_ref()).collect();
if !versions.is_empty() {
if let Some(version) = versions.first() {
suggestions.push(format!(
"Consider using version {} for all resources using '{}'",
version, redundancy.source_file
));
}
}
let has_latest = redundancy.usages.iter().any(|u| u.version.is_none());
let has_specific = redundancy.usages.iter().any(|u| u.version.is_some());
if has_latest && has_specific {
suggestions.push(
"Consider using specific versions for all resources for reproducibility"
.to_string(),
);
}
suggestions.push(format!(
"Note: Each resource ({}) will be installed independently",
redundancy
.usages
.iter()
.map(|u| &u.resource_name)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
suggestions
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::DetailedDependency;
#[test]
fn test_detect_simple_redundancy() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"app-agent".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
detector.add_usage(
"tool-agent".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let redundancies = detector.detect_redundancies();
assert_eq!(redundancies.len(), 1);
let redundancy = &redundancies[0];
assert_eq!(redundancy.source_file, "community:agents/shared.md");
assert_eq!(redundancy.usages.len(), 2);
}
#[test]
fn test_no_redundancy_same_version() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"agent1".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
detector.add_usage(
"agent2".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let redundancies = detector.detect_redundancies();
assert_eq!(redundancies.len(), 0);
}
#[test]
fn test_redundancy_latest_vs_specific() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"agent1".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: None, branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
detector.add_usage(
"agent2".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let redundancies = detector.detect_redundancies();
assert_eq!(redundancies.len(), 1);
}
#[test]
fn test_local_dependencies_ignored() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"local1".to_string(),
&ResourceDependency::Simple("../agents/agent1.md".to_string()),
);
detector.add_usage(
"local2".to_string(),
&ResourceDependency::Simple("../agents/agent2.md".to_string()),
);
let redundancies = detector.detect_redundancies();
assert_eq!(redundancies.len(), 0);
}
#[test]
fn test_generate_redundancy_warning() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"app".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
detector.add_usage(
"tool".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let redundancies = detector.detect_redundancies();
let warning = detector.generate_redundancy_warning(&redundancies);
assert!(warning.contains("Redundant dependencies detected"));
assert!(warning.contains("app"));
assert!(warning.contains("tool"));
assert!(warning.contains("not an error"));
}
#[test]
fn test_suggest_consolidation() {
let mut detector = RedundancyDetector::new();
detector.add_usage(
"app".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: None, branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
detector.add_usage(
"tool".to_string(),
&ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("community".to_string()),
path: "agents/shared.md".to_string(),
version: Some("v2.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: "claude-code".to_string(),
})),
);
let redundancies = detector.detect_redundancies();
let suggestions = detector.suggest_consolidation(&redundancies[0]);
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.contains("v2.0.0")));
assert!(suggestions.iter().any(|s| s.contains("independently")));
}
}