Skip to main content

runmat_package/source/
catalog.rs

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    /// Stable namespace for compiler and executable caches derived from this project.
102    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}