pub use super::manage_error::ProtoManageError;
use crate::flow::install::{InstallOptions, Installer};
use crate::flow::link::Linker;
use crate::flow::lock::Locker;
use crate::flow::resolve::Resolver;
use crate::lockfile::LockRecord;
use crate::telemetry::cache_status;
use crate::tool::Tool;
use crate::tool_manifest::ToolManifestVersion;
use crate::tool_spec::ToolSpec;
use proto_pdk_api::{InstallStrategy, PluginFunction, SyncManifestInput, SyncManifestOutput};
use starbase_utils::fs;
use std::collections::{BTreeMap, BTreeSet};
use tracing::{debug, instrument};
#[allow(clippy::large_enum_variant)]
enum InstallOutcome {
AlreadyInstalled,
InstalledConcurrently,
Installed(LockRecord),
}
pub struct Manager<'tool> {
tool: &'tool mut Tool,
}
impl<'tool> Manager<'tool> {
pub fn new(tool: &'tool mut Tool) -> Self {
Self { tool }
}
#[instrument(skip(self, options))]
pub async fn install(
&mut self,
spec: &mut ToolSpec,
options: InstallOptions,
) -> Result<Option<LockRecord>, ProtoManageError> {
let timer = self.tool.proto.create_metric();
let strategy = install_strategy_name(&options.strategy);
let mut cache = "unknown";
let temp_dir = self.tool.get_version_temp_dir(spec);
let mut install_lock = fs::lock_directory(&temp_dir)?;
let result = async {
match self.do_install(spec, options).await? {
InstallOutcome::AlreadyInstalled | InstallOutcome::InstalledConcurrently => {
cache = "hit";
self.post_install(spec, None).await?;
Ok(None)
}
InstallOutcome::Installed(record) => {
cache = "miss";
self.post_install(spec, Some(&record)).await?;
Ok(Some(record))
}
}
}
.await;
install_lock.unlock()?;
let _ = fs::remove_dir_all(temp_dir);
timer.record_tool_install(&self.tool.context, strategy, cache, result)
}
async fn do_install(
&mut self,
spec: &mut ToolSpec,
options: InstallOptions,
) -> Result<InstallOutcome, ProtoManageError> {
let version = Resolver::resolve(self.tool, spec, false).await?;
if self.tool.is_installed(spec) && !options.force {
return Ok(InstallOutcome::AlreadyInstalled);
}
if self.was_installed_concurrently(spec, &options).await? {
return Ok(InstallOutcome::InstalledConcurrently);
}
let record = match Installer::new(self.tool, spec).install(options).await {
Ok(Some(mut record)) => {
record.version = Some(version.clone());
record.spec = Some(spec.req.clone());
record
}
Ok(None) => {
return Ok(InstallOutcome::AlreadyInstalled);
}
Err(error) => {
debug!(
tool = self.tool.context.as_str(),
install_dir = ?self.tool.get_product_dir(spec),
"Failed to install tool, cleaning up",
);
let _ = fs::remove_dir_all(self.tool.get_product_dir(spec));
return Err(error.into());
}
};
Ok(InstallOutcome::Installed(record))
}
#[instrument(skip(self))]
async fn post_install(
&mut self,
spec: &mut ToolSpec,
record: Option<&LockRecord>,
) -> Result<(), ProtoManageError> {
if let Some(record) = record {
if spec.update_lockfile {
Locker::new(self.tool).insert_record_into_lockfile(record)?;
}
self.tool.inventory.manifest.add_version(
record.version.as_ref().unwrap(),
ToolManifestVersion {
lock: Some(record.for_manifest()),
suffix: self.tool.inventory.config.version_suffix.clone(),
..Default::default()
},
);
self.tool.inventory.manifest.save()?;
}
Linker::link(self.tool, spec, record.is_some()).await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn uninstall(&mut self, spec: &mut ToolSpec) -> Result<bool, ProtoManageError> {
let timer = self.tool.proto.create_metric();
let mut cache = "unknown";
let result = async {
self.cleanup().await?;
let version = Resolver::resolve(self.tool, spec, false).await?;
cache = cache_status(self.tool.is_installed(spec));
if !Installer::new(self.tool, spec).uninstall().await? {
return Ok(false);
}
if spec.update_lockfile {
Locker::new(self.tool).remove_version_from_lockfile(&version)?;
}
let linker = Linker::new(self.tool, spec)?;
if self.tool.inventory.manifest.installed_versions.is_empty()
|| self.tool.inventory.manifest.is_only_version(&version)
{
linker.unlink_bins().await?;
linker.unlink_shims().await?;
} else {
linker.unlink_bins_by_version(&version).await?;
}
self.tool.inventory.manifest.remove_version(&version);
Ok(true)
}
.await;
timer.record_tool_uninstall(&self.tool.context, "version", cache, result)
}
#[instrument(skip(self))]
pub async fn cleanup(&self) -> Result<(), ProtoManageError> {
debug!(
tool = self.tool.context.as_str(),
"Cleaning up temporary files and downloads"
);
let temp_dir = self.tool.get_temp_dir();
if !temp_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(temp_dir)? {
let path = entry.path();
if path.is_dir() {
if fs::is_dir_locked(&path) {
debug!(
tool = self.tool.context.as_str(),
dir = ?path,
"Skipping temporary directory, an install is currently in progress"
);
continue;
}
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
}
}
Ok(())
}
#[instrument(skip(self))]
pub async fn sync_manifest(self) -> Result<(), ProtoManageError> {
if !self
.tool
.plugin
.has_func(PluginFunction::SyncManifest)
.await
{
self.tool.inventory.manifest.save()?;
return Ok(());
}
debug!(
tool = self.tool.context.as_str(),
"Syncing manifest with changes"
);
let output: SyncManifestOutput = self
.tool
.plugin
.call_func_with(
PluginFunction::SyncManifest,
SyncManifestInput {
context: self.tool.create_plugin_unresolved_context(),
},
)
.await?;
if !output.skip_sync
&& let Some(versions) = output.versions
{
let mut entries = BTreeMap::default();
let mut installed = BTreeSet::default();
for key in versions {
let value = self
.tool
.inventory
.manifest
.versions
.get(&key)
.cloned()
.unwrap_or_default();
installed.insert(key.clone());
entries.insert(key, value);
}
self.tool.inventory.manifest.versions = entries;
self.tool.inventory.manifest.installed_versions = installed;
}
self.tool.inventory.manifest.save()?;
Ok(())
}
async fn was_installed_concurrently(
&mut self,
spec: &ToolSpec,
options: &InstallOptions,
) -> Result<bool, ProtoManageError> {
self.tool.inventory.manifest.reload_from_disk()?;
if self.tool.is_installed(spec) && !options.force {
debug!(
tool = self.tool.context.as_str(),
"Tool was installed by another process while waiting on the install lock, continuing"
);
return Ok(true);
}
Ok(false)
}
}
fn install_strategy_name(strategy: &InstallStrategy) -> &'static str {
match strategy {
InstallStrategy::BuildFromSource => "build-from-source",
InstallStrategy::DownloadPrebuilt => "download-prebuilt",
}
}