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                types::BuildDependencies::List(entries) => {
186                    if entries.is_empty() {
187                        report.errors.push(
188                            "dependencies.build list is empty.".to_string()
189                        );
190                    }
191                    for entry in entries {
192                        let context =
193                            format!("build.type={}", entry.build_type);
194                        if !package.types.contains(&entry.build_type) {
195                            report.warnings.push(format!(
196                                "dependencies.build has type '{}' but \
197                                 metadata.types does not list it.",
198                                entry.build_type,
199                            ));
200                        }
201                        for dep in &entry.packages {
202                            validate_dependency_string(
203                                dep,
204                                &context,
205                                "packages",
206                                &mut report
207                            );
208                        }
209                    }
210                }
211            }
212        }
213    }
214
215    let lua_code = std::fs::read_to_string(package_file)?;
216    validate_lua_functions(&lua_code, &mut report);
217
218    validate_path_consistency(package_file, &package, &mut report);
219
220    Ok(report)
221}
222
223/// Validates a dependency group, ensuring all dependency strings are
224/// well-formed and consistent.
225fn validate_dependency_group(
226    group: &types::DependencyGroup,
227    context: &str,
228    package: &types::Package,
229    report: &mut DoctorReport
230) {
231    for dep in group.required() {
232        validate_dependency_string(dep, context, "required", report);
233    }
234
235    for option in group.options() {
236        for dep in &option.depends {
237            validate_dependency_string(
238                dep,
239                context,
240                &format!("options.{}", option.name),
241                report
242            );
243        }
244    }
245
246    for dep in group.optional() {
247        validate_dependency_string(dep, context, "optional", report);
248    }
249
250    if let types::DependencyGroup::Complex(complex) = group
251        && let Some(subs) = &complex.sub_packages
252    {
253        let declared_subs = package
254            .sub_packages
255            .as_ref()
256            .map(|v| v.iter().cloned().collect::<HashSet<String>>())
257            .unwrap_or_default();
258
259        for (sub_name, sub_group) in subs {
260            if !declared_subs.is_empty() && !declared_subs.contains(sub_name) {
261                report.warnings.push(format!(
262                    "Dependency group for sub-package '{sub_name}' is \
263                     declared, but metadata.sub_packages does not include it.",
264                ));
265            }
266            validate_dependency_group(
267                sub_group,
268                &format!("{context}.sub_package={sub_name}"),
269                package,
270                report
271            );
272        }
273    }
274}
275
276/// Validates an individual dependency string.
277fn validate_dependency_string(
278    dep: &str,
279    context: &str,
280    bucket: &str,
281    report: &mut DoctorReport
282) {
283    if let Err(err) = dependencies::parse_dependency_string(dep) {
284        report.errors.push(format!(
285            "Invalid dependency '{dep}' in {context}.{bucket}: {err}",
286        ));
287    }
288}
289
290/// Scans Lua code for expected lifecycle functions and reports missing ones as
291/// warnings.
292fn validate_lua_functions(lua_code: &str, report: &mut DoctorReport) {
293    let has_prepare = Regex::new(r"(?m)\bfunction\s+prepare\s*\(")
294        .is_ok_and(|re| re.is_match(lua_code));
295    let has_package = Regex::new(r"(?m)\bfunction\s+package\s*\(")
296        .is_ok_and(|re| re.is_match(lua_code));
297    let has_test = Regex::new(r"(?m)\bfunction\s+test\s*\(")
298        .is_ok_and(|re| re.is_match(lua_code));
299
300    if !has_prepare {
301        report.warnings.push(
302            "No prepare() function detected. Add it if source fetching/setup \
303             is needed."
304                .to_string()
305        );
306    }
307    if !has_package {
308        report.warnings.push(
309            "No package() function detected. Build/install steps may be \
310             incomplete."
311                .to_string()
312        );
313    }
314    if !has_test {
315        report.warnings.push(
316            "No test() function detected. Add package tests for \
317             maintainability."
318                .to_string()
319        );
320    }
321}
322
323/// Ensures that the package's declared repo matches its filesystem location
324/// within a registry.
325fn validate_path_consistency(
326    package_file: &Path,
327    package: &types::Package,
328    report: &mut DoctorReport
329) {
330    let Ok(abs_path) = std::fs::canonicalize(package_file) else {
331        return;
332    };
333
334    let mut current = abs_path.parent();
335    let mut registry_root = None;
336    while let Some(path) = current {
337        if path.join("repo.yaml").exists() {
338            registry_root = Some(path.to_path_buf());
339            break;
340        }
341        current = path.parent();
342    }
343
344    if let Some(root) = registry_root
345        && let Ok(rel_path) = abs_path.strip_prefix(&root)
346    {
347        let rel_dir = rel_path.parent().unwrap_or(Path::new(""));
348        let rel_dir_str = rel_dir.to_string_lossy().replace('\\', "/");
349
350        let mut parts: Vec<&str> = rel_dir_str.split('/').collect();
351        if !parts.is_empty() {
352            parts.pop();
353        }
354        let expected_repo = parts.join("/");
355
356        if !expected_repo.is_empty() && package.repo != expected_repo {
357            report.errors.push(format!(
358                "Path-Repo mismatch: metadata.repo is '{}' but file is \
359                 located in registry tier '{expected_repo}'.",
360                package.repo
361            ));
362        } else if expected_repo.is_empty() && !package.repo.is_empty() {
363            report.errors.push(format!(
364                "Path-Repo mismatch: metadata.repo is '{}' but file is \
365                 located at the registry root.",
366                package.repo
367            ));
368        }
369    }
370}