use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use chrono::{DateTime, SecondsFormat, Utc};
use reqwest::{Client, Method, StatusCode, Url, header};
use serde::{Serialize, de::DeserializeOwned};
use crate::{
BulkUpdateOperation, BulkUpdateResponse, Error, Project, Result, StartTimeEntry, TimeEntry,
UpdateTimeEntry, User, Workspace,
};
const DEFAULT_BASE_URL: &str = "https://api.track.toggl.com/api/v9/";
const DEFAULT_USER_AGENT: &str = concat!("toggl-track/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone)]
pub struct QuotaInfo {
pub remaining: Option<u32>,
pub resets_in: Option<u32>,
pub last_updated: Instant,
}
impl Default for QuotaInfo {
fn default() -> Self {
Self {
remaining: None,
resets_in: None,
last_updated: Instant::now(),
}
}
}
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base_delay: Duration,
pub wait_for_quota_reset: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_secs(2),
wait_for_quota_reset: true,
}
}
}
#[derive(Debug, Clone)]
pub struct TogglTrackClient {
client: Client,
api_token: String,
base_url: Url,
quota: Arc<Mutex<QuotaInfo>>,
retry_policy: RetryPolicy,
}
impl TogglTrackClient {
pub fn new(api_token: impl Into<String>) -> Result<Self> {
ClientBuilder::new(api_token).build()
}
pub fn builder(api_token: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(api_token)
}
pub fn quota_info(&self) -> Option<QuotaInfo> {
self.quota.lock().ok().map(|info| info.clone())
}
pub async fn current_user(&self) -> Result<User> {
self.request(Method::GET, "me", Option::<&()>::None).await
}
pub async fn current_user_id(&self) -> Result<i64> {
Ok(self.current_user().await?.id)
}
pub async fn current_user_email(&self) -> Result<String> {
Ok(self.current_user().await?.email)
}
pub async fn time_entries(
&self,
start_date: DateTime<Utc>,
end_date: DateTime<Utc>,
) -> Result<Vec<TimeEntry>> {
let path = format!(
"me/time_entries?start_date={}&end_date={}",
start_date.to_rfc3339_opts(SecondsFormat::Secs, true),
end_date.to_rfc3339_opts(SecondsFormat::Secs, true)
);
self.request_with_retry(Method::GET, &path, Option::<&()>::None)
.await
}
pub async fn time_entry(&self, time_entry_id: i64) -> Result<TimeEntry> {
self.request(
Method::GET,
&format!("me/time_entries/{time_entry_id}"),
Option::<&()>::None,
)
.await
}
pub async fn current_time_entry(&self) -> Result<Option<TimeEntry>> {
self.request(Method::GET, "me/time_entries/current", Option::<&()>::None)
.await
}
pub async fn workspaces(&self) -> Result<Vec<Workspace>> {
self.request(Method::GET, "workspaces", Option::<&()>::None)
.await
}
pub async fn projects(&self, workspace_id: i64) -> Result<Vec<Project>> {
self.request(
Method::GET,
&format!("workspaces/{workspace_id}/projects"),
Option::<&()>::None,
)
.await
}
pub async fn update_time_entry(
&self,
workspace_id: i64,
entry_id: i64,
update: &UpdateTimeEntry,
) -> Result<TimeEntry> {
self.request(
Method::PUT,
&format!("workspaces/{workspace_id}/time_entries/{entry_id}"),
Some(update),
)
.await
}
pub async fn update_time_entry_project(
&self,
workspace_id: i64,
entry_id: i64,
project_id: Option<i64>,
) -> Result<TimeEntry> {
self.update_time_entry(
workspace_id,
entry_id,
&UpdateTimeEntry::project_id(project_id),
)
.await
}
pub async fn update_time_entry_description(
&self,
workspace_id: i64,
entry_id: i64,
description: impl Into<String>,
) -> Result<TimeEntry> {
self.update_time_entry(
workspace_id,
entry_id,
&UpdateTimeEntry::description(description),
)
.await
}
pub async fn start_time_entry(&self, request: &StartTimeEntry) -> Result<TimeEntry> {
self.request(
Method::POST,
&format!("workspaces/{}/time_entries", request.workspace_id),
Some(request),
)
.await
}
pub async fn stop_time_entry(&self, workspace_id: i64, entry_id: i64) -> Result<TimeEntry> {
self.request(
Method::PATCH,
&format!("workspaces/{workspace_id}/time_entries/{entry_id}/stop"),
Option::<&()>::None,
)
.await
}
pub async fn delete_time_entry(&self, workspace_id: i64, entry_id: i64) -> Result<()> {
self.request_empty(
Method::DELETE,
&format!("workspaces/{workspace_id}/time_entries/{entry_id}"),
Option::<&()>::None,
)
.await
}
pub async fn bulk_update_time_entries(
&self,
workspace_id: i64,
entry_ids: &[i64],
operations: &[BulkUpdateOperation],
) -> Result<BulkUpdateResponse> {
if entry_ids.is_empty() {
return Err(Error::InvalidInput(
"cannot update zero time entries".to_string(),
));
}
if entry_ids.len() > 100 {
return Err(Error::InvalidInput(format!(
"cannot update more than 100 time entries per request (got {})",
entry_ids.len()
)));
}
if operations.is_empty() {
return Err(Error::InvalidInput(
"cannot send zero bulk update operations".to_string(),
));
}
let ids = entry_ids
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
self.request_with_retry(
Method::PATCH,
&format!("workspaces/{workspace_id}/time_entries/{ids}"),
Some(operations),
)
.await
}
pub async fn bulk_assign_project(
&self,
workspace_id: i64,
entry_ids: &[i64],
project_id: Option<i64>,
) -> Result<BulkUpdateResponse> {
let value = project_id.map_or(serde_json::Value::Null, serde_json::Value::from);
let operations = [BulkUpdateOperation::replace("/project_id", value)];
self.bulk_update_time_entries(workspace_id, entry_ids, &operations)
.await
}
pub async fn bulk_update_descriptions(
&self,
workspace_id: i64,
entry_ids: &[i64],
description: impl Into<String>,
) -> Result<BulkUpdateResponse> {
let operations = [BulkUpdateOperation::replace(
"/description",
serde_json::Value::String(description.into()),
)];
self.bulk_update_time_entries(workspace_id, entry_ids, &operations)
.await
}
async fn request<T, B>(&self, method: Method, path: &str, body: Option<B>) -> Result<T>
where
T: DeserializeOwned,
B: Serialize,
{
self.request_once(method, path, body).await
}
async fn request_with_retry<T, B>(
&self,
method: Method,
path: &str,
body: Option<B>,
) -> Result<T>
where
T: DeserializeOwned,
B: Serialize + Clone,
{
let mut last_error = None;
for attempt in 0..self.retry_policy.max_retries.max(1) {
self.wait_before_request().await;
match self.request_once(method.clone(), path, body.clone()).await {
Ok(value) => return Ok(value),
Err(error)
if self.should_retry(&error) && attempt + 1 < self.retry_policy.max_retries =>
{
let delay = self.retry_policy.base_delay * 2_u32.pow(attempt);
tokio::time::sleep(delay).await;
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_error.unwrap_or_else(|| {
Error::InvalidInput("request retry loop did not execute".to_string())
}))
}
async fn request_once<T, B>(&self, method: Method, path: &str, body: Option<B>) -> Result<T>
where
T: DeserializeOwned,
B: Serialize,
{
let response = self.send(method, path, body).await?;
let status = response.status();
if status.is_success() {
return Ok(response.json::<T>().await?);
}
let body = response.text().await.unwrap_or_default();
Err(Error::api(status, body, self.quota_info()))
}
async fn request_empty<B>(&self, method: Method, path: &str, body: Option<B>) -> Result<()>
where
B: Serialize,
{
let response = self.send(method, path, body).await?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let body = response.text().await.unwrap_or_default();
Err(Error::api(status, body, self.quota_info()))
}
async fn send<B>(
&self,
method: Method,
path: &str,
body: Option<B>,
) -> Result<reqwest::Response>
where
B: Serialize,
{
let url = self.url(path)?;
let mut request = self
.client
.request(method, url)
.basic_auth(&self.api_token, Some("api_token"));
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await?;
self.capture_quota(&response);
Ok(response)
}
fn url(&self, path: &str) -> Result<Url> {
self.base_url
.join(path.trim_start_matches('/'))
.map_err(|error| Error::InvalidBaseUrl(error.to_string()))
}
fn capture_quota(&self, response: &reqwest::Response) {
let remaining = response
.headers()
.get("X-Toggl-Quota-Remaining")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u32>().ok());
let resets_in = response
.headers()
.get("X-Toggl-Quota-Resets-In")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u32>().ok());
if remaining.is_none() && resets_in.is_none() {
return;
}
if let Ok(mut quota) = self.quota.lock() {
quota.remaining = remaining;
quota.resets_in = resets_in;
quota.last_updated = Instant::now();
}
}
async fn wait_before_request(&self) {
let Some(quota) = self.quota_info() else {
return;
};
if quota.remaining == Some(0) && self.retry_policy.wait_for_quota_reset {
tokio::time::sleep(Duration::from_secs(quota.resets_in.unwrap_or(60) as u64)).await;
}
}
fn should_retry(&self, error: &Error) -> bool {
match error {
Error::Request(_) => true,
Error::Api { status, .. } => matches!(
*status,
StatusCode::TOO_MANY_REQUESTS
| StatusCode::PAYMENT_REQUIRED
| StatusCode::INTERNAL_SERVER_ERROR
| StatusCode::BAD_GATEWAY
| StatusCode::SERVICE_UNAVAILABLE
| StatusCode::GATEWAY_TIMEOUT
),
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub struct ClientBuilder {
api_token: String,
base_url: String,
user_agent: String,
client: Option<Client>,
retry_policy: RetryPolicy,
}
impl ClientBuilder {
pub fn new(api_token: impl Into<String>) -> Self {
Self {
api_token: api_token.into(),
base_url: DEFAULT_BASE_URL.to_string(),
user_agent: DEFAULT_USER_AGENT.to_string(),
client: None,
retry_policy: RetryPolicy::default(),
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn http_client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
self.retry_policy = retry_policy;
self
}
pub fn build(self) -> Result<TogglTrackClient> {
let base_url =
Url::parse(&self.base_url).map_err(|error| Error::InvalidBaseUrl(error.to_string()))?;
let mut headers = header::HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
let client = match self.client {
Some(client) => client,
None => Client::builder()
.default_headers(headers)
.user_agent(self.user_agent)
.build()
.map_err(Error::ClientBuild)?,
};
Ok(TogglTrackClient {
client,
api_token: self.api_token,
base_url,
quota: Arc::new(Mutex::new(QuotaInfo::default())),
retry_policy: self.retry_policy,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use mockito::{Matcher, Server};
fn client(server: &Server) -> TogglTrackClient {
TogglTrackClient::builder("test-token")
.base_url(format!("{}/api/v9/", server.url()))
.retry_policy(RetryPolicy {
max_retries: 1,
..RetryPolicy::default()
})
.build()
.unwrap()
}
#[test]
fn builds_default_client() {
let client = TogglTrackClient::new("test-token");
assert!(client.is_ok());
}
#[tokio::test]
async fn fetches_current_user_and_quota_headers() {
let mut server = Server::new_async().await;
let client = client(&server);
let _mock = server
.mock("GET", "/api/v9/me")
.match_header("authorization", "Basic dGVzdC10b2tlbjphcGlfdG9rZW4=")
.with_status(200)
.with_header("content-type", "application/json")
.with_header("X-Toggl-Quota-Remaining", "7")
.with_header("X-Toggl-Quota-Resets-In", "42")
.with_body(r#"{"id":123,"email":"user@example.com","default_workspace_id":456}"#)
.expect(1)
.create_async()
.await;
let user = client.current_user().await.unwrap();
assert_eq!(user.id, 123);
assert_eq!(user.email, "user@example.com");
let quota = client.quota_info().unwrap();
assert_eq!(quota.remaining, Some(7));
assert_eq!(quota.resets_in, Some(42));
}
#[tokio::test]
async fn fetches_time_entries_with_rfc3339_query_dates() {
let mut server = Server::new_async().await;
let client = client(&server);
let _mock = server
.mock("GET", "/api/v9/me/time_entries")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("start_date".into(), "2025-01-01T00:00:00Z".into()),
Matcher::UrlEncoded("end_date".into(), "2025-01-02T00:00:00Z".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"id":1,"start":"2025-01-01T09:00:00Z","duration":3600,"description":"Work"}]"#,
)
.expect(1)
.create_async()
.await;
let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
let entries = client.time_entries(start, end).await.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].description.as_deref(), Some("Work"));
}
#[tokio::test]
async fn validates_bulk_update_entry_count() {
let client = TogglTrackClient::new("test-token").unwrap();
let operation = BulkUpdateOperation::replace("/project_id", serde_json::Value::from(42));
let empty = client
.bulk_update_time_entries(1, &[], std::slice::from_ref(&operation))
.await;
assert!(matches!(empty, Err(Error::InvalidInput(message)) if message.contains("zero")));
let ids = (1..=101).collect::<Vec<_>>();
let too_many = client.bulk_update_time_entries(1, &ids, &[operation]).await;
assert!(
matches!(too_many, Err(Error::InvalidInput(message)) if message.contains("more than 100"))
);
}
#[tokio::test]
async fn sends_bulk_project_assignment() {
let mut server = Server::new_async().await;
let client = client(&server);
let _mock = server
.mock("PATCH", "/api/v9/workspaces/55/time_entries/1,2")
.match_body(r#"[{"op":"replace","path":"/project_id","value":99}]"#)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"success":[1,2],"failure":[]}"#)
.expect(1)
.create_async()
.await;
let response = client
.bulk_assign_project(55, &[1, 2], Some(99))
.await
.unwrap();
assert_eq!(response.success, vec![1, 2]);
assert!(response.failure.is_empty());
}
#[tokio::test]
async fn deletes_time_entry_without_json_body() {
let mut server = Server::new_async().await;
let client = client(&server);
let _mock = server
.mock("DELETE", "/api/v9/workspaces/55/time_entries/99")
.with_status(200)
.expect(1)
.create_async()
.await;
client.delete_time_entry(55, 99).await.unwrap();
}
}