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
125#[path = "resolve_project.rs"]
126mod project;
127pub use project::{resolve_manifest_in_context, resolve_project_in_context};
128
129fn resolve_package(
130 project_root: &Path,
131 sources: &BTreeMap<String, ManifestSource>,
132 git_sources: &BTreeMap<String, GitSourceCheckout>,
133 user_config: &crate::config::PrayConfig,
134 declaration: &ManifestPackage,
135 lockfile: Option<&Lockfile>,
136 options: &ResolveOptions,
137) -> PrayResult<ResolvedPackage> {
138 let PackageRootResolution {
139 root,
140 signer_fingerprint,
141 registry_latest_version,
142 } = resolve_package_root(
143 project_root,
144 sources,
145 git_sources,
146 user_config,
147 declaration,
148 lockfile,
149 options,
150 )?;
151 let spec_path = find_prayspec_file(&root)?;
152 let spec_text = fs::read_to_string(&spec_path)?;
153 let spec = parse_package_spec(&spec_text)?.canonicalized();
154 if spec.name != declaration.name {
155 return Err(PrayError::Resolution(format!(
156 "package path {:?} declares {:?}, expected {:?}",
157 root, spec.name, declaration.name
158 )));
159 }
160 if !version_satisfies(&spec.version, &declaration.constraint)? {
161 return Err(PrayError::Resolution(format!(
162 "package {} version {} does not satisfy constraint {}",
163 declaration.name, spec.version, declaration.constraint
164 )));
165 }
166 let selected_exports = select_exports(declaration, &spec)?;
167 let file_bytes = load_package_file_bytes(&root, &spec)?;
168 let tree_hash = PackageSpec::tree_hash_from_file_bytes(&file_bytes)?;
169 let export_bodies = load_export_bodies(&file_bytes, &spec, &selected_exports)?;
170 let skill_files = build_skill_file_index(&spec);
171 let source_checksum = tree_hash.clone();
172 Ok(ResolvedPackage {
173 declaration: declaration.clone(),
174 root,
175 spec: spec.clone(),
176 tree_hash: tree_hash.clone(),
177 artifact_hash: tree_hash.clone(),
178 artifact: format!(
179 "path:{}",
180 spec_path.parent().unwrap_or(&spec_path).to_string_lossy()
181 ),
182 selected_exports,
183 source_checksum,
184 export_bodies,
185 skill_files,
186 signer_fingerprint,
187 registry_latest_version,
188 explicit: false,
189 })
190}
191
192#[derive(Debug, Clone)]
193struct PackageRootResolution {
194 root: PathBuf,
195 signer_fingerprint: Option<String>,
196 registry_latest_version: Option<String>,
197}
198
199fn resolve_package_root(
200 project_root: &Path,
201 sources: &BTreeMap<String, ManifestSource>,
202 git_sources: &BTreeMap<String, GitSourceCheckout>,
203 user_config: &crate::config::PrayConfig,
204 declaration: &ManifestPackage,
205 lockfile: Option<&Lockfile>,
206 options: &ResolveOptions,
207) -> PrayResult<PackageRootResolution> {
208 if let Some(local_path) = user_config.local.package.get(&declaration.name) {
209 return Ok(PackageRootResolution {
210 root: project_root.join(local_path),
211 signer_fingerprint: None,
212 registry_latest_version: None,
213 });
214 }
215 if let Some(path) = &declaration.path {
216 return Ok(PackageRootResolution {
217 root: project_root.join(path),
218 signer_fingerprint: None,
219 registry_latest_version: None,
220 });
221 }
222 let source_name = implied_source_name(declaration, sources)?;
223 if let Some(source_name) = source_name {
224 let source = sources
225 .get(&source_name)
226 .ok_or_else(|| PrayError::Resolution(format!("unknown source: {source_name}")))?;
227 let context = PackageResolutionContext::from_lockfile(lockfile, &declaration.name, options);
228 if let Some(local_path) = user_config.local.source.get(&source_name) {
229 let source_root = project_root.join(local_path);
230 let resolved = resolve_local_registry_package_root(
231 project_root,
232 &format!("local:{source_name}"),
233 &source_root,
234 declaration,
235 &context,
236 )?;
237 return Ok(PackageRootResolution {
238 root: resolved.root,
239 signer_fingerprint: resolved.signer_fingerprint,
240 registry_latest_version: resolved.registry_latest_version,
241 });
242 }
243 if source.kind == "path" {
244 let slug = declaration.name.replace('/', "-");
245 return Ok(PackageRootResolution {
246 root: project_root.join(&source.url).join(slug),
247 signer_fingerprint: None,
248 registry_latest_version: None,
249 });
250 }
251 if source.kind == "registry" || source.kind == "static index" || source.kind == "pray_ssh" {
252 let resolved =
253 resolve_registry_package_root(project_root, &source.url, declaration, &context)?;
254 return Ok(PackageRootResolution {
255 root: resolved.root,
256 signer_fingerprint: resolved.signer_fingerprint,
257 registry_latest_version: resolved.registry_latest_version,
258 });
259 }
260 if source.kind == "git" {
261 let resolved = resolve_git_package_root(
262 project_root,
263 &source_name,
264 &source.url,
265 git_sources,
266 declaration,
267 &context,
268 )?;
269 return Ok(PackageRootResolution {
270 root: resolved.root,
271 signer_fingerprint: resolved.signer_fingerprint,
272 registry_latest_version: resolved.registry_latest_version,
273 });
274 }
275 return Err(PrayError::Unsupported(format!(
276 "source kind {} not implemented yet",
277 source.kind
278 )));
279 }
280 if declaration.git.is_some() || declaration.tarball.is_some() || declaration.oci.is_some() {
281 return Err(PrayError::Unsupported(
282 "remote sources are not implemented yet".to_string(),
283 ));
284 }
285 let slug = declaration.name.replace('/', "-");
286 Ok(PackageRootResolution {
287 root: project_root.join(slug),
288 signer_fingerprint: None,
289 registry_latest_version: None,
290 })
291}
292
293pub fn missing_local_embed_guidance(path: impl AsRef<str>) -> String {
294 let path = path.as_ref();
295 format!(
296 "Prayfile lists `local \"{path}\"` but the file does not exist. \
297 Create the file or remove the entry from Prayfile, then run `pray install`."
298 )
299}
300
301fn resolve_local_file(
302 project_root: &Path,
303 declaration: &crate::manifest::ManifestLocal,
304) -> PrayResult<ResolvedLocalFile> {
305 let path = project_root.join(&declaration.path);
306 if !path.exists() {
307 if declaration.optional {
308 return Ok(ResolvedLocalFile {
309 path,
310 manifest_path: declaration.path.clone(),
311 content: String::new(),
312 position: declaration.position.clone(),
313 optional: true,
314 });
315 }
316 return Err(PrayError::Resolution(missing_local_embed_guidance(
317 &declaration.path,
318 )));
319 }
320 Ok(ResolvedLocalFile {
321 content: read_text(&path)?,
322 path,
323 manifest_path: declaration.path.clone(),
324 position: declaration.position.clone(),
325 optional: declaration.optional,
326 })
327}
328
329fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
330 let mut prayspec_files = Vec::new();
331 for entry in fs::read_dir(root)? {
332 let entry = entry?;
333 let path = entry.path();
334 if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
335 prayspec_files.push(path);
336 }
337 }
338 match prayspec_files.len() {
339 1 => Ok(prayspec_files.remove(0)),
340 0 => Err(PrayError::Resolution(format!(
341 "no prayspec file found in {:?}",
342 root
343 ))),
344 _ => Err(PrayError::Resolution(format!(
345 "multiple prayspec files found in {:?}",
346 root
347 ))),
348 }
349}
350
351fn source_map(sources: &[ManifestSource]) -> BTreeMap<String, ManifestSource> {
352 sources
353 .iter()
354 .map(|source| (source.name.clone(), source.clone()))
355 .collect()
356}
357
358fn package_namespace(name: &str) -> Option<&str> {
359 name.split_once('/').map(|(namespace, _)| namespace)
360}
361
362fn implied_source_name(
363 declaration: &ManifestPackage,
364 sources: &BTreeMap<String, ManifestSource>,
365) -> PrayResult<Option<String>> {
366 if let Some(name) = &declaration.source {
367 return Ok(Some(name.clone()));
368 }
369 if let Some(namespace) = package_namespace(&declaration.name) {
370 if sources.contains_key(namespace) {
371 return Ok(Some(namespace.to_string()));
372 }
373 }
374 match sources.len() {
375 0 => Ok(None),
376 1 => Ok(sources.keys().next().cloned()),
377 _ => Err(PrayError::Resolution(format!(
378 "package {} requires source: when multiple sources are declared and the package namespace does not match a source",
379 declaration.name
380 ))),
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::{discover_distribution_root, project_root_from_manifest};
387 use std::fs;
388 use std::path::Path;
389
390 #[test]
391 fn project_root_from_manifest_uses_cwd_for_bare_filename() {
392 let root = project_root_from_manifest(Path::new("Prayfile"));
393 assert_eq!(root, Path::new("."));
394 }
395
396 #[test]
397 fn project_root_from_manifest_uses_parent_directory() {
398 let root = project_root_from_manifest(Path::new("examples/simple-project/Prayfile"));
399 assert_eq!(root, Path::new("examples/simple-project"));
400 }
401
402 #[test]
403 fn discover_distribution_root_finds_root_and_prayers_subdirectory() {
404 let workspace =
405 std::env::temp_dir().join(format!("pray-discover-distribution-{}", std::process::id()));
406 let _ = fs::remove_dir_all(&workspace);
407 let repo_root = workspace.join("repo");
408 let prayers_root = repo_root.join("prayers");
409 fs::create_dir_all(prayers_root.join("v1/packages")).expect("prayers distribution");
410 fs::create_dir_all(repo_root.join("v1/packages")).expect("root distribution");
411
412 assert_eq!(
413 discover_distribution_root(&repo_root),
414 Some(repo_root.clone())
415 );
416
417 fs::remove_dir_all(repo_root.join("v1")).expect("remove root distribution");
418 assert_eq!(discover_distribution_root(&repo_root), Some(prayers_root));
419 let _ = fs::remove_dir_all(&workspace);
420 }
421
422 #[test]
423 fn discover_distribution_root_returns_none_without_registry_layout() {
424 let workspace =
425 std::env::temp_dir().join(format!("pray-discover-missing-{}", std::process::id()));
426 let _ = fs::remove_dir_all(&workspace);
427 let repo_root = workspace.join("repo");
428 fs::create_dir_all(&repo_root).expect("repo root");
429 assert_eq!(discover_distribution_root(&repo_root), None);
430 let _ = fs::remove_dir_all(&workspace);
431 }
432}