From 2138269c989357a391ffbe09c21c20f72aaf9143 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Fri, 5 Jun 2026 09:44:47 +0800 Subject: [PATCH] feat: add catalog inspection commands --- README.md | 19 +- README_ZH.md | 19 +- src/agents/catalog_explainer.rs | 397 ++++++++++++++++++++++++++++++++ src/agents/mod.rs | 4 + src/cli.rs | 17 ++ src/main.rs | 1 + src/workflow.rs | 71 +++++- src/workflow/presenter.rs | 107 ++++++++- 8 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 src/agents/catalog_explainer.rs diff --git a/README.md b/README.md index ee995e3..d0fdf94 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-stable-orange.svg)](https://www.rust-lang.org) -A fast, standalone CLI for managing Gradle version catalogs (`libs.versions.toml`): check, list, update, add, audit, and diagnose dependencies or plugins with confidence. +A fast, standalone CLI for managing Gradle version catalogs (`libs.versions.toml`): check, list, explain, update, add, audit, and diagnose dependencies or plugins with confidence. English | [简体中文](README_ZH.md) @@ -13,11 +13,12 @@ English | [简体中文](README_ZH.md) - 🚀 **Direct Maven repository queries** - No Gradle runtime needed, pure Rust performance - 📦 **Multi-repository support** - Maven Central, Google Maven, custom repositories with smart filtering - 🎯 **Intelligent version detection** - Semantic versioning with stability filtering (alpha, beta, RC, dev) -- 📋 **Seven commands**: +- 📋 **Eight commands**: - `check` - View available updates without applying - `outdated` - Show outdated entries in a package-manager style table - `update` - Apply dependency updates - `list` - Display all dependencies in Maven coordinate format + - `why` - Explain a catalog entry by alias or coordinate - `audit` - Find catalog quality issues such as duplicate coordinates or missing version refs - `add` - Insert dependencies or plugins directly into the catalog with version aliasing - `doctor` - Diagnose Kotlin/Android catalog consistency @@ -71,6 +72,7 @@ cargo build --release ```bash gvc check # validate project and list available upgrades gvc outdated # show outdated catalog entries in a table +gvc why androidx-core # explain a catalog entry by alias or coordinate gvc audit # inspect catalog quality without network access gvc update --no-git # apply upgrades without creating a Git branch gvc check --format json --fail-on-updates # agent/CI-friendly update gate @@ -91,6 +93,7 @@ gvc doctor --format json --fail-on-issues # Kotlin/Android catalog diagnostics | `gvc outdated` | Prints outdated version aliases, libraries, and plugins in a package-manager style table. | `--include-unstable` to include pre-releases; `--fail-on-updates` exits with code 2 for automation. | | `gvc update` | Applies or previews catalog updates, honoring stability filters and optional Git integration. | `--dry-run` to preview; `--apply` to be explicit; `--target "*glob*"` for targeted upgrades; `--no-git` to skip branch/commit; `--no-stable-only` to include pre-releases. | | `gvc list` | Displays the resolved version catalog as Maven coordinates for quick auditing. | `--path` to point at another project. | +| `gvc why ` | Explains a catalog entry's coordinate, version source, duplicate aliases, and recommendations. | Query by alias, library coordinate (`group:artifact`), or plugin id; `--format json` for automation. | | `gvc audit` | Checks catalog maintainability without network access. | `--fail-on-issues` exits with code 2 when warnings/errors are found; `--format json` for automation. | | `gvc doctor` | Checks Kotlin, KSP, Android Gradle Plugin, and Compose catalog consistency without network access. | `--fail-on-issues` exits with code 2 when warnings/errors are found; `--format json` for automation. | | `gvc add` | Inserts a new entry into `[libraries]` (default) or `[plugins]`. | `-P/--plugin` targets plugins; `--no-stable-only` allows pre-releases when resolving `:latest`; `--alias` / `--version-alias` override generated keys. | @@ -187,6 +190,18 @@ Summary: 2 plugins ``` +### Explain a Catalog Entry + +Use `why` to inspect how an alias or coordinate is declared and resolved: + +```bash +gvc why androidx-core +gvc why androidx.core:core-ktx +gvc why com.android.application --format json +``` + +The report shows the matched entry, coordinate, inline version or `version.ref`, resolved version, duplicate aliases for the same coordinate, and any low-risk recommendations. + ### Diagnose Kotlin/Android Catalogs Run catalog-only diagnostics for Kotlin-heavy Gradle projects: diff --git a/README_ZH.md b/README_ZH.md index 29a639c..fed1272 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -4,7 +4,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-stable-orange.svg)](https://www.rust-lang.org) -一个快速、独立的 CLI 工具,用于检查、列出、更新、新增、审计并诊断 Gradle 版本目录(`libs.versions.toml`)中的依赖与插件。 +一个快速、独立的 CLI 工具,用于检查、列出、解释、更新、新增、审计并诊断 Gradle 版本目录(`libs.versions.toml`)中的依赖与插件。 [English](README.md) | 简体中文 @@ -13,11 +13,12 @@ - 🚀 **直接查询 Maven 仓库** - 无需 Gradle 运行时,纯 Rust 性能 - 📦 **多仓库支持** - Maven Central、Google Maven、自定义仓库,智能过滤 - 🎯 **智能版本检测** - 语义化版本控制,稳定性过滤(alpha、beta、RC、dev) -- 📋 **七个命令**: +- 📋 **八个命令**: - `check` - 查看可用更新但不应用 - `outdated` - 以类似包管理器的表格展示过期条目 - `update` - 应用依赖更新 - `list` - 以 Maven 坐标格式显示所有依赖 + - `why` - 按 alias 或坐标解释版本目录条目 - `audit` - 发现重复坐标、缺失版本引用等版本目录质量问题 - `add` - 直接向版本目录写入依赖或插件并自动管理版本别名 - `doctor` - 诊断 Kotlin/Android 版本目录一致性 @@ -71,6 +72,7 @@ cargo build --release ```bash gvc check # 验证项目并列出可用更新 gvc outdated # 以表格展示过期的版本目录条目 +gvc why androidx-core # 按 alias 或坐标解释版本目录条目 gvc audit # 离线检查版本目录质量 gvc update --no-git # 在不创建 Git 分支的情况下应用更新 gvc check --format json --fail-on-updates # 适合 agent/CI 的更新检查 @@ -91,6 +93,7 @@ gvc doctor --format json --fail-on-issues # Kotlin/Android 版本目录诊断 | `gvc outdated` | 以类似包管理器的表格展示过期的版本别名、库和插件。 | `--include-unstable` 包含预发布版本;`--fail-on-updates` 在自动化场景中以退出码 2 表示发现更新。 | | `gvc update` | 预览或应用版本目录更新,支持稳定性过滤与 Git 集成。 | `--dry-run` 预览;`--apply` 明确应用;`--target "*glob*"` 定向升级;`--no-git` 跳过 Git;`--no-stable-only` 允许预发布版本。 | | `gvc list` | 以 Maven 坐标格式展示版本目录中的所有条目。 | `--path` 指向其他项目。 | +| `gvc why ` | 解释某个条目的坐标、版本来源、重复 alias 和建议。 | 可用 alias、库坐标(`group:artifact`)或插件 ID 查询;`--format json` 适合自动化。 | | `gvc audit` | 离线检查版本目录的可维护性。 | `--fail-on-issues` 在发现 warning/error 时以退出码 2 结束;`--format json` 适合自动化。 | | `gvc doctor` | 离线检查 Kotlin、KSP、Android Gradle Plugin 与 Compose 的版本目录一致性。 | `--fail-on-issues` 在发现 warning/error 时以退出码 2 结束;`--format json` 适合自动化。 | | `gvc add` | 默认向 `[libraries]` 插入新条目,也可写入 `[plugins]`。 | `-P/--plugin` 指定插件;`--no-stable-only` 解析 `:latest` 时允许预发布版本;`--alias` / `--version-alias` 自定义键名。 | @@ -187,6 +190,18 @@ Summary: 2 plugins ``` +### 解释版本目录条目 + +使用 `why` 查看某个 alias 或坐标是如何声明并解析的: + +```bash +gvc why androidx-core +gvc why androidx.core:core-ktx +gvc why com.android.application --format json +``` + +报告会展示匹配到的条目、坐标、inline version 或 `version.ref`、解析后的版本、同一坐标的重复 alias,以及低风险建议。 + ### 诊断 Kotlin/Android 版本目录 对 Kotlin/Android 项目的版本目录运行离线诊断: diff --git a/src/agents/catalog_explainer.rs b/src/agents/catalog_explainer.rs new file mode 100644 index 0000000..dc40e98 --- /dev/null +++ b/src/agents/catalog_explainer.rs @@ -0,0 +1,397 @@ +use crate::error::{GvcError, Result}; +use crate::utils::toml::TomlUtils; +use serde::Serialize; +use std::collections::BTreeMap; +use toml_edit::DocumentMut; + +pub struct CatalogExplainer; + +impl CatalogExplainer { + pub fn explain(doc: &DocumentMut, query: &str) -> Result { + let query = query.trim(); + if query.is_empty() { + return Err(GvcError::ProjectValidation( + "Query is required. Use an alias, library coordinate, or plugin id.".into(), + )); + } + + let snapshot = CatalogExplainSnapshot::from_doc(doc); + let (matched_by, matches) = snapshot.find(query); + if matches.is_empty() { + return Err(GvcError::ProjectValidation(format!( + "No catalog entry matched '{}'. Use an alias, library coordinate, or plugin id.", + query + ))); + } + + let entries = matches + .into_iter() + .map(|entry| snapshot.explain_entry(entry)) + .collect(); + + Ok(WhyReport { + query: query.to_string(), + matched_by, + entries, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct WhyReport { + pub query: String, + pub matched_by: WhyMatchKind, + pub entries: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WhyMatchKind { + Alias, + AliasCaseInsensitive, + Coordinate, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct WhyEntry { + pub kind: WhyEntryKind, + pub alias: String, + pub coordinate: String, + pub version: WhyVersion, + pub duplicate_aliases: Vec, + pub recommendations: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WhyEntryKind { + Library, + Plugin, +} + +impl WhyEntryKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Library => "library", + Self::Plugin => "plugin", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct WhyVersion { + pub declared: Option, + pub version_ref: Option, + pub resolved: Option, + pub source: WhyVersionSource, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WhyVersionSource { + Inline, + VersionRef, + MissingVersionRef, + Unspecified, +} + +#[derive(Clone, Debug)] +struct CatalogExplainSnapshot { + version_aliases: BTreeMap, + entries: Vec, +} + +impl CatalogExplainSnapshot { + fn from_doc(doc: &DocumentMut) -> Self { + Self { + version_aliases: collect_version_aliases(doc), + entries: collect_entries(doc), + } + } + + fn find(&self, query: &str) -> (WhyMatchKind, Vec<&CatalogExplainEntry>) { + let alias_matches: Vec<_> = self + .entries + .iter() + .filter(|entry| entry.alias == query) + .collect(); + if !alias_matches.is_empty() { + return (WhyMatchKind::Alias, alias_matches); + } + + let coordinate = normalize_coordinate_query(query); + let coordinate_matches: Vec<_> = self + .entries + .iter() + .filter(|entry| entry.coordinate == coordinate) + .collect(); + if !coordinate_matches.is_empty() { + return (WhyMatchKind::Coordinate, coordinate_matches); + } + + let query_lower = query.to_lowercase(); + let alias_ci_matches: Vec<_> = self + .entries + .iter() + .filter(|entry| entry.alias.to_lowercase() == query_lower) + .collect(); + (WhyMatchKind::AliasCaseInsensitive, alias_ci_matches) + } + + fn explain_entry(&self, entry: &CatalogExplainEntry) -> WhyEntry { + let duplicate_aliases = self.duplicate_aliases(entry); + let version = self.explain_version(entry); + let recommendations = recommendations_for(entry, &version, &duplicate_aliases); + + WhyEntry { + kind: entry.kind, + alias: entry.alias.clone(), + coordinate: entry.coordinate.clone(), + version, + duplicate_aliases, + recommendations, + } + } + + fn duplicate_aliases(&self, entry: &CatalogExplainEntry) -> Vec { + let mut aliases: Vec<_> = self + .entries + .iter() + .filter(|candidate| candidate.kind == entry.kind) + .filter(|candidate| candidate.coordinate == entry.coordinate) + .filter(|candidate| candidate.alias != entry.alias) + .map(|candidate| candidate.alias.clone()) + .collect(); + aliases.sort(); + aliases + } + + fn explain_version(&self, entry: &CatalogExplainEntry) -> WhyVersion { + if let Some(version) = entry.version.as_ref() { + return WhyVersion { + declared: Some(version.clone()), + version_ref: None, + resolved: Some(version.clone()), + source: WhyVersionSource::Inline, + }; + } + + if let Some(version_ref) = entry.version_ref.as_ref() { + return match self.version_aliases.get(version_ref) { + Some(resolved) => WhyVersion { + declared: None, + version_ref: Some(version_ref.clone()), + resolved: Some(resolved.clone()), + source: WhyVersionSource::VersionRef, + }, + None => WhyVersion { + declared: None, + version_ref: Some(version_ref.clone()), + resolved: None, + source: WhyVersionSource::MissingVersionRef, + }, + }; + } + + WhyVersion { + declared: None, + version_ref: None, + resolved: None, + source: WhyVersionSource::Unspecified, + } + } +} + +#[derive(Clone, Debug)] +struct CatalogExplainEntry { + kind: WhyEntryKind, + alias: String, + coordinate: String, + version: Option, + version_ref: Option, +} + +fn collect_version_aliases(doc: &DocumentMut) -> BTreeMap { + doc.get("versions") + .and_then(|item| item.as_table()) + .map(|versions| { + versions + .iter() + .filter_map(|(alias, item)| { + item.as_str() + .map(|version| (alias.to_string(), version.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +fn collect_entries(doc: &DocumentMut) -> Vec { + let mut entries = Vec::new(); + + if let Some(libraries) = doc.get("libraries").and_then(|item| item.as_table()) { + for (alias, item) in libraries { + let Some(details) = TomlUtils::extract_library_details(item) else { + continue; + }; + + entries.push(CatalogExplainEntry { + kind: WhyEntryKind::Library, + alias: alias.to_string(), + coordinate: format!("{}:{}", details.group, details.artifact), + version: details.version, + version_ref: details.version_ref, + }); + } + } + + if let Some(plugins) = doc.get("plugins").and_then(|item| item.as_table()) { + for (alias, item) in plugins { + let Some(details) = TomlUtils::extract_plugin_details(alias, item) else { + continue; + }; + + entries.push(CatalogExplainEntry { + kind: WhyEntryKind::Plugin, + alias: alias.to_string(), + coordinate: details.id, + version: details.version, + version_ref: details.version_ref, + }); + } + } + + entries +} + +fn normalize_coordinate_query(query: &str) -> String { + let parts: Vec<_> = query.split(':').collect(); + if parts.len() >= 3 { + return format!("{}:{}", parts[0], parts[1]); + } + + query.to_string() +} + +fn recommendations_for( + entry: &CatalogExplainEntry, + version: &WhyVersion, + duplicate_aliases: &[String], +) -> Vec { + let mut recommendations = Vec::new(); + + match version.source { + WhyVersionSource::Inline => { + recommendations.push("Consider moving this inline version into [versions] when it is shared or expected to move with related entries.".to_string()); + } + WhyVersionSource::MissingVersionRef => { + if let Some(version_ref) = version.version_ref.as_deref() { + recommendations.push(format!( + "Define [versions].{} or update the entry to reference an existing version alias.", + version_ref + )); + } + } + WhyVersionSource::Unspecified => { + recommendations.push(format!( + "Declare a version for {} '{}' or ensure it is intentionally managed elsewhere.", + entry.kind.as_str(), + entry.alias + )); + } + WhyVersionSource::VersionRef => {} + } + + if !duplicate_aliases.is_empty() { + recommendations.push(format!( + "Review duplicate aliases for the same coordinate: {}.", + duplicate_aliases.join(", ") + )); + } + + recommendations +} + +#[cfg(test)] +mod tests { + use super::*; + + fn explain(input: &str, query: &str) -> WhyReport { + let doc: DocumentMut = input.parse().unwrap(); + CatalogExplainer::explain(&doc, query).unwrap() + } + + #[test] + fn explains_library_by_alias_with_version_ref() { + let report = explain( + r#" +[versions] +core = "1.12.0" + +[libraries] +androidxCore = { module = "androidx.core:core-ktx", version = { ref = "core" } } +"#, + "androidxCore", + ); + + assert_eq!(report.matched_by, WhyMatchKind::Alias); + assert_eq!(report.entries.len(), 1); + assert_eq!(report.entries[0].kind, WhyEntryKind::Library); + assert_eq!(report.entries[0].coordinate, "androidx.core:core-ktx"); + assert_eq!( + report.entries[0].version.resolved.as_deref(), + Some("1.12.0") + ); + assert_eq!( + report.entries[0].version.source, + WhyVersionSource::VersionRef + ); + } + + #[test] + fn explains_coordinate_matches_and_duplicates() { + let report = explain( + r#" +[libraries] +core = { module = "androidx.core:core-ktx", version = "1.12.0" } +coreAgain = { group = "androidx.core", name = "core-ktx", version = "1.12.0" } +"#, + "androidx.core:core-ktx:1.12.0", + ); + + assert_eq!(report.matched_by, WhyMatchKind::Coordinate); + assert_eq!(report.entries.len(), 2); + assert_eq!( + report.entries[0].duplicate_aliases, + vec!["coreAgain".to_string()] + ); + } + + #[test] + fn reports_missing_version_ref() { + let report = explain( + r#" +[plugins] +kotlin = { id = "org.jetbrains.kotlin.jvm", version = { ref = "kotlinVersion" } } +"#, + "kotlin", + ); + + assert_eq!( + report.entries[0].version.source, + WhyVersionSource::MissingVersionRef + ); + assert_eq!( + report.entries[0].version.version_ref.as_deref(), + Some("kotlinVersion") + ); + assert!(!report.entries[0].recommendations.is_empty()); + } + + #[test] + fn rejects_empty_query() { + let doc: DocumentMut = "".parse().unwrap(); + let err = CatalogExplainer::explain(&doc, " ").unwrap_err(); + assert!(matches!(err, GvcError::ProjectValidation(_))); + } +} diff --git a/src/agents/mod.rs b/src/agents/mod.rs index b5ae370..ca7d4bd 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -1,5 +1,6 @@ pub mod catalog_auditor; pub mod catalog_editor; +pub mod catalog_explainer; pub mod dependency_updater; pub mod doctor; pub mod project_scanner; @@ -11,6 +12,9 @@ pub use update::UpdateReport; pub use catalog_auditor::CatalogAuditor; pub use catalog_editor::{AddResult, AddTargetKind, CatalogEditor}; +pub use catalog_explainer::{ + CatalogExplainer, WhyEntryKind, WhyMatchKind, WhyReport, WhyVersionSource, +}; pub use dependency_updater::DependencyUpdater; pub use doctor::{DoctorReport, DoctorSeverity, KotlinDoctor}; pub use project_scanner::ProjectScannerAgent; diff --git a/src/cli.rs b/src/cli.rs index a8833e2..46ade27 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -98,6 +98,13 @@ pub enum Commands { /// List all dependencies in the version catalog List, + /// Explain a catalog entry by alias or coordinate + Why { + /// Alias, library coordinate (group:artifact), or plugin id to explain + #[arg(value_name = "QUERY")] + query: String, + }, + /// Audit version catalog quality and maintainability Audit { /// Exit with code 2 when warnings or errors are found @@ -249,6 +256,16 @@ mod tests { assert!(fail_on_issues); } + #[test] + fn why_accepts_query() { + let cli = Cli::parse_from(["gvc", "why", "androidxCore"]); + let Commands::Why { query } = cli.command else { + panic!("expected why command"); + }; + + assert_eq!(query, "androidxCore"); + } + #[test] fn add_accepts_update_version_alias_flag() { let cli = Cli::parse_from([ diff --git a/src/main.rs b/src/main.rs index c83a57b..379fad5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,7 @@ fn main() { fail_on_updates, } => workflow::execute_outdated(&cli.path, options, !include_unstable, fail_on_updates), Commands::List => workflow::execute_list(&cli.path, options), + Commands::Why { query } => workflow::execute_why(&cli.path, options, &query), Commands::Audit { fail_on_issues } => { workflow::execute_audit(&cli.path, options, fail_on_issues) } diff --git a/src/workflow.rs b/src/workflow.rs index 975ff4e..e1c823b 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -2,8 +2,8 @@ mod add_resolver; mod presenter; use crate::agents::{ - AddTargetKind, CatalogAuditor, CatalogEditor, DependencyUpdater, KotlinDoctor, - ProjectScannerAgent, VersionControlAgent, + AddTargetKind, CatalogAuditor, CatalogEditor, CatalogExplainer, DependencyUpdater, + KotlinDoctor, ProjectScannerAgent, VersionControlAgent, }; use crate::cli::OutputFormat; use crate::error::{GvcError, Result}; @@ -14,7 +14,8 @@ use colored::Colorize; use presenter::{ add_target_json, dependencies_json, doctor_json, findings_json, print_add_result, print_audit_report, print_available_updates, print_dependencies, print_doctor_report, - print_json, print_outdated_report, print_repositories, print_update_report, updates_json, + print_json, print_outdated_report, print_repositories, print_update_report, print_why_report, + updates_json, why_json, }; use regex::Regex; use serde_json::json; @@ -600,6 +601,39 @@ pub fn execute_list>( Ok(WorkflowStatus::Success) } +/// Explain a catalog entry by alias or coordinate. +pub fn execute_why>( + project_path: P, + options: RunOptions<'_>, + query: &str, +) -> Result { + let project_path = PathValidator::validate_project_path(project_path)?; + crate::outln!("{}", "Explaining version catalog entry...".cyan().bold()); + + crate::outln!("\n{}", "1. Validating project structure...".yellow()); + let project_info = validate_project(&project_path, &options)?; + crate::outln!("{}", "✓ Project structure is valid".green()); + + crate::outln!("\n{}", "2. Reading version catalog...".yellow()); + let doc = load_catalog_document(&project_info.toml_path)?; + crate::outln!("{}", "✓ Catalog loaded".green()); + + crate::outln!("\n{}", "3. Explaining catalog entry...".yellow()); + let report = CatalogExplainer::explain(&doc, query)?; + print_why_report(&report); + + if options.is_json() { + print_json(&json!({ + "status": "ok", + "command": "why", + "catalog": project_info.toml_path.display().to_string(), + "why": why_json(&report)?, + }))?; + } + + Ok(WorkflowStatus::Success) +} + /// Execute the catalog audit workflow. pub fn execute_audit>( project_path: P, @@ -746,4 +780,35 @@ coreAgain = { group = "androidx.core", name = "core-ktx", version = "1.12.0" } assert_eq!(status, WorkflowStatus::IssuesFound); } + + #[test] + fn execute_why_explains_existing_alias() { + crate::utils::output::init(true); + let project = tempfile::tempdir().unwrap(); + fs::write(project.path().join("gradlew"), "").unwrap(); + fs::create_dir(project.path().join("gradle")).unwrap(); + fs::write( + project.path().join("gradle/libs.versions.toml"), + r#" +[versions] +core = "1.12.0" + +[libraries] +androidxCore = { module = "androidx.core:core-ktx", version = { ref = "core" } } +"#, + ) + .unwrap(); + + let status = execute_why( + project.path(), + RunOptions { + catalog_path: None, + output_format: OutputFormat::Json, + }, + "androidxCore", + ) + .unwrap(); + + assert_eq!(status, WorkflowStatus::Success); + } } diff --git a/src/workflow/presenter.rs b/src/workflow/presenter.rs index c064c11..771b5ac 100644 --- a/src/workflow/presenter.rs +++ b/src/workflow/presenter.rs @@ -1,4 +1,7 @@ -use crate::agents::{AddResult, AddTargetKind, DoctorReport, DoctorSeverity, UpdateReport}; +use crate::agents::{ + AddResult, AddTargetKind, DoctorReport, DoctorSeverity, UpdateReport, WhyEntryKind, + WhyMatchKind, WhyReport, WhyVersionSource, +}; use crate::error::{GvcError, Result}; use crate::gradle::Repository; use crate::maven::version::Version; @@ -39,9 +42,14 @@ pub(super) fn dependencies_json(doc: &DocumentMut) -> Value { }) } +pub(super) fn why_json(report: &WhyReport) -> Result { + serde_json::to_value(report) + .map_err(|e| GvcError::TomlParsing(format!("Failed to serialize why report: {e}"))) +} + pub(super) fn findings_json(report: &DoctorReport) -> Result { let findings = serde_json::to_value(&report.findings) - .map_err(|e| GvcError::TomlParsing(format!("Failed to serialize doctor findings: {e}")))?; + .map_err(|e| GvcError::TomlParsing(format!("Failed to serialize findings: {e}")))?; Ok(json!({ "summary": { @@ -288,6 +296,101 @@ pub(super) fn print_dependencies(doc: &DocumentMut) { print_summary(doc); } +pub(super) fn print_why_report(report: &WhyReport) { + crate::outln!("\n{}", "Catalog Explanation:".cyan().bold()); + crate::outln!( + " Query: {} ({})", + report.query.white().bold(), + match_kind_label(report.matched_by).dimmed() + ); + + for entry in &report.entries { + crate::outln!( + "\n{} {}", + entry.kind.as_str().yellow().bold(), + entry.alias.white().bold() + ); + crate::outln!( + " Coordinate: {}", + coordinate_display(entry.kind, &entry.coordinate) + ); + + match entry.version.source { + WhyVersionSource::Inline => { + crate::outln!( + " Version: {} {}", + entry + .version + .resolved + .as_deref() + .unwrap_or("(unknown)") + .green(), + "(inline)".dimmed() + ); + } + WhyVersionSource::VersionRef => { + crate::outln!( + " Version: {} {}", + entry + .version + .resolved + .as_deref() + .unwrap_or("(unknown)") + .green(), + format!( + "(from version.ref '{}')", + entry.version.version_ref.as_deref().unwrap_or("") + ) + .dimmed() + ); + } + WhyVersionSource::MissingVersionRef => { + crate::outln!( + " Version: {} {}", + "(unresolved)".red(), + format!( + "(missing version.ref '{}')", + entry.version.version_ref.as_deref().unwrap_or("") + ) + .dimmed() + ); + } + WhyVersionSource::Unspecified => { + crate::outln!(" Version: {}", "(not declared)".yellow()); + } + } + + if !entry.duplicate_aliases.is_empty() { + crate::outln!( + " Duplicate aliases: {}", + entry.duplicate_aliases.join(", ").yellow() + ); + } + + if !entry.recommendations.is_empty() { + crate::outln!(" Recommendations:"); + for recommendation in &entry.recommendations { + crate::outln!(" - {}", recommendation.dimmed()); + } + } + } +} + +fn coordinate_display(kind: WhyEntryKind, coordinate: &str) -> colored::ColoredString { + match kind { + WhyEntryKind::Library => coordinate.cyan(), + WhyEntryKind::Plugin => coordinate.magenta(), + } +} + +fn match_kind_label(kind: WhyMatchKind) -> &'static str { + match kind { + WhyMatchKind::Alias => "alias", + WhyMatchKind::AliasCaseInsensitive => "alias_case_insensitive", + WhyMatchKind::Coordinate => "coordinate", + } +} + #[derive(Clone, Debug)] struct OutdatedRow { kind: &'static str,