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, 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> { // 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 = 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, ) -> 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) -> 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()); } }