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}