gvc 0.2.0

CLI manager for Gradle version catalogs—check, list, update, and add dependencies with automatic version aliases
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<WhyReport> {
        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<WhyEntry>,
}

#[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<String>,
    pub recommendations: Vec<String>,
}

#[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<String>,
    pub version_ref: Option<String>,
    pub resolved: Option<String>,
    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<String, String>,
    entries: Vec<CatalogExplainEntry>,
}

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<String> {
        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<String>,
    version_ref: Option<String>,
}

fn collect_version_aliases(doc: &DocumentMut) -> BTreeMap<String, String> {
    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<CatalogExplainEntry> {
    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<String> {
    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(_)));
    }
}