1use crate::{
2 ContentDigest, IdentityError, NormalizedRelativePath, PackageAlias, PackageGraph, SourceId,
3};
4use runmat_config::project::ProjectSourceFile;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9pub(crate) fn compute_source_revision(
10 graph_digest: &ContentDigest,
11 packages: &BTreeMap<ContentDigest, PackageSourceCatalog>,
12) -> Result<ContentDigest, serde_json::Error> {
13 #[derive(Serialize)]
14 struct Input<'a> {
15 format: &'static str,
16 graph_digest: &'a ContentDigest,
17 packages: &'a BTreeMap<ContentDigest, PackageSourceCatalog>,
18 }
19
20 serde_json::to_vec(&Input {
21 format: "runmat-source-catalog-v1",
22 graph_digest,
23 packages,
24 })
25 .map(ContentDigest::sha256)
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct StableSourceId {
31 pub package_instance: ContentDigest,
32 pub relative_path: NormalizedRelativePath,
33 pub content_digest: ContentDigest,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct FrozenSourceDescriptor {
39 pub id: StableSourceId,
40 pub qualified_name: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub package_path: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub class_name: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub class_qualified_name: Option<String>,
47 pub is_private: bool,
48}
49
50impl FrozenSourceDescriptor {
51 pub fn class_definition_qualified_name(&self) -> Option<&str> {
52 self.class_qualified_name.as_deref().or_else(|| {
53 self.package_path
54 .as_ref()
55 .map(|_| self.qualified_name.as_str())
56 })
57 }
58
59 pub fn function_qualified_name(&self) -> Option<&str> {
60 if self.is_private {
61 return None;
62 }
63 (self.package_path.is_some() || self.class_name.is_some())
64 .then_some(self.qualified_name.as_str())
65 .filter(|name| name.contains('.'))
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct PackageMount {
72 pub package_instance: ContentDigest,
73 pub source: SourceId,
74 pub logical_root: NormalizedRelativePath,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct PackageSourceCatalog {
80 pub package_instance: ContentDigest,
81 pub local_name: String,
82 pub mount: PackageMount,
83 pub sources: Vec<FrozenSourceDescriptor>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct SourceCatalog {
89 pub packages: BTreeMap<ContentDigest, PackageSourceCatalog>,
90 pub revision: ContentDigest,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ProjectRevision {
96 pub graph_digest: ContentDigest,
97 pub source_revision: ContentDigest,
98}
99
100impl ProjectRevision {
101 pub fn cache_namespace(&self) -> String {
103 format!(
104 "runmat-project-v1\0{}\0{}",
105 self.graph_digest, self.source_revision
106 )
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct FrozenProject {
113 pub manifest_path: PathBuf,
114 pub workspace_root: PathBuf,
115 pub graph: PackageGraph,
116 pub sources: SourceCatalog,
117 #[serde(with = "stable_source_path_map")]
118 pub access_paths: BTreeMap<StableSourceId, PathBuf>,
119}
120
121pub(crate) struct FrozenPackageInput {
122 pub instance: ContentDigest,
123 pub local_name: String,
124 pub source: SourceId,
125 pub root: PathBuf,
126 pub files: Vec<FrozenSourceInput>,
127}
128
129pub(crate) struct FrozenSourceInput {
130 pub descriptor: ProjectSourceFile,
131 pub bytes: Vec<u8>,
132}
133
134pub(crate) fn assemble_frozen_project(
135 manifest_path: PathBuf,
136 workspace_root: PathBuf,
137 graph: PackageGraph,
138 package_inputs: Vec<FrozenPackageInput>,
139) -> Result<FrozenProject, CatalogAssemblyError> {
140 let mut packages = BTreeMap::new();
141 let mut access_paths = BTreeMap::new();
142 for package in package_inputs {
143 if !graph.packages.contains_key(&package.instance) {
144 return Err(CatalogAssemblyError::MissingInstance(package.instance));
145 }
146 let mut sources = Vec::with_capacity(package.files.len());
147 for file in package.files {
148 let relative_path = source_relative_path(&file.descriptor)?;
149 let id = StableSourceId {
150 package_instance: package.instance.clone(),
151 relative_path,
152 content_digest: ContentDigest::sha256(&file.bytes),
153 };
154 let access_path = package
155 .root
156 .join(&file.descriptor.source_root)
157 .join(&file.descriptor.relative_path);
158 if access_paths.insert(id.clone(), access_path).is_some() {
159 return Err(CatalogAssemblyError::DuplicateSource(id));
160 }
161 sources.push(FrozenSourceDescriptor {
162 id,
163 qualified_name: file.descriptor.qualified_name,
164 package_path: file.descriptor.package_path,
165 class_name: file.descriptor.class_name,
166 class_qualified_name: file.descriptor.class_qualified_name,
167 is_private: file.descriptor.is_private,
168 });
169 }
170 sources.sort_by(|left, right| left.id.cmp(&right.id));
171 let logical_root = logical_mount_root(&package.instance)?;
172 packages.insert(
173 package.instance.clone(),
174 PackageSourceCatalog {
175 package_instance: package.instance.clone(),
176 local_name: package.local_name,
177 mount: PackageMount {
178 package_instance: package.instance.clone(),
179 source: package.source,
180 logical_root,
181 },
182 sources,
183 },
184 );
185 }
186 let revision = compute_source_revision(&graph.graph_digest, &packages)
187 .map_err(|error| CatalogAssemblyError::Revision(error.to_string()))?;
188 Ok(FrozenProject {
189 manifest_path,
190 workspace_root,
191 graph,
192 sources: SourceCatalog { packages, revision },
193 access_paths,
194 })
195}
196
197#[derive(Debug, thiserror::Error)]
198pub(crate) enum CatalogAssemblyError {
199 #[error("package graph has no instance {0}")]
200 MissingInstance(ContentDigest),
201 #[error("source catalog contains duplicate source identity {0:?}")]
202 DuplicateSource(StableSourceId),
203 #[error("invalid project source path: {0}")]
204 SourcePath(#[from] IdentityError),
205 #[error("failed to encode source revision: {0}")]
206 Revision(String),
207}
208
209fn source_relative_path(
210 source: &ProjectSourceFile,
211) -> Result<NormalizedRelativePath, IdentityError> {
212 NormalizedRelativePath::new(source.source_root.join(&source.relative_path))
213}
214
215fn logical_mount_root(identity: &ContentDigest) -> Result<NormalizedRelativePath, IdentityError> {
216 NormalizedRelativePath::new(format!(
217 "packages/{}",
218 identity.to_string().replace(':', "_")
219 ))
220}
221
222mod stable_source_path_map {
223 use super::StableSourceId;
224 use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
225 use std::collections::BTreeMap;
226 use std::path::PathBuf;
227
228 pub fn serialize<S>(
229 paths: &BTreeMap<StableSourceId, PathBuf>,
230 serializer: S,
231 ) -> Result<S::Ok, S::Error>
232 where
233 S: Serializer,
234 {
235 paths.iter().collect::<Vec<_>>().serialize(serializer)
236 }
237
238 pub fn deserialize<'de, D>(
239 deserializer: D,
240 ) -> Result<BTreeMap<StableSourceId, PathBuf>, D::Error>
241 where
242 D: Deserializer<'de>,
243 {
244 let entries = Vec::<(StableSourceId, PathBuf)>::deserialize(deserializer)?;
245 let expected_len = entries.len();
246 let paths = entries.into_iter().collect::<BTreeMap<_, _>>();
247 if paths.len() != expected_len {
248 return Err(D::Error::custom(
249 "frozen-project access paths contain duplicate source identities",
250 ));
251 }
252 Ok(paths)
253 }
254}
255
256#[derive(Debug, Clone, Copy)]
257pub struct VisibleProjectSource<'a> {
258 pub package: &'a PackageSourceCatalog,
259 pub source: &'a FrozenSourceDescriptor,
260 pub access_path: &'a Path,
261 pub dependency_alias: Option<&'a PackageAlias>,
262 pub directly_visible: bool,
263}
264
265impl FrozenProject {
266 pub fn source_revision(&self) -> &ContentDigest {
267 &self.sources.revision
268 }
269
270 pub fn graph_digest(&self) -> &ContentDigest {
271 &self.graph.graph_digest
272 }
273
274 pub fn revision(&self) -> ProjectRevision {
275 ProjectRevision {
276 graph_digest: self.graph.graph_digest.clone(),
277 source_revision: self.sources.revision.clone(),
278 }
279 }
280
281 pub fn all_sources(&self) -> impl Iterator<Item = (&FrozenSourceDescriptor, &PathBuf)> {
282 self.sources
283 .packages
284 .values()
285 .flat_map(|package| package.sources.iter())
286 .filter_map(|source| self.access_paths.get(&source.id).map(|path| (source, path)))
287 }
288
289 pub fn requester_instance(&self, requester_path: &Path) -> &ContentDigest {
290 self.all_sources()
291 .find(|(_, path)| paths_equivalent(path, requester_path))
292 .map(|(source, _)| &source.id.package_instance)
293 .unwrap_or(&self.graph.root)
294 }
295
296 pub fn visible_sources(&self, requester_path: &Path) -> Vec<VisibleProjectSource<'_>> {
297 let requester = self.requester_instance(requester_path);
298 let mut visible = Vec::new();
299 if let Some(package) = self.sources.packages.get(requester) {
300 for source in &package.sources {
301 let Some(access_path) = self.access_paths.get(&source.id) else {
302 continue;
303 };
304 visible.push(VisibleProjectSource {
305 package,
306 source,
307 access_path,
308 dependency_alias: None,
309 directly_visible: !source.is_private
310 || private_source_visible(requester_path, access_path),
311 });
312 }
313 }
314 for edge in self
315 .graph
316 .edges
317 .iter()
318 .filter(|edge| &edge.from == requester)
319 {
320 let Some(package) = self.sources.packages.get(&edge.to) else {
321 continue;
322 };
323 for source in package.sources.iter().filter(|source| !source.is_private) {
324 if let Some(access_path) = self.access_paths.get(&source.id) {
325 visible.push(VisibleProjectSource {
326 package,
327 source,
328 access_path,
329 dependency_alias: Some(&edge.alias),
330 directly_visible: true,
331 });
332 }
333 }
334 }
335 visible
336 }
337}
338
339fn private_source_visible(requester_path: &Path, private_source: &Path) -> bool {
340 private_source
341 .parent()
342 .and_then(Path::parent)
343 .zip(requester_path.parent())
344 .is_some_and(|(owner, requester)| paths_equivalent(owner, requester))
345}
346
347fn paths_equivalent(left: &Path, right: &Path) -> bool {
348 if left == right {
349 return true;
350 }
351 #[cfg(target_arch = "wasm32")]
352 {
353 false
354 }
355 #[cfg(not(target_arch = "wasm32"))]
356 match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
357 (Ok(left), Ok(right)) => left == right,
358 _ => false,
359 }
360}