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