diff --git a/Cargo.lock b/Cargo.lock index 71710e3..4645006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,21 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -71,12 +86,24 @@ version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca87830a3e3fb156dc96cfbd31cb620265dd053be734723f22b760d6cc3c3051" +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + [[package]] name = "base64" version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "bumpalo" +version = "3.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea184aa71bb362a1157c896979544cc23974e08fd265f29ea96b59f0b4a555b" + [[package]] name = "byteorder" version = "1.5.0" @@ -136,6 +163,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "clap" version = "4.4.12" @@ -182,6 +223,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "crc32fast" version = "1.3.2" @@ -232,6 +279,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fmutex" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e84c17070603126a7b0cd07d0ecc8e8cca4d15b67934ac2740286a84f3086c" +dependencies = [ + "libc", +] + [[package]] name = "getrandom" version = "0.2.11" @@ -286,6 +342,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -302,6 +381,15 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "js-sys" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.151" @@ -349,6 +437,15 @@ dependencies = [ "adler", ] +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -395,12 +492,29 @@ name = "powerpack" version = "0.5.0" dependencies = [ "goldie", + "powerpack-cache", "powerpack-detach", "powerpack-env", "serde", "serde_json", ] +[[package]] +name = "powerpack-cache" +version = "0.5.0" +dependencies = [ + "anyhow", + "chrono", + "fmutex", + "home", + "log", + "powerpack-detach", + "powerpack-env", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "powerpack-cli" version = "0.5.0" @@ -442,9 +556,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.72" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a293318316cf6478ec1ad2a21c49390a8d5b5eae9fab736467d93fbc0edc29c5" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -460,9 +574,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -527,9 +641,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "2.0.43" +version = "2.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee659fb5f3d355364e1f3e5bc10fb82068efbf824a1e9d1c9504244a6469ad53" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" dependencies = [ "proc-macro2", "quote", @@ -538,18 +652,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.53" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cd5904763bad08ad5513ddbb12cf2ae273ca53fa9f68e843e236ec6dfccc09" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.53" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dcf4a824cce0aeacd6f38ae6f24234c8e80d68632338ebaa1443b5df9e29e19" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", @@ -652,6 +766,60 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" + [[package]] name = "winapi" version = "0.3.9" @@ -674,6 +842,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 0e0ba74..fbd7a74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ categories = ["command-line-utilities"] [workspace.dependencies] powerpack = { version = "0.5.0", path = "." } +powerpack-cache = { version = "0.5.0", path = "crates/cache" } powerpack-detach = { version = "0.5.0", path = "crates/detach" } powerpack-env = { version = "0.5.0", path = "crates/env" } @@ -30,6 +31,7 @@ keywords.workspace = true categories.workspace = true [dependencies] +powerpack-cache = { workspace = true, optional = true } powerpack-detach = { workspace = true, optional = true } powerpack-env = { workspace = true, optional = true } serde = { version = "1.0.193", features = ["derive"] } @@ -40,6 +42,7 @@ goldie = "0.4.3" [features] default = ["env"] +cache = ["dep:powerpack-cache"] detach = ["dep:powerpack-detach"] env = ["dep:powerpack-env"] diff --git a/crates/cache/Cargo.toml b/crates/cache/Cargo.toml new file mode 100644 index 0000000..52a8c1f --- /dev/null +++ b/crates/cache/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "powerpack-cache" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "⚡ Cache management for your Alfred workflow" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +anyhow = "1.0.56" +chrono = "0.4.19" +fmutex = "0.1.0" +home = "0.5.3" +log = { version = "0.4.16", features = ["std"] } +powerpack-detach.workspace = true +powerpack-env.workspace = true +serde = { version = "1.0.136", features = ["derive"] } +serde_json = "1.0.79" +thiserror = "1.0.57" diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs new file mode 100644 index 0000000..20e9c20 --- /dev/null +++ b/crates/cache/src/lib.rs @@ -0,0 +1,227 @@ +//! Construct a [`Cache`] in your workflow, providing any necessary +//! configuration. +//! +//! ```no_run +//! # use std::time::Duration; +//! # use powerpack_cache::Cache; +//! let cache = Cache::builder() +//! .bundle_id("com.example.bundle") +//! .ttl(Duration::from_secs(60)) +//! .build() +//! .unwrap(); +//! ``` +//! +//! Then the only function to call is [`.load(..)`][Cache::load] which will +//! fetch the cached value and/or detach a process to update it. +//! ```no_run +//! # let mut cache = powerpack_cache::Cache::builder().build().unwrap(); +//! let expensive_fn = || { +//! // ... +//! # Ok::(String::from("")) +//! }; +//! +//! let data = cache.load("key", "checksum", expensive_fn).unwrap(); +//! ``` + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use serde_json as json; +use thiserror::Error; + +use powerpack_detach as detach; +use powerpack_env as env; + +/// Raised when the cache is not populated within the poll duration. +#[derive(Debug, Clone, Error)] +#[non_exhaustive] +#[error("timeout waiting for cached data")] +pub struct TimeoutError {} + +/// A builder for a cache. +/// +/// Constructed using [`Cache::builder`]. +#[derive(Debug, Clone)] +pub struct Builder { + directory: Option, + bundle_id: Option, + ttl: Option, + poll_interval: Option, + poll_duration: Option, +} + +/// Manage a cache of data. +#[derive(Debug)] +pub struct Cache { + directory: PathBuf, + ttl: Duration, + poll_interval: Duration, + poll_duration: Duration, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Data<'a> { + modified: SystemTime, + checksum: &'a str, + data: String, +} + +impl Builder { + /// Set the cache directory. + pub fn directory(mut self, directory: impl Into) -> Self { + self.directory = Some(directory.into()); + self + } + + /// Set the bundle id. + pub fn bundle_id(mut self, bundle_id: impl Into) -> Self { + self.bundle_id = Some(bundle_id.into()); + self + } + + /// Set the interval at which the cache will be updated. + pub fn poll_interval(mut self, poll_interval: Duration) -> Self { + self.poll_interval = Some(poll_interval); + self + } + + /// Set the duration to wait for the cache to be populated. + pub fn poll_duration(mut self, poll_duration: Duration) -> Self { + self.poll_duration = Some(poll_duration); + self + } + + /// Set the Time To Live (TTL) for the data in the cache. + /// + /// If the data in the cache is older than this then the cache will be + /// automatically refreshed. + pub fn ttl(mut self, tll: Duration) -> Self { + self.ttl = Some(tll); + self + } + + /// Build the cache. + pub fn build(self) -> Result { + let Self { + directory, + bundle_id, + ttl, + poll_interval, + poll_duration, + } = self; + + let directory = match directory { + Some(d) => d, + None => match env::workflow_cache() { + Some(d) => d, + None => { + let bundle_id = env::workflow_bundle_id() + .or(bundle_id) + .ok_or_else(|| anyhow!("no bundle id set"))?; + home::home_dir() + .ok_or_else(|| anyhow!("failed to find current user's home directory"))? + .join("Library/Caches/com.runningwithcrayons.Alfred/Workflow Data") + .join(bundle_id) + } + }, + }; + let ttl = ttl.unwrap_or_else(|| Duration::from_secs(30)); + let poll_interval = poll_interval.unwrap_or_else(|| Duration::from_millis(100)); + let poll_duration = poll_duration.unwrap_or_else(|| Duration::from_secs(1)); + + Ok(Cache { + directory, + ttl, + poll_interval, + poll_duration, + }) + } +} + +impl Cache { + /// Returns a new cache builder. + pub fn builder() -> Builder { + Builder { + directory: None, + bundle_id: None, + ttl: None, + poll_interval: None, + poll_duration: None, + } + } + + /// Fetches the cache value and/or detaches a process to update it. + pub fn load(&mut self, key: &str, checksum: &str, f: F) -> Result + where + F: FnOnce() -> Result, + { + let directory = self.directory.join(key); + let path = directory.join("data.json"); + + let update_cache = || match update(&directory, &path, checksum, f) { + Ok(true) => log::info!("fetched {} and updated cache", path.display()), + Ok(false) => log::info!("another process updated cache for {}", path.display()), + Err(err) => log::error!("{:#}", err), + }; + + match fs::read(&path) { + Ok(data) => { + let curr: Data = json::from_slice(&data)?; + let needs_update = curr.checksum != checksum || { + let now = SystemTime::now(); + now.duration_since(curr.modified)? > self.ttl + }; + if needs_update { + detach::spawn(update_cache)?; + } + Ok(curr.data) + } + + Err(err) if err.kind() == io::ErrorKind::NotFound => { + detach::spawn(update_cache)?; + // wait for the cache to be populated + let start = Instant::now(); + while Instant::now().duration_since(start) < self.poll_duration { + thread::sleep(self.poll_interval); + if let Ok(data) = fs::read(&path) { + let curr: Data = json::from_slice(&data)?; + return Ok(curr.data); + } + } + Err(TimeoutError {}.into()) + } + + Err(err) => Err(err.into()), + } + } +} + +fn update(directory: &Path, path: &Path, checksum: &str, f: F) -> Result +where + F: FnOnce() -> Result, +{ + let tmp = path.with_extension("tmp"); + if let Some(_guard) = fmutex::try_lock(directory)? { + let data = f()?; + fs::create_dir_all(path.parent().unwrap())?; + let file = fs::File::create(&tmp)?; + let modified = SystemTime::now(); + json::to_writer( + &file, + &Data { + checksum, + modified, + data, + }, + )?; + fs::rename(tmp, path)?; + Ok(true) + } else { + Ok(false) + } +} diff --git a/src/lib.rs b/src/lib.rs index adedf01..c05b0f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,9 @@ use serde::{Serialize, Serializer}; pub use serde_json::json as value; pub use serde_json::Value; +#[cfg(feature = "cache")] +pub use powerpack_cache as cache; + #[cfg(feature = "detach")] pub use powerpack_detach as detach;