Skip to main content

zoi_package/
doctor.rs

1//! Package health and metadata validation.
2//!
3//! This module implements the `zoi package doctor` command, which performs
4//! static analysis on a `.pkg.lua` file to identify potential issues,
5//! such as missing metadata, invalid dependency strings, or missing
6//! lifecycle functions.
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use anyhow::{Result, anyhow};
12use regex::Regex;
13use zoi_core::types;
14use zoi_deps as dependencies;
15use zoi_lua;
16
17/// A report containing errors and warnings found during package validation.
18#[derive(Debug, Default)]
19pub struct DoctorReport {
20    /// List of critical issues that prevent the package from being built or
21    /// used correctly.
22    pub errors: Vec<String>,
23    /// List of non-critical issues or suggestions for improvement.
24    pub warnings: Vec<String>
25}
26
27/// Runs a health check on a Zoi package definition.
28///
29/// # Errors
30///
31/// Returns an error if the package file path contains invalid UTF-8 characters,
32/// or if parsing the Lua package definition fails.
33/// # Errors
34///
35/// Returns an error if the package file cannot be read or parsed.
36pub fn run(
37    package_file: &Path,
38    platform_override: Option<&str>,
39    version_override: Option<&str>
40) -> Result<DoctorReport> {
41    let package_path_str = package_file.to_str().ok_or_else(|| {
42        anyhow!(
43            "Path contains invalid UTF-8 characters: {}",
44            package_file.display()
45        )
46    })?;
47
48    let platform = match platform_override {
49        Some(p) => p.to_string(),
50        None => zoi_core::utils::get_platform()?
51    };
52
53    let package = zoi_lua::parser::parse_lua_package_for_platform(
54        package_path_str,
55        &platform,
56        version_override,
57        None,
58        true
59    )?;
60
61    let mut report = DoctorReport::default();
62
63    if package.name.trim().is_empty() {
64        report.errors.push("metadata.name is empty.".to_string());
65    }
66    if package.repo.trim().is_empty() {
67        report.errors.push("metadata.repo is empty.".to_string());
68    }
69    if package.description.trim().is_empty() {
70        report
71            .errors
72            .push("metadata.description is empty.".to_string());
73    }
74    if package.license.trim().is_empty() {
75        report.warnings.push(
76            "metadata.license is empty; set an SPDX license expression."
77                .to_string()
78        );
79    }
80    if package.maintainer.name.trim().is_empty() {
81        report
82            .warnings
83            .push("metadata.maintainer.name is empty.".to_string());
84    }
85    if package.maintainer.email.trim().is_empty() {
86        report
87            .warnings
88            .push("metadata.maintainer.email is empty.".to_string());
89    }
90
91    if package.version.is_none()
92        && package.versions.as_ref().is_none_or(HashMap::is_empty)
93    {
94        report.errors.push(
95            "Package has no version information. Set metadata.version or \
96             metadata.versions."
97                .to_string()
98        );
99    }
100
101    if package.types.is_empty() {
102        report.errors.push(
103            "metadata.types is empty; at least one build type is required."
104                .to_string()
105        );
106    } else {
107        let known = ["source", "pre-compiled"];
108        for t in &package.types {
109            if !known.contains(&t.as_str()) {
110                report.warnings.push(format!(
111                    "Build type '{t}' is custom. Ensure your pipeline \
112                     supports it.",
113                ));
114            }
115        }
116    }
117
118    if let Some(subs) = &package.sub_packages {
119        let mut seen = HashSet::new();
120        for sub in subs {
121            if !seen.insert(sub.clone()) {
122                report.errors.push(format!(
123                    "Duplicate sub-package '{sub}' in metadata.sub_packages.",
124                ));
125            }
126        }
127    }
128
129    if let Some(main_subs) = &package.main_subs {
130        let allowed = package
131            .sub_packages
132            .as_ref()
133            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
134            .unwrap_or_default();
135        for sub in main_subs {
136            if !allowed.contains(sub) {
137                report.errors.push(format!(
138                    "main_subs contains '{sub}' but it is missing from \
139                     sub_packages.",
140                ));
141            }
142        }
143    }
144
145    if let Some(deps) = &package.dependencies {
146        if let Some(runtime) = &deps.runtime {
147            validate_dependency_group(
148                runtime,
149                "runtime",
150                &package,
151                &mut report
152            );
153        }
154        if let Some(build) = &deps.build {
155            match build {
156                types::BuildDependencies::Group(group) => {
157                    validate_dependency_group(
158                        group,
159                        "build",
160                        &package,
161                        &mut report
162                    );
163                }
164                types::BuildDependencies::Typed(typed) => {
165                    if typed.types.is_empty() {
166                        report.errors.push(
167                            "dependencies.build.types is empty.".to_string()
168                        );
169                    }
170                    for (build_type, group) in &typed.types {
171                        if !package.types.contains(build_type) {
172                            report.warnings.push(format!(
173                                "dependencies.build.types has '{build_type}' \
174                                 but metadata.types does not list it.",
175                            ));
176                        }
177                        validate_dependency_group(
178                            group,
179                            &format!("build.type={build_type}"),
180                            &package,
181                            &mut report
182                        );
183                    }
184                }
185            }
186        }
187    }
188
189    let lua_code = std::fs::read_to_string(package_file)?;
190    validate_lua_functions(&lua_code, &mut report);
191
192    validate_path_consistency(package_file, &package, &mut report);
193
194    Ok(report)
195}
196
197/// Validates a dependency group, ensuring all dependency strings are
198/// well-formed and consistent.
199fn validate_dependency_group(
200    group: &types::DependencyGroup,
201    context: &str,
202    package: &types::Package,
203    report: &mut DoctorReport
204) {
205    for dep in group.required() {
206        validate_dependency_string(dep, context, "required", report);
207    }
208
209    for option in group.options() {
210        for dep in &option.depends {
211            validate_dependency_string(
212                dep,
213                context,
214                &format!("options.{}", option.name),
215                report
216            );
217        }
218    }
219
220    for dep in group.optional() {
221        validate_dependency_string(dep, context, "optional", report);
222    }
223
224    if let types::DependencyGroup::Complex(complex) = group
225        && let Some(subs) = &complex.sub_packages
226    {
227        let declared_subs = package
228            .sub_packages
229            .as_ref()
230            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
231            .unwrap_or_default();
232
233        for (sub_name, sub_group) in subs {
234            if !declared_subs.is_empty() && !declared_subs.contains(sub_name) {
235                report.warnings.push(format!(
236                    "Dependency group for sub-package '{sub_name}' is \
237                     declared, but metadata.sub_packages does not include it.",
238                ));
239            }
240            validate_dependency_group(
241                sub_group,
242                &format!("{context}.sub_package={sub_name}"),
243                package,
244                report
245            );
246        }
247    }
248}
249
250/// Validates an individual dependency string.
251fn validate_dependency_string(
252    dep: &str,
253    context: &str,
254    bucket: &str,
255    report: &mut DoctorReport
256) {
257    if let Err(err) = dependencies::parse_dependency_string(dep) {
258        report.errors.push(format!(
259            "Invalid dependency '{dep}' in {context}.{bucket}: {err}",
260        ));
261    }
262}
263
264/// Scans Lua code for expected lifecycle functions and reports missing ones as
265/// warnings.
266fn validate_lua_functions(lua_code: &str, report: &mut DoctorReport) {
267    let has_prepare = Regex::new(r"(?m)\bfunction\s+prepare\s*\(")
268        .is_ok_and(|re| re.is_match(lua_code));
269    let has_package = Regex::new(r"(?m)\bfunction\s+package\s*\(")
270        .is_ok_and(|re| re.is_match(lua_code));
271    let has_test = Regex::new(r"(?m)\bfunction\s+test\s*\(")
272        .is_ok_and(|re| re.is_match(lua_code));
273
274    if !has_prepare {
275        report.warnings.push(
276            "No prepare() function detected. Add it if source fetching/setup \
277             is needed."
278                .to_string()
279        );
280    }
281    if !has_package {
282        report.warnings.push(
283            "No package() function detected. Build/install steps may be \
284             incomplete."
285                .to_string()
286        );
287    }
288    if !has_test {
289        report.warnings.push(
290            "No test() function detected. Add package tests for \
291             maintainability."
292                .to_string()
293        );
294    }
295}
296
297/// Ensures that the package's declared repo matches its filesystem location
298/// within a registry.
299fn validate_path_consistency(
300    package_file: &Path,
301    package: &types::Package,
302    report: &mut DoctorReport
303) {
304    let Ok(abs_path) = std::fs::canonicalize(package_file) else {
305        return;
306    };
307
308    let mut current = abs_path.parent();
309    let mut registry_root = None;
310    while let Some(path) = current {
311        if path.join("repo.yaml").exists() {
312            registry_root = Some(path.to_path_buf());
313            break;
314        }
315        current = path.parent();
316    }
317
318    if let Some(root) = registry_root
319        && let Ok(rel_path) = abs_path.strip_prefix(&root)
320    {
321        let rel_dir = rel_path.parent().unwrap_or(Path::new(""));
322        let rel_dir_str = rel_dir.to_string_lossy().replace('\\', "/");
323
324        let mut parts: Vec<&str> = rel_dir_str.split('/').collect();
325        if !parts.is_empty() {
326            parts.pop();
327        }
328        let expected_repo = parts.join("/");
329
330        if !expected_repo.is_empty() && package.repo != expected_repo {
331            report.errors.push(format!(
332                "Path-Repo mismatch: metadata.repo is '{}' but file is \
333                 located in registry tier '{expected_repo}'.",
334                package.repo
335            ));
336        } else if expected_repo.is_empty() && !package.repo.is_empty() {
337            report.errors.push(format!(
338                "Path-Repo mismatch: metadata.repo is '{}' but file is \
339                 located at the registry root.",
340                package.repo
341            ));
342        }
343    }
344}