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