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