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