1use crate::constraint::version_satisfies;
2use crate::lockfile::Lockfile;
3use crate::manifest::{Manifest, ManifestPackage, ManifestSource};
4use crate::package_spec::{parse_package_spec, PackageSpec};
5use crate::registry::{resolve_local_registry_package_root, resolve_registry_package_root};
6use crate::resolve_context::{PackageResolutionContext, ResolveOptions};
7use crate::resolve_exports::{
8 build_skill_file_index, load_export_bodies, load_package_file_bytes, read_text, select_exports,
9};
10use crate::resolve_git_sources::{
11 prepare_git_sources, prepare_pray_ssh_host_keys, resolve_git_package_root, GitSourceCheckout,
12};
13
14pub use crate::resolve_git::{discover_distribution_root, git_source_cache_directory};
15pub use crate::resolve_git_sources::refresh_git_sources;
16use crate::{PrayError, PrayResult};
17use std::collections::{BTreeMap, BTreeSet};
18use std::fs;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone)]
22pub struct ResolvedProject {
23 pub manifest_path: PathBuf,
24 pub project_root: PathBuf,
25 pub manifest: Manifest,
26 pub manifest_hash: String,
27 pub packages: Vec<ResolvedPackage>,
28 pub local_files: Vec<ResolvedLocalFile>,
29 pub source_revisions: BTreeMap<String, String>,
30 pub source_host_keys: BTreeMap<String, String>,
31 pub environment: Option<String>,
32}
33
34#[derive(Debug, Clone)]
35pub struct ResolvedPackage {
36 pub declaration: ManifestPackage,
37 pub root: PathBuf,
38 pub spec: PackageSpec,
39 pub tree_hash: String,
40 pub artifact_hash: String,
41 pub artifact: String,
42 pub selected_exports: Vec<String>,
43 pub source_checksum: String,
44 pub export_bodies: BTreeMap<String, String>,
45 pub skill_files: BTreeMap<String, Vec<String>>,
46 pub signer_fingerprint: Option<String>,
47 pub registry_latest_version: Option<String>,
49}
50
51#[derive(Debug, Clone)]
52pub struct ResolvedLocalFile {
53 pub path: PathBuf,
54 pub manifest_path: String,
55 pub content: String,
56 pub position: String,
57 pub optional: bool,
58}
59
60impl ResolvedProject {
61 pub fn lockfile_hash(&self) -> PrayResult<String> {
62 Ok(self.manifest_hash.clone())
63 }
64}
65
66pub fn project_root_from_manifest(manifest_path: &Path) -> PathBuf {
67 match manifest_path.parent() {
68 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
69 _ => PathBuf::from("."),
70 }
71}
72
73fn canonical_project_root(manifest_path: &Path) -> PrayResult<PathBuf> {
74 let root = project_root_from_manifest(manifest_path);
75 if root.is_absolute() {
76 return Ok(root);
77 }
78 let cwd = std::env::current_dir().map_err(|error| {
79 PrayError::Resolution(format!("failed to resolve project root from cwd: {error}"))
80 })?;
81 Ok(cwd.join(root))
82}
83
84pub fn resolve_project(manifest_path: &Path) -> PrayResult<ResolvedProject> {
85 resolve_project_with_options(manifest_path, &ResolveOptions::default())
86}
87
88pub fn resolve_project_with_git_refresh_fallback(
89 manifest_path: &Path,
90 options: &ResolveOptions,
91 allow_git_refresh_fallback: bool,
92) -> PrayResult<ResolvedProject> {
93 match resolve_project_with_options(manifest_path, options) {
94 Ok(project) => Ok(project),
95 Err(PrayError::Resolution(message))
96 if allow_git_refresh_fallback
97 && !options.offline
98 && !options.refresh_source_revisions
99 && resolution_may_benefit_from_git_source_refresh(&message) =>
100 {
101 let refreshed_options = ResolveOptions {
102 refresh_source_revisions: true,
103 ..options.clone()
104 };
105 resolve_project_with_options(manifest_path, &refreshed_options)
106 }
107 Err(error) => Err(error),
108 }
109}
110
111fn resolution_may_benefit_from_git_source_refresh(message: &str) -> bool {
112 message.contains("no registry version")
113}
114
115pub fn resolve_project_with_options(
116 manifest_path: &Path,
117 options: &ResolveOptions,
118) -> PrayResult<ResolvedProject> {
119 let project_root = canonical_project_root(manifest_path)?;
120 resolve_project_in_context(manifest_path, &project_root, options)
121}
122
123pub fn resolve_project_in_context(
124 manifest_path: &Path,
125 project_root: &Path,
126 options: &ResolveOptions,
127) -> PrayResult<ResolvedProject> {
128 let user_config = crate::config::load_user_config()?;
129 let lockfile_path = project_root.join("Prayfile.lock");
130 let lockfile_hints = crate::lockfile::read_lockfile(&lockfile_path).ok();
131 let manifest_text = crate::manifest::read_manifest_text(manifest_path)?;
132 let manifest = crate::manifest::parse_manifest(&manifest_text)?;
133 crate::environment::validate_environment(&manifest, options.environment.as_deref())?;
134 let manifest_hash = manifest.manifest_hash()?;
135 let sources = source_map(&manifest.sources);
136 let git_sources = prepare_git_sources(
137 project_root,
138 &manifest.sources,
139 lockfile_hints.as_ref(),
140 options,
141 )?;
142 let source_host_keys = prepare_pray_ssh_host_keys(&manifest.sources)?;
143 let mut packages = Vec::new();
144 let mut seen = BTreeSet::new();
145 let mut resolution_errors = Vec::new();
146 for declaration in &manifest.packages {
147 match resolve_package(
148 project_root,
149 &sources,
150 &git_sources,
151 &user_config,
152 declaration,
153 lockfile_hints.as_ref(),
154 options,
155 ) {
156 Ok(package) => {
157 if !seen.insert(package.declaration.name.clone()) {
158 return Err(PrayError::Resolution(format!(
159 "duplicate package declaration: {}",
160 package.declaration.name
161 )));
162 }
163 packages.push(package);
164 }
165 Err(error) => resolution_errors.push(format!("{}: {error}", declaration.name)),
166 }
167 }
168 if !resolution_errors.is_empty() {
169 return Err(PrayError::Resolution(resolution_errors.join("\n")));
170 }
171 let mut local_files = Vec::new();
172 let mut local_errors = Vec::new();
173 for local in &manifest.local {
174 match resolve_local_file(project_root, local) {
175 Ok(resolved) => local_files.push(resolved),
176 Err(error) => local_errors.push(format!("local {}: {error}", local.path)),
177 }
178 }
179 if !local_errors.is_empty() {
180 return Err(PrayError::Resolution(local_errors.join("\n")));
181 }
182 Ok(ResolvedProject {
183 manifest_path: manifest_path.to_path_buf(),
184 project_root: project_root.to_path_buf(),
185 manifest,
186 manifest_hash,
187 packages,
188 local_files,
189 source_revisions: git_sources
190 .into_iter()
191 .filter_map(|(name, checkout)| {
192 if checkout.revision.is_empty() {
193 None
194 } else {
195 Some((name, checkout.revision))
196 }
197 })
198 .collect(),
199 source_host_keys,
200 environment: options.environment.clone(),
201 })
202}
203
204fn resolve_package(
205 project_root: &Path,
206 sources: &BTreeMap<String, ManifestSource>,
207 git_sources: &BTreeMap<String, GitSourceCheckout>,
208 user_config: &crate::config::PrayConfig,
209 declaration: &ManifestPackage,
210 lockfile: Option<&Lockfile>,
211 options: &ResolveOptions,
212) -> PrayResult<ResolvedPackage> {
213 let PackageRootResolution {
214 root,
215 signer_fingerprint,
216 registry_latest_version,
217 } = resolve_package_root(
218 project_root,
219 sources,
220 git_sources,
221 user_config,
222 declaration,
223 lockfile,
224 options,
225 )?;
226 let spec_path = find_prayspec_file(&root)?;
227 let spec_text = fs::read_to_string(&spec_path)?;
228 let spec = parse_package_spec(&spec_text)?.canonicalized();
229 if spec.name != declaration.name {
230 return Err(PrayError::Resolution(format!(
231 "package path {:?} declares {:?}, expected {:?}",
232 root, spec.name, declaration.name
233 )));
234 }
235 if !version_satisfies(&spec.version, &declaration.constraint)? {
236 return Err(PrayError::Resolution(format!(
237 "package {} version {} does not satisfy constraint {}",
238 declaration.name, spec.version, declaration.constraint
239 )));
240 }
241 let selected_exports = select_exports(declaration, &spec)?;
242 let file_bytes = load_package_file_bytes(&root, &spec)?;
243 let tree_hash = PackageSpec::tree_hash_from_file_bytes(&file_bytes)?;
244 let export_bodies = load_export_bodies(&file_bytes, &spec, &selected_exports)?;
245 let skill_files = build_skill_file_index(&spec);
246 let source_checksum = tree_hash.clone();
247 Ok(ResolvedPackage {
248 declaration: declaration.clone(),
249 root,
250 spec: spec.clone(),
251 tree_hash: tree_hash.clone(),
252 artifact_hash: tree_hash.clone(),
253 artifact: format!(
254 "path:{}",
255 spec_path.parent().unwrap_or(&spec_path).to_string_lossy()
256 ),
257 selected_exports,
258 source_checksum,
259 export_bodies,
260 skill_files,
261 signer_fingerprint,
262 registry_latest_version,
263 })
264}
265
266#[derive(Debug, Clone)]
267struct PackageRootResolution {
268 root: PathBuf,
269 signer_fingerprint: Option<String>,
270 registry_latest_version: Option<String>,
271}
272
273fn resolve_package_root(
274 project_root: &Path,
275 sources: &BTreeMap<String, ManifestSource>,
276 git_sources: &BTreeMap<String, GitSourceCheckout>,
277 user_config: &crate::config::PrayConfig,
278 declaration: &ManifestPackage,
279 lockfile: Option<&Lockfile>,
280 options: &ResolveOptions,
281) -> PrayResult<PackageRootResolution> {
282 if let Some(local_path) = user_config.local.package.get(&declaration.name) {
283 return Ok(PackageRootResolution {
284 root: project_root.join(local_path),
285 signer_fingerprint: None,
286 registry_latest_version: None,
287 });
288 }
289 if let Some(path) = &declaration.path {
290 return Ok(PackageRootResolution {
291 root: project_root.join(path),
292 signer_fingerprint: None,
293 registry_latest_version: None,
294 });
295 }
296 let source_name = implied_source_name(declaration, sources)?;
297 if let Some(source_name) = source_name {
298 let source = sources
299 .get(&source_name)
300 .ok_or_else(|| PrayError::Resolution(format!("unknown source: {source_name}")))?;
301 let context = PackageResolutionContext::from_lockfile(lockfile, &declaration.name, options);
302 if let Some(local_path) = user_config.local.source.get(&source_name) {
303 let source_root = project_root.join(local_path);
304 let resolved = resolve_local_registry_package_root(
305 project_root,
306 &format!("local:{source_name}"),
307 &source_root,
308 declaration,
309 &context,
310 )?;
311 return Ok(PackageRootResolution {
312 root: resolved.root,
313 signer_fingerprint: resolved.signer_fingerprint,
314 registry_latest_version: resolved.registry_latest_version,
315 });
316 }
317 if source.kind == "path" {
318 let slug = declaration.name.replace('/', "-");
319 return Ok(PackageRootResolution {
320 root: project_root.join(&source.url).join(slug),
321 signer_fingerprint: None,
322 registry_latest_version: None,
323 });
324 }
325 if source.kind == "registry" || source.kind == "static index" || source.kind == "pray_ssh" {
326 let resolved =
327 resolve_registry_package_root(project_root, &source.url, declaration, &context)?;
328 return Ok(PackageRootResolution {
329 root: resolved.root,
330 signer_fingerprint: resolved.signer_fingerprint,
331 registry_latest_version: resolved.registry_latest_version,
332 });
333 }
334 if source.kind == "git" {
335 let resolved = resolve_git_package_root(
336 project_root,
337 &source_name,
338 &source.url,
339 git_sources,
340 declaration,
341 &context,
342 )?;
343 return Ok(PackageRootResolution {
344 root: resolved.root,
345 signer_fingerprint: resolved.signer_fingerprint,
346 registry_latest_version: resolved.registry_latest_version,
347 });
348 }
349 return Err(PrayError::Unsupported(format!(
350 "source kind {} not implemented yet",
351 source.kind
352 )));
353 }
354 if declaration.git.is_some() || declaration.tarball.is_some() || declaration.oci.is_some() {
355 return Err(PrayError::Unsupported(
356 "remote sources are not implemented yet".to_string(),
357 ));
358 }
359 let slug = declaration.name.replace('/', "-");
360 Ok(PackageRootResolution {
361 root: project_root.join(slug),
362 signer_fingerprint: None,
363 registry_latest_version: None,
364 })
365}
366
367pub fn missing_local_embed_guidance(path: impl AsRef<str>) -> String {
368 let path = path.as_ref();
369 format!(
370 "Prayfile lists `local \"{path}\"` but the file does not exist. \
371 Create the file or remove the entry from Prayfile, then run `pray install`."
372 )
373}
374
375fn resolve_local_file(
376 project_root: &Path,
377 declaration: &crate::manifest::ManifestLocal,
378) -> PrayResult<ResolvedLocalFile> {
379 let path = project_root.join(&declaration.path);
380 if !path.exists() {
381 if declaration.optional {
382 return Ok(ResolvedLocalFile {
383 path,
384 manifest_path: declaration.path.clone(),
385 content: String::new(),
386 position: declaration.position.clone(),
387 optional: true,
388 });
389 }
390 return Err(PrayError::Resolution(missing_local_embed_guidance(
391 &declaration.path,
392 )));
393 }
394 Ok(ResolvedLocalFile {
395 content: read_text(&path)?,
396 path,
397 manifest_path: declaration.path.clone(),
398 position: declaration.position.clone(),
399 optional: declaration.optional,
400 })
401}
402
403fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
404 let mut prayspec_files = Vec::new();
405 for entry in fs::read_dir(root)? {
406 let entry = entry?;
407 let path = entry.path();
408 if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
409 prayspec_files.push(path);
410 }
411 }
412 match prayspec_files.len() {
413 1 => Ok(prayspec_files.remove(0)),
414 0 => Err(PrayError::Resolution(format!(
415 "no prayspec file found in {:?}",
416 root
417 ))),
418 _ => Err(PrayError::Resolution(format!(
419 "multiple prayspec files found in {:?}",
420 root
421 ))),
422 }
423}
424
425fn source_map(sources: &[ManifestSource]) -> BTreeMap<String, ManifestSource> {
426 sources
427 .iter()
428 .map(|source| (source.name.clone(), source.clone()))
429 .collect()
430}
431
432fn package_namespace(name: &str) -> Option<&str> {
433 name.split_once('/').map(|(namespace, _)| namespace)
434}
435
436fn implied_source_name(
437 declaration: &ManifestPackage,
438 sources: &BTreeMap<String, ManifestSource>,
439) -> PrayResult<Option<String>> {
440 if let Some(name) = &declaration.source {
441 return Ok(Some(name.clone()));
442 }
443 if let Some(namespace) = package_namespace(&declaration.name) {
444 if sources.contains_key(namespace) {
445 return Ok(Some(namespace.to_string()));
446 }
447 }
448 match sources.len() {
449 0 => Ok(None),
450 1 => Ok(sources.keys().next().cloned()),
451 _ => Err(PrayError::Resolution(format!(
452 "package {} requires source: when multiple sources are declared and the package namespace does not match a source",
453 declaration.name
454 ))),
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::{discover_distribution_root, project_root_from_manifest};
461 use std::fs;
462 use std::path::Path;
463
464 #[test]
465 fn project_root_from_manifest_uses_cwd_for_bare_filename() {
466 let root = project_root_from_manifest(Path::new("Prayfile"));
467 assert_eq!(root, Path::new("."));
468 }
469
470 #[test]
471 fn project_root_from_manifest_uses_parent_directory() {
472 let root = project_root_from_manifest(Path::new("examples/simple-project/Prayfile"));
473 assert_eq!(root, Path::new("examples/simple-project"));
474 }
475
476 #[test]
477 fn discover_distribution_root_finds_root_and_prayers_subdirectory() {
478 let workspace =
479 std::env::temp_dir().join(format!("pray-discover-distribution-{}", std::process::id()));
480 let _ = fs::remove_dir_all(&workspace);
481 let repo_root = workspace.join("repo");
482 let prayers_root = repo_root.join("prayers");
483 fs::create_dir_all(prayers_root.join("v1/packages")).expect("prayers distribution");
484 fs::create_dir_all(repo_root.join("v1/packages")).expect("root distribution");
485
486 assert_eq!(
487 discover_distribution_root(&repo_root),
488 Some(repo_root.clone())
489 );
490
491 fs::remove_dir_all(repo_root.join("v1")).expect("remove root distribution");
492 assert_eq!(discover_distribution_root(&repo_root), Some(prayers_root));
493 let _ = fs::remove_dir_all(&workspace);
494 }
495
496 #[test]
497 fn discover_distribution_root_returns_none_without_registry_layout() {
498 let workspace =
499 std::env::temp_dir().join(format!("pray-discover-missing-{}", std::process::id()));
500 let _ = fs::remove_dir_all(&workspace);
501 let repo_root = workspace.join("repo");
502 fs::create_dir_all(&repo_root).expect("repo root");
503 assert_eq!(discover_distribution_root(&repo_root), None);
504 let _ = fs::remove_dir_all(&workspace);
505 }
506}