Skip to main content

zoi_package/
doctor.rs

1use anyhow::{Result, anyhow};
2use regex::Regex;
3use std::collections::HashSet;
4use std::path::Path;
5use zoi_core::types;
6use zoi_deps as dependencies;
7use zoi_lua;
8
9#[derive(Debug, Default)]
10pub struct DoctorReport {
11    pub errors: Vec<String>,
12    pub warnings: Vec<String>,
13}
14
15pub fn run(
16    package_file: &Path,
17    platform_override: Option<&str>,
18    version_override: Option<&str>,
19) -> Result<DoctorReport> {
20    let file_path = package_file
21        .to_str()
22        .ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", package_file))?;
23    let platform = match platform_override {
24        Some(p) => p.to_string(),
25        None => zoi_core::utils::get_platform()?,
26    };
27
28    let package = zoi_lua::parser::parse_lua_package_for_platform(
29        file_path,
30        &platform,
31        version_override,
32        None,
33        true,
34    )?;
35
36    let mut report = DoctorReport::default();
37
38    if package.name.trim().is_empty() {
39        report.errors.push("metadata.name is empty.".to_string());
40    }
41    if package.repo.trim().is_empty() {
42        report.errors.push("metadata.repo is empty.".to_string());
43    }
44    if package.description.trim().is_empty() {
45        report
46            .errors
47            .push("metadata.description is empty.".to_string());
48    }
49    if package.license.trim().is_empty() {
50        report
51            .warnings
52            .push("metadata.license is empty; set an SPDX license expression.".to_string());
53    }
54    if package.maintainer.name.trim().is_empty() {
55        report
56            .warnings
57            .push("metadata.maintainer.name is empty.".to_string());
58    }
59    if package.maintainer.email.trim().is_empty() {
60        report
61            .warnings
62            .push("metadata.maintainer.email is empty.".to_string());
63    }
64
65    if package.version.is_none() && package.versions.as_ref().is_none_or(|m| m.is_empty()) {
66        report.errors.push(
67            "Package has no version information. Set metadata.version or metadata.versions."
68                .to_string(),
69        );
70    }
71
72    if package.types.is_empty() {
73        report
74            .errors
75            .push("metadata.types is empty; at least one build type is required.".to_string());
76    } else {
77        let known = ["source", "pre-compiled"];
78        for t in &package.types {
79            if !known.contains(&t.as_str()) {
80                report.warnings.push(format!(
81                    "Build type '{}' is custom. Ensure your pipeline supports it.",
82                    t
83                ));
84            }
85        }
86    }
87
88    if let Some(subs) = &package.sub_packages {
89        let mut seen = HashSet::new();
90        for sub in subs {
91            if !seen.insert(sub.clone()) {
92                report.errors.push(format!(
93                    "Duplicate sub-package '{}' in metadata.sub_packages.",
94                    sub
95                ));
96            }
97        }
98    }
99
100    if let Some(main_subs) = &package.main_subs {
101        let allowed = package
102            .sub_packages
103            .as_ref()
104            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
105            .unwrap_or_default();
106        for sub in main_subs {
107            if !allowed.contains(sub) {
108                report.errors.push(format!(
109                    "main_subs contains '{}' but it is missing from sub_packages.",
110                    sub
111                ));
112            }
113        }
114    }
115
116    if let Some(deps) = &package.dependencies {
117        if let Some(runtime) = &deps.runtime {
118            validate_dependency_group(runtime, "runtime", &package, &mut report);
119        }
120        if let Some(build) = &deps.build {
121            match build {
122                types::BuildDependencies::Group(group) => {
123                    validate_dependency_group(group, "build", &package, &mut report);
124                }
125                types::BuildDependencies::Typed(typed) => {
126                    if typed.types.is_empty() {
127                        report
128                            .errors
129                            .push("dependencies.build.types is empty.".to_string());
130                    }
131                    for (build_type, group) in &typed.types {
132                        if !package.types.contains(build_type) {
133                            report.warnings.push(format!(
134                                "dependencies.build.types has '{}' but metadata.types does not list it.",
135                                build_type
136                            ));
137                        }
138                        validate_dependency_group(
139                            group,
140                            &format!("build.type={}", build_type),
141                            &package,
142                            &mut report,
143                        );
144                    }
145                }
146            }
147        }
148    }
149
150    let lua_code = std::fs::read_to_string(package_file)?;
151    validate_lua_functions(&lua_code, &mut report);
152
153    validate_path_consistency(package_file, &package, &mut report);
154
155    Ok(report)
156}
157
158fn validate_dependency_group(
159    group: &types::DependencyGroup,
160    context: &str,
161    package: &types::Package,
162    report: &mut DoctorReport,
163) {
164    for dep in group.required() {
165        validate_dependency_string(dep, context, "required", report);
166    }
167
168    for option in group.options() {
169        for dep in &option.depends {
170            validate_dependency_string(dep, context, &format!("options.{}", option.name), report);
171        }
172    }
173
174    for dep in group.optional() {
175        validate_dependency_string(dep, context, "optional", report);
176    }
177
178    if let types::DependencyGroup::Complex(complex) = group
179        && let Some(subs) = &complex.sub_packages
180    {
181        let declared_subs = package
182            .sub_packages
183            .as_ref()
184            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
185            .unwrap_or_default();
186
187        for (sub_name, sub_group) in subs {
188            if !declared_subs.is_empty() && !declared_subs.contains(sub_name) {
189                report.warnings.push(format!(
190                    "Dependency group for sub-package '{}' is declared, but metadata.sub_packages does not include it.",
191                    sub_name
192                ));
193            }
194            validate_dependency_group(
195                sub_group,
196                &format!("{}.sub_package={}", context, sub_name),
197                package,
198                report,
199            );
200        }
201    }
202}
203
204fn validate_dependency_string(dep: &str, context: &str, bucket: &str, report: &mut DoctorReport) {
205    if let Err(err) = dependencies::parse_dependency_string(dep) {
206        report.errors.push(format!(
207            "Invalid dependency '{}' in {}.{}: {}",
208            dep, context, bucket, err
209        ));
210    }
211}
212
213fn validate_lua_functions(lua_code: &str, report: &mut DoctorReport) {
214    let has_prepare = Regex::new(r"(?m)\bfunction\s+prepare\s*\(")
215        .map(|re| re.is_match(lua_code))
216        .unwrap_or(false);
217    let has_package = Regex::new(r"(?m)\bfunction\s+package\s*\(")
218        .map(|re| re.is_match(lua_code))
219        .unwrap_or(false);
220    let has_test = Regex::new(r"(?m)\bfunction\s+test\s*\(")
221        .map(|re| re.is_match(lua_code))
222        .unwrap_or(false);
223
224    if !has_prepare {
225        report.warnings.push(
226            "No prepare() function detected. Add it if source fetching/setup is needed."
227                .to_string(),
228        );
229    }
230    if !has_package {
231        report.warnings.push(
232            "No package() function detected. Build/install steps may be incomplete.".to_string(),
233        );
234    }
235    if !has_test {
236        report.warnings.push(
237            "No test() function detected. Add package tests for maintainability.".to_string(),
238        );
239    }
240}
241
242fn validate_path_consistency(
243    package_file: &Path,
244    package: &types::Package,
245    report: &mut DoctorReport,
246) {
247    let abs_path = match std::fs::canonicalize(package_file) {
248        Ok(p) => p,
249        Err(_) => return,
250    };
251
252    let mut current = abs_path.parent();
253    let mut registry_root = None;
254    while let Some(path) = current {
255        if path.join("repo.yaml").exists() {
256            registry_root = Some(path.to_path_buf());
257            break;
258        }
259        current = path.parent();
260    }
261
262    if let Some(root) = registry_root
263        && let Ok(rel_path) = abs_path.strip_prefix(&root)
264    {
265        let rel_dir = rel_path.parent().unwrap_or(Path::new(""));
266        let rel_dir_str = rel_dir.to_string_lossy().replace("\\", "/");
267
268        let mut parts: Vec<&str> = rel_dir_str.split('/').collect();
269        if !parts.is_empty() {
270            parts.pop();
271        }
272        let expected_repo = parts.join("/");
273
274        if !expected_repo.is_empty() && package.repo != expected_repo {
275            report.errors.push(format!(
276                    "Path-Repo mismatch: metadata.repo is '{}' but file is located in registry tier '{}'.",
277                    package.repo, expected_repo
278                ));
279        } else if expected_repo.is_empty() && !package.repo.is_empty() {
280            report.errors.push(format!(
281                    "Path-Repo mismatch: metadata.repo is '{}' but file is located at the registry root.",
282                    package.repo
283                ));
284        }
285    }
286}