use crate::error::CliError;
use crate::project::{DependencyDownloader, into_utf8_path, read_toml};
use camino::Utf8Path;
use gleam_core::manifest::{Manifest, ManifestPackage, ManifestPackageSource};
use std::fs;
use toml_edit::{Array, DocumentMut, InlineTable, Table, value};
pub(super) fn acquire(
project_root: &Utf8Path,
packages: &[ManifestPackage],
downloader: &dyn DependencyDownloader,
) -> Result<tempfile::TempDir, CliError> {
let directory = project_root.join("build");
let workspace = fs::create_dir_all(&directory)
.and_then(|()| {
tempfile::Builder::new()
.prefix("geam-download-")
.tempdir_in(&directory)
})
.map_err(CliError::TemporaryDependencyWorkspace)?;
into_utf8_path(workspace.path().to_path_buf()).and_then(|root| {
let (config, manifest) = documents(packages);
let path = root.join("manifest.toml");
write_documents(&root, config, manifest)
.and_then(|()| read_toml::<Manifest>("Gleam download manifest", &path))
.and_then(|expected| {
downloader.download(&root, &mut crate::progress::Progress::Hidden)?;
let mut actual = read_toml::<Manifest>("Gleam download manifest", &path)?;
actual.packages.sort();
let mut expected_packages = expected.packages;
expected_packages.sort();
if actual.requirements != expected.requirements
|| actual.packages != expected_packages
{
return Err(CliError::GleamDownloadChangedLock {
path: project_root.join("manifest.toml"),
});
}
Ok(workspace)
})
})
}
fn write_documents(
root: &Utf8Path,
config: DocumentMut,
manifest: DocumentMut,
) -> Result<(), CliError> {
for (name, document) in [("gleam.toml", config), ("manifest.toml", manifest)] {
let path = root.join(name);
fs::write(&path, document.to_string())
.map_err(|error| CliError::FileWrite { path, error })?;
}
Ok(())
}
fn documents(packages: &[ManifestPackage]) -> (DocumentMut, DocumentMut) {
let mut config = DocumentMut::new();
config["name"] = value("geam_locked_download");
config["version"] = value("1.0.0");
let mut requirements = Table::new();
let mut entries = Array::new();
for package in packages {
let mut entry = InlineTable::new();
let mut requirement = InlineTable::new();
match &package.source {
ManifestPackageSource::Hex { outer_checksum } => {
entry.insert("source", "hex".into());
entry.insert(
"outer_checksum",
outer_checksum.base_16_encoded_string().into(),
);
requirement.insert("version", format!("== {}", package.version).into());
}
ManifestPackageSource::Git { repo, commit, path } => {
entry.insert("source", "git".into());
entry.insert("repo", repo.as_str().into());
entry.insert("commit", commit.as_str().into());
requirement.insert("git", repo.as_str().into());
requirement.insert("ref", commit.as_str().into());
if let Some(path) = path {
entry.insert("path", path.as_str().into());
requirement.insert("path", path.as_str().into());
}
}
ManifestPackageSource::Local { .. } => continue,
}
entry.insert("name", package.name.as_str().into());
entry.insert("version", package.version.to_string().into());
entry.insert(
"build_tools",
package
.build_tools
.iter()
.map(|tool| tool.as_str())
.collect::<Array>()
.into(),
);
entry.insert(
"requirements",
package
.requirements
.iter()
.map(|name| name.as_str())
.collect::<Array>()
.into(),
);
if let Some(app) = &package.otp_app {
entry.insert("otp_app", app.as_str().into());
}
entries.push(entry);
requirements.insert(package.name.as_str(), value(requirement));
}
config["dependencies"] = requirements.clone().into();
let mut manifest = DocumentMut::new();
manifest["packages"] = value(entries);
manifest["requirements"] = requirements.into();
(config, manifest)
}
#[cfg(test)]
mod tests {
use super::{acquire, documents, write_documents};
use crate::error::CliError;
use crate::progress::Progress;
use crate::project::{DependencyDownloader, ProcessDependencyDownloader};
use camino::{Utf8Path, Utf8PathBuf};
use gleam_core::config::PackageConfig;
use gleam_core::manifest::Manifest;
use std::fs;
use tempfile::tempdir;
#[test]
fn renders_an_exact_remote_download_view_without_local_resolution_roots() {
let original: Manifest = toml::from_str(r#"packages = [
{ name = "hex_dep", version = "1.2.3", build_tools = ["gleam", "rebar3"], requirements = ["git_subdir"], source = "hex", outer_checksum = "00AB", otp_app = "hex_app" },
{ name = "git_subdir", version = "2.0.0", build_tools = ["gleam"], requirements = ["git_root"], source = "git", repo = "https://example.invalid/repository", commit = "locked", path = "packages/child" },
{ name = "git_root", version = "3.0.0", build_tools = [], requirements = [], source = "git", repo = "https://example.invalid/root", commit = "root_commit" },
{ name = "local_dep", version = "1.0.0", build_tools = ["gleam"], requirements = ["hex_dep"], source = "local", path = "../local_dep" },
]
[requirements]
local_dep = { path = "../local_dep" }
"#).expect("original locked packages");
let (config, manifest) = documents(&original.packages);
assert_eq!(
config.to_string(),
r#"name = "geam_locked_download"
version = "1.0.0"
[dependencies]
hex_dep = { version = "== 1.2.3" }
git_subdir = { git = "https://example.invalid/repository", ref = "locked", path = "packages/child" }
git_root = { git = "https://example.invalid/root", ref = "root_commit" }
"#
);
assert_eq!(
manifest.to_string(),
concat!(
"packages = [{ source = \"hex\", outer_checksum = \"00AB\", name = \"hex_dep\", version = \"1.2.3\", build_tools = [\"gleam\", \"rebar3\"], requirements = [\"git_subdir\"], otp_app = \"hex_app\" }, ",
"{ source = \"git\", repo = \"https://example.invalid/repository\", commit = \"locked\", path = \"packages/child\", name = \"git_subdir\", version = \"2.0.0\", build_tools = [\"gleam\"], requirements = [\"git_root\"] }, ",
"{ source = \"git\", repo = \"https://example.invalid/root\", commit = \"root_commit\", name = \"git_root\", version = \"3.0.0\", build_tools = [], requirements = [] }]\n",
"\n[requirements]\n",
"hex_dep = { version = \"== 1.2.3\" }\n",
"git_subdir = { git = \"https://example.invalid/repository\", ref = \"locked\", path = \"packages/child\" }\n",
"git_root = { git = \"https://example.invalid/root\", ref = \"root_commit\" }\n",
)
);
let parsed_config: PackageConfig =
toml::from_str(&config.to_string()).expect("Gleam config protocol");
let parsed_manifest: Manifest =
toml::from_str(&manifest.to_string()).expect("Gleam lock protocol");
assert_eq!(parsed_config.dependencies, parsed_manifest.requirements);
assert_eq!(parsed_manifest.packages, original.packages[..3]);
}
struct ManifestDownload<'a> {
manifest: Option<&'a str>,
}
impl DependencyDownloader for ManifestDownload<'_> {
fn download(&self, root: &Utf8Path, _progress: &mut Progress<'_>) -> Result<(), CliError> {
fs::create_dir_all(root.join("build/packages/unexpected"))
.expect("downloaded cache fixture");
match self.manifest {
Some(source) => {
fs::write(root.join("manifest.toml"), source).expect("tool-produced manifest")
}
None => {
fs::remove_file(root.join("manifest.toml")).expect("lost download manifest")
}
}
Ok(())
}
}
#[test]
fn rejects_changed_download_locks_before_publishing_any_cache_material() {
let directory = tempdir().expect("download fixture");
let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 path");
let config = "name = \"application\"\nversion = \"1.0.0\"\n";
let lock = "packages = []\n[requirements]\n";
fs::write(root.join("gleam.toml"), config).expect("original config");
fs::write(root.join("manifest.toml"), lock).expect("original lock");
for changed in [
"packages = []\n[requirements]\nnew_dep = { version = \"1.0.0\" }\n",
r#"packages = [{ name = "new_dep", version = "1.0.0", build_tools = [], requirements = [], source = "hex", outer_checksum = "00" }]
[requirements]
"#,
] {
let error = acquire(
&root,
&[],
&ManifestDownload {
manifest: Some(changed),
},
)
.expect_err("changed locked selection");
assert_eq!(
error.to_string(),
format!(
"Gleam dependency download for {} did not preserve the locked package set",
root.join("manifest.toml")
)
);
assert_eq!(
fs::read_to_string(root.join("manifest.toml")).expect("preserved original lock"),
lock
);
assert_eq!(
fs::read_to_string(root.join("gleam.toml")).expect("preserved original config"),
config
);
assert!(!root.join("build/packages").exists());
assert_eq!(
fs::read_dir(root.join("build"))
.expect("temporary view removed")
.count(),
0
);
}
}
#[test]
fn accepts_package_reordering_but_not_a_different_identity() {
let source = r#"packages = [
{ name = "second", version = "2.0.0", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://example.invalid/repo", commit = "locked" },
{ name = "first", version = "1.0.0", build_tools = ["gleam"], requirements = ["second"], source = "hex", outer_checksum = "00" },
]
[requirements]
first = { version = "== 1.0.0" }
second = { git = "https://example.invalid/repo", ref = "locked" }
"#;
let mut manifest: Manifest = toml::from_str(source).expect("tool-produced order");
manifest.packages.reverse();
let directory = tempdir().expect("download fixture");
let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 path");
let workspace = acquire(
&root,
&manifest.packages,
&ManifestDownload {
manifest: Some(source),
},
)
.expect("unchanged package identity");
assert!(workspace.path().join("build/packages/unexpected").is_dir());
assert!(!root.join("build/packages").exists());
for changed in [
source.replace("outer_checksum = \"00\"", "outer_checksum = \"01\""),
source.replace("commit = \"locked\"", "commit = \"different\""),
] {
assert!(matches!(
acquire(
&root,
&manifest.packages,
&ManifestDownload {
manifest: Some(&changed)
}
)
.expect_err("changed locked identity"),
CliError::GleamDownloadChangedLock { path }
if path == root.join("manifest.toml")
));
}
}
#[test]
fn preserves_download_process_and_file_failures_without_project_writes() {
let directory = tempdir().expect("download fixture");
let root = Utf8PathBuf::from_path_buf(directory.path().to_owned()).expect("UTF-8 path");
fs::write(root.join("manifest.toml"), "original lock").expect("original project file");
assert!(matches!(
acquire(&root, &[], &ManifestDownload { manifest: None })
.expect_err("removed download manifest"),
CliError::FileRead { path, error }
if path.starts_with(root.join("build"))
&& path.file_name() == Some("manifest.toml")
&& error.kind() == std::io::ErrorKind::NotFound
));
assert!(matches!(
acquire(
&root,
&[],
&ManifestDownload {
manifest: Some("invalid")
}
)
.expect_err("malformed download manifest"),
CliError::InvalidToml {
kind: "Gleam download manifest",
..
}
));
let missing =
ProcessDependencyDownloader::new(root.join("missing-gleam"), ["deps", "download"]);
assert!(matches!(
acquire(&root, &[], &missing).expect_err("missing download tool"),
CliError::ProcessIo { command, error }
if command == format!("{} deps download", root.join("missing-gleam"))
&& error.kind() == std::io::ErrorKind::NotFound
));
fs::remove_dir(root.join("build")).expect("download views cleaned on error");
fs::write(root.join("build"), "not a directory").expect("blocked cache directory");
assert!(matches!(
acquire(&root, &[], &missing).expect_err("blocked download directory"),
CliError::TemporaryDependencyWorkspace(error)
if error.kind() == std::io::ErrorKind::AlreadyExists
));
assert_eq!(
fs::read_to_string(root.join("manifest.toml")).expect("unchanged lock"),
"original lock"
);
fs::create_dir(root.join("gleam.toml")).expect("blocked download config");
let (config, manifest) = documents(&[]);
assert!(
matches!(write_documents(&root, config, manifest), Err(CliError::FileWrite { path, .. }) if path == root.join("gleam.toml"))
);
fs::remove_dir(root.join("gleam.toml")).expect("remove blocked config");
fs::remove_file(root.join("manifest.toml")).expect("prepare blocked manifest");
fs::create_dir(root.join("manifest.toml")).expect("blocked download manifest");
let (config, manifest) = documents(&[]);
assert!(
matches!(write_documents(&root, config, manifest), Err(CliError::FileWrite { path, .. }) if path == root.join("manifest.toml"))
);
}
}