Fork of daniellemaywood.uk/gleam — Wasm codegen work
38 kB
1205 lines
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2021 The Gleam contributors
3
4use camino::{Utf8Path, Utf8PathBuf};
5use ecow::EcoString;
6use flate2::{Compression, write::GzEncoder};
7use gleam_core::{
8 Error, Result,
9 analyse::TargetSupport,
10 ast::{CallArg, Statement, TypedExpr, TypedFunction},
11 build::{Codegen, Compile, Mode, Options, Package, Target},
12 config::{GleamVersion, PackageConfig, SpdxLicense},
13 docs::{Dependency, DependencyKind, DocContext},
14 error::{InvalidReadmeReason, SmallVersion, wrap},
15 hex,
16 manifest::ManifestPackageSource,
17 paths::{self, ProjectPaths},
18 requirement::Requirement,
19 type_,
20};
21use hexpm::version::{Range, Version};
22use itertools::Itertools;
23use sha2::Digest;
24use std::{collections::HashMap, io::Write};
25
26use crate::{build, cli, docs, fs, http::HttpClient, new::default_readme};
27
28const CORE_TEAM_PUBLISH_PASSWORD: &str = "Trans rights are human rights";
29
30pub fn command(paths: &ProjectPaths, replace: bool, i_am_sure: bool) -> Result<()> {
31 let mut config = crate::config::root_config(paths)?;
32
33 let should_publish = check_for_gleam_prefix(&config)?
34 && check_for_version_zero(&config)?
35 && check_repo_url(&config, i_am_sure)?;
36
37 check_for_invalid_readme(&config, paths)?;
38
39 if !should_publish {
40 println!("Not publishing.");
41 return Ok(());
42 }
43
44 let Tarball {
45 mut compile_result,
46 cached_modules,
47 data: package_tarball,
48 src_files_added,
49 generated_files_added,
50 dependencies,
51 } = do_build_hex_tarball(paths, &mut config)?;
52
53 check_for_name_squatting(&compile_result)?;
54 check_for_multiple_top_level_modules(&compile_result, i_am_sure)?;
55 check_for_default_main(&compile_result)?;
56
57 // Build HTML documentation
58 let docs_tarball = fs::create_tar_archive(docs::build_documentation(
59 paths,
60 &config,
61 dependencies,
62 &mut compile_result,
63 DocContext::HexPublish,
64 &cached_modules,
65 )?)?;
66
67 // Ask user if this is correct
68 if !generated_files_added.is_empty() {
69 println!("\nGenerated files:");
70 for file in generated_files_added.iter().sorted() {
71 println!(" - {}", file.0);
72 }
73 }
74 println!("\nSource files:");
75 for file in src_files_added.iter().sorted() {
76 println!(" - {file}");
77 }
78 println!("\nName: {}", config.name);
79 println!("Version: {}", config.version);
80
81 let should_publish = i_am_sure || cli::confirm("\nDo you wish to publish this package?")?;
82 if !should_publish {
83 println!("Not publishing.");
84 return Ok(());
85 }
86
87 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime");
88 let http = HttpClient::new();
89 let hex_config = hexpm::Config::new();
90 let credentials = crate::hex::HexAuthentication::new(&runtime, &http, hex_config.clone())
91 .get_or_create_api_credentials()?;
92 let credentials = crate::hex::write_credentials(&credentials)?;
93 cli::print_publishing(&config.name, &config.version);
94
95 runtime.block_on(hex::publish_package(
96 package_tarball,
97 config.version.to_string(),
98 &config.name,
99 &credentials,
100 &hex_config,
101 replace,
102 &http,
103 ))?;
104
105 cli::print_publishing_documentation();
106 runtime.block_on(hex::publish_documentation(
107 &config.name,
108 &config.version,
109 docs_tarball,
110 &credentials,
111 &hex_config,
112 &http,
113 ))?;
114 cli::print_published("package and documentation");
115 println!(
116 "\nView your package at https://hex.pm/packages/{}",
117 config.name
118 );
119
120 // Prompt the user to make a git tag if they have not.
121 let has_repo = config.repository.is_some();
122 let repository_root = fs::get_git_repository_root(".".into());
123 if has_repo && let Some(repository_root) = repository_root {
124 let git = repository_root.join(".git");
125
126 let tag_name = config.tag_for_version(&config.version);
127 let git_tag = git.join("refs").join("tags").join(&tag_name);
128 let tag_exists = git_tag.exists();
129
130 if !tag_exists {
131 println!(
132 "
133Please push a git tag for this release so source code links in the
134HTML documentation will work:
135
136 git tag {tag_name}
137 git push origin {tag_name}
138"
139 );
140 }
141 }
142 Ok(())
143}
144
145fn check_for_invalid_readme(config: &PackageConfig, paths: &ProjectPaths) -> Result<(), Error> {
146 let normalise = |string: String| {
147 string
148 .trim()
149 .replace("\r\n", "")
150 .replace("\n", "")
151 .replace("\t", "")
152 .replace(" ", "")
153 };
154
155 let project_readme = match fs::read(paths.readme()) {
156 Err(Error::FileIo {
157 err: Some(message), ..
158 }) if message.contains("No such file or directory") => {
159 return Err(Error::CannotPublishWithInvalidReadme {
160 reason: InvalidReadmeReason::Missing,
161 });
162 }
163 Err(error) => return Err(error),
164 Ok(project_readme) => project_readme,
165 };
166
167 let normalised_project_readme = normalise(project_readme);
168 if normalised_project_readme.is_empty() {
169 return Err(Error::CannotPublishWithInvalidReadme {
170 reason: InvalidReadmeReason::Empty,
171 });
172 }
173
174 let default_readme = default_readme(config.name.as_str());
175 if normalised_project_readme == normalise(default_readme) {
176 return Err(Error::CannotPublishWithInvalidReadme {
177 reason: InvalidReadmeReason::Default,
178 });
179 }
180
181 Ok(())
182}
183
184fn check_for_name_squatting(package: &Package) -> Result<(), Error> {
185 if package.modules.len() > 1 {
186 return Ok(());
187 }
188
189 let Some(module) = package.modules.first() else {
190 return Err(Error::HexPackageSquatting);
191 };
192
193 if module.dependencies.len() > 1 {
194 return Ok(());
195 }
196
197 if module.ast.definitions_len() > 2 {
198 return Ok(());
199 }
200
201 let Some(main) = module
202 .ast
203 .definitions
204 .functions
205 .iter()
206 .find_map(|function| function.main_function())
207 else {
208 return Ok(());
209 };
210
211 if let Some(first) = &main.body.first()
212 && first.is_println()
213 {
214 return Err(Error::HexPackageSquatting);
215 }
216
217 Ok(())
218}
219
220/// Checks if publishing packages contain default main functions.
221/// Main functions with documentation are considered intentional and allowed.
222fn check_for_default_main(package: &Package) -> Result<(), Error> {
223 let package_name = &package.config.name;
224
225 let has_default_main = package
226 .modules
227 .iter()
228 .flat_map(|module| module.ast.definitions.functions.iter())
229 .filter_map(|function| function.main_function())
230 .any(|main| main.documentation.is_none() && is_default_main(main, package_name));
231
232 if has_default_main {
233 return Err(Error::CannotPublishWithDefaultMain {
234 package_name: package_name.clone(),
235 });
236 }
237
238 Ok(())
239}
240
241fn is_default_main(main: &TypedFunction, package_name: &EcoString) -> bool {
242 if main.body.len() != 1 {
243 return false;
244 }
245
246 let Some(Statement::Expression(expression)) = main.body.first() else {
247 return false;
248 };
249
250 if !expression.is_println() {
251 return false;
252 }
253
254 match expression {
255 TypedExpr::Call { arguments, .. } => {
256 if arguments.len() != 1 {
257 return false;
258 }
259
260 match arguments.first() {
261 Some(CallArg {
262 value: TypedExpr::String { value, .. },
263 ..
264 }) => {
265 let default_argument = format!("Hello from {}!", package_name);
266 value == &default_argument
267 }
268 _ => false,
269 }
270 }
271 _ => false,
272 }
273}
274
275fn check_for_multiple_top_level_modules(package: &Package, i_am_sure: bool) -> Result<(), Error> {
276 // Collect top-level module names
277 let mut top_level_module_names = package
278 .modules
279 .iter()
280 .filter_map(|module| {
281 // Top-level modules are those that don't contain any path separators
282 if module.name.contains('/') {
283 None
284 } else {
285 Some(module.name.clone())
286 }
287 })
288 .collect::<Vec<_>>();
289
290 // Remove duplicates
291 top_level_module_names.sort_unstable();
292 top_level_module_names.dedup();
293
294 // If more than one top-level module name is found, prompt for confirmation
295 if top_level_module_names.len() > 1 {
296 let text = wrap(&format!(
297 "Your package defines multiple top-level modules: {}.
298
299Defining multiple top-level modules can lead to namespace pollution \
300and potential conflicts for consumers.
301
302To fix this, move all your modules under a single top-level module of your choice.
303
304For example:
305 src/{1}.gleam
306 src/{1}/module1.gleam
307 src/{1}/module2.gleam",
308 top_level_module_names.join(", "),
309 package.config.name
310 ));
311 println!("{text}\n");
312
313 let should_publish =
314 i_am_sure || cli::confirm("\nDo you wish to continue publishing this package?")?;
315 println!();
316
317 if !should_publish {
318 println!("Not publishing.");
319 std::process::exit(0);
320 }
321 }
322
323 Ok(())
324}
325
326fn check_repo_url(config: &PackageConfig, i_am_sure: bool) -> Result<bool, Error> {
327 let Some(repo) = config.repository.as_ref() else {
328 return Ok(true);
329 };
330 let url = repo.url();
331
332 let runtime = tokio::runtime::Runtime::new().expect("Unable to start Tokio async runtime");
333 let response = runtime.block_on(reqwest::get(&url)).map_err(Error::http)?;
334
335 if response.status().is_success() {
336 return Ok(true);
337 }
338
339 println!(
340 "The repository configuration in your `gleam.toml` file does not appear to be
341valid, {} returned status {}",
342 url,
343 response.status()
344 );
345 let should_publish = i_am_sure || cli::confirm("\nDo you wish to continue?")?;
346 println!();
347 Ok(should_publish)
348}
349
350/// Ask for confirmation if the package name if a v0.x.x version
351fn check_for_version_zero(config: &PackageConfig) -> Result<bool, Error> {
352 if config.version.major != 0 {
353 return Ok(true);
354 }
355
356 println!(
357 "You are about to publish a release that is below version 1.0.0.
358
359Semantic versioning doesn't apply to version 0.x.x releases, so your
360users will not be protected from breaking changes. This can result
361in a poor user experience where packages can break unexpectedly with
362updates that would normally be safe.
363
364If your package is not ready to be used in production it should not
365be published.
366\n"
367 );
368 let should_publish = cli::confirm_with_text("I am not using semantic versioning")?;
369 println!();
370 Ok(should_publish)
371}
372
373/// Ask for confirmation if the package name if `gleam_*`
374fn check_for_gleam_prefix(config: &PackageConfig) -> Result<bool, Error> {
375 if !config.name.starts_with("gleam_") || config.name.starts_with("gleam_community_") {
376 return Ok(true);
377 }
378
379 println!(
380 "You are about to publish a package with a name that starts with
381the prefix `gleam_`, which is preferred for packages maintained by the
382Gleam core team.
383
384Security: do not assume the owner of a package from the name, always check
385the maintainers listed on https://hex.pm/.
386\n",
387 );
388 let password = cli::ask_password("Please enter the core team password to continue")?;
389 println!();
390 Ok(password == CORE_TEAM_PUBLISH_PASSWORD)
391}
392
393struct Tarball {
394 compile_result: Package,
395 cached_modules: im::HashMap<EcoString, type_::ModuleInterface>,
396 data: Vec<u8>,
397 src_files_added: Vec<Utf8PathBuf>,
398 generated_files_added: Vec<(Utf8PathBuf, String)>,
399 dependencies: HashMap<EcoString, Dependency>,
400}
401
402pub fn build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Vec<u8>> {
403 let Tarball { data, .. } = do_build_hex_tarball(paths, config)?;
404 Ok(data)
405}
406
407fn do_build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Result<Tarball> {
408 let target = config.target;
409 check_config_for_publishing(config)?;
410
411 // Reset the build directory so we know the state of the project
412 fs::delete_directory(&paths.build_directory_for_target(Mode::Prod, target))?;
413
414 let manifest = build::download_dependencies(paths, cli::Reporter::new())?;
415 let dependencies = manifest
416 .packages
417 .iter()
418 .map(|package| {
419 (
420 package.name.clone(),
421 Dependency {
422 version: package.version.clone(),
423 kind: match &package.source {
424 ManifestPackageSource::Hex { .. } => DependencyKind::Hex,
425 ManifestPackageSource::Git { .. } => DependencyKind::Git,
426 ManifestPackageSource::Local { .. } => DependencyKind::Path,
427 },
428 },
429 )
430 })
431 .collect();
432
433 // Build a lookup from hex package name to OTP application name
434 let hex_to_otp_app = manifest
435 .packages
436 .iter()
437 .filter_map(|package| {
438 package
439 .otp_app
440 .as_ref()
441 .map(|otp_app_name| (package.name.clone(), otp_app_name.clone()))
442 })
443 .collect::<HashMap<_, _>>();
444
445 // Build the project to check that it is valid
446 let built = build::main(
447 paths,
448 Options {
449 root_target_support: TargetSupport::Enforced,
450 warnings_as_errors: false,
451 mode: Mode::Prod,
452 target: Some(target),
453 codegen: Codegen::All,
454 compile: Compile::All,
455 no_print_progress: false,
456 },
457 manifest,
458 )?;
459
460 let minimum_required_version = built.minimum_required_version();
461 match &config.gleam_version {
462 // If the package has no explicit `gleam` version in its `gleam.toml`
463 // then we want to add the automatically inferred one so we know it's
464 // correct and folks getting the package from Hex won't have unpleasant
465 // surprises if the author forgot to manualy write it down.
466 None => {
467 // If we're automatically adding the minimum required version
468 // constraint we want it to at least be `>= 1.0.0`, even if the
469 // inferred lower bound could be lower.
470 let minimum_required_version =
471 std::cmp::max(minimum_required_version, Version::new(1, 0, 0));
472 let inferred_version_range = pubgrub::Range::higher_than(minimum_required_version);
473 config.gleam_version = Some(GleamVersion::from_pubgrub(inferred_version_range));
474 }
475 // Otherwise we need to check that the annotated version range is
476 // correct and includes the minimum required version.
477 Some(gleam_version) => {
478 if let Some(lowest_allowed_version) = gleam_version.lowest_version()
479 && lowest_allowed_version < minimum_required_version
480 {
481 return Err(Error::CannotPublishWrongVersion {
482 minimum_required_version: SmallVersion::from_hexpm(minimum_required_version),
483 wrongfully_allowed_version: SmallVersion::from_hexpm(lowest_allowed_version),
484 });
485 }
486 }
487 }
488
489 // If any of the modules in the package contain a todo or an echo then
490 // refuse to publish as the package is not yet finished.
491 let mut modules_containing_todo = vec![];
492 let mut modules_containing_echo = vec![];
493
494 for module in built.root_package.modules.iter() {
495 if module.ast.type_info.contains_todo() {
496 modules_containing_todo.push(module.name.clone());
497 } else if module.ast.type_info.contains_echo {
498 modules_containing_echo.push(module.name.clone());
499 }
500 }
501
502 if !modules_containing_todo.is_empty() {
503 return Err(Error::CannotPublishTodo {
504 unfinished: modules_containing_todo,
505 });
506 }
507
508 if !modules_containing_echo.is_empty() {
509 return Err(Error::CannotPublishEcho {
510 unfinished: modules_containing_echo,
511 });
512 }
513
514 // empty_modules is a list of modules that do not export any values or types.
515 // We do not allow publishing packages that contain empty modules.
516 let empty_modules: Vec<_> = built
517 .root_package
518 .modules
519 .iter()
520 .filter(|module| {
521 built
522 .module_interfaces
523 .get(&module.name)
524 .map(|interface| {
525 // Check if the module exports any values or types
526 interface.values.is_empty() && interface.types.is_empty()
527 })
528 .unwrap_or(false)
529 })
530 .map(|module| module.name.clone())
531 .collect();
532
533 if !empty_modules.is_empty() {
534 return Err(Error::CannotPublishEmptyModules {
535 unfinished: empty_modules,
536 });
537 }
538
539 // TODO: If any of the modules in the package contain a leaked internal type then
540 // refuse to publish as the package is not yet finished.
541 // We need to move aliases in to the type system first.
542 // context: https://discord.com/channels/768594524158427167/768594524158427170/1227250677734969386
543
544 // Collect all the files we want to include in the tarball
545 let generated_files = match target {
546 Target::Erlang => generated_erlang_files(paths, &built.root_package)?,
547 Target::JavaScript => vec![],
548 Target::Wasm => vec![],
549 };
550 let src_files = project_files(Utf8Path::new(""))?;
551 let contents_tar_gz = contents_tarball(paths, &src_files, &generated_files)?;
552 let version = "3";
553 let metadata = metadata_config(
554 &built.root_package.config,
555 &hex_to_otp_app,
556 &src_files,
557 &generated_files,
558 )?;
559
560 // Calculate checksum
561 let mut hasher = sha2::Sha256::new();
562 hasher.update(version.as_bytes());
563 hasher.update(metadata.as_bytes());
564 hasher.update(contents_tar_gz.as_slice());
565 let checksum = base16::encode_upper(&hasher.finalize());
566 tracing::info!(checksum = %checksum, "Generated Hex package inner checksum");
567
568 // Build tarball
569 let mut tarball = Vec::new();
570 {
571 let mut tarball = tar::Builder::new(&mut tarball);
572 add_to_tar_from_memory(&mut tarball, "VERSION", version.as_bytes())?;
573 add_to_tar_from_memory(&mut tarball, "metadata.config", metadata.as_bytes())?;
574 add_to_tar_from_memory(&mut tarball, "contents.tar.gz", contents_tar_gz.as_slice())?;
575 add_to_tar_from_memory(&mut tarball, "CHECKSUM", checksum.as_bytes())?;
576 tarball.finish().map_err(Error::finish_tar)?;
577 }
578 tracing::info!("Generated package Hex release tarball");
579 Ok(Tarball {
580 compile_result: built.root_package,
581 cached_modules: built.module_interfaces,
582 data: tarball,
583 src_files_added: src_files,
584 generated_files_added: generated_files,
585 dependencies,
586 })
587}
588
589fn check_config_for_publishing(config: &PackageConfig) -> Result<()> {
590 // These fields are required to publish a Hex package. Hex will reject
591 // packages without them.
592 if config.description.is_empty() || config.licences.is_empty() {
593 Err(Error::MissingHexPublishFields {
594 description_missing: config.description.is_empty(),
595 licence_missing: config.licences.is_empty(),
596 })
597 } else {
598 Ok(())
599 }
600}
601
602fn metadata_config<'a>(
603 config: &'a PackageConfig,
604 hex_to_otp_app: &'a HashMap<EcoString, EcoString>,
605 source_files: &[Utf8PathBuf],
606 generated_files: &[(Utf8PathBuf, String)],
607) -> Result<String> {
608 let repo_url = http::Uri::try_from(
609 config
610 .repository
611 .as_ref()
612 .map(|r| r.url())
613 .unwrap_or_default(),
614 )
615 .ok();
616 let requirements: Result<Vec<ReleaseRequirement<'a>>> = config
617 .dependencies
618 .iter()
619 .map(|(name, requirement)| match requirement {
620 Requirement::Hex { version } => Ok(ReleaseRequirement {
621 name,
622 otp_app: hex_to_otp_app
623 .get(name)
624 .map(EcoString::as_str)
625 .unwrap_or(name.as_str()),
626 requirement: version,
627 }),
628 _ => Err(Error::PublishNonHexDependencies {
629 package: name.to_string(),
630 }),
631 })
632 .collect();
633 let metadata = ReleaseMetadata {
634 name: &config.name,
635 version: &config.version,
636 description: &config.description,
637 source_files,
638 generated_files,
639 licenses: &config.licences,
640 links: config
641 .links
642 .iter()
643 .map(|l| (l.title.as_str(), l.href.clone()))
644 .chain(repo_url.into_iter().map(|u| ("Repository", u)))
645 .collect(),
646 requirements: requirements?,
647 build_tools: vec!["gleam"],
648 }
649 .as_erlang();
650 tracing::info!(contents = ?metadata, "Generated Hex metadata.config");
651 Ok(metadata)
652}
653
654fn contents_tarball(
655 paths: &ProjectPaths,
656 files: &[Utf8PathBuf],
657 data_files: &[(Utf8PathBuf, String)],
658) -> Result<Vec<u8>, Error> {
659 let mut contents_tar_gz = Vec::new();
660 {
661 let mut tarball =
662 tar::Builder::new(GzEncoder::new(&mut contents_tar_gz, Compression::default()));
663 for path in files {
664 add_to_tar_from_file_system(&mut tarball, paths, path)?;
665 }
666 for (path, contents) in data_files {
667 add_to_tar_from_memory(&mut tarball, path, contents.as_bytes())?;
668 }
669 tarball.finish().map_err(Error::finish_tar)?;
670 }
671 tracing::info!("Generated contents.tar.gz");
672 Ok(contents_tar_gz)
673}
674
675fn project_files(base_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
676 let src = base_path.join(Utf8Path::new("src"));
677 let mut files: Vec<Utf8PathBuf> = fs::gleam_files(&src)
678 .chain(fs::native_files(&src))
679 .collect();
680 let private = base_path.join(Utf8Path::new("priv"));
681 let mut private_files: Vec<Utf8PathBuf> = fs::priv_directory_files(&private).collect();
682 files.append(&mut private_files);
683 let mut add = |path| {
684 let path = base_path.join(path);
685 if path.exists() {
686 files.push(path);
687 }
688 };
689 add("README");
690 add("README.md");
691 add("README.txt");
692 add("gleam.toml");
693 add("LICENSE");
694 add("LICENCE");
695 add("LICENSE.md");
696 add("LICENCE.md");
697 add("LICENSE.txt");
698 add("LICENCE.txt");
699 add("NOTICE");
700 add("NOTICE.md");
701 add("NOTICE.txt");
702 Ok(files)
703}
704
705// TODO: test
706fn generated_erlang_files(
707 paths: &ProjectPaths,
708 package: &Package,
709) -> Result<Vec<(Utf8PathBuf, String)>> {
710 let mut files = vec![];
711
712 let dir = paths.build_directory_for_package(Mode::Prod, Target::Erlang, &package.config.name);
713 let ebin = dir.join("ebin");
714 let build = dir.join(paths::ARTEFACT_DIRECTORY_NAME);
715 let include = dir.join("include");
716
717 let tar_src = Utf8Path::new("src");
718 let tar_include = Utf8Path::new("include");
719
720 // Erlang modules
721 for module in &package.modules {
722 // Do not publish test/ and dev/ code
723 if !module.origin.is_src() {
724 continue;
725 }
726
727 let name = module.compiled_erlang_path();
728 files.push((tar_src.join(&name), fs::read(build.join(name))?));
729 }
730
731 // Erlang headers
732 if include.is_dir() {
733 for file in fs::erlang_files(&include) {
734 let name = file.file_name().expect("generated_files include file name");
735 files.push((tar_include.join(name), fs::read(file)?));
736 }
737 }
738
739 // src/package.app.src file
740 let app = format!("{}.app", package.config.name);
741 let appsrc = format!("{}.src", app);
742 files.push((tar_src.join(appsrc), fs::read(ebin.join(app))?));
743
744 Ok(files)
745}
746
747fn add_to_tar_from_memory<P, W>(tarball: &mut tar::Builder<W>, path: P, data: &[u8]) -> Result<()>
748where
749 P: AsRef<Utf8Path>,
750 W: Write,
751{
752 let path = path.as_ref();
753 tracing::info!(file=?path, "Adding in memory file to tarball");
754
755 let mut header = tar::Header::new_gnu();
756 header.set_mode(0o600);
757 header.set_size(data.len() as u64);
758 header.set_cksum();
759 tarball
760 .append_data(&mut header, path, data)
761 .map_err(|e| Error::add_tar(path, e))
762}
763
764fn add_to_tar_from_file_system<P, W>(
765 tarball: &mut tar::Builder<W>,
766 paths: &ProjectPaths,
767 path: P,
768) -> Result<()>
769where
770 P: AsRef<Utf8Path>,
771 W: Write,
772{
773 let path = path.as_ref();
774 tracing::info!(file=?&path, "Adding file system file to tarball");
775
776 let path = fs::canonicalise(path)?;
777
778 let Ok(path) = path.strip_prefix(paths.root()) else {
779 return Err(Error::TarPathOutsideOfProjectRoot { path });
780 };
781
782 tarball
783 .append_path(path)
784 .map_err(|e| Error::add_tar(path, e))
785}
786
787#[test]
788fn add_to_tar_symlink_rejection_test() {
789 let tmp_dir = tempfile::tempdir().unwrap();
790 let path = std::fs::canonicalize(tmp_dir.path()).unwrap();
791 let path = Utf8Path::from_path(&path).expect("Non Utf-8 Path");
792 let paths = ProjectPaths::new(path.join("package"));
793 let mut contents_tar_gz = Vec::new();
794 let mut tarball = tar::Builder::new(&mut contents_tar_gz);
795
796 // This file is outside the root of the project, so it should
797 // not be possible to add it to the tar archive.
798 let outside_path = path.join("outside.txt");
799 std::fs::write(&outside_path, "Hello").unwrap();
800 match add_to_tar_from_file_system(&mut tarball, &paths, &outside_path).unwrap_err() {
801 Error::TarPathOutsideOfProjectRoot { path } => assert_eq!(path, outside_path),
802 other => panic!("Unexpected error {other:?}"),
803 }
804}
805
806#[derive(Debug, Clone)]
807pub struct ReleaseMetadata<'a> {
808 name: &'a str,
809 version: &'a Version,
810 description: &'a str,
811 source_files: &'a [Utf8PathBuf],
812 generated_files: &'a [(Utf8PathBuf, String)],
813 licenses: &'a Vec<SpdxLicense>,
814 links: Vec<(&'a str, http::Uri)>,
815 requirements: Vec<ReleaseRequirement<'a>>,
816 build_tools: Vec<&'a str>,
817 // What should this be? I can't find it in the API anywhere.
818 // extra: (kvlist(string => kvlist(...))) (optional)
819}
820
821impl ReleaseMetadata<'_> {
822 pub fn as_erlang(&self) -> String {
823 fn link(link: &(&str, http::Uri)) -> String {
824 format!(
825 "\n {{<<\"{name}\"/utf8>>, <<\"{url}\"/utf8>>}}",
826 name = link.0,
827 url = link.1
828 )
829 }
830 fn file(name: impl AsRef<Utf8Path>) -> String {
831 format!("\n <<\"{name}\"/utf8>>", name = name.as_ref())
832 }
833
834 format!(
835 r#"{{<<"name">>, <<"{name}"/utf8>>}}.
836{{<<"app">>, <<"{name}"/utf8>>}}.
837{{<<"version">>, <<"{version}"/utf8>>}}.
838{{<<"description">>, <<"{description}"/utf8>>}}.
839{{<<"licenses">>, [{licenses}]}}.
840{{<<"build_tools">>, [{build_tools}]}}.
841{{<<"links">>, [{links}
842]}}.
843{{<<"requirements">>, [{requirements}
844]}}.
845{{<<"files">>, [{files}
846]}}.
847"#,
848 name = self.name,
849 version = self.version,
850 description = self.description,
851 files = self
852 .source_files
853 .iter()
854 .chain(self.generated_files.iter().map(|(p, _)| p))
855 .map(file)
856 .sorted()
857 .join(","),
858 links = self.links.iter().map(link).join(","),
859 licenses = self.licenses.iter().map(|l| quotes(l.as_ref())).join(", "),
860 build_tools = self.build_tools.iter().map(|l| quotes(l)).join(", "),
861 requirements = self
862 .requirements
863 .iter()
864 .map(ReleaseRequirement::as_erlang)
865 .join(",")
866 )
867 }
868}
869
870#[derive(Debug, Clone)]
871struct ReleaseRequirement<'a> {
872 name: &'a str,
873 // optional: bool,
874 requirement: &'a Range,
875 // Support alternate repositories at a later date.
876 // repository: String,
877 otp_app: &'a str,
878}
879impl ReleaseRequirement<'_> {
880 pub fn as_erlang(&self) -> String {
881 format!(
882 r#"
883 {{<<"{name}"/utf8>>, [
884 {{<<"app">>, <<"{otp_app}"/utf8>>}},
885 {{<<"optional">>, false}},
886 {{<<"requirement">>, <<"{requirement}"/utf8>>}}
887 ]}}"#,
888 name = self.name,
889 otp_app = self.otp_app,
890 requirement = self.requirement,
891 )
892 }
893}
894
895#[test]
896fn release_metadata_as_erlang() {
897 let licences = vec![
898 SpdxLicense {
899 licence: "MIT".into(),
900 },
901 SpdxLicense {
902 licence: "MPL-2.0".into(),
903 },
904 ];
905 let version = "1.2.3".try_into().unwrap();
906 let homepage = "https://gleam.run".parse().unwrap();
907 let github = "https://github.com/lpil/myapp".parse().unwrap();
908 let req1 = Range::new("~> 1.2.3 or >= 5.0.0".into()).unwrap();
909 let req2 = Range::new("~> 1.2".into()).unwrap();
910 let meta = ReleaseMetadata {
911 name: "myapp",
912 version: &version,
913 description: "description goes here 🌈",
914 source_files: &[
915 Utf8PathBuf::from("gleam.toml"),
916 Utf8PathBuf::from("src/thingy.gleam"),
917 Utf8PathBuf::from("src/whatever.gleam"),
918 ],
919 generated_files: &[
920 (Utf8PathBuf::from("src/myapp.app"), "".into()),
921 (Utf8PathBuf::from("src/thingy.erl"), "".into()),
922 (Utf8PathBuf::from("src/whatever.erl"), "".into()),
923 ],
924 licenses: &licences,
925 links: vec![("homepage", homepage), ("github", github)],
926 requirements: vec![
927 ReleaseRequirement {
928 name: "wibble",
929 otp_app: "wibble",
930 requirement: &req1,
931 },
932 ReleaseRequirement {
933 name: "wobble",
934 otp_app: "wobble",
935 requirement: &req2,
936 },
937 ReleaseRequirement {
938 name: "weeble_erl",
939 otp_app: "weeble",
940 requirement: &req2,
941 },
942 ],
943 build_tools: vec!["gleam", "rebar3"],
944 };
945 assert_eq!(
946 meta.as_erlang(),
947 r#"{<<"name">>, <<"myapp"/utf8>>}.
948{<<"app">>, <<"myapp"/utf8>>}.
949{<<"version">>, <<"1.2.3"/utf8>>}.
950{<<"description">>, <<"description goes here 🌈"/utf8>>}.
951{<<"licenses">>, [<<"MIT"/utf8>>, <<"MPL-2.0"/utf8>>]}.
952{<<"build_tools">>, [<<"gleam"/utf8>>, <<"rebar3"/utf8>>]}.
953{<<"links">>, [
954 {<<"homepage"/utf8>>, <<"https://gleam.run/"/utf8>>},
955 {<<"github"/utf8>>, <<"https://github.com/lpil/myapp"/utf8>>}
956]}.
957{<<"requirements">>, [
958 {<<"wibble"/utf8>>, [
959 {<<"app">>, <<"wibble"/utf8>>},
960 {<<"optional">>, false},
961 {<<"requirement">>, <<"~> 1.2.3 or >= 5.0.0"/utf8>>}
962 ]},
963 {<<"wobble"/utf8>>, [
964 {<<"app">>, <<"wobble"/utf8>>},
965 {<<"optional">>, false},
966 {<<"requirement">>, <<"~> 1.2"/utf8>>}
967 ]},
968 {<<"weeble_erl"/utf8>>, [
969 {<<"app">>, <<"weeble"/utf8>>},
970 {<<"optional">>, false},
971 {<<"requirement">>, <<"~> 1.2"/utf8>>}
972 ]}
973]}.
974{<<"files">>, [
975 <<"gleam.toml"/utf8>>,
976 <<"src/myapp.app"/utf8>>,
977 <<"src/thingy.erl"/utf8>>,
978 <<"src/thingy.gleam"/utf8>>,
979 <<"src/whatever.erl"/utf8>>,
980 <<"src/whatever.gleam"/utf8>>
981]}.
982"#
983 .to_string()
984 );
985}
986
987#[test]
988fn prevent_publish_local_dependency() {
989 let config = PackageConfig {
990 dependencies: [("provided".into(), Requirement::path("./path/to/package"))].into(),
991 ..PackageConfig::default()
992 };
993 assert_eq!(
994 metadata_config(&config, &HashMap::new(), &[], &[]),
995 Err(Error::PublishNonHexDependencies {
996 package: "provided".into()
997 })
998 );
999}
1000
1001#[test]
1002fn prevent_publish_git_dependency() {
1003 let config = PackageConfig {
1004 dependencies: [(
1005 "provided".into(),
1006 Requirement::git("https://github.com/gleam-lang/gleam.git", "da6e917"),
1007 )]
1008 .into(),
1009 ..PackageConfig::default()
1010 };
1011 assert_eq!(
1012 metadata_config(&config, &HashMap::new(), &[], &[]),
1013 Err(Error::PublishNonHexDependencies {
1014 package: "provided".into()
1015 })
1016 );
1017}
1018
1019fn quotes(x: &str) -> String {
1020 format!(r#"<<"{x}"/utf8>>"#)
1021}
1022
1023#[test]
1024fn exported_project_files_test() {
1025 let tmp = tempfile::tempdir().unwrap();
1026 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1027
1028 let exported_project_files = &[
1029 "LICENCE",
1030 "LICENCE.md",
1031 "LICENCE.txt",
1032 "LICENSE",
1033 "LICENSE.md",
1034 "LICENSE.txt",
1035 "NOTICE",
1036 "NOTICE.md",
1037 "NOTICE.txt",
1038 "README",
1039 "README.md",
1040 "README.txt",
1041 "gleam.toml",
1042 "priv/ignored",
1043 "priv/wibble",
1044 "priv/wobble.js",
1045 "src/.hidden/hidden_ffi.erl",
1046 "src/.hidden/hidden_ffi.mjs",
1047 "src/.hidden_ffi.erl",
1048 "src/.hidden_ffi.mjs",
1049 "src/exported.gleam",
1050 "src/exported_ffi.erl",
1051 "src/exported_ffi.ex",
1052 "src/exported_ffi.hrl",
1053 "src/exported_ffi.js",
1054 "src/exported_ffi.mjs",
1055 "src/exported_ffi.ts",
1056 "src/ignored.gleam",
1057 "src/ignored_ffi.erl",
1058 "src/ignored_ffi.mjs",
1059 "src/nested/exported.gleam",
1060 "src/nested/exported_ffi.erl",
1061 "src/nested/exported_ffi.ex",
1062 "src/nested/exported_ffi.hrl",
1063 "src/nested/exported_ffi.js",
1064 "src/nested/exported_ffi.mjs",
1065 "src/nested/exported_ffi.ts",
1066 "src/nested/ignored.gleam",
1067 "src/nested/ignored_ffi.erl",
1068 "src/nested/ignored_ffi.mjs",
1069 ];
1070
1071 let unexported_project_files = &[
1072 ".git/",
1073 ".github/workflows/test.yml",
1074 ".gitignore",
1075 "build/",
1076 "ignored.txt",
1077 "src/.hidden/hidden.gleam", // Not a valid Gleam module path
1078 "src/.hidden.gleam", // Not a valid Gleam module name
1079 "src/also-ignored.gleam", // Not a valid Gleam module name
1080 "test/exported_test.gleam",
1081 "test/exported_test_ffi.erl",
1082 "test/exported_test_ffi.ex",
1083 "test/exported_test_ffi.hrl",
1084 "test/exported_test_ffi.js",
1085 "test/exported_test_ffi.mjs",
1086 "test/exported_test_ffi.ts",
1087 "test/ignored_test.gleam",
1088 "test/ignored_test_ffi.erl",
1089 "test/ignored_test_ffi.mjs",
1090 "test/nested/exported_test.gleam",
1091 "test/nested/exported_test_ffi.erl",
1092 "test/nested/exported_test_ffi.ex",
1093 "test/nested/exported_test_ffi.hrl",
1094 "test/nested/exported_test_ffi.js",
1095 "test/nested/exported_test_ffi.mjs",
1096 "test/nested/exported_test_ffi.ts",
1097 "test/nested/ignored.gleam",
1098 "test/nested/ignored_test_ffi.erl",
1099 "test/nested/ignored_test_ffi.mjs",
1100 "dev/exported_test_ffi.erl",
1101 "dev/exported_test_ffi.ex",
1102 "dev/exported_test_ffi.hrl",
1103 "dev/exported_test_ffi.js",
1104 "dev/exported_test_ffi.mjs",
1105 "dev/exported_test_ffi.ts",
1106 "dev/ignored_test.gleam",
1107 "dev/ignored_test_ffi.erl",
1108 "dev/ignored_test_ffi.mjs",
1109 "dev/nested/exported_test.gleam",
1110 "dev/nested/exported_test_ffi.erl",
1111 "dev/nested/exported_test_ffi.ex",
1112 "dev/nested/exported_test_ffi.hrl",
1113 "dev/nested/exported_test_ffi.js",
1114 "dev/nested/exported_test_ffi.mjs",
1115 "dev/nested/exported_test_ffi.ts",
1116 "dev/nested/ignored.gleam",
1117 "dev/nested/ignored_test_ffi.erl",
1118 "dev/nested/ignored_test_ffi.mjs",
1119 "unrelated-file.txt",
1120 ];
1121
1122 let gitignore = "ignored*
1123src/also-ignored.gleam";
1124
1125 for &file in exported_project_files
1126 .iter()
1127 .chain(unexported_project_files)
1128 {
1129 if file.ends_with("/") {
1130 fs::mkdir(path.join(file)).unwrap();
1131 continue;
1132 }
1133
1134 let contents = match file {
1135 ".gitignore" => gitignore,
1136 _ => "",
1137 };
1138
1139 fs::write(&path.join(file), contents).unwrap();
1140 }
1141
1142 let mut chosen_exported_files = project_files(&path).unwrap();
1143 chosen_exported_files.sort_unstable();
1144
1145 let expected_exported_files = exported_project_files
1146 .iter()
1147 .map(|s| path.join(s))
1148 .collect_vec();
1149
1150 assert_eq!(expected_exported_files, chosen_exported_files);
1151}
1152
1153#[test]
1154fn find_git_repo_root_at_same_level() {
1155 let tmp = tempfile::tempdir().unwrap();
1156 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1157
1158 fs::mkdir(path.join(".git")).expect("Create directory");
1159
1160 let repository_root = fs::get_git_repository_root(path);
1161 assert!(
1162 repository_root
1163 .expect("There should be repo root")
1164 .ends_with("my_project")
1165 );
1166}
1167
1168#[test]
1169fn find_missing_git_repo_root_at_same_level() {
1170 let tmp = tempfile::tempdir().unwrap();
1171 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1172
1173 let repository_root = fs::get_git_repository_root(path);
1174 assert!(repository_root.is_none());
1175}
1176
1177#[test]
1178fn find_git_repo_root_at_lower_level() {
1179 let tmp = tempfile::tempdir().unwrap();
1180 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1181
1182 fs::mkdir(path.join(".git")).expect("Create directory");
1183
1184 let current_path = path.join("subdirectory").join("another_subdirectory");
1185 fs::mkdir(¤t_path).expect("Create directory");
1186
1187 let repository_root = fs::get_git_repository_root(current_path);
1188 assert!(
1189 repository_root
1190 .expect("There should be repo root")
1191 .ends_with("my_project")
1192 );
1193}
1194
1195#[test]
1196fn find_missing_git_repo_root_at_lower_level() {
1197 let tmp = tempfile::tempdir().unwrap();
1198 let path = Utf8PathBuf::from_path_buf(tmp.path().join("my_project")).expect("Non Utf8 Path");
1199
1200 let current_path = path.join("subdirectory").join("another_subdirectory");
1201 fs::mkdir(¤t_path).expect("Create directory");
1202
1203 let repository_root = fs::get_git_repository_root(current_path);
1204 assert!(repository_root.is_none());
1205}