Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,70 @@ From source:
cargo install --path .
```

## Using as a Rust SDK

`things3-cloud` also exposes an experimental SDK for embedding the same Things
Cloud behavior in other Rust applications. The command-line interface remains
fully supported; the SDK is intended for UI clients and local integrations that
should not shell out to `things3`.

```rust
use things3_cloud::sdk::{ThingsService, ThingsServiceConfig};

let things = ThingsService::new(ThingsServiceConfig::default());
let today = things.today()?;

for task in today {
println!("{}", serde_json::to_string_pretty(&task)?);
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

Configure auth through the SDK:

```rust
use things3_cloud::sdk::{ThingsService, ThingsServiceConfig};

let things = ThingsService::new(ThingsServiceConfig::default());
things.save_auth("you@example.com", "app-password")?;
# Ok::<(), Box<dyn std::error::Error>>(())
```

Create, schedule, and complete a task:

```rust
use things3_cloud::sdk::{
CreateTaskRequest, MarkStatus, MarkTasksRequest, ScheduleTaskRequest,
ThingsService, ThingsServiceConfig,
};

let things = ThingsService::new(ThingsServiceConfig::default());

let created = things.create_task(CreateTaskRequest {
title: "Follow up with team".to_string(),
in_target: None,
when: Some("today".to_string()),
before_id: None,
after_id: None,
notes: Some("Send the launch notes.".to_string()),
tags: Some("Work".to_string()),
deadline: Some("2026-04-10".to_string()),
})?;

let task_id = created.ids[0].clone();
things.schedule_task(ScheduleTaskRequest {
task_id: task_id.clone(),
when: Some("evening".to_string()),
deadline: None,
clear_deadline: false,
})?;
things.mark_tasks(MarkTasksRequest {
task_ids: vec![task_id],
status: MarkStatus::Done,
})?;
# Ok::<(), Box<dyn std::error::Error>>(())
```

## Configure auth

```bash
Expand Down
21 changes: 4 additions & 17 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
use std::fs;

use anyhow::{Context, Result, anyhow};
use figment::{
Figment,
providers::{Env, Format, Json},
};
use serde::{Deserialize, Serialize};

use crate::dirs::auth_file_path;
use crate::dirs::{auth_file_path, ensure_private_dir, write_private_atomic};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct AuthPayload {
Expand Down Expand Up @@ -75,23 +73,12 @@ pub fn write_auth(email: &str, password: &str) -> Result<std::path::PathBuf> {
.parent()
.ok_or_else(|| anyhow!("Invalid auth file path"))?
.to_path_buf();
fs::create_dir_all(&parent).with_context(|| format!("Failed creating {}", parent.display()))?;
ensure_private_dir(&parent).with_context(|| format!("Failed creating {}", parent.display()))?;

let payload = AuthPayload { email, password };
let serialized = serde_json::to_string(&payload)?;
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, serialized)
.with_context(|| format!("Failed writing {}", tmp_path.display()))?;
fs::rename(&tmp_path, &path)
.with_context(|| format!("Failed finalizing {}", path.display()))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&path, perms)?;
}
write_private_atomic(&path, serialized.as_bytes())
.with_context(|| format!("Failed writing {}", path.display()))?;

Ok(path)
}
47 changes: 36 additions & 11 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ pub(crate) fn now_timestamp() -> f64 {
now_ts()
}

#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct ThingsCloudClient {
pub email: String,
pub password: String,
email: String,
password: String,
pub history_key: Option<String>,
pub head_index: i64,
http: Client,
Expand Down Expand Up @@ -88,18 +88,24 @@ impl ThingsCloudClient {
.json(&payload);
}

let safe_url = redact_url(url);
let resp = req
.send()
.with_context(|| format!("request failed: {url}"))?;
.with_context(|| format!("request failed: {safe_url}"))?;
let status = resp.status();
let text = resp.text().unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("HTTP {} for {}: {}", status.as_u16(), url, text));
return Err(anyhow!(
"HTTP {} for {}: {}",
status.as_u16(),
safe_url,
text
));
}
if text.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&text).with_context(|| format!("invalid json from {url}"))
serde_json::from_str(&text).with_context(|| format!("invalid json from {safe_url}"))
}

pub fn authenticate(&mut self) -> Result<String> {
Expand Down Expand Up @@ -144,8 +150,8 @@ impl ThingsCloudClient {
let items = page
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
.ok_or_else(|| anyhow!("history page missing array field: items"))?
.clone();
let item_count = items.len();
self.head_index = page
.get("current-item-index")
Expand All @@ -160,11 +166,20 @@ impl ThingsCloudClient {
let end = page
.get("end-total-content-size")
.and_then(Value::as_i64)
.unwrap_or(0);
.ok_or_else(|| {
anyhow!("history page missing integer field: end-total-content-size")
})?;
let latest = page
.get("latest-total-content-size")
.and_then(Value::as_i64)
.unwrap_or(0);
.ok_or_else(|| {
anyhow!("history page missing integer field: latest-total-content-size")
})?;
if item_count == 0 && end < latest {
return Err(anyhow!(
"history page made no progress: empty items with end {end} before latest {latest}"
));
}
if end >= latest {
break;
}
Expand Down Expand Up @@ -201,7 +216,7 @@ impl ThingsCloudClient {
let new_index = result
.get("server-head-index")
.and_then(Value::as_i64)
.unwrap_or(idx);
.ok_or_else(|| anyhow!("missing server-head-index in commit response"))?;
self.head_index = new_index;
Ok(new_index)
}
Expand Down Expand Up @@ -230,3 +245,13 @@ impl ThingsCloudClient {
self.commit(changes, None)
}
}

fn redact_url(url: &str) -> String {
let Some((prefix, suffix)) = url.split_once("/history/") else {
return url.to_string();
};
let Some((_, rest)) = suffix.split_once('/') else {
return format!("{prefix}/history/<redacted>");
};
format!("{prefix}/history/<redacted>/{rest}")
}
41 changes: 26 additions & 15 deletions src/cloud_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,32 @@ impl CloudWriter for LoggingCloudWriter {
ancestor_index: Option<i64>,
) -> Result<i64> {
let uuids = changes.keys().cloned().collect::<Vec<_>>();
let request_value = json!({
"ancestor_index": ancestor_index.unwrap_or(self.inner.head_index()),
"changes": &changes,
});
let request_json =
serde_json::to_string(&request_value).unwrap_or_else(|_| "{}".to_string());
debug!(
target: "things_cli::cloud_commit::request",
event = "cloud.commit.request",
ancestor_index,
change_count = uuids.len(),
uuids = ?uuids,
request_json = %request_json,
"cloud commit request"
);
if std::env::var_os("THINGS3_LOG_COMMIT_PAYLOADS").is_some() {
let request_value = json!({
"ancestor_index": ancestor_index.unwrap_or(self.inner.head_index()),
"changes": &changes,
});
let request_json =
serde_json::to_string(&request_value).unwrap_or_else(|_| "{}".to_string());
debug!(
target: "things_cli::cloud_commit::request",
event = "cloud.commit.request",
ancestor_index,
change_count = uuids.len(),
uuids = ?uuids,
request_json = %request_json,
"cloud commit request"
);
} else {
debug!(
target: "things_cli::cloud_commit::request",
event = "cloud.commit.request",
ancestor_index,
change_count = uuids.len(),
uuids = ?uuids,
"cloud commit request"
);
}

match self.inner.commit(changes, ancestor_index) {
Ok(head_index) => {
Expand Down
Loading