zoi-package 1.24.1

Advanced Package Manager & Environment Orchestrator
Documentation
use anyhow::{Result, anyhow};
use regex::Regex;
use std::collections::HashSet;
use std::path::Path;
use zoi_core::types;
use zoi_deps as dependencies;
use zoi_lua;

#[derive(Debug, Default)]
pub struct DoctorReport {
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
}

pub fn run(
    package_file: &Path,
    platform_override: Option<&str>,
    version_override: Option<&str>,
) -> Result<DoctorReport> {
    let file_path = package_file
        .to_str()
        .ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", package_file))?;
    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(
        file_path,
        &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(|m| m.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 '{}' is custom. Ensure your pipeline supports it.",
                    t
                ));
            }
        }
    }

    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 '{}' in metadata.sub_packages.",
                    sub
                ));
            }
        }
    }

    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 '{}' but it is missing from sub_packages.",
                    sub
                ));
            }
        }
    }

    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 '{}' but metadata.types does not list it.",
                                build_type
                            ));
                        }
                        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)
}

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 '{}' is declared, but metadata.sub_packages does not include it.",
                    sub_name
                ));
            }
            validate_dependency_group(
                sub_group,
                &format!("{}.sub_package={}", context, sub_name),
                package,
                report,
            );
        }
    }
}

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 '{}' in {}.{}: {}",
            dep, context, bucket, err
        ));
    }
}

fn validate_lua_functions(lua_code: &str, report: &mut DoctorReport) {
    let has_prepare = Regex::new(r"(?m)\bfunction\s+prepare\s*\(")
        .map(|re| re.is_match(lua_code))
        .unwrap_or(false);
    let has_package = Regex::new(r"(?m)\bfunction\s+package\s*\(")
        .map(|re| re.is_match(lua_code))
        .unwrap_or(false);
    let has_test = Regex::new(r"(?m)\bfunction\s+test\s*\(")
        .map(|re| re.is_match(lua_code))
        .unwrap_or(false);

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

fn validate_path_consistency(
    package_file: &Path,
    package: &types::Package,
    report: &mut DoctorReport,
) {
    let abs_path = match std::fs::canonicalize(package_file) {
        Ok(p) => p,
        Err(_) => 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 '{}'.",
                    package.repo, expected_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
                ));
        }
    }
}