initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
commit
ef28bdaeb4
63 changed files with 17292 additions and 0 deletions
596
src/cli/commands/inspect.rs
Normal file
596
src/cli/commands/inspect.rs
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
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)?;
|
||||
|
||||
// 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]) -> 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
|
||||
let file_path = format!("{}={}", file.file_type, file.file_name);
|
||||
table.add_row(vec![
|
||||
Cell::new(format!("{file_path}:{status_text}")).fg(if idx == 0 {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::White
|
||||
}),
|
||||
]);
|
||||
|
||||
// Date published
|
||||
table.add_row(vec![Cell::new(&file.date_published).fg(Color::DarkGrey)]);
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue