Skip to main content

runmat_package/source/path_project/
freeze.rs

1use super::load::load_path_project;
2use super::model::{LoadedPathPackage, LoadedPathProject, LoadedSource};
3use super::FrozenProjectError;
4use crate::{
5    build_path_graph, CanonicalPackageId, ContentDigest, GraphError, HostCapability,
6    NormalizedRelativePath, PackageAlias, PackageGraph, PackageManifest, PathGraphInput,
7    PathPackageInput, RegistryId, SourceId,
8};
9use runmat_config::project::ProjectSourceFile;
10use semver::{Version, VersionReq};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13use thiserror::Error;
14
15use crate::source::{
16    catalog::{assemble_frozen_project, FrozenPackageInput, FrozenSourceInput},
17    FrozenProject,
18};
19
20#[derive(Debug, Error)]
21pub enum PathProjectError {
22    #[error("failed to read graph-declared source {path}: {reason}")]
23    ReadSource { path: PathBuf, reason: String },
24    #[error("invalid project source path {path}: {reason}")]
25    SourcePath { path: PathBuf, reason: String },
26    #[error("package graph has no instance for manifest {0}")]
27    MissingInstance(PathBuf),
28    #[error("failed to encode source revision: {0}")]
29    Revision(String),
30    #[error("{0}")]
31    Invalid(String),
32}
33
34pub async fn build_frozen_project_async(
35    manifest_path: &Path,
36    host_capabilities: BTreeSet<HostCapability>,
37) -> Result<FrozenProject, FrozenProjectError> {
38    let loaded = load_path_project(manifest_path).await?;
39    validate_path_versions(&loaded)?;
40    let (input, package_keys) = graph_input(&loaded, host_capabilities)?;
41    let graph = build_path_graph(input)?;
42    build_catalog(loaded, graph, &package_keys).map_err(Into::into)
43}
44
45fn graph_input(
46    project: &LoadedPathProject,
47    host_capabilities: BTreeSet<HostCapability>,
48) -> Result<(PathGraphInput, BTreeMap<PathBuf, String>), FrozenProjectError> {
49    let package_keys = project
50        .packages
51        .keys()
52        .map(|manifest| {
53            stable_package_key(&project.workspace_root, manifest).map(|key| (manifest.clone(), key))
54        })
55        .collect::<Result<BTreeMap<_, _>, _>>()?;
56    let mut packages = BTreeMap::new();
57    for (manifest_path, package) in &project.packages {
58        let key = package_keys[manifest_path].clone();
59        let domain = PackageManifest::try_from(&package.manifest)
60            .map_err(|error| GraphError::Invalid(error.to_string()))?;
61        let workspace_path = workspace_path(&project.workspace_root, &package.project_root)?;
62        let canonical_manifest = toml::to_string(&package.manifest).map_err(|error| {
63            GraphError::Invalid(format!(
64                "cannot encode manifest {}: {error}",
65                package.manifest_path.display()
66            ))
67        })?;
68        let dependencies = package
69            .dependencies
70            .iter()
71            .map(|(alias, target)| {
72                let alias = alias.parse::<PackageAlias>().map_err(|error| {
73                    GraphError::Invalid(format!("invalid dependency alias `{alias}`: {error}"))
74                })?;
75                let target = package_keys.get(target).cloned().ok_or_else(|| {
76                    GraphError::Invalid(format!(
77                        "dependency `{alias}` references unloaded manifest {}",
78                        target.display()
79                    ))
80                })?;
81                Ok((alias, target))
82            })
83            .collect::<Result<_, GraphError>>()?;
84        packages.insert(
85            key,
86            PathPackageInput {
87                package: canonical_package(package)?,
88                local_name: package.manifest.package.name.clone(),
89                workspace_path,
90                manifest_digest: ContentDigest::sha256(canonical_manifest),
91                tree_digest: source_tree_digest(&package.sources)?,
92                version: domain.version,
93                dependencies,
94                required_capabilities: domain.required_capabilities,
95                singleton: domain.singleton,
96            },
97        );
98    }
99    Ok((
100        PathGraphInput {
101            root: package_keys[&project.root_manifest].clone(),
102            packages,
103            host_capabilities,
104        },
105        package_keys,
106    ))
107}
108
109fn build_catalog(
110    project: LoadedPathProject,
111    graph: PackageGraph,
112    package_keys: &BTreeMap<PathBuf, String>,
113) -> Result<FrozenProject, PathProjectError> {
114    let inputs = project
115        .packages
116        .values()
117        .map(|package| {
118            package_keys
119                .get(&package.manifest_path)
120                .ok_or_else(|| PathProjectError::MissingInstance(package.manifest_path.clone()))?;
121            let expected_path = workspace_path(&project.workspace_root, &package.project_root)
122                .map_err(|error| PathProjectError::Invalid(error.to_string()))?;
123            let graph_package = graph
124                .packages
125                .values()
126                .find(|candidate| {
127                    candidate.local_name == package.manifest.package.name
128                        && matches!(
129                            &candidate.instance.source,
130                            SourceId::Path(path) if path.workspace_path == expected_path
131                        )
132                })
133                .ok_or_else(|| PathProjectError::MissingInstance(package.manifest_path.clone()))?;
134            Ok(FrozenPackageInput {
135                instance: graph_package.instance.identity_digest.clone(),
136                local_name: package.manifest.package.name.clone(),
137                source: graph_package.instance.source.clone(),
138                root: package.project_root.clone(),
139                files: package
140                    .sources
141                    .iter()
142                    .map(|source| FrozenSourceInput {
143                        descriptor: source.descriptor.clone(),
144                        bytes: source.bytes.clone(),
145                    })
146                    .collect(),
147            })
148        })
149        .collect::<Result<Vec<_>, PathProjectError>>()?;
150    assemble_frozen_project(project.root_manifest, project.workspace_root, graph, inputs)
151        .map_err(|error| PathProjectError::Invalid(error.to_string()))
152}
153
154fn stable_package_key(workspace_root: &Path, manifest: &Path) -> Result<String, GraphError> {
155    manifest
156        .strip_prefix(workspace_root)
157        .map_err(|_| {
158            GraphError::Invalid(format!(
159                "manifest {} is outside workspace {}",
160                manifest.display(),
161                workspace_root.display()
162            ))
163        })
164        .and_then(|path| {
165            NormalizedRelativePath::new(path)
166                .map(|path| path.as_str().to_string())
167                .map_err(|error| GraphError::Invalid(error.to_string()))
168        })
169}
170
171fn workspace_path(
172    workspace_root: &Path,
173    project_root: &Path,
174) -> Result<NormalizedRelativePath, GraphError> {
175    let relative = project_root.strip_prefix(workspace_root).map_err(|_| {
176        GraphError::Invalid(format!(
177            "package root {} is outside workspace {}",
178            project_root.display(),
179            workspace_root.display()
180        ))
181    })?;
182    NormalizedRelativePath::new(relative).map_err(|error| GraphError::Invalid(error.to_string()))
183}
184
185fn canonical_package(package: &LoadedPathPackage) -> Result<CanonicalPackageId, GraphError> {
186    let declaration = &package.manifest.package;
187    if let Some(organization) = declaration.organization.as_deref() {
188        return CanonicalPackageId::new(
189            declaration
190                .registry
191                .as_deref()
192                .unwrap_or("default")
193                .parse::<RegistryId>()
194                .map_err(|error| GraphError::Invalid(error.to_string()))?,
195            organization,
196            &declaration.name,
197        )
198        .map_err(|error| GraphError::Invalid(error.to_string()));
199    }
200    CanonicalPackageId::new("workspace".parse().unwrap(), "local", &declaration.name)
201        .map_err(|error| GraphError::Invalid(error.to_string()))
202}
203
204fn source_tree_digest(sources: &[LoadedSource]) -> Result<ContentDigest, GraphError> {
205    let mut input = Vec::new();
206    for source in sources {
207        append_tree_entry(&mut input, &source.descriptor, &source.bytes)?;
208    }
209    Ok(ContentDigest::sha256(input))
210}
211
212fn append_tree_entry(
213    output: &mut Vec<u8>,
214    source: &ProjectSourceFile,
215    bytes: &[u8],
216) -> Result<(), GraphError> {
217    let path = NormalizedRelativePath::new(source.source_root.join(&source.relative_path))
218        .map_err(|error| GraphError::Invalid(error.to_string()))?;
219    output.extend_from_slice(path.as_str().as_bytes());
220    output.push(0);
221    output.extend_from_slice(bytes.len().to_string().as_bytes());
222    output.push(0);
223    output.extend_from_slice(bytes);
224    output.push(0);
225    Ok(())
226}
227
228fn validate_path_versions(project: &LoadedPathProject) -> Result<(), PathProjectError> {
229    for package in project.packages.values() {
230        for (alias, target_manifest) in &package.dependencies {
231            let dependency = package.manifest.dependencies.get(alias).ok_or_else(|| {
232                PathProjectError::Invalid(format!(
233                    "dependency edge `{alias}` is absent from package `{}` manifest",
234                    package.manifest.package.name
235                ))
236            })?;
237            let Some(requirement) = dependency.version.as_deref() else {
238                continue;
239            };
240            let requirement = VersionReq::parse(requirement).map_err(|error| {
241                PathProjectError::Invalid(format!(
242                    "dependency `{alias}` of `{}` has invalid version requirement `{requirement}`: {error}",
243                    package.manifest.package.name
244                ))
245            })?;
246            let target = project.packages.get(target_manifest).ok_or_else(|| {
247                PathProjectError::Invalid(format!(
248                    "dependency `{alias}` references unloaded manifest {}",
249                    target_manifest.display()
250                ))
251            })?;
252            let version_text = target.manifest.package.version.as_deref().ok_or_else(|| {
253                PathProjectError::Invalid(format!(
254                    "path dependency `{alias}` requires {requirement}, but package `{}` has no version",
255                    target.manifest.package.name
256                ))
257            })?;
258            let version = Version::parse(version_text).map_err(|error| {
259                PathProjectError::Invalid(format!(
260                    "path dependency `{alias}` has invalid declared version `{version_text}`: {error}"
261                ))
262            })?;
263            if !requirement.matches(&version) {
264                return Err(PathProjectError::Invalid(format!(
265                    "path dependency `{alias}` of `{}` requires {requirement}, but package `{}` declares {version}",
266                    package.manifest.package.name, target.manifest.package.name
267                )));
268            }
269        }
270    }
271    Ok(())
272}