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 }
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
197fn 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
250fn 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
264fn 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
297fn 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}