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