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