1use 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#[derive(Debug, Default)]
19pub struct DoctorReport {
20 pub errors: Vec<String>,
23 pub warnings: Vec<String>
25}
26
27pub 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
223fn 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
276fn 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
290fn 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
323fn 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}