Files
AWatch-rus/adk-rust/crates/detmir-aw-client/src/lib.rs
T

120 lines
3.5 KiB
Rust

use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use detmir_core::parse_utc_rfc3339;
use reqwest::blocking::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
const DEFAULT_GET_ATTEMPTS: usize = 6;
#[derive(Debug, Clone)]
pub struct ActivityWatchClient {
api_base: String,
client: Client,
}
impl ActivityWatchClient {
pub fn new(api_base: impl Into<String>, timeout: Duration) -> Result<Self> {
let client = Client::builder()
.timeout(timeout)
.no_proxy()
.build()
.context("failed to build ActivityWatch HTTP client")?;
Ok(Self {
api_base: api_base.into().trim_end_matches('/').to_string(),
client,
})
}
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = self.url(path);
let mut last_error = None;
for attempt in 0..DEFAULT_GET_ATTEMPTS {
let result = self
.client
.get(&url)
.send()
.with_context(|| format!("ActivityWatch request failed: {url}"))
.and_then(|response| {
response.error_for_status().with_context(|| {
format!("ActivityWatch returned non-success status: {url}")
})
})
.and_then(|response| {
response
.json()
.with_context(|| format!("failed to parse ActivityWatch JSON: {url}"))
});
match result {
Ok(value) => return Ok(value),
Err(err) => last_error = Some(err),
}
if attempt + 1 < DEFAULT_GET_ATTEMPTS {
std::thread::sleep(Duration::from_millis(500 * (attempt as u64 + 1)));
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("ActivityWatch request failed: {url}")))
}
pub fn latest_event(&self, bucket_id: &str) -> Result<Option<AwEvent>> {
let path = format!("/buckets/{bucket_id}/events?limit=1");
let mut events: Vec<AwEvent> = self.get_json(&path)?;
events.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
Ok(events.into_iter().next())
}
fn url(&self, path: &str) -> String {
if path.starts_with('/') {
format!("{}{}", self.api_base, path)
} else {
format!("{}/{}", self.api_base, path)
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AwEvent {
pub timestamp: String,
#[serde(default)]
pub duration: f64,
#[serde(default)]
pub data: serde_json::Value,
}
impl AwEvent {
pub fn timestamp_utc(&self) -> Result<DateTime<Utc>> {
parse_utc_rfc3339(&self.timestamp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_paths_without_double_slashes() {
let client =
ActivityWatchClient::new("http://127.0.0.1:5600/api/0/", Duration::from_secs(1))
.unwrap();
assert_eq!(
client.url("/buckets/x/events?limit=1"),
"http://127.0.0.1:5600/api/0/buckets/x/events?limit=1"
);
}
#[test]
fn parses_event_timestamp() {
let event = AwEvent {
timestamp: "2026-05-31T10:20:30Z".to_string(),
duration: 0.0,
data: serde_json::json!({}),
};
assert_eq!(event.timestamp_utc().unwrap().timestamp(), 1_780_222_830);
}
}