pakker/src/cli/commands/inspect.rs
NotAShelf 1db1d4d6d2
cli: wire get_site_url in inspect; fix clippy in remote_update
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifdbc34dd7a5a51edc5dff326eac095516a6a6964
2026-02-19 00:22:41 +03:00

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());
}
}