Skip to main content

pray_core/
resolve.rs

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    /// Highest non-yanked version in registry metadata when the package came from a registry source.
52    pub registry_latest_version: Option<String>,
53    /// True when the package was declared in Prayfile; false for transitive dependencies.
54    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}