Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifdbc34dd7a5a51edc5dff326eac095516a6a6964
611 lines
16 KiB
Rust
611 lines
16 KiB
Rust
use std::{collections::HashSet, path::Path};
|
|
|
|
use comfy_table::{Cell, Color, ContentArrangement, Table, presets};
|
|
use strsim::levenshtein;
|
|
use yansi::Paint;
|
|
|
|
use crate::{
|
|
error::Result,
|
|
model::{Config, LockFile, Project, ProjectFile},
|
|
};
|
|
|
|
pub async fn execute(
|
|
projects: Vec<String>,
|
|
lockfile_path: &Path,
|
|
config_path: &Path,
|
|
) -> Result<()> {
|
|
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
|
|
let config_dir = config_path.parent().unwrap_or(Path::new("."));
|
|
|
|
let lockfile = LockFile::load(lockfile_dir)?;
|
|
let _config = Config::load(config_dir)?;
|
|
|
|
let mut found_any = false;
|
|
let total_projects = projects.len();
|
|
|
|
for (idx, project_input) in projects.iter().enumerate() {
|
|
if let Some(project) = find_project(&lockfile, project_input) {
|
|
display_project_inspection(project, &lockfile)?;
|
|
found_any = true;
|
|
|
|
// Add separator between projects (but not after the last one)
|
|
if idx < total_projects - 1 {
|
|
let width = 80; // Default terminal width
|
|
println!("{}", "─".repeat(width));
|
|
println!();
|
|
}
|
|
} else {
|
|
eprintln!(
|
|
"{}: {}",
|
|
"Error".red(),
|
|
format!("Project '{project_input}' not found in lockfile.").red()
|
|
);
|
|
|
|
// Suggest similar projects
|
|
if let Some(suggestions) =
|
|
find_similar_projects(&lockfile, project_input, 5)
|
|
{
|
|
eprintln!();
|
|
eprintln!("{}", "Did you mean one of these?".yellow());
|
|
for suggestion in suggestions {
|
|
eprintln!(" - {}", suggestion.cyan());
|
|
}
|
|
}
|
|
eprintln!();
|
|
}
|
|
}
|
|
|
|
if !found_any && !projects.is_empty() {
|
|
return Err(crate::error::PakkerError::ProjectNotFound(
|
|
"No projects found".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn find_project<'a>(
|
|
lockfile: &'a LockFile,
|
|
query: &str,
|
|
) -> Option<&'a Project> {
|
|
lockfile.projects.iter().find(|p| project_matches(p, query))
|
|
}
|
|
|
|
fn project_matches(project: &Project, query: &str) -> bool {
|
|
// Check slugs
|
|
for slug in project.slug.values() {
|
|
if slug.eq_ignore_ascii_case(query) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check names
|
|
for name in project.name.values() {
|
|
if name.eq_ignore_ascii_case(query) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check pakku_id
|
|
if let Some(ref pakku_id) = project.pakku_id
|
|
&& pakku_id.eq_ignore_ascii_case(query)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Check aliases
|
|
for alias in &project.aliases {
|
|
if alias.eq_ignore_ascii_case(query) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn find_similar_projects(
|
|
lockfile: &LockFile,
|
|
query: &str,
|
|
max_results: usize,
|
|
) -> Option<Vec<String>> {
|
|
// Calculate similarity scores for all projects
|
|
let mut candidates: Vec<(String, usize)> = lockfile
|
|
.projects
|
|
.iter()
|
|
.flat_map(|p| {
|
|
let mut scores = Vec::new();
|
|
|
|
// Check slug similarity
|
|
for slug in p.slug.values() {
|
|
let distance = levenshtein(slug, query);
|
|
if distance <= 3 {
|
|
scores.push((slug.clone(), distance));
|
|
}
|
|
}
|
|
|
|
// Check name similarity (case-insensitive)
|
|
for name in p.name.values() {
|
|
let distance = levenshtein(&name.to_lowercase(), &query.to_lowercase());
|
|
if distance <= 3 {
|
|
scores.push((name.clone(), distance));
|
|
}
|
|
}
|
|
|
|
// Check aliases
|
|
for alias in &p.aliases {
|
|
let distance = levenshtein(alias, query);
|
|
if distance <= 3 {
|
|
scores.push((alias.clone(), distance));
|
|
}
|
|
}
|
|
|
|
scores
|
|
})
|
|
.collect();
|
|
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Sort by distance (closest first)
|
|
candidates.sort_by_key(|(_, dist)| *dist);
|
|
|
|
// Deduplicate and take top N
|
|
let mut seen = HashSet::new();
|
|
let suggestions: Vec<String> = candidates
|
|
.into_iter()
|
|
.filter_map(|(name, _)| {
|
|
if seen.insert(name.clone()) {
|
|
Some(name)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.take(max_results)
|
|
.collect();
|
|
|
|
Some(suggestions)
|
|
}
|
|
|
|
fn display_project_inspection(
|
|
project: &Project,
|
|
lockfile: &LockFile,
|
|
) -> Result<()> {
|
|
// Display project header panel
|
|
display_project_header(project)?;
|
|
|
|
// Display project files
|
|
println!();
|
|
display_project_files(&project.files, project)?;
|
|
|
|
// Display properties
|
|
println!();
|
|
display_properties(project)?;
|
|
|
|
// Display dependency tree
|
|
println!();
|
|
display_dependencies(project, lockfile)?;
|
|
|
|
println!();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_project_header(project: &Project) -> Result<()> {
|
|
let name = get_project_name(project);
|
|
let default_slug = String::from("N/A");
|
|
let slug = project.slug.values().next().unwrap_or(&default_slug);
|
|
|
|
// Create header table with comfy-table
|
|
let mut table = Table::new();
|
|
table
|
|
.load_preset(presets::UTF8_FULL)
|
|
.set_content_arrangement(ContentArrangement::Dynamic);
|
|
|
|
// Title row with name
|
|
table.add_row(vec![
|
|
Cell::new(name)
|
|
.fg(Color::Cyan)
|
|
.set_alignment(comfy_table::CellAlignment::Left),
|
|
]);
|
|
|
|
// Second row with slug, type, side
|
|
let metadata = format!(
|
|
"{} ({}) • {} • {}",
|
|
slug,
|
|
project.id.keys().next().unwrap_or(&"unknown".to_string()),
|
|
format!("{:?}", project.r#type).to_lowercase(),
|
|
format!("{:?}", project.side).to_lowercase()
|
|
);
|
|
table.add_row(vec![
|
|
Cell::new(metadata)
|
|
.fg(Color::DarkGrey)
|
|
.set_alignment(comfy_table::CellAlignment::Left),
|
|
]);
|
|
|
|
println!("{table}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_project_files(
|
|
files: &[ProjectFile],
|
|
project: &Project,
|
|
) -> Result<()> {
|
|
if files.is_empty() {
|
|
println!("{}", "No files available".yellow());
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}", "Project Files".cyan().bold());
|
|
|
|
for (idx, file) in files.iter().enumerate() {
|
|
let mut table = Table::new();
|
|
table
|
|
.load_preset(presets::UTF8_FULL)
|
|
.set_content_arrangement(ContentArrangement::Dynamic);
|
|
|
|
// Mark the first file as "current"
|
|
let status = if idx == 0 { "current" } else { "" };
|
|
let status_text = if status.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {status}")
|
|
};
|
|
|
|
// File path line with optional site URL
|
|
let file_path = format!("{}={}", file.file_type, file.file_name);
|
|
let file_display = if let Some(site_url) = file.get_site_url(project) {
|
|
// Create hyperlink for the file
|
|
let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path);
|
|
format!("{hyperlink}:{status_text}")
|
|
} else {
|
|
format!("{file_path}:{status_text}")
|
|
};
|
|
|
|
table.add_row(vec![Cell::new(file_display).fg(if idx == 0 {
|
|
Color::Green
|
|
} else {
|
|
Color::White
|
|
})]);
|
|
|
|
// Date published
|
|
table.add_row(vec![Cell::new(&file.date_published).fg(Color::DarkGrey)]);
|
|
|
|
// Show site URL if available (for non-hyperlink terminals)
|
|
if let Some(site_url) = file.get_site_url(project) {
|
|
table
|
|
.add_row(vec![Cell::new(format!("URL: {site_url}")).fg(Color::Blue)]);
|
|
}
|
|
|
|
// Empty line
|
|
table.add_row(vec![Cell::new("")]);
|
|
|
|
// Hashes (truncated)
|
|
if !file.hashes.is_empty() {
|
|
for (hash_type, hash_value) in &file.hashes {
|
|
let display_hash = if hash_value.len() > 32 {
|
|
format!(
|
|
"{}...{}",
|
|
&hash_value[..16],
|
|
&hash_value[hash_value.len() - 16..]
|
|
)
|
|
} else {
|
|
hash_value.clone()
|
|
};
|
|
table.add_row(vec![
|
|
Cell::new(format!("{hash_type}={display_hash}")).fg(Color::DarkGrey),
|
|
]);
|
|
}
|
|
}
|
|
|
|
println!("{table}");
|
|
println!();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_properties(project: &Project) -> Result<()> {
|
|
println!("{}", "Properties".cyan().bold());
|
|
|
|
println!(
|
|
" {}={}",
|
|
"type".yellow(),
|
|
format!("{:?}", project.r#type).to_lowercase()
|
|
);
|
|
println!(
|
|
" {}={}",
|
|
"side".yellow(),
|
|
format!("{:?}", project.side).to_lowercase()
|
|
);
|
|
println!(
|
|
" {}={}",
|
|
"update_strategy".yellow(),
|
|
format!("{:?}", project.update_strategy).to_lowercase()
|
|
);
|
|
println!(
|
|
" {}={}",
|
|
"redistributable".yellow(),
|
|
project.redistributable
|
|
);
|
|
|
|
if let Some(subpath) = &project.subpath {
|
|
println!(" {}={}", "subpath".yellow(), subpath);
|
|
}
|
|
|
|
if !project.aliases.is_empty() {
|
|
let aliases: Vec<_> = project.aliases.iter().cloned().collect();
|
|
println!(" {}={}", "aliases".yellow(), aliases.join(", "));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_dependencies(project: &Project, lockfile: &LockFile) -> Result<()> {
|
|
println!("{}", "Dependencies".cyan().bold());
|
|
|
|
// Collect all dependencies from all files
|
|
let mut all_deps = HashSet::new();
|
|
for file in &project.files {
|
|
for dep in &file.required_dependencies {
|
|
all_deps.insert(dep.clone());
|
|
}
|
|
}
|
|
|
|
if all_deps.is_empty() {
|
|
println!(" {}", "No dependencies".dim());
|
|
return Ok(());
|
|
}
|
|
|
|
// Display dependency tree
|
|
let mut visited = HashSet::new();
|
|
for dep_id in all_deps {
|
|
display_dependency_tree(&dep_id, lockfile, 1, &mut visited)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn display_dependency_tree(
|
|
dep_id: &str,
|
|
lockfile: &LockFile,
|
|
depth: usize,
|
|
visited: &mut HashSet<String>,
|
|
) -> Result<()> {
|
|
let indent = " ".repeat(depth);
|
|
let tree_char = if depth == 1 { "└─" } else { "├─" };
|
|
|
|
// Find the project in lockfile
|
|
let project = lockfile.projects.iter().find(|p| {
|
|
// Check if any ID matches
|
|
p.id.values().any(|id| id == dep_id)
|
|
|| p.slug.values().any(|slug| slug == dep_id)
|
|
|| p.pakku_id.as_ref() == Some(&dep_id.to_string())
|
|
});
|
|
|
|
if let Some(proj) = project {
|
|
let name = get_project_name(proj);
|
|
|
|
// Check for circular dependency
|
|
if visited.contains(&name) {
|
|
println!("{}{} {} {}", indent, tree_char, name, "(circular)".red());
|
|
return Ok(());
|
|
}
|
|
|
|
println!("{}{} {} (required)", indent, tree_char, name.green());
|
|
visited.insert(name);
|
|
|
|
// Recursively display nested dependencies (limit depth to avoid infinite
|
|
// loops)
|
|
if depth < 5 {
|
|
for file in &proj.files {
|
|
for nested_dep in &file.required_dependencies {
|
|
display_dependency_tree(nested_dep, lockfile, depth + 1, visited)?;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Dependency not found in lockfile
|
|
println!(
|
|
"{}{} {} {}",
|
|
indent,
|
|
tree_char,
|
|
dep_id,
|
|
"(not in lockfile)".yellow()
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_project_name(project: &Project) -> String {
|
|
project
|
|
.name
|
|
.values()
|
|
.next()
|
|
.or_else(|| project.slug.values().next())
|
|
.cloned()
|
|
.unwrap_or_else(|| "Unknown".to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use super::*;
|
|
use crate::model::enums::{
|
|
ProjectSide,
|
|
ProjectType,
|
|
ReleaseType,
|
|
UpdateStrategy,
|
|
};
|
|
|
|
fn create_test_project(pakku_id: &str, slug: &str, name: &str) -> Project {
|
|
let mut slug_map = HashMap::new();
|
|
slug_map.insert("modrinth".to_string(), slug.to_string());
|
|
|
|
let mut name_map = HashMap::new();
|
|
name_map.insert("modrinth".to_string(), name.to_string());
|
|
|
|
let mut id_map = HashMap::new();
|
|
id_map.insert("modrinth".to_string(), pakku_id.to_string());
|
|
|
|
Project {
|
|
pakku_id: Some(pakku_id.to_string()),
|
|
pakku_links: HashSet::new(),
|
|
r#type: ProjectType::Mod,
|
|
side: ProjectSide::Both,
|
|
slug: slug_map,
|
|
name: name_map,
|
|
id: id_map,
|
|
update_strategy: UpdateStrategy::Latest,
|
|
redistributable: true,
|
|
subpath: None,
|
|
aliases: HashSet::new(),
|
|
export: true,
|
|
files: vec![],
|
|
}
|
|
}
|
|
|
|
fn create_test_lockfile(projects: Vec<Project>) -> LockFile {
|
|
use crate::model::enums::Target;
|
|
let mut loaders = HashMap::new();
|
|
loaders.insert("fabric".to_string(), "0.15.0".to_string());
|
|
|
|
let mut lockfile = LockFile {
|
|
target: Some(Target::Modrinth),
|
|
mc_versions: vec!["1.20.1".to_string()],
|
|
loaders,
|
|
projects: Vec::new(),
|
|
lockfile_version: 1,
|
|
};
|
|
|
|
for project in projects {
|
|
lockfile.add_project(project);
|
|
}
|
|
|
|
lockfile
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_project_by_slug() {
|
|
let project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
let lockfile = create_test_lockfile(vec![project]);
|
|
|
|
let found = find_project(&lockfile, "test-slug");
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_project_by_name() {
|
|
let project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
let lockfile = create_test_lockfile(vec![project]);
|
|
|
|
let found = find_project(&lockfile, "test mod"); // Case-insensitive
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_project_by_pakku_id() {
|
|
let project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
let lockfile = create_test_lockfile(vec![project]);
|
|
|
|
let found = find_project(&lockfile, "test-id");
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_project_not_found() {
|
|
let project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
let lockfile = create_test_lockfile(vec![project]);
|
|
|
|
let found = find_project(&lockfile, "nonexistent");
|
|
assert!(found.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_matching_close_match() {
|
|
let project1 = create_test_project("id1", "fabric-api", "Fabric API");
|
|
let project2 = create_test_project("id2", "sodium", "Sodium");
|
|
let lockfile = create_test_lockfile(vec![project1, project2]);
|
|
|
|
// Typo: "fabrc-api" should suggest "fabric-api"
|
|
let suggestions = find_similar_projects(&lockfile, "fabrc-api", 5);
|
|
assert!(suggestions.is_some());
|
|
let suggestions = suggestions.unwrap();
|
|
assert!(!suggestions.is_empty());
|
|
assert!(suggestions.contains(&"fabric-api".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_matching_no_match() {
|
|
let project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
let lockfile = create_test_lockfile(vec![project]);
|
|
|
|
// Very different query, should have no suggestions (distance > 3)
|
|
let suggestions =
|
|
find_similar_projects(&lockfile, "completely-different-xyz", 5);
|
|
assert!(suggestions.is_none() || suggestions.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_matches_alias() {
|
|
let mut project = create_test_project("test-id", "test-slug", "Test Mod");
|
|
project.aliases.insert("test-alias".to_string());
|
|
|
|
assert!(project_matches(&project, "test-alias"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_circular_dependency_detection() {
|
|
// This is a conceptual test - in practice, we'd need to set up files with
|
|
// dependencies
|
|
let mut project1 = create_test_project("dep1", "dep1-slug", "Dependency 1");
|
|
let mut project2 = create_test_project("dep2", "dep2-slug", "Dependency 2");
|
|
|
|
// Create files with circular dependencies
|
|
let file1 = ProjectFile {
|
|
file_type: "modrinth".to_string(),
|
|
file_name: "dep1.jar".to_string(),
|
|
mc_versions: vec!["1.20.1".to_string()],
|
|
loaders: vec!["fabric".to_string()],
|
|
release_type: ReleaseType::Release,
|
|
url: "https://example.com/dep1.jar".to_string(),
|
|
id: "file1".to_string(),
|
|
parent_id: "dep1".to_string(),
|
|
hashes: HashMap::new(),
|
|
required_dependencies: vec!["dep2".to_string()],
|
|
size: 1000,
|
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
|
};
|
|
|
|
let file2 = ProjectFile {
|
|
file_type: "modrinth".to_string(),
|
|
file_name: "dep2.jar".to_string(),
|
|
mc_versions: vec!["1.20.1".to_string()],
|
|
loaders: vec!["fabric".to_string()],
|
|
release_type: ReleaseType::Release,
|
|
url: "https://example.com/dep2.jar".to_string(),
|
|
id: "file2".to_string(),
|
|
parent_id: "dep2".to_string(),
|
|
hashes: HashMap::new(),
|
|
required_dependencies: vec!["dep1".to_string()],
|
|
size: 1000,
|
|
date_published: "2024-01-01T00:00:00Z".to_string(),
|
|
};
|
|
|
|
project1.files.push(file1);
|
|
project2.files.push(file2);
|
|
|
|
let lockfile = create_test_lockfile(vec![project1, project2]);
|
|
|
|
// Test that display_dependency_tree handles circular deps gracefully
|
|
let mut visited = HashSet::new();
|
|
let result = display_dependency_tree("dep1", &lockfile, 1, &mut visited);
|
|
assert!(result.is_ok());
|
|
}
|
|
}
|