zoi-package 1.25.1

Advanced Package Manager & Environment Orchestrator
Documentation
//! Package health and metadata validation.
//!
//! This module implements the `zoi package doctor` command, which performs
//! static analysis on a `.pkg.lua` file to identify potential issues,
//! such as missing metadata, invalid dependency strings, or missing
//! lifecycle functions.

use std::collections::{HashMap, HashSet};
use std::path::Path;

use anyhow::{Result, anyhow};
use regex::Regex;
use zoi_core::types;
use zoi_deps as dependencies;
use zoi_lua;

/// A report containing errors and warnings found during package validation.
#[derive(Debug, Default)]
pub struct DoctorReport {
    /// List of critical issues that prevent the package from being built or
    /// used correctly.
    pub errors: Vec<String>,
    /// List of non-critical issues or suggestions for improvement.
    pub warnings: Vec<String>
}

/// Runs a health check on a Zoi package definition.
///
/// # Errors
///
/// Returns an error if the package file path contains invalid UTF-8 characters,
/// or if parsing the Lua package definition fails.
/// # Errors
///
/// Returns an error if the package file cannot be read or parsed.
pub fn run(
    package_file: &Path,
    platform_override: Option<&str>,
    version_override: Option<&str>
) -> Result<DoctorReport> {
    let package_path_str = package_file.to_str().ok_or_else(|| {
        anyhow!(
            "Path contains invalid UTF-8 characters: {}",
            package_file.display()
        )
    })?;

    let platform = match platform_override {
        Some(p) => p.to_string(),
        None => zoi_core::utils::get_platform()?
    };

    let package = zoi_lua::parser::parse_lua_package_for_platform(
        package_path_str,
        &platform,
        version_override,
        None,
        true
    )?;

    let mut report = DoctorReport::default();

    if package.name.trim().is_empty() {
        report.errors.push("metadata.name is empty.".to_string());
    }
    if package.repo.trim().is_empty() {
        report.errors.push("metadata.repo is empty.".to_string());
    }
    if package.description.trim().is_empty() {
        report
            .errors
            .push("metadata.description is empty.".to_string());
    }
    if package.license.trim().is_empty() {
        report.warnings.push(
            "metadata.license is empty; set an SPDX license expression."
                .to_string()
        );
    }
    if package.maintainer.name.trim().is_empty() {
        report
            .warnings
            .push("metadata.maintainer.name is empty.".to_string());
    }
    if package.maintainer.email.trim().is_empty() {
        report
            .warnings
            .push("metadata.maintainer.email is empty.".to_string());
    }

    if package.version.is_none()
        && package.versions.as_ref().is_none_or(HashMap::is_empty)
    {
        report.errors.push(
            "Package has no version information. Set metadata.version or \
             metadata.versions."
                .to_string()
        );
    }

    if package.types.is_empty() {
        report.errors.push(
            "metadata.types is empty; at least one build type is required."
                .to_string()
        );
    } else {
        let known = ["source", "pre-compiled"];
        for t in &package.types {
            if !known.contains(&t.as_str()) {
                report.warnings.push(format!(
                    "Build type '{t}' is custom. Ensure your pipeline \
                     supports it.",
                ));
            }
        }
    }

    if let Some(subs) = &package.sub_packages {
        let mut seen = HashSet::new();
        for sub in subs {
            if !seen.insert(sub.clone()) {
                report.errors.push(format!(
                    "Duplicate sub-package '{sub}' in metadata.sub_packages.",
                ));
            }
        }
    }

    if let Some(main_subs) = &package.main_subs {
        let allowed = package
            .sub_packages
            .as_ref()
            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
            .unwrap_or_default();
        for sub in main_subs {
            if !allowed.contains(sub) {
                report.errors.push(format!(
                    "main_subs contains '{sub}' but it is missing from \
                     sub_packages.",
                ));
            }
        }
    }

    if let Some(deps) = &package.dependencies {
        if let Some(runtime) = &deps.runtime {
            validate_dependency_group(
                runtime,
                "runtime",
                &package,
                &mut report
            );
        }
        if let Some(build) = &deps.build {
            match build {
                types::BuildDependencies::Group(group) => {
                    validate_dependency_group(
                        group,
                        "build",
                        &package,
                        &mut report
                    );
                }
                types::BuildDependencies::Typed(typed) => {
                    if typed.types.is_empty() {
                        report.errors.push(
                            "dependencies.build.types is empty.".to_string()
                        );
                    }
                    for (build_type, group) in &typed.types {
                        if !package.types.contains(build_type) {
                            report.warnings.push(format!(
                                "dependencies.build.types has '{build_type}' \
                                 but metadata.types does not list it.",
                            ));
                        }
                        validate_dependency_group(
                            group,
                            &format!("build.type={build_type}"),
                            &package,
                            &mut report
                        );
                    }
                }
            }
        }
    }

    let lua_code = std::fs::read_to_string(package_file)?;
    validate_lua_functions(&lua_code, &mut report);

    validate_path_consistency(package_file, &package, &mut report);

    Ok(report)
}

/// Validates a dependency group, ensuring all dependency strings are
/// well-formed and consistent.
fn validate_dependency_group(
    group: &types::DependencyGroup,
    context: &str,
    package: &types::Package,
    report: &mut DoctorReport
) {
    for dep in group.required() {
        validate_dependency_string(dep, context, "required", report);
    }

    for option in group.options() {
        for dep in &option.depends {
            validate_dependency_string(
                dep,
                context,
                &format!("options.{}", option.name),
                report
            );
        }
    }

    for dep in group.optional() {
        validate_dependency_string(dep, context, "optional", report);
    }

    if let types::DependencyGroup::Complex(complex) = group
        && let Some(subs) = &complex.sub_packages
    {
        let declared_subs = package
            .sub_packages
            .as_ref()
            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
            .unwrap_or_default();

        for (sub_name, sub_group) in subs {
            if !declared_subs.is_empty() && !declared_subs.contains(sub_name) {
                report.warnings.push(format!(
                    "Dependency group for sub-package '{sub_name}' is \
                     declared, but metadata.sub_packages does not include it.",
                ));
            }
            validate_dependency_group(
                sub_group,
                &format!("{context}.sub_package={sub_name}"),
                package,
                report
            );
        }
    }
}

/// Validates an individual dependency string.
fn validate_dependency_string(
    dep: &str,
    context: &str,
    bucket: &str,
    report: &mut DoctorReport
) {
    if let Err(err) = dependencies::parse_dependency_string(dep) {
        report.errors.push(format!(
            "Invalid dependency '{dep}' in {context}.{bucket}: {err}",
        ));
    }
}

/// Scans Lua code for expected lifecycle functions and reports missing ones as
/// warnings.
fn validate_lua_functions(lua_code: &str, report: &mut DoctorReport) {
    let has_prepare = Regex::new(r"(?m)\bfunction\s+prepare\s*\(")
        .is_ok_and(|re| re.is_match(lua_code));
    let has_package = Regex::new(r"(?m)\bfunction\s+package\s*\(")
        .is_ok_and(|re| re.is_match(lua_code));
    let has_test = Regex::new(r"(?m)\bfunction\s+test\s*\(")
        .is_ok_and(|re| re.is_match(lua_code));

    if !has_prepare {
        report.warnings.push(
            "No prepare() function detected. Add it if source fetching/setup \
             is needed."
                .to_string()
        );
    }
    if !has_package {
        report.warnings.push(
            "No package() function detected. Build/install steps may be \
             incomplete."
                .to_string()
        );
    }
    if !has_test {
        report.warnings.push(
            "No test() function detected. Add package tests for \
             maintainability."
                .to_string()
        );
    }
}

/// Ensures that the package's declared repo matches its filesystem location
/// within a registry.
fn validate_path_consistency(
    package_file: &Path,
    package: &types::Package,
    report: &mut DoctorReport
) {
    let Ok(abs_path) = std::fs::canonicalize(package_file) else {
        return;
    };

    let mut current = abs_path.parent();
    let mut registry_root = None;
    while let Some(path) = current {
        if path.join("repo.yaml").exists() {
            registry_root = Some(path.to_path_buf());
            break;
        }
        current = path.parent();
    }

    if let Some(root) = registry_root
        && let Ok(rel_path) = abs_path.strip_prefix(&root)
    {
        let rel_dir = rel_path.parent().unwrap_or(Path::new(""));
        let rel_dir_str = rel_dir.to_string_lossy().replace('\\', "/");

        let mut parts: Vec<&str> = rel_dir_str.split('/').collect();
        if !parts.is_empty() {
            parts.pop();
        }
        let expected_repo = parts.join("/");

        if !expected_repo.is_empty() && package.repo != expected_repo {
            report.errors.push(format!(
                "Path-Repo mismatch: metadata.repo is '{}' but file is \
                 located in registry tier '{expected_repo}'.",
                package.repo
            ));
        } else if expected_repo.is_empty() && !package.repo.is_empty() {
            report.errors.push(format!(
                "Path-Repo mismatch: metadata.repo is '{}' but file is \
                 located at the registry root.",
                package.repo
            ));
        }
    }
}