Repository: redbadger/crux (language: rust)

Issue:
Implement async API in crux_time
See https://github.com/redbadger/crux/pull/179 for context.

This should hopefully be a backwards compatible refactoring


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index c0ef67d3..469b6f64 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,21 @@ dependencies = [
  "memchr",
 ]
 
+[[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 = "anstream"
 version = "0.6.11"
@@ -337,12 +352,36 @@ version = "3.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
 
+[[package]]
+name = "cc"
+version = "1.0.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "cfg-if"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "chrono"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-targets 0.52.0",
+]
+
 [[package]]
 name = "clap"
 version = "4.4.18"
@@ -417,6 +456,12 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
 
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
 [[package]]
 name = "crossbeam-channel"
 version = "0.5.11"
@@ -554,6 +599,7 @@ name = "crux_time"
 version = "0.1.8"
 dependencies = [
  "anyhow",
+ "chrono",
  "crux_core",
  "crux_macros",
  "serde",
@@ -1020,6 +1066,29 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "iana-time-zone"
+version = "0.1.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539"
+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 = "ident_case"
 version = "1.0.1"
@@ -1217,6 +1286,15 @@ version = "2.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
 
+[[package]]
+name = "num-traits"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "once_cell"
 version = "1.19.0"
@@ -2132,6 +2210,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 0.52.0",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.48.0"
diff --git a/crux_core/src/capabilities/compose.rs b/crux_core/src/capabilities/compose.rs
index 4f94f70a..70afc95d 100644
--- a/crux_core/src/capabilities/compose.rs
+++ b/crux_core/src/capabilities/compose.rs
@@ -62,7 +62,7 @@ impl<Ev> Compose<Ev> {
     /// # pub struct Model {
     /// #     pub total: usize,
     /// # }
-    /// # #[derive(Effect, Clone)]
+    /// # #[derive(Effect)]
     /// # pub struct Capabilities {
     /// #     one: doctest_support::compose::capabilities::capability_one::CapabilityOne<Event>,
     /// #     two: doctest_support::compose::capabilities::capability_two::CapabilityTwo<Event>,
@@ -77,13 +77,14 @@ impl<Ev> Compose<Ev> {
     ///     fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
     ///         match event {
     ///             Event::Trigger => caps.compose.spawn(|context| {
-    ///                 let caps = caps.clone();
+    ///                 let one = caps.one.clone();
+    ///                 let two = caps.two.clone();
     ///
     ///                 async move {
     ///                     let (result_one, result_two) =
     ///                         futures::future::join(
-    ///                             caps.one.one_async(10),
-    ///                             caps.two.two_async(20)
+    ///                             one.one_async(10),
+    ///                             two.two_async(20)
     ///                         ).await;
     ///
     ///                     context.update_app(Event::Finished(result_one, result_two))
diff --git a/crux_core/src/capabilities/render.rs b/crux_core/src/capabilities/render.rs
index baae6408..4440a462 100644
--- a/crux_core/src/capabilities/render.rs
+++ b/crux_core/src/capabilities/render.rs
@@ -18,6 +18,14 @@ pub struct Render<Ev> {
     context: CapabilityContext<RenderOperation, Ev>,
 }
 
+impl<Ev> Clone for Render<Ev> {
+    fn clone(&self) -> Self {
+        Self {
+            context: self.context.clone(),
+        }
+    }
+}
+
 /// The single operation `Render` implements.
 #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
 pub struct RenderOperation;
diff --git a/crux_kv/src/lib.rs b/crux_kv/src/lib.rs
index af6828c0..a2856f91 100644
--- a/crux_kv/src/lib.rs
+++ b/crux_kv/src/lib.rs
@@ -34,6 +34,14 @@ pub struct KeyValue<Ev> {
     context: CapabilityContext<KeyValueOperation, Ev>,
 }
 
+impl<Ev> Clone for KeyValue<Ev> {
+    fn clone(&self) -> Self {
+        Self {
+            context: self.context.clone(),
+        }
+    }
+}
+
 impl<Ev> KeyValue<Ev>
 where
     Ev: 'static,
@@ -57,6 +65,14 @@ where
         });
     }
 
+    /// Read a value under `key`, while in an async context. This is used together with
+    /// [`crux_core::compose::Compose`].
+    pub async fn read_async(&self, key: &str) -> KeyValueOutput {
+        self.context
+            .request_from_shell(KeyValueOperation::Read(key.to_string()))
+            .await
+    }
+
     /// Set `key` to be the provided `value`. Typically the bytes would be
     /// a value serialized/deserialized by the app.
     ///
@@ -77,4 +93,12 @@ where
             }
         });
     }
+
+    /// Set `key` to be the provided `value`, while in an async context. This is used together with
+    /// [`crux_core::compose::Compose`].
+    pub async fn write_async(&self, key: &str, value: Vec<u8>) -> KeyValueOutput {
+        self.context
+            .request_from_shell(KeyValueOperation::Write(key.to_string(), value))
+            .await
+    }
 }
diff --git a/crux_time/Cargo.toml b/crux_time/Cargo.toml
index e47e86be..39347eb3 100644
--- a/crux_time/Cargo.toml
+++ b/crux_time/Cargo.toml
@@ -15,3 +15,4 @@ anyhow.workspace = true
 crux_core = { version = "0.7", path = "../crux_core" }
 crux_macros = { version = "0.3", path = "../crux_macros" }
 serde = { workspace = true, features = ["derive"] }
+chrono = { version = "0.4", features = ["serde"] }
diff --git a/crux_time/src/lib.rs b/crux_time/src/lib.rs
index 9f102cd5..779b315c 100644
--- a/crux_time/src/lib.rs
+++ b/crux_time/src/lib.rs
@@ -4,7 +4,8 @@
 //! more of a side-cause) by Crux, and has to be obtained externally. This capability provides a simple
 //! interface to do so.
 //!
-//! This is still work in progress and as such very basic. It returns time as an IS08601 string.
+//! This is still work in progress and as such very basic.
+use chrono::{DateTime, Utc};
 use crux_core::capability::{CapabilityContext, Operation};
 use crux_macros::Capability;
 use serde::{Deserialize, Serialize};
@@ -12,20 +13,27 @@ use serde::{Deserialize, Serialize};
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct TimeRequest;
 
-// TODO revisit this
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct TimeResponse(pub String);
-
 impl Operation for TimeRequest {
-    type Output = TimeResponse;
+    type Output = DateTime<Utc>;
 }
 
-/// The Time capability API.
+/// The Time capability API. Uses the `chrono` crate's timezone aware representation
+/// of the current date and time.
+///
+/// The time value serializes as an RFC3339 string across the FFI boundary.
 #[derive(Capability)]
 pub struct Time<Ev> {
     context: CapabilityContext<TimeRequest, Ev>,
 }
 
+impl<Ev> Clone for Time<Ev> {
+    fn clone(&self) -> Self {
+        Self {
+            context: self.context.clone(),
+        }
+    }
+}
+
 impl<Ev> Time<Ev>
 where
     Ev: 'static,
@@ -34,11 +42,11 @@ where
         Self { context }
     }
 
-    /// Request current time, which will be passed to the app as `TimeResponse`
+    /// Request current time, which will be passed to the app as `chrono::DateTime<Utc>`
     /// wrapped in the event produced by the `callback`.
-    pub fn get<F>(&self, callback: F)
+    pub fn now<F>(&self, callback: F)
     where
-        F: Fn(TimeResponse) -> Ev + Send + Sync + 'static,
+        F: Fn(DateTime<Utc>) -> Ev + Send + Sync + 'static,
     {
         self.context.spawn({
             let context = self.context.clone();
@@ -49,4 +57,10 @@ where
             }
         });
     }
+
+    /// Request current time, which will be returned as `chrono::DateTime<Utc>`.
+    /// This is an async call to use with [`crux_core::compose::Compose`].
+    pub async fn now_async(&self) -> DateTime<Utc> {
+        self.context.request_from_shell(TimeRequest).await
+    }
 }
diff --git a/docs/src/guide/capabilities.md b/docs/src/guide/capabilities.md
index f199e4b8..dbef3261 100644
--- a/docs/src/guide/capabilities.md
+++ b/docs/src/guide/capabilities.md
@@ -237,11 +237,10 @@ Then, you can use it in your update function like this:
 fn update(&self, msg: Event, model: &mut Model, caps: &Capabilities) {
     match msg {
         Event::GetDocuments => caps.compose.spawn(|context| {
-            let caps = caps.clone();
+            let http = caps.http.clone();
 
             async move {
-                let ids = caps
-                    .http
+                let ids = http
                     .get(DOCS_URL)
                     .await
                     .expect("Request should send")
@@ -252,7 +251,7 @@ fn update(&self, msg: Event, model: &mut Model, caps: &Capabilities) {
                 let futs: Vec<_> = ids
                     .iter()
                     .map(|id| {
-                        let http = caps.http.clone();
+                        let http = http.clone();
 
                         async move {
                             http.get(&format!("{}/{}", DOCS_URL, id))


Test patch:
diff --git a/crux_core/tests/capability_orchestration.rs b/crux_core/tests/capability_orchestration.rs
index 6b044bcb..bece79b4 100644
--- a/crux_core/tests/capability_orchestration.rs
+++ b/crux_core/tests/capability_orchestration.rs
@@ -17,7 +17,7 @@ mod app {
         pub total: usize,
     }
 
-    #[derive(Effect, Clone)]
+    #[derive(Effect)]
     pub struct Capabilities {
         one: super::capabilities::one::CapabilityOne<Event>,
         two: super::capabilities::two::CapabilityTwo<Event>,
@@ -34,11 +34,12 @@ mod app {
         fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
             match event {
                 Event::Trigger => caps.compose.spawn(|context| {
-                    let caps = caps.clone();
+                    let one = caps.one.clone();
+                    let two = caps.two.clone();
 
                     async move {
                         let (result_one, result_two) =
-                            join(caps.one.one_async(10), caps.two.two_async(20)).await;
+                            join(one.one_async(10), two.two_async(20)).await;
 
                         context.update_app(Event::Finished(result_one, result_two))
                     }
diff --git a/crux_http/tests/with_tester.rs b/crux_http/tests/with_tester.rs
index 7901e174..e94e0e82 100644
--- a/crux_http/tests/with_tester.rs
+++ b/crux_http/tests/with_tester.rs
@@ -59,11 +59,10 @@ mod shared {
                         .send(Event::Set);
                 }
                 Event::GetPostChain => caps.compose.spawn(|context| {
-                    let caps = caps.clone();
+                    let http = caps.http.clone();
 
                     async move {
-                        let mut response = caps
-                            .http
+                        let mut response = http
                             .get("http://example.com")
                             .await
                             .expect("Send async should succeed");
@@ -72,8 +71,7 @@ mod shared {
                             .await
                             .expect("response should have body");
 
-                        let response = caps
-                            .http
+                        let response = http
                             .post(format!("http://example.com/{}", text))
                             .await
                             .expect("Send async should succeed");
@@ -82,11 +80,11 @@ mod shared {
                     }
                 }),
                 Event::ConcurrentGets => caps.compose.spawn(|ctx| {
-                    let caps = caps.clone();
+                    let http = caps.http.clone();
 
                     async move {
-                        let one = caps.http.get("http://example.com/one").into_future();
-                        let two = caps.http.get("http://example.com/two").send_async();
+                        let one = http.get("http://example.com/one").into_future();
+                        let two = http.get("http://example.com/two").send_async();
 
                         let (response_one, response_two) = join!(one, two);
 
@@ -125,7 +123,7 @@ mod shared {
         }
     }
 
-    #[derive(Effect, Clone)]
+    #[derive(Effect)]
     pub(crate) struct Capabilities {
         pub http: Http<Event>,
         #[effect(skip)]
diff --git a/crux_kv/tests/kv_test.rs b/crux_kv/tests/kv_test.rs
index 3feff83a..25c80d06 100644
--- a/crux_kv/tests/kv_test.rs
+++ b/crux_kv/tests/kv_test.rs
@@ -11,6 +11,7 @@ mod shared {
     pub enum Event {
         Write,
         Read,
+        ReadThenWrite,
         Set(KeyValueOutput),
     }
 
@@ -53,6 +54,26 @@ mod shared {
                     }
                     caps.render.render()
                 }
+                Event::ReadThenWrite => caps.compose.spawn(|ctx| {
+                    let kv = caps.key_value.clone();
+
+                    async move {
+                        let KeyValueOutput::Read(out) = kv.read_async("test_num").await else {
+                            panic!("Expected read and got write");
+                        };
+
+                        let Some(out) = out else {
+                            panic!("Read failed;");
+                        };
+
+                        let num = i32::from_ne_bytes(out.try_into().unwrap());
+                        let result = kv
+                            .write_async("test_num", (num + 1).to_ne_bytes().to_vec())
+                            .await;
+
+                        ctx.update_app(Event::Set(result))
+                    }
+                }),
             }
         }
 
@@ -67,6 +88,8 @@ mod shared {
     pub struct Capabilities {
         pub key_value: KeyValue<Event>,
         pub render: Render<Event>,
+        #[effect(skip)]
+        pub compose: crux_core::compose::Compose<Event>,
     }
 }
 
@@ -143,9 +166,14 @@ mod shell {
 }
 
 mod tests {
-    use crate::{shared::App, shared::Effect, shell::run};
+    use crate::{
+        shared::App,
+        shared::{Effect, Event, Model},
+        shell::run,
+    };
     use anyhow::Result;
-    use crux_core::Core;
+    use crux_core::{testing::AppTester, Core};
+    use crux_kv::{KeyValueOperation, KeyValueOutput};
 
     #[test]
     pub fn test_kv() -> Result<()> {
@@ -159,4 +187,53 @@ mod tests {
 
         Ok(())
     }
+
+    #[test]
+    pub fn test_kv_async() -> Result<()> {
+        let app = AppTester::<App, _>::default();
+        let mut model = Model::default();
+
+        let update = app.update(Event::ReadThenWrite, &mut model);
+
+        let effect = update.into_effects().next().unwrap();
+        let Effect::KeyValue(mut request) = effect else {
+            panic!("Expected KeyValue effect");
+        };
+
+        let KeyValueOperation::Read(key) = request.operation.clone() else {
+            panic!("Expected read operation");
+        };
+
+        assert_eq!(key, "test_num");
+
+        let update = app
+            .resolve(
+                &mut request,
+                KeyValueOutput::Read(Some(17u32.to_ne_bytes().to_vec())),
+            )
+            .unwrap();
+
+        let effect = update.into_effects().next().unwrap();
+        let Effect::KeyValue(mut request) = effect else {
+            panic!("Expected KeyValue effect");
+        };
+
+        let KeyValueOperation::Write(key, value) = request.operation.clone() else {
+            panic!("Expected read operation");
+        };
+
+        assert_eq!(key, "test_num".to_string());
+        assert_eq!(value, 18u32.to_ne_bytes().to_vec());
+
+        let update = app
+            .resolve(&mut request, KeyValueOutput::Write(true))
+            .unwrap();
+
+        let event = update.events.into_iter().next().unwrap();
+        app.update(event, &mut model);
+
+        assert!(model.successful);
+
+        Ok(())
+    }
 }
diff --git a/crux_time/tests/time_test.rs b/crux_time/tests/time_test.rs
index 3189c182..716deefe 100644
--- a/crux_time/tests/time_test.rs
+++ b/crux_time/tests/time_test.rs
@@ -1,7 +1,7 @@
 mod shared {
     use crux_core::render::Render;
     use crux_macros::Effect;
-    use crux_time::{Time, TimeResponse};
+    use crux_time::Time;
     use serde::{Deserialize, Serialize};
 
     #[derive(Default)]
@@ -9,8 +9,9 @@ mod shared {
 
     #[derive(Serialize, Deserialize)]
     pub enum Event {
-        TimeGet,
-        TimeSet(TimeResponse),
+        Get,
+        GetAsync,
+        Set(chrono::DateTime<chrono::Utc>),
     }
 
     #[derive(Default, Serialize, Deserialize)]
@@ -31,9 +32,16 @@ mod shared {
 
         fn update(&self, event: Event, model: &mut Model, caps: &Capabilities) {
             match event {
-                Event::TimeGet => caps.time.get(Event::TimeSet),
-                Event::TimeSet(time) => {
-                    model.time = time.0;
+                Event::Get => caps.time.now(Event::Set),
+                Event::GetAsync => caps.compose.spawn(|ctx| {
+                    let time = caps.time.clone();
+
+                    async move {
+                        ctx.update_app(Event::Set(time.now_async().await));
+                    }
+                }),
+                Event::Set(time) => {
+                    model.time = time.to_rfc3339();
                     caps.render.render()
                 }
             }
@@ -50,17 +58,20 @@ mod shared {
     pub struct Capabilities {
         pub time: Time<Event>,
         pub render: Render<Event>,
+        #[effect(skip)]
+        pub compose: crux_core::compose::Compose<Event>,
     }
 }
 
 mod shell {
     use super::shared::{App, Effect, Event};
+    use chrono::{DateTime, Utc};
     use crux_core::{Core, Request};
-    use crux_time::{TimeRequest, TimeResponse};
+    use crux_time::TimeRequest;
     use std::collections::VecDeque;
 
     pub enum Outcome {
-        Time(Request<TimeRequest>, TimeResponse),
+        Time(Request<TimeRequest>, DateTime<Utc>),
     }
 
     enum CoreMessage {
@@ -71,7 +82,7 @@ mod shell {
     pub fn run(core: &Core<Effect, App>) {
         let mut queue: VecDeque<CoreMessage> = VecDeque::new();
 
-        queue.push_back(CoreMessage::Event(Event::TimeGet));
+        queue.push_back(CoreMessage::Event(Event::Get));
 
         while !queue.is_empty() {
             let msg = queue.pop_front();
@@ -88,7 +99,7 @@ mod shell {
                 if let Effect::Time(request) = effect {
                     queue.push_back(CoreMessage::Response(Outcome::Time(
                         request,
-                        TimeResponse("2022-12-01T01:47:12.746202562+00:00".to_string()),
+                        "2022-12-01T01:47:12.746202562+00:00".parse().unwrap(),
                     )));
                 }
             }
@@ -98,10 +109,11 @@ mod shell {
 
 mod tests {
     use crate::{
-        shared::{App, Effect},
+        shared::{App, Effect, Event, Model},
         shell::run,
     };
-    use crux_core::Core;
+    use chrono::{DateTime, Utc};
+    use crux_core::{testing::AppTester, Core};
 
     #[test]
     pub fn test_time() {
@@ -111,4 +123,25 @@ mod tests {
 
         assert_eq!(core.view().time, "2022-12-01T01:47:12.746202562+00:00");
     }
+
+    #[test]
+    pub fn test_time_async() {
+        let app = AppTester::<App, _>::default();
+        let mut model = Model::default();
+
+        let update = app.update(Event::GetAsync, &mut model);
+
+        let effect = update.into_effects().next().unwrap();
+        let Effect::Time(mut request) = effect else {
+            panic!("Expected Time effect");
+        };
+
+        let now: DateTime<Utc> = "2022-12-01T01:47:12.746202562+00:00".parse().unwrap();
+        let update = app.resolve(&mut request, now).unwrap();
+
+        let event = update.events.into_iter().next().unwrap();
+        app.update(event, &mut model);
+
+        assert_eq!(app.view(&model).time, "2022-12-01T01:47:12.746202562+00:00");
+    }
 }