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