runmat_package/source/path_project/
freeze.rs1use 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}