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