use super::approval::ProviderApproval;
use super::discovery::ProviderDiscovery;
use super::manifest::{ManagedProject, ProviderSelection, ProviderSource};
use super::metadata::ProviderMetadata;
use crate::error::CliError;
use crate::progress::Progress;
use crate::project::ResolvedProject;
use camino::Utf8Path;
use hexpm::version::Version as GleamVersion;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) trait ProviderSelectionReconciler {
fn reconcile(
&mut self,
project_root: &Utf8Path,
project: &ResolvedProject,
program: &geam_core::TypedProgram,
managed: &mut ManagedProject,
progress: &mut Progress<'_>,
) -> Result<(), CliError>;
}
pub(super) trait ApprovedProviderResolver {
fn resolve(
&self,
project_root: &Utf8Path,
selection: &ProviderSelection,
progress: &mut Progress<'_>,
) -> Result<ProviderMetadata, CliError>;
}
pub(super) struct SystemApprovedProviderResolver;
impl ApprovedProviderResolver for SystemApprovedProviderResolver {
fn resolve(
&self,
project_root: &Utf8Path,
selection: &ProviderSelection,
progress: &mut Progress<'_>,
) -> Result<ProviderMetadata, CliError> {
super::resolution::resolve_selection(project_root, selection, progress)
}
}
pub(super) struct ProviderReconciler<'a> {
resolver: &'a dyn ApprovedProviderResolver,
discovery: &'a dyn ProviderDiscovery,
approval: &'a mut dyn ProviderApproval,
}
impl<'a> ProviderReconciler<'a> {
pub(super) fn new(
resolver: &'a dyn ApprovedProviderResolver,
discovery: &'a dyn ProviderDiscovery,
approval: &'a mut dyn ProviderApproval,
) -> Self {
Self {
resolver,
discovery,
approval,
}
}
fn reconcile_packages(
&mut self,
project_root: &Utf8Path,
project: &ResolvedProject,
required_packages: BTreeSet<String>,
managed: &mut ManagedProject,
progress: &mut Progress<'_>,
) -> Result<(), CliError> {
let mut compatible = BTreeSet::new();
let mut pending = BTreeMap::new();
for (package, version) in project.packages() {
let Some(selection) = managed.provider(package).cloned() else {
continue;
};
if super::is_built_in_package(package) {
return Err(CliError::BuiltInProviderPackage {
package: package.to_owned(),
});
}
progress.report(format_args!(
"Resolving provider {} for {package} {version}",
selection.crate_name()
))?;
let metadata = self.resolver.resolve(project_root, &selection, progress)?;
validate_selected_metadata(&selection, &metadata)?;
if metadata.supports(version) {
compatible.insert(package.to_owned());
} else {
pending.insert(
package.to_owned(),
PendingProvider {
package: package.to_owned(),
version: version.clone(),
replacing: Some(selection.crate_name().to_owned()),
},
);
}
}
for (package, version) in project.packages() {
if !required_packages.contains(package)
|| super::is_built_in_package(package)
|| compatible.contains(package)
|| pending.contains_key(package)
{
continue;
}
pending.insert(
package.to_owned(),
PendingProvider {
package: package.to_owned(),
version: version.clone(),
replacing: None,
},
);
}
let mut discovered = Vec::new();
for pending in pending.into_values() {
progress.report(format_args!(
"Discovering native providers for {} {}",
pending.package, pending.version
))?;
let candidates = self
.discovery
.discover(&pending.package, &pending.version)?;
if candidates.is_empty() {
return Err(CliError::ProviderCandidatesUnavailable {
package: pending.package,
version: pending.version.to_string(),
details: "registry returned no candidates".to_owned(),
});
}
discovered.push((pending, candidates));
}
let mut approved = Vec::new();
for (pending, candidates) in discovered {
let candidate = self.approval.approve(
&pending.package,
&pending.version,
pending.replacing.as_deref(),
&candidates,
)?;
approved.push(ProviderSelection::new(
pending.package,
candidate.crate_name().to_owned(),
ProviderSource::Registry {
version: candidate.version().clone(),
},
));
}
for selection in approved {
managed.replace(selection);
}
Ok(())
}
}
impl ProviderSelectionReconciler for ProviderReconciler<'_> {
fn reconcile(
&mut self,
project_root: &Utf8Path,
project: &ResolvedProject,
program: &geam_core::TypedProgram,
managed: &mut ManagedProject,
progress: &mut Progress<'_>,
) -> Result<(), CliError> {
let required_packages = geam_core::required_host_functions(program)
.into_iter()
.map(|requirement| requirement.package().to_string())
.collect();
self.reconcile_packages(project_root, project, required_packages, managed, progress)
}
}
struct PendingProvider {
package: String,
version: GleamVersion,
replacing: Option<String>,
}
fn validate_selected_metadata(
selection: &ProviderSelection,
metadata: &ProviderMetadata,
) -> Result<(), CliError> {
if metadata.crate_name() != selection.crate_name() {
return Err(CliError::InvalidProviderMetadata {
package: selection.crate_name().to_owned(),
reason: format!(
"Cargo resolved package {}, expected {}",
metadata.crate_name(),
selection.crate_name(),
),
});
}
if metadata.gleam_package() != selection.gleam_package() {
return Err(CliError::InvalidProviderMetadata {
package: selection.crate_name().to_owned(),
reason: format!(
"provider targets Gleam package {}, expected {}",
metadata.gleam_package(),
selection.gleam_package(),
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
ApprovedProviderResolver, ProviderDiscovery, ProviderReconciler,
SystemApprovedProviderResolver,
};
use crate::error::CliError;
use crate::progress::Progress;
use crate::project::read_resolved_project;
use crate::provider::approval::ProviderApproval;
use crate::provider::manifest::{ManagedProject, ProviderSelection, ProviderSource};
use crate::provider::metadata::ProviderMetadata;
use crate::provider::registry::ProviderCandidate;
use camino::{Utf8Path, Utf8PathBuf};
use hexpm::version::Version as GleamVersion;
use semver::Version;
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fs;
use std::process::Command;
use tempfile::{TempDir, tempdir};
#[test]
fn retains_compatible_approved_providers_and_discovers_only_missing_requirements() {
let project = resolved_project(&[
("fallback", "1.0.0"),
("images", "1.5.0"),
("search", "2.0.0"),
]);
let root = utf8_path(&project);
let resolved = read_resolved_project(&root).expect("project should resolve");
let mut managed =
ManagedProject::load(&root, "application").expect("managed project should initialize");
managed.replace(selection("fallback", "geam-fallback", "4.0.0"));
managed.replace(selection("images", "geam-images", "1.0.0"));
let resolver = FixedResolver::new([
metadata("geam-fallback", "fallback", ">= 1.0.0 and < 2.0.0"),
metadata("geam-images", "images", ">= 1.0.0 and < 2.0.0"),
]);
let discovery = FixedDiscovery::new([(
"search",
vec![
candidate("geam-search", "3.0.0", "search", ">= 2.0.0"),
candidate("geam-search-alt", "9.0.0", "search", ">= 2.0.0"),
],
)]);
let mut approval = FixedApproval::new([Decision::Select("geam-search-alt")]);
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
let mut output = Vec::new();
reconciler
.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned(), "search".to_owned()]),
&mut managed,
&mut Progress::Visible(&mut output),
)
.expect("missing provider should be approved");
assert_eq!(
output,
concat!(
"geam: Resolving provider geam-fallback for fallback 1.0.0\n",
"geam: Resolving provider geam-images for images 1.5.0\n",
"geam: Discovering native providers for search 2.0.0\n",
)
.as_bytes()
);
assert_eq!(
resolver.calls.borrow().as_slice(),
["geam-fallback", "geam-images"],
);
assert_eq!(discovery.calls.borrow().as_slice(), ["search@2.0.0"]);
assert_eq!(approval.calls.borrow().as_slice(), ["search:new"]);
assert_eq!(
managed
.provider("images")
.expect("compatible selection should remain")
.source(),
&ProviderSource::Registry {
version: "1.0.0".parse().expect("version should parse"),
},
);
let search = managed
.provider("search")
.expect("approved candidate should be selected");
assert_eq!(search.crate_name(), "geam-search-alt");
assert_eq!(
search.source(),
&ProviderSource::Registry {
version: "9.0.0".parse().expect("version should parse"),
},
);
assert!(managed.has_provider("fallback"));
}
#[test]
fn failed_progress_stops_resolution_or_discovery_before_approval_and_mutation() {
let project = resolved_project(&[("images", "1.0.0")]);
let root = utf8_path(&project);
let resolved = read_resolved_project(&root).expect("project should resolve");
for selected in [false, true] {
let mut managed = ManagedProject::load(&root, "application")
.expect("managed project should initialize");
if selected {
managed.replace(selection("images", "geam-images", "1.0.0"));
}
let original = managed.provider("images").cloned();
let resolver = FixedResolver::new([]);
let discovery = FixedDiscovery::new([]);
let mut approval = FixedApproval::new([]);
let mut output = fs::File::open(root.join("gleam.toml")).expect("read-only output");
let error = ProviderReconciler::new(&resolver, &discovery, &mut approval)
.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned()]),
&mut managed,
&mut Progress::Visible(&mut output),
)
.expect_err("progress failure should stop before provider work");
assert_eq!(error.to_string(), "failed to write preparation progress");
assert!(resolver.calls.borrow().is_empty());
assert!(discovery.calls.borrow().is_empty());
assert!(approval.calls.borrow().is_empty());
assert_eq!(managed.provider("images"), original.as_ref());
}
}
#[test]
fn replaces_incompatible_providers_only_after_every_approval_succeeds() {
let project = resolved_project(&[("images", "2.0.0"), ("search", "1.0.0")]);
let root = utf8_path(&project);
let resolved = read_resolved_project(&root).expect("project should resolve");
let resolver = FixedResolver::new([metadata(
"geam-images-old",
"images",
">= 1.0.0 and < 2.0.0",
)]);
let discovery = FixedDiscovery::new([
(
"images",
vec![candidate("geam-images", "2.1.0", "images", ">= 2.0.0")],
),
(
"search",
vec![candidate("geam-search", "1.1.0", "search", ">= 1.0.0")],
),
]);
let mut managed =
ManagedProject::load(&root, "application").expect("managed project should initialize");
managed.replace(selection("images", "geam-images-old", "1.0.0"));
let original = managed
.provider("images")
.expect("original selection should exist")
.clone();
let mut approval =
FixedApproval::new([Decision::Select("geam-images"), Decision::Cancel("search")]);
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
assert!(matches!(
reconciler.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned(), "search".to_owned()]),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::ProviderApprovalCancelled { ref package }) if package == "search"
));
assert_eq!(managed.provider("images"), Some(&original));
assert!(!managed.has_provider("search"));
assert_eq!(
approval.calls.borrow().as_slice(),
["images:replace:geam-images-old", "search:new"],
);
let mut approval = FixedApproval::new([
Decision::Select("geam-images"),
Decision::Select("geam-search"),
]);
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
reconciler
.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned(), "search".to_owned()]),
&mut managed,
&mut Progress::Hidden,
)
.expect("all approved replacements should commit together");
assert_eq!(
managed
.provider("images")
.expect("replacement should exist")
.crate_name(),
"geam-images",
);
assert!(managed.has_provider("search"));
}
#[test]
fn rejects_selected_builtins() {
let resolver = FixedResolver::new([]);
let discovery = FixedDiscovery::new([]);
let mut approval = FixedApproval::new([]);
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
let builtin = resolved_project(&[("gleam_json", "3.1.0")]);
let builtin_root = utf8_path(&builtin);
let builtin_resolved =
read_resolved_project(&builtin_root).expect("built-in project should resolve");
let mut managed = ManagedProject::load(&builtin_root, "application")
.expect("managed project should initialize");
managed.replace(selection("gleam_json", "geam-json-other", "1.0.0"));
assert!(matches!(
reconciler.reconcile_packages(
&builtin_root,
&builtin_resolved,
BTreeSet::new(),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::BuiltInProviderPackage { ref package }) if package == "gleam_json"
));
}
#[test]
fn rejects_mismatched_selected_metadata_and_empty_discovery() {
let project = resolved_project(&[("images", "1.0.0")]);
let root = utf8_path(&project);
let resolved = read_resolved_project(&root).expect("project should resolve");
for (metadata, expected_reason) in [
(
metadata("different-crate", "images", ">= 1.0.0"),
"Cargo resolved package different-crate, expected geam-images",
),
(
metadata("geam-images", "search", ">= 1.0.0"),
"provider targets Gleam package search, expected images",
),
] {
let resolver = FixedResolver::for_selections([("geam-images", metadata)]);
let discovery = FixedDiscovery::new([]);
let mut approval = FixedApproval::new([]);
let mut managed = ManagedProject::load(&root, "application")
.expect("managed project should initialize");
managed.replace(selection("images", "geam-images", "1.0.0"));
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
assert!(matches!(
reconciler.reconcile_packages(
&root,
&resolved,
BTreeSet::new(),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::InvalidProviderMetadata { package, reason })
if package == "geam-images" && reason == expected_reason
));
}
let resolver = FixedResolver::new([]);
let discovery = FixedDiscovery::new([("images", Vec::new())]);
let mut approval = FixedApproval::new([]);
let mut managed =
ManagedProject::load(&root, "application").expect("managed project should initialize");
let mut reconciler = ProviderReconciler::new(&resolver, &discovery, &mut approval);
assert!(matches!(
reconciler.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned()]),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::ProviderCandidatesUnavailable {
package,
version,
details,
}) if package == "images"
&& version == "1.0.0"
&& details == "registry returned no candidates"
));
}
#[test]
fn preserves_resolution_and_discovery_failures_without_changing_selections() {
let project = resolved_project(&[("images", "1.0.0")]);
let root = utf8_path(&project);
let resolved = read_resolved_project(&root).expect("project should resolve");
let mut managed =
ManagedProject::load(&root, "application").expect("managed project should initialize");
managed.replace(selection("images", "geam-images", "1.0.0"));
let original = managed
.provider("images")
.expect("original selection should exist")
.clone();
let discovery = FixedDiscovery::new([]);
let mut approval = FixedApproval::new([]);
let mut reconciler = ProviderReconciler::new(&FailingResolver, &discovery, &mut approval);
assert!(matches!(
reconciler.reconcile_packages(
&root,
&resolved,
BTreeSet::new(),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::InvalidProviderMetadata { package, reason })
if package == "geam-images" && reason == "fixture resolution failure"
));
assert_eq!(managed.provider("images"), Some(&original));
managed
.remove("images")
.expect("fixture selection should be removable");
let resolver = FixedResolver::new([]);
let mut reconciler = ProviderReconciler::new(&resolver, &FailingDiscovery, &mut approval);
assert!(matches!(
reconciler.reconcile_packages(
&root,
&resolved,
BTreeSet::from(["images".to_owned()]),
&mut managed,
&mut Progress::Hidden,
),
Err(CliError::ProviderRegistryAccess { package, reason })
if package == "images" && reason == "fixture discovery failure"
));
assert!(!managed.has_provider("images"));
}
#[test]
fn connects_the_system_metadata_adapter() {
let provider = provider_package("geam-images", "images", ">= 1.0.0 and < 2.0.0");
let project = tempdir().expect("temporary project should be created");
let root = utf8_path(&project);
let provider_path = utf8_path(&provider);
fs::create_dir(root.join("src")).expect("runner source directory should be created");
fs::write(root.join("src/main.rs"), "fn main() {}\n")
.expect("runner source should be written");
fs::write(
root.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture-runner\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[dependencies]\ngeam_provider_images = {{ package = \"geam-images\", path = {:?} }}\n\n[workspace]\nresolver = \"3\"\n",
provider_path.as_str(),
),
)
.expect("runner manifest should be written");
assert!(
Command::new("cargo")
.arg("generate-lockfile")
.current_dir(&root)
.status()
.expect("Cargo should start")
.success(),
);
let selection = ProviderSelection::new(
"images".to_owned(),
"geam-images".to_owned(),
ProviderSource::Path {
path: provider_path,
},
);
let metadata = SystemApprovedProviderResolver
.resolve(&root, &selection, &mut Progress::Hidden)
.expect("path provider should resolve through Cargo metadata");
assert_eq!(metadata.gleam_package(), "images");
}
struct FixedResolver {
metadata: BTreeMap<String, ProviderMetadata>,
calls: RefCell<Vec<String>>,
}
impl FixedResolver {
fn new<const N: usize>(metadata: [ProviderMetadata; N]) -> Self {
Self {
metadata: metadata
.into_iter()
.map(|metadata| (metadata.crate_name().to_owned(), metadata))
.collect(),
calls: RefCell::new(Vec::new()),
}
}
fn for_selections<const N: usize>(metadata: [(&str, ProviderMetadata); N]) -> Self {
Self {
metadata: metadata
.into_iter()
.map(|(selection, metadata)| (selection.to_owned(), metadata))
.collect(),
calls: RefCell::new(Vec::new()),
}
}
}
impl ApprovedProviderResolver for FixedResolver {
fn resolve(
&self,
_project_root: &Utf8Path,
selection: &ProviderSelection,
_progress: &mut Progress<'_>,
) -> Result<ProviderMetadata, CliError> {
self.calls
.borrow_mut()
.push(selection.crate_name().to_owned());
Ok(self
.metadata
.get(selection.crate_name())
.expect("fixture metadata should exist")
.clone())
}
}
struct FailingResolver;
impl ApprovedProviderResolver for FailingResolver {
fn resolve(
&self,
_project_root: &Utf8Path,
selection: &ProviderSelection,
_progress: &mut Progress<'_>,
) -> Result<ProviderMetadata, CliError> {
Err(CliError::InvalidProviderMetadata {
package: selection.crate_name().to_owned(),
reason: "fixture resolution failure".to_owned(),
})
}
}
struct FixedDiscovery {
candidates: BTreeMap<String, Vec<ProviderCandidate>>,
calls: RefCell<Vec<String>>,
}
impl FixedDiscovery {
fn new<const N: usize>(candidates: [(&str, Vec<ProviderCandidate>); N]) -> Self {
Self {
candidates: candidates
.into_iter()
.map(|(package, candidates)| (package.to_owned(), candidates))
.collect(),
calls: RefCell::new(Vec::new()),
}
}
}
impl ProviderDiscovery for FixedDiscovery {
fn discover(
&self,
package: &str,
version: &GleamVersion,
) -> Result<Vec<ProviderCandidate>, CliError> {
self.calls.borrow_mut().push(format!("{package}@{version}"));
Ok(self
.candidates
.get(package)
.expect("fixture candidates should exist")
.clone())
}
}
struct FailingDiscovery;
impl ProviderDiscovery for FailingDiscovery {
fn discover(
&self,
package: &str,
_version: &GleamVersion,
) -> Result<Vec<ProviderCandidate>, CliError> {
Err(CliError::ProviderRegistryAccess {
package: package.to_owned(),
reason: "fixture discovery failure".to_owned(),
})
}
}
enum Decision {
Select(&'static str),
Cancel(&'static str),
}
struct FixedApproval {
decisions: VecDeque<Decision>,
calls: RefCell<Vec<String>>,
}
impl FixedApproval {
fn new<const N: usize>(decisions: [Decision; N]) -> Self {
Self {
decisions: decisions.into(),
calls: RefCell::new(Vec::new()),
}
}
}
impl ProviderApproval for FixedApproval {
fn approve(
&mut self,
package: &str,
_gleam_version: &GleamVersion,
replacing: Option<&str>,
candidates: &[ProviderCandidate],
) -> Result<ProviderCandidate, CliError> {
self.calls.borrow_mut().push(match replacing {
Some(provider) => format!("{package}:replace:{provider}"),
None => format!("{package}:new"),
});
match self
.decisions
.pop_front()
.expect("fixture decision should exist")
{
Decision::Select(crate_name) => Ok(candidates
.iter()
.find(|candidate| candidate.crate_name() == crate_name)
.expect("selected fixture candidate should exist")
.clone()),
Decision::Cancel(expected_package) => {
assert_eq!(package, expected_package);
Err(CliError::ProviderApprovalCancelled {
package: package.to_owned(),
})
}
}
}
}
fn selection(package: &str, crate_name: &str, version: &str) -> ProviderSelection {
ProviderSelection::new(
package.to_owned(),
crate_name.to_owned(),
ProviderSource::Registry {
version: version.parse().expect("version should parse"),
},
)
}
fn candidate(crate_name: &str, version: &str, package: &str, range: &str) -> ProviderCandidate {
ProviderCandidate::new(
version.parse::<Version>().expect("version should parse"),
metadata(crate_name, package, range),
)
}
fn metadata(crate_name: &str, package: &str, range: &str) -> ProviderMetadata {
let source = format!(
"name = \"{crate_name}\"\nversion = \"1.0.0\"\n\n[metadata.geam.provider]\nschema = 1\ngleam-package = \"{package}\"\ngleam-version = \"{range}\"\n"
);
ProviderMetadata::from_manifest(
crate_name,
&source
.parse::<toml::Table>()
.expect("metadata should parse"),
)
.expect("metadata should be valid")
}
fn resolved_project(packages: &[(&str, &str)]) -> TempDir {
let project = tempdir().expect("temporary project should be created");
fs::write(
project.path().join("gleam.toml"),
"name = \"application\"\nversion = \"1.0.0\"\n",
)
.expect("Gleam config should be written");
let packages = packages
.iter()
.map(|(name, version)| {
format!(
" {{ name = \"{name}\", version = \"{version}\", build_tools = [\"gleam\"], requirements = [], source = \"hex\", outer_checksum = \"00\" }},\n"
)
})
.collect::<String>();
fs::write(
project.path().join("manifest.toml"),
format!("packages = [\n{packages}]\n\n[requirements]\n"),
)
.expect("Gleam manifest should be written");
project
}
fn provider_package(crate_name: &str, package: &str, range: &str) -> TempDir {
let provider = tempdir().expect("provider directory should be created");
fs::create_dir(provider.path().join("src"))
.expect("provider source directory should be created");
fs::write(
provider.path().join("Cargo.toml"),
format!(
"[package]\nname = \"{crate_name}\"\nversion = \"1.0.0\"\nedition = \"2024\"\n\n[package.metadata.geam.provider]\nschema = 1\ngleam-package = \"{package}\"\ngleam-version = \"{range}\"\n"
),
)
.expect("provider manifest should be written");
fs::write(
provider.path().join("src/lib.rs"),
"pub struct Component;\n",
)
.expect("provider source should be written");
provider
}
fn utf8_path(directory: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
.expect("temporary path should be valid UTF-8")
}
}