From f5f4fc0b8a98a19afd25886f1c9b4c56d1a6c2a6 Mon Sep 17 00:00:00 2001
From: Ross MacArthur <ross@macarthur.io>
Date: Sat, 24 Feb 2024 19:31:08 +0200
Subject: [PATCH] cache: Add crate

---
 Cargo.lock              | 197 ++++++++++++++++++++++++++++++++--
 Cargo.toml              |   3 +
 crates/cache/Cargo.toml |  23 ++++
 crates/cache/src/lib.rs | 227 ++++++++++++++++++++++++++++++++++++++++
 src/lib.rs              |   3 +
 5 files changed, 443 insertions(+), 10 deletions(-)
 create mode 100644 crates/cache/Cargo.toml
 create mode 100644 crates/cache/src/lib.rs

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, anyhow::Error>(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<PathBuf>,
+    bundle_id: Option<String>,
+    ttl: Option<Duration>,
+    poll_interval: Option<Duration>,
+    poll_duration: Option<Duration>,
+}
+
+/// 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<PathBuf>) -> Self {
+        self.directory = Some(directory.into());
+        self
+    }
+
+    /// Set the bundle id.
+    pub fn bundle_id(mut self, bundle_id: impl Into<String>) -> 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<Cache> {
+        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<F>(&mut self, key: &str, checksum: &str, f: F) -> Result<String>
+    where
+        F: FnOnce() -> Result<String>,
+    {
+        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<F>(directory: &Path, path: &Path, checksum: &str, f: F) -> Result<bool>
+where
+    F: FnOnce() -> Result<String>,
+{
+    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;