Skip to main content

dealer/
project.rs

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}