Skip to main content

semifold_resolver/
adapter.rs

1use camino::{Utf8Path, Utf8PathBuf};
2use semifold_core::{
3    DependencyKind, EcosystemId, FileEdit, PackageId, PackageSnapshot, VersionMap, VersionSource,
4};
5use semver::Version;
6use std::path::PathBuf;
7
8use crate::error::ResolveError;
9
10/// A configured package location that an ecosystem adapter can inspect.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct PackageLocation {
13    pub id: PackageId,
14    pub project_root: Utf8PathBuf,
15    pub path: Utf8PathBuf,
16}
17
18/// A dependency declaration whose manifest name has not yet been bound to a stable package id.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct ManifestDependency {
21    pub manifest_name: String,
22    pub kind: DependencyKind,
23    pub requirement: Option<String>,
24}
25
26/// Manifest data used internally while an adapter discovers or inspects a package.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub(crate) struct ParsedPackage {
29    pub name: String,
30    pub version: Version,
31    pub version_source: VersionSource,
32    pub path: PathBuf,
33    pub private: bool,
34}
35
36/// Immutable package data parsed by an adapter before workspace dependency binding.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct PackageInspection {
39    pub id: PackageId,
40    pub manifest_name: String,
41    pub version: Version,
42    pub version_source: VersionSource,
43    pub ecosystem: EcosystemId,
44    pub path: Utf8PathBuf,
45    pub publishable: bool,
46    pub dependencies: Vec<ManifestDependency>,
47}
48
49/// Immutable, complete input for one ecosystem's edit planning pass.
50#[derive(Clone, Copy, Debug)]
51pub struct EcosystemPlanInput<'input> {
52    pub project_root: &'input Utf8Path,
53    pub workspace_packages: &'input [PackageSnapshot],
54    pub released_packages: &'input [PackageId],
55    pub versions: &'input VersionMap,
56}
57
58/// Side-effect-free package discovery, inspection, and file-edit planning.
59pub trait EcosystemAdapter: Send + Sync {
60    fn ecosystem(&self) -> EcosystemId;
61
62    /// Validates a planned domain version and encodes it for this ecosystem's manifests.
63    fn encode_version(&self, version: &Version) -> Result<String, AdapterError>;
64
65    fn discover(&self, root: &Utf8Path) -> Result<Vec<PackageInspection>, AdapterError>;
66
67    fn inspect(&self, package: &PackageLocation) -> Result<PackageInspection, AdapterError>;
68
69    fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError>;
70}
71
72/// Failures produced at an ecosystem adapter boundary.
73#[derive(Debug, thiserror::Error)]
74pub enum AdapterError {
75    #[error(transparent)]
76    Manifest(#[from] ResolveError),
77    #[error(transparent)]
78    Plugin(#[from] crate::plugin::adapter::PluginAdapterError),
79    #[error("invalid adapter input: {reason}")]
80    InvalidInput { reason: String },
81    #[error(
82        "{ecosystem_name} cannot encode version {version}: {reason}",
83        ecosystem_name = .ecosystem.display_name()
84    )]
85    InvalidVersion {
86        ecosystem: EcosystemId,
87        version: Version,
88        reason: String,
89    },
90}
91
92#[cfg(test)]
93mod tests {
94    use std::collections::BTreeMap;
95
96    use semver::Version;
97
98    use super::*;
99
100    struct ContractAdapter;
101
102    impl EcosystemAdapter for ContractAdapter {
103        fn ecosystem(&self) -> EcosystemId {
104            EcosystemId::NODE
105        }
106
107        fn encode_version(&self, version: &Version) -> Result<String, AdapterError> {
108            Ok(version.to_string())
109        }
110
111        fn discover(&self, _root: &Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
112            Ok(Vec::new())
113        }
114
115        fn inspect(&self, package: &PackageLocation) -> Result<PackageInspection, AdapterError> {
116            Ok(PackageInspection {
117                id: package.id.clone(),
118                manifest_name: "example".to_string(),
119                version: Version::new(1, 0, 0),
120                version_source: VersionSource::PackageManifest,
121                ecosystem: self.ecosystem(),
122                path: package.path.clone(),
123                publishable: true,
124                dependencies: Vec::new(),
125            })
126        }
127
128        fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
129            if input
130                .released_packages
131                .iter()
132                .all(|package| input.versions.contains_key(package))
133            {
134                Ok(Vec::new())
135            } else {
136                Err(AdapterError::InvalidInput {
137                    reason: "released package is missing from VersionMap".to_string(),
138                })
139            }
140        }
141    }
142
143    #[test]
144    fn adapter_contract_is_object_safe_and_receives_complete_plan_input() {
145        let adapter: Box<dyn EcosystemAdapter> = Box::new(ContractAdapter);
146        let location = PackageLocation {
147            id: PackageId::new("configured-id"),
148            project_root: "/project".into(),
149            path: "packages/example".into(),
150        };
151        let inspection = adapter.inspect(&location).unwrap();
152        let snapshot = PackageSnapshot {
153            id: inspection.id.clone(),
154            manifest_name: inspection.manifest_name.clone(),
155            version: inspection.version.clone(),
156            version_source: inspection.version_source.clone(),
157            ecosystem: inspection.ecosystem,
158            path: inspection.path.clone(),
159            publishable: inspection.publishable,
160            dependencies: Vec::new(),
161        };
162        let versions = BTreeMap::from([(PackageId::new("configured-id"), Version::new(1, 0, 1))]);
163
164        assert_eq!(inspection.id, PackageId::new("configured-id"));
165        assert_eq!(inspection.path, "packages/example");
166        assert!(
167            adapter
168                .plan_edits(EcosystemPlanInput {
169                    project_root: &location.project_root,
170                    workspace_packages: std::slice::from_ref(&snapshot),
171                    released_packages: std::slice::from_ref(&snapshot.id),
172                    versions: &versions,
173                })
174                .unwrap()
175                .is_empty()
176        );
177    }
178}