pesde 0.7.3

A package manager for the Luau programming language, supporting multiple runtimes including Roblox and Lune
Documentation
use crate::{
	cli::{
		bin_dir,
		config::{read_config, write_config, CliConfig},
		style::{ADDED_STYLE, CLI_STYLE, REMOVED_STYLE, URL_STYLE},
	},
	util::no_build_metadata,
};
use anyhow::Context as _;
use console::Style;
use fs_err::tokio as fs;
use jiff::SignedDuration;
use pesde::{
	engine::{
		source::{
			traits::{DownloadOptions, EngineSource as _, ResolveOptions},
			EngineSources,
		},
		EngineKind,
	},
	reporters::DownloadsReporter,
	version_matches, AuthConfig,
};
use semver::{Version, VersionReq};
use std::{
	collections::BTreeSet,
	env::current_exe,
	path::{Path, PathBuf},
	sync::Arc,
};
use tokio::task::JoinSet;
use tracing::instrument;

use super::engines_dir;

pub fn current_version() -> Version {
	Version::parse(env!("CARGO_PKG_VERSION")).unwrap()
}

const CHECK_INTERVAL: SignedDuration = SignedDuration::from_hours(6);

pub async fn find_latest_version(
	reqwest: &reqwest::Client,
	include_pre: bool,
	auth_config: &AuthConfig,
) -> anyhow::Result<Version> {
	let include_pre = include_pre || !current_version().pre.is_empty();

	let version = EngineSources::pesde()
		.resolve(
			&VersionReq::STAR,
			&ResolveOptions {
				reqwest: reqwest.clone(),
				auth_config: auth_config.clone(),
			},
		)
		.await
		.context("failed to resolve version")?
		.into_keys()
		// since the iterator is from a BTreeMap it is already sorted
		.rfind(|ver| include_pre || ver.pre.is_empty())
		.context("no versions found")?;

	Ok(version)
}

#[instrument(skip(reqwest, auth_config), level = "trace")]
pub async fn check_for_updates(
	reqwest: &reqwest::Client,
	auth_config: &AuthConfig,
) -> anyhow::Result<()> {
	let config = read_config().await?;

	let version = if let Some((_, version)) = config
		.last_checked_updates
		.filter(|(time, _)| jiff::Timestamp::now().duration_since(*time) < CHECK_INTERVAL)
	{
		tracing::debug!("using cached version");
		version
	} else {
		tracing::debug!("checking for updates");
		let version = find_latest_version(reqwest, false, auth_config).await?;

		write_config(&CliConfig {
			last_checked_updates: Some((jiff::Timestamp::now(), version.clone())),
			..config
		})
		.await?;

		version
	};
	let current_version = current_version();
	let version_no_metadata = no_build_metadata(&version);

	if version_no_metadata <= current_version {
		return Ok(());
	}

	let alert_style = Style::new().yellow();
	let changelog = format!("{}/releases/tag/v{version}", env!("CARGO_PKG_REPOSITORY"));

	let messages = [
		format!(
			"{} {} → {}",
			alert_style.apply_to("update available!").bold(),
			REMOVED_STYLE.apply_to(current_version),
			ADDED_STYLE.apply_to(version_no_metadata)
		),
		format!(
			"run {} to upgrade",
			CLI_STYLE.apply_to(concat!("`", env!("CARGO_BIN_NAME"), " self-upgrade`")),
		),
		"".to_string(),
		format!("changelog: {}", URL_STYLE.apply_to(changelog)),
	];

	let column = alert_style.apply_to("┃");

	let message = messages
		.into_iter()
		.map(|s| format!("{column}  {s}"))
		.collect::<Vec<_>>()
		.join("\n");

	eprintln!("\n{message}\n");

	Ok(())
}

#[instrument(level = "trace")]
pub async fn get_installed_versions(engine: EngineKind) -> anyhow::Result<BTreeSet<Version>> {
	let source = engine.source();
	let path = engines_dir()?.join(source.directory());
	let mut installed_versions = BTreeSet::new();

	let mut read_dir = match fs::read_dir(&path).await {
		Ok(read_dir) => read_dir,
		Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(installed_versions),
		Err(e) => return Err(e).context("failed to read engines directory"),
	};

	while let Some(entry) = read_dir.next_entry().await? {
		let path = entry.path();

		let Some(version) = path.file_name().and_then(|s| s.to_str()) else {
			continue;
		};

		if let Ok(version) = Version::parse(version) {
			installed_versions.insert(version);
		}
	}

	Ok(installed_versions)
}

#[instrument(skip(reqwest, reporter, auth_config), level = "trace")]
pub async fn get_or_download_engine(
	reqwest: &reqwest::Client,
	engine: EngineKind,
	req: VersionReq,
	reporter: Arc<impl DownloadsReporter>,
	auth_config: &AuthConfig,
) -> anyhow::Result<(PathBuf, Version)> {
	let source = engine.source();
	let path = engines_dir()?.join(source.directory());

	let installed_versions = get_installed_versions(engine).await?;

	let max_matching = installed_versions
		.iter()
		.rfind(|v| version_matches(&req, v));
	if let Some(version) = max_matching {
		return Ok((
			path.join(version.to_string())
				.join(source.expected_file_name())
				.with_extension(std::env::consts::EXE_EXTENSION),
			version.clone(),
		));
	}

	let mut versions = source
		.resolve(
			&req,
			&ResolveOptions {
				reqwest: reqwest.clone(),
				auth_config: auth_config.clone(),
			},
		)
		.await
		.context("failed to resolve versions")?;
	let (version, engine_ref) = versions.pop_last().context("no matching versions found")?;

	let reporter = reporter.report_download(format!("{engine} v{}", no_build_metadata(&version)));

	let archive = source
		.download(
			&engine_ref,
			&DownloadOptions {
				reqwest: reqwest.clone(),
				reporter: reporter.into(),
				version: version.clone(),
				auth_config: auth_config.clone(),
			},
		)
		.await
		.context("failed to download engine")?;

	let path = path.join(version.to_string());
	fs::create_dir_all(&path)
		.await
		.context("failed to create engine container folder")?;
	let path = path
		.join(source.expected_file_name())
		.with_extension(std::env::consts::EXE_EXTENSION);

	let mut file = fs::File::create(&path)
		.await
		.context("failed to create new file")?;

	tokio::io::copy(
		&mut archive
			.find_executable(source.expected_file_name())
			.await
			.context("failed to find executable")?,
		&mut file,
	)
	.await
	.context("failed to write to file")?;

	make_executable(&path)
		.await
		.context("failed to make downloaded version executable")?;

	if engine != EngineKind::Pesde {
		make_linker_if_needed(engine).await?;
	}

	Ok((path, version))
}

#[instrument(level = "trace")]
pub async fn replace_pesde_bin_exe(with: &Path) -> anyhow::Result<()> {
	let bin_dir = bin_dir()?;
	let bin_name = EngineKind::Pesde.to_string();
	let bin_exe_path = bin_dir
		.join(&bin_name)
		.with_extension(std::env::consts::EXE_EXTENSION);

	let exists = fs::metadata(&bin_exe_path).await.is_ok();

	if cfg!(target_os = "linux") && exists {
		fs::remove_file(&bin_exe_path)
			.await
			.context("failed to remove existing executable")?;
	} else if exists {
		let tempfile = tempfile::Builder::new()
			.make(|_| Ok(()))
			.context("failed to create temporary file")?;
		let temp_path = tempfile.into_temp_path().to_path_buf();
		#[cfg(windows)]
		let temp_path = temp_path.with_extension("exe");

		match fs::rename(&bin_exe_path, &temp_path).await {
			Ok(_) => {}
			Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
			Err(e) => return Err(e).context("failed to rename existing executable"),
		}
	}

	if let Some(parent) = bin_exe_path.parent() {
		fs::create_dir_all(parent)
			.await
			.context("failed to create bin directory")?;
	}

	fs::copy(with, &bin_exe_path)
		.await
		.context("failed to copy executable to bin folder")?;

	make_executable(&bin_exe_path).await?;

	let bin_exe_path: Arc<Path> = bin_exe_path.into();

	let mut entries = fs::read_dir(bin_dir)
		.await
		.context("failed to read bin directory")?;

	let mut tasks = JoinSet::new();

	while let Some(entry) = entries
		.next_entry()
		.await
		.context("failed to read bin directory entry")?
	{
		if entry
			.file_type()
			.await
			.context("failed to get bin entry type")?
			.is_dir()
		{
			continue;
		}

		let path = entry.path();

		if path
			.file_stem()
			.is_some_and(|name| name.eq_ignore_ascii_case(&bin_name))
		{
			continue;
		}

		let bin_exe_path = bin_exe_path.clone();

		tasks.spawn(async move {
			fs::remove_file(&path)
				.await
				.context("failed to remove bin directory entry")?;
			fs::hard_link(bin_exe_path, path)
				.await
				.context("failed to hard link new linker in bin directory")
		});
	}

	while let Some(res) = tasks.join_next().await {
		res.unwrap()?;
	}

	Ok(())
}

#[instrument(level = "trace")]
pub async fn make_linker_if_needed(engine: EngineKind) -> anyhow::Result<()> {
	let linker = bin_dir()?
		.join(engine.to_string())
		.with_extension(std::env::consts::EXE_EXTENSION);

	let exe = current_exe().context("failed to get current exe path")?;

	if let Some(parent) = linker.parent() {
		fs::create_dir_all(parent)
			.await
			.context("failed to create linker directory")?;
	}

	match fs::hard_link(exe, linker).await {
		Ok(_) => {}
		Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
		e => e.context("failed to hard link engine executable")?,
	}

	Ok(())
}

#[cfg_attr(windows, allow(clippy::unused_async))]
pub async fn make_executable(_path: impl AsRef<Path>) -> anyhow::Result<()> {
	#[cfg(unix)]
	{
		use anyhow::Context as _;
		use fs_err::tokio as fs;
		use std::os::unix::fs::PermissionsExt as _;

		let mut perms = fs::metadata(&_path)
			.await
			.context("failed to get bin link file metadata")?
			.permissions();
		perms.set_mode(perms.mode() | 0o111);
		fs::set_permissions(&_path, perms)
			.await
			.context("failed to set bin link file permissions")?;
	}

	Ok(())
}