diff --git a/Cargo.lock b/Cargo.lock index edf5517..eb4751f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "anyhow", "clap", "command-group", + "const_format", "dashmap", "dialoguer", "dirs", @@ -336,6 +337,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "const_format" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a214c7af3d04997541b18d432afaff4c455e79e2029079647e72fc2bd27673" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1925,6 +1946,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unicode-xid" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 50002f0..7b53c43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ default-run = "aftman" [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +const_format = "0.2" dashmap = "5.5" dialoguer = "0.11" dirs = "5.0" diff --git a/lib/storage/home.rs b/lib/storage/home.rs index 23bd2db..132d0cf 100644 --- a/lib/storage/home.rs +++ b/lib/storage/home.rs @@ -3,12 +3,12 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use super::{StorageError, StorageResult, TrustStorage}; +use super::{InstalledStorage, StorageError, StorageResult, TrustStorage}; /** - Aftman's home directory. - - This is where Aftman stores its configuration, tools, and other data. + Aftman's home directory - this is where Aftman stores its + configuration, tools, and other data. Can be cheaply cloned + while still referring to the same underlying data. By default, this is `$HOME/.aftman`, but can be overridden by setting the `AFTMAN_ROOT` environment variable. @@ -18,6 +18,7 @@ pub struct Home { path: Arc, saved: Arc, trust: TrustStorage, + installed: InstalledStorage, } impl Home { @@ -29,8 +30,14 @@ impl Home { let saved = Arc::new(AtomicBool::new(false)); let trust = TrustStorage::load(&path).await?; + let installed = InstalledStorage::load(&path).await?; - Ok(Self { path, saved, trust }) + Ok(Self { + path, + saved, + trust, + installed, + }) } /** @@ -62,25 +69,37 @@ impl Home { &self.trust } + /** + Returns a reference to the `InstalledStorage` for this `Home`. + */ + pub fn installed(&self) -> &InstalledStorage { + &self.installed + } + /** Saves the contents of this `Home` to disk. */ pub async fn save(&self) -> StorageResult<()> { self.trust.save(&self.path).await?; + self.installed.save(&self.path).await?; self.saved.store(true, Ordering::SeqCst); Ok(()) } } -// Implement Drop with an error message if the Home was dropped -// without being saved - this should never happen since a Home -// should always be loaded once on startup and saved on shutdown -// in the CLI, but this detail may be missed during refactoring. -// In the future, if AsyncDrop ever becomes a thing, we can just -// force the save to happen in the Drop implementation instead. +/* + Implement Drop with an error message if the Home was dropped + without being saved - this should never happen since a Home + should always be loaded once on startup and saved on shutdown + in the CLI, but this detail may be missed during refactoring. + + In the future, if AsyncDrop ever becomes a thing, we can just + force the save to happen in the Drop implementation instead. +*/ impl Drop for Home { fn drop(&mut self) { - if !self.saved.load(Ordering::SeqCst) { + let is_last = Arc::strong_count(&self.path) <= 1; + if is_last && !self.saved.load(Ordering::SeqCst) { tracing::error!( "Aftman home was dropped without being saved!\ \nChanges to trust, tools, and more may have been lost." diff --git a/lib/storage/installed.rs b/lib/storage/installed.rs new file mode 100644 index 0000000..3f7e1b7 --- /dev/null +++ b/lib/storage/installed.rs @@ -0,0 +1,143 @@ +#![allow(clippy::should_implement_trait)] +#![allow(clippy::inherent_to_string)] + +use std::{collections::BTreeSet, convert::Infallible, str::FromStr, sync::Arc}; + +use dashmap::DashSet; +use semver::Version; + +use crate::tool::{ToolId, ToolSpec}; + +/** + Storage for installed tool specifications. + + Can be cheaply cloned while still + referring to the same underlying data. +*/ +#[derive(Debug, Default, Clone)] +pub struct InstalledStorage { + tools: Arc>, +} + +impl InstalledStorage { + /** + Create a new, **empty** `InstalledStorage`. + */ + pub fn new() -> Self { + Self::default() + } + + /** + Parse the contents of a string into a `InstalledStorage`. + + Note that this is not fallible - any invalid + lines or tool specifications will simply be ignored. + + This means that, worst case, if the installed storage file is corrupted, + the user will simply have to re-install the tools they want to use. + */ + pub fn from_str(s: impl AsRef) -> Self { + let tools = s + .as_ref() + .lines() + .filter_map(|line| line.parse::().ok()) + .collect::>(); + Self { + tools: Arc::new(tools), + } + } + + /** + Add a tool to this `InstalledStorage`. + + Returns `true` if the tool was added and not already trusted. + */ + pub fn add_spec(&self, tool: ToolSpec) -> bool { + self.tools.insert(tool) + } + + /** + Remove a tool from this `InstalledStorage`. + + Returns `true` if the tool was previously trusted and has now been removed. + */ + pub fn remove_spec(&self, tool: &ToolSpec) -> bool { + self.tools.remove(tool).is_some() + } + + /** + Check if a tool is cached in this `InstalledStorage`. + */ + pub fn is_installed(&self, tool: &ToolSpec) -> bool { + self.tools.contains(tool) + } + + /** + Get a sorted copy of the installed tools in this `InstalledStorage`. + */ + pub fn all_specs(&self) -> Vec { + let mut sorted_tools = self.tools.iter().map(|id| id.clone()).collect::>(); + sorted_tools.sort(); + sorted_tools + } + + /** + Get a sorted list of all unique tool identifiers in this `InstalledStorage`. + */ + pub fn all_ids(&self) -> Vec { + let sorted_set = self + .all_specs() + .into_iter() + .map(ToolId::from) + .collect::>(); + sorted_set.into_iter().collect() + } + + /** + Get a sorted list of all unique versions for a + given tool identifier in this `InstalledStorage`. + */ + pub fn all_versions_for_id(&self, id: &ToolId) -> Vec { + let sorted_set = self + .all_specs() + .into_iter() + .filter_map(|spec| { + if ToolId::from(spec.clone()) == *id { + Some(spec.version().clone()) + } else { + None + } + }) + .collect::>(); + sorted_set.into_iter().collect() + } + + /** + Render the contents of this `InstalledStorage` to a string. + + This will be a sorted list of all tool specifications, separated by newlines. + */ + pub fn to_string(&self) -> String { + let mut contents = self + .all_specs() + .into_iter() + .map(|id| id.to_string()) + .collect::>() + .join("\n"); + contents.push('\n'); + contents + } +} + +impl FromStr for InstalledStorage { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(InstalledStorage::from_str(s)) + } +} + +impl ToString for InstalledStorage { + fn to_string(&self) -> String { + self.to_string() + } +} diff --git a/lib/storage/load_and_save.rs b/lib/storage/load_and_save.rs index fd95ec7..456a179 100644 --- a/lib/storage/load_and_save.rs +++ b/lib/storage/load_and_save.rs @@ -1,10 +1,12 @@ -use std::path::Path; +use std::path::{Path, MAIN_SEPARATOR}; +use const_format::concatcp; use tokio::fs::{read_to_string, write}; -use super::{StorageResult, TrustStorage}; +use super::{InstalledStorage, StorageResult, TrustStorage}; const FILE_PATH_TRUST: &str = "trusted.txt"; +const FILE_PATH_INSTALLED: &str = concatcp!("tool-storage", MAIN_SEPARATOR, "installed.txt"); impl TrustStorage { pub(super) async fn load(home_path: impl AsRef) -> StorageResult { @@ -21,3 +23,19 @@ impl TrustStorage { Ok(write(path, self.to_string()).await?) } } + +impl InstalledStorage { + pub(super) async fn load(home_path: impl AsRef) -> StorageResult { + let path = home_path.as_ref().join(FILE_PATH_INSTALLED); + match read_to_string(&path).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledStorage::new()), + Err(e) => Err(e.into()), + Ok(s) => Ok(InstalledStorage::from_str(s)), + } + } + + pub(super) async fn save(&self, home_path: impl AsRef) -> StorageResult<()> { + let path = home_path.as_ref().join(FILE_PATH_INSTALLED); + Ok(write(path, self.to_string()).await?) + } +} diff --git a/lib/storage/mod.rs b/lib/storage/mod.rs index abde34e..965b869 100644 --- a/lib/storage/mod.rs +++ b/lib/storage/mod.rs @@ -1,8 +1,10 @@ mod home; +mod installed; mod load_and_save; mod result; mod trust; pub use home::Home; +pub use installed::InstalledStorage; pub use result::{StorageError, StorageResult}; pub use trust::TrustStorage; diff --git a/lib/storage/trust.rs b/lib/storage/trust.rs index ec2b929..8dcae2a 100644 --- a/lib/storage/trust.rs +++ b/lib/storage/trust.rs @@ -9,6 +9,9 @@ use crate::tool::ToolId; /** Storage for trusted tool identifiers. + + Can be cheaply cloned while still + referring to the same underlying data. */ #[derive(Debug, Default, Clone)] pub struct TrustStorage { diff --git a/lib/tool/spec.rs b/lib/tool/spec.rs index 4d2fc28..247467a 100644 --- a/lib/tool/spec.rs +++ b/lib/tool/spec.rs @@ -29,7 +29,9 @@ pub enum ToolSpecParseError { This is an extension of [`ToolId`] used to uniquely identify a *specific version requirement* of a given tool. */ -#[derive(Debug, Clone, PartialEq, Eq, Hash, DeserializeFromStr, SerializeDisplay)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, DeserializeFromStr, SerializeDisplay, +)] pub struct ToolSpec { pub(super) author: String, pub(super) name: String, @@ -96,6 +98,15 @@ impl From<(ToolId, Version)> for ToolSpec { } } +impl From for ToolId { + fn from(spec: ToolSpec) -> Self { + ToolId { + author: spec.author, + name: spec.name, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/list.rs b/src/cli/list.rs index 76a2564..1f0d216 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -8,7 +8,31 @@ use aftman::storage::Home; pub struct ListSubcommand {} impl ListSubcommand { - pub async fn run(&self, _home: &Home) -> Result<()> { + pub async fn run(&self, home: &Home) -> Result<()> { + let installed = home.installed(); + let tools = installed + .all_ids() + .into_iter() + .map(|id| (id.clone(), installed.all_versions_for_id(&id))) + .collect::>(); + + if tools.is_empty() { + println!("No tools installed."); + } else { + println!("Installed tools:\n"); + for (id, mut versions) in tools { + versions.reverse(); // List newest versions first + + let vers = versions + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(", "); + + println!("{id}\n {vers}"); + } + } + Ok(()) } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5eccbca..43f94c2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,5 @@ use aftman::storage::Home; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; mod debug_system_info; @@ -42,7 +42,9 @@ pub enum Subcommand { impl Subcommand { pub async fn run(self) -> Result<()> { - let home = Home::load_from_env().await?; + let home = Home::load_from_env() + .await + .context("Failed to load Aftman home!")?; let result = match self { // Hidden subcommands @@ -54,7 +56,10 @@ impl Subcommand { Self::Untrust(cmd) => cmd.run(&home).await, }; - home.save().await?; + home.save().await.context( + "Failed to save Aftman data!\ + \nChanges to trust, tools, and more may have been lost.", + )?; result }