1use crate::compiler::CompilerBackend;
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug)]
7pub(crate) struct ProjectRoot {
8 pub(crate) root_dir: PathBuf,
9 pub(crate) root_file: PathBuf,
10 pub(crate) package_name: String,
11}
12
13pub(crate) fn validate_project_root(path: &Path) -> Result<ProjectRoot, String> {
14 let root_dir = fs::canonicalize(path).map_err(|error| {
15 format!(
16 "failed to read project folder '{}': {error}",
17 path.display()
18 )
19 })?;
20 if !root_dir.is_dir() {
21 return Err(format!(
22 "expected a project folder, found '{}'",
23 root_dir.display()
24 ));
25 }
26
27 let app = root_dir.join("app.x");
28 let package = root_dir.join("package.x");
29 match (app.is_file(), package.is_file()) {
30 (true, false) => Ok(project_root(root_dir, app)),
31 (false, true) => Ok(project_root(root_dir, package)),
32 (false, false) => Err(format!(
33 "'{}' is not a valid Xtazy project: expected app.x or package.x",
34 root_dir.display()
35 )),
36 (true, true) => Err(format!(
37 "'{}' is ambiguous: app.x and package.x cannot exist together",
38 root_dir.display()
39 )),
40 }
41}
42
43fn project_root(root_dir: PathBuf, root_file: PathBuf) -> ProjectRoot {
44 let package_name = root_dir
45 .file_name()
46 .and_then(|name| name.to_str())
47 .unwrap_or("xtazy_project")
48 .to_string();
49
50 ProjectRoot {
51 root_dir,
52 root_file,
53 package_name,
54 }
55}
56
57pub(crate) fn resolve_dependencies(
58 project: &ProjectRoot,
59 compiler: &dyn CompilerBackend,
60) -> Result<HashMap<String, PathBuf>, String> {
61 let mut resolved = HashMap::new();
62 let mut path_stack = Vec::new();
63
64 let root_meta = compiler
65 .metadata(&project.root_file)
66 .map_err(|e| format!("Failed to read project root metadata: {e}"))?;
67
68 resolve_recursive(
69 &root_meta.dependencies,
70 &project.root_dir,
71 compiler,
72 &mut resolved,
73 &mut path_stack,
74 )?;
75
76 Ok(resolved)
77}
78
79fn resolve_recursive(
80 dependencies: &[crate::compiler::MetadataDependency],
81 base_dir: &Path,
82 compiler: &dyn CompilerBackend,
83 resolved: &mut HashMap<String, PathBuf>,
84 path_stack: &mut Vec<String>,
85) -> Result<(), String> {
86 for dep in dependencies {
87 let dep_path = match dep.source_type.as_str() {
88 "local_path" => {
89 let p = Path::new(&dep.arg1);
90 if p.is_absolute() {
91 p.to_path_buf()
92 } else {
93 base_dir.join(p)
94 }
95 }
96 "registry_exact" => {
97 let workspace_root = crate::workspace_root();
98 let index_pkg = workspace_root.join("package").join(&dep.name);
99 if index_pkg.is_dir() {
100 index_pkg
101 } else {
102 crate::state::DealerState::from_process_env(&workspace_root)
103 .cache_dir()
104 .join("package")
105 .join(&dep.name)
106 }
107 }
108 _ => {
109 return Err(format!(
110 "Dependency source type '{}' with values '{}'{} is not supported in resolver MVP yet",
111 dep.source_type,
112 dep.arg1,
113 dep.arg2
114 .as_deref()
115 .map(|value| format!(", '{}'", value))
116 .unwrap_or_default()
117 ));
118 }
119 };
120
121 let dep_path = fs::canonicalize(&dep_path).map_err(|e| {
122 format!(
123 "Failed to locate dependency '{}' at '{}': {e}",
124 dep.name,
125 dep_path.display()
126 )
127 })?;
128
129 let package_x = dep_path.join("package.x");
130 if !package_x.is_file() {
131 return Err(format!(
132 "Dependency '{}' at '{}' is invalid: missing package.x",
133 dep.name,
134 dep_path.display()
135 ));
136 }
137
138 let dep_meta = compiler
139 .metadata(&package_x)
140 .map_err(|e| format!("Failed to read metadata for dependency '{}': {e}", dep.name))?;
141
142 if dep_meta.project_type != "package" {
143 return Err(format!(
144 "Dependency '{}' resolved to '{}', which is an app, not a package",
145 dep.name,
146 dep_path.display()
147 ));
148 }
149
150 if dep_meta.name != dep.name {
151 return Err(format!(
152 "Package name mismatch: declared dependency is '{}', but resolved package claims name '{}'",
153 dep.name, dep_meta.name
154 ));
155 }
156
157 if path_stack.contains(&dep.name) {
158 return Err(format!("Circular dependency detected: {:?}", path_stack));
159 }
160
161 if let Some(existing_path) = resolved.get(&dep.name) {
162 if existing_path != &dep_path {
163 return Err(format!(
164 "Conflict detected: package '{}' is resolved to two different paths: '{}' and '{}'",
165 dep.name,
166 existing_path.display(),
167 dep_path.display()
168 ));
169 }
170 continue;
171 }
172
173 resolved.insert(dep.name.clone(), dep_path.clone());
174 path_stack.push(dep.name.clone());
175
176 resolve_recursive(
177 &dep_meta.dependencies,
178 &dep_path,
179 compiler,
180 resolved,
181 path_stack,
182 )?;
183
184 path_stack.pop();
185 }
186 Ok(())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::test_support::TempProject;
193
194 #[test]
195 fn validate_project_accepts_app_root() {
196 let temp = TempProject::new("app-root");
197 fs::write(temp.path().join("app.x"), "app Main\n").expect("app root should be written");
198
199 let project = validate_project_root(temp.path()).expect("app.x root should be valid");
200 let expected_root = fs::canonicalize(temp.path()).expect("temp root should canonicalize");
201 let expected_name = temp.path().file_name().unwrap().to_string_lossy();
202
203 assert_eq!(project.root_file, expected_root.join("app.x"));
204 assert_eq!(project.package_name, expected_name);
205 }
206
207 #[test]
208 fn validate_project_accepts_package_root() {
209 let temp = TempProject::new("package-root");
210 fs::write(temp.path().join("package.x"), "entity Thing\n")
211 .expect("package root should be written");
212
213 let project = validate_project_root(temp.path()).expect("package.x root should be valid");
214 let expected_root = fs::canonicalize(temp.path()).expect("temp root should canonicalize");
215
216 assert_eq!(project.root_file, expected_root.join("package.x"));
217 }
218
219 #[test]
220 fn validate_project_rejects_missing_root_file() {
221 let temp = TempProject::new("missing-root");
222
223 let error = validate_project_root(temp.path()).expect_err("missing root should fail");
224
225 assert!(error.contains("expected app.x or package.x"));
226 }
227
228 #[test]
229 fn validate_project_rejects_ambiguous_root_files() {
230 let temp = TempProject::new("ambiguous-root");
231 fs::write(temp.path().join("app.x"), "app Main\n").expect("app root should be written");
232 fs::write(temp.path().join("package.x"), "entity Thing\n")
233 .expect("package root should be written");
234
235 let error = validate_project_root(temp.path()).expect_err("ambiguous root should fail");
236
237 assert!(error.contains("app.x and package.x cannot exist together"));
238 }
239}