Adds basic config api and constants to the library.

This commit is contained in:
2026-08-23 05:45:52 -07:00
parent 737b92469f
commit 3703d4ddf2
8 changed files with 737 additions and 30 deletions

252
Cargo.lock generated
View File

@@ -519,7 +519,7 @@ dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1012,6 +1012,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1019,7 +1028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1033,6 +1042,12 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1539,6 +1554,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2092,6 +2123,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2125,6 +2173,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nextcloud_client"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"thiserror 2.0.20",
"tokio",
"url",
]
[[package]]
name = "num-conv"
@@ -2379,6 +2435,49 @@ dependencies = [
"libc",
]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2787,6 +2886,45 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2817,7 +2955,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -2849,12 +2987,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2864,6 +3017,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2921,6 +3083,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3045,6 +3230,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
@@ -3417,7 +3614,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3766,9 +3963,31 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -4125,6 +4344,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4246,6 +4471,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -4937,6 +5175,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"

View File

@@ -70,7 +70,7 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type |
|---|---|---|---|
| [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation |
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Triage | Foundation |
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
| [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Triage | Feature |
| [NUT-004](#nut-004) | Implement OCS Share Link Generation | Triage | Feature |
| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature |
@@ -116,21 +116,21 @@ Create the initial repository layout for the project, including the shared Rust
None
<a id="nut-002" class="task" data-status="triage" data-task-type="foundation"></a>
<a id="nut-002" class="task" data-status="done" data-task-type="foundation"></a>
### Implement Shared Rust Backend Library
**ID:** NUT-002
**Status:** Triage
**Status:** Fixed
**Type:** Foundation
**Description:**
Implement the shared Rust library that provides all core functionality: WebDAV upload, OCS share creation, credential storage, and progress callbacks. This library is used by both the CLI and GUI.
**Requirements:**
- [ ] Create `NextcloudClient` struct
- [ ] Implement async runtime setup
- [ ] Define error types
- [ ] Define configuration structs
- [ ] Provide high-level API for upload + share
- [x] Create `NextcloudClient` struct
- [x] Implement async runtime setup
- [x] Define error types
- [x] Define configuration structs
- [x] Provide high-level API for upload + share
**Dependencies:**
- NUT-001
@@ -199,9 +199,10 @@ Generate direct-download URLs from share tokens.
**Type:** Feature
**Description:**
Implement secure credential storage using OS keychain when available, falling back to encrypted config files.
Implement secure credential storage using OS keychain when available, falling back to encrypted config files. Support Nextcloud Login Flow v2 (`/index.php/login/v2`) for browser-based interactive authentication (supporting 2FA/SSO) alongside manual app password entry.
**Requirements:**
- [ ] Implement Nextcloud Login Flow v2 client (initiate + browser open + polling)
- [ ] macOS Keychain support
- [ ] Windows Credential Manager support
- [ ] Linux Secret Service support
@@ -237,9 +238,10 @@ Support multiple Nextcloud accounts in the backend credential system.
**Type:** Feature
**Description:**
Implement the CLI tool using the shared backend library.
Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands.
**Requirements:**
- [ ] Add `login` command (browser-based Login Flow v2)
- [ ] Add `upload` command
- [ ] Add `--account` flag
- [ ] Add `--stdin` support
@@ -355,11 +357,12 @@ Add drag-and-drop file support and a queue system for multiple uploads.
**Type:** Feature
**Description:**
Add UI for managing accounts, logging in, logging out, and switching accounts.
Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts.
**Requirements:**
- [ ] Account list UI
- [ ] Login form
- [ ] Browser-based login button (Login Flow v2)
- [ ] Manual login form (server/user/token)
- [ ] Logout button
- [ ] Switch account dropdown
@@ -497,7 +500,7 @@ This section defines how contributors and AI agents must interpret, update, and
### Single Source of Truth (SSOT)
- **Task Anchor Tag**: The HTML anchor tag directly above each task title is the SSOT for machine-readable attributes:
- **Task ID**: `id` attribute (lowercase, e.g. `id=\"nut-001\"`)
- **Task ID**: `id` attribute (lowercase, e.g. `id="nut-001"`)
- **Task Status**: `data-status` attribute (must match a value in `task-statuses` frontmatter)
- **Task Type**: `data-task-type` attribute (must match a value in `task-types` frontmatter)
- **Detailed Task Section**: The markdown text under the task heading is the SSOT for human-readable content:
@@ -522,7 +525,7 @@ Tasks progress through defined statuses:
- **Task ID Schema**: `${project.prefix}-XXX` where `XXX` is a zero-padded monotonic 3-digit number (e.g. `NUT-001`).
- IDs must be monotonic and never renumbered or reused.
- Anchor IDs are lowercase: `<a id=\"nut-001\" class=\"task\" data-status=\"pending\" data-task-type=\"foundation\"></a>`.
- Anchor IDs are lowercase: `<a id="nut-001" class="task" data-status="pending" data-task-type="foundation"></a>`.
- Table and header IDs are uppercase: `[NUT-001](#nut-001)`.
- Dependencies list **direct dependencies only** (no transitive dependencies).
- Checklists must use standard GitHub Markdown `- [ ]` and `- [x]`.

View File

@@ -4,3 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "default-tls"] }
tokio = { version = "1", features = ["sync", "fs", "io-util", "macros", "time"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
url = { version = "2.5", features = ["serde"] }

View File

@@ -0,0 +1,216 @@
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::config::ClientConfig;
use crate::error::{NextcloudError, Result};
/// Response payload from Nextcloud's `/status.php` endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerStatus {
pub installed: bool,
pub maintenance: bool,
pub version: String,
#[serde(rename = "versionstring")]
pub version_string: String,
pub edition: String,
#[serde(rename = "productname", default)]
pub product_name: Option<String>,
}
/// The core Nextcloud client instance.
#[derive(Debug, Clone)]
pub struct NextcloudClient {
config: ClientConfig,
http: reqwest::Client,
}
impl NextcloudClient {
/// Create a new `NextcloudClient` from a [`ClientConfig`].
pub fn new(config: ClientConfig) -> Result<Self> {
let mut headers = HeaderMap::new();
// Set User-Agent
headers.insert(
USER_AGENT,
HeaderValue::from_str(&config.user_agent)
.map_err(|e| NextcloudError::Other(e.to_string()))?,
);
// Set Basic Auth header if credentials are provided
if let Some(ref creds) = config.credentials {
use reqwest::header::HeaderValue;
let raw_auth = format!("{}:{}", creds.username, creds.app_password);
let encoded = base64_encode(raw_auth.as_bytes());
let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}"))
.map_err(|e| NextcloudError::Other(e.to_string()))?;
auth_val.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_val);
}
// Standard Nextcloud OCS headers for JSON responses
headers.insert("OCS-APIRequest", HeaderValue::from_static("true"));
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(config.timeout)
.build()?;
Ok(Self { config, http })
}
/// Reference to the underlying client configuration.
pub fn config(&self) -> &ClientConfig {
&self.config
}
/// Access the underlying `reqwest::Client` for raw HTTP operations.
pub fn http(&self) -> &reqwest::Client {
&self.http
}
/// Nextcloud username configured for this client (if any).
pub fn username(&self) -> Option<&str> {
self.config.credentials.as_ref().map(|c| c.username.as_str())
}
/// Build the full WebDAV URL for a given remote file path.
///
/// Nextcloud WebDAV endpoint format:
/// `https://<host>/remote.php/dav/files/<username>/<path>`
pub fn webdav_url(&self, remote_path: &str) -> Result<Url> {
let username = self.username().ok_or_else(|| {
NextcloudError::Other("Cannot generate WebDAV URL without username credentials".into())
})?;
let clean_path = remote_path.trim_start_matches('/');
let path_segment = format!("remote.php/dav/files/{username}/{clean_path}");
self.config
.server_url
.join(&path_segment)
.map_err(NextcloudError::from)
}
/// Build the OCS sharing API URL.
///
/// Nextcloud OCS sharing endpoint:
/// `https://<host>/ocs/v2.php/apps/files_sharing/api/v1/shares`
pub fn ocs_shares_url(&self) -> Result<Url> {
self.config
.server_url
.join("ocs/v2.php/apps/files_sharing/api/v1/shares?format=json")
.map_err(NextcloudError::from)
}
/// Build the public download URL from a share token.
///
/// Format: `https://<host>/index.php/s/<token>/download`
pub fn direct_download_url(&self, share_token: &str) -> Result<Url> {
let path_segment = format!("index.php/s/{share_token}/download");
self.config
.server_url
.join(&path_segment)
.map_err(NextcloudError::from)
}
/// Check server reachability and retrieve server version info from `/status.php`.
///
/// This endpoint does not require authentication.
pub async fn check_status(&self) -> Result<ServerStatus> {
let status_url = self.config.server_url.join("status.php")?;
let response = self
.http
.get(status_url)
.send()
.await?
.error_for_status()?;
let status = response.json::<ServerStatus>().await?;
Ok(status)
}
}
/// Simple Base64 encoder helper avoiding extra external crate dependencies.
fn base64_encode(data: &[u8]) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(CHARSET[(b0 >> 2) as usize] as char);
out.push(CHARSET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
if chunk.len() > 1 {
out.push(CHARSET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(CHARSET[(b2 & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AccountCredentials;
#[test]
fn test_webdav_url_builder() {
let config = ClientConfig::new(
"https://nextcloud.example.com",
Some(AccountCredentials::new("bob", "app-pass")),
)
.unwrap();
let client = NextcloudClient::new(config).unwrap();
let url = client.webdav_url("Documents/test.pdf").unwrap();
assert_eq!(
url.as_str(),
"https://nextcloud.example.com/remote.php/dav/files/bob/Documents/test.pdf"
);
}
#[test]
fn test_direct_download_url() {
let config = ClientConfig::new("https://nextcloud.example.com", None).unwrap();
let client = NextcloudClient::new(config).unwrap();
let download_url = client.direct_download_url("AbCdEf12345").unwrap();
assert_eq!(
download_url.as_str(),
"https://nextcloud.example.com/index.php/s/AbCdEf12345/download"
);
}
#[test]
fn test_status_deserialization() {
let sample_json = r#"{
"installed": true,
"maintenance": false,
"version": "28.0.2.5",
"versionstring": "28.0.2",
"edition": "Community",
"productname": "Nextcloud"
}"#;
let status: ServerStatus = serde_json::from_str(sample_json).unwrap();
assert_eq!(status.version_string, "28.0.2");
assert_eq!(status.product_name.as_deref(), Some("Nextcloud"));
}
}

View File

@@ -0,0 +1,135 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use url::Url;
use crate::error::{NextcloudError, Result};
/// Default request timeout for API calls (excluding long streaming uploads).
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Default User-Agent header string.
pub const DEFAULT_USER_AGENT: &str = "NextcloudUploadTool/0.1.0 (Rust)";
/// Credentials used to authenticate against a Nextcloud instance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountCredentials {
pub username: String,
pub app_password: String,
}
impl AccountCredentials {
pub fn new(username: impl Into<String>, app_password: impl Into<String>) -> Self {
Self {
username: username.into(),
app_password: app_password.into(),
}
}
}
/// Configuration settings for connecting to a Nextcloud server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
/// Base URL of the Nextcloud instance (e.g. `https://cloud.example.com`).
pub server_url: Url,
/// User authentication credentials.
pub credentials: Option<AccountCredentials>,
/// Request timeout for standard API calls.
#[serde(with = "humantime_serde", default = "default_timeout")]
pub timeout: Duration,
/// Custom User-Agent header.
pub user_agent: String,
}
fn default_timeout() -> Duration {
Duration::from_secs(DEFAULT_TIMEOUT_SECS)
}
// Simple serde helper for duration if humantime_serde is not pulled in
mod humantime_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
impl ClientConfig {
/// Create a new `ClientConfig` by parsing a URL string and optional credentials.
pub fn new(
server_url_str: &str,
credentials: Option<AccountCredentials>,
) -> Result<Self> {
let mut server_url = Url::parse(server_url_str)?;
// Ensure scheme is http or https
if server_url.scheme() != "http" && server_url.scheme() != "https" {
return Err(NextcloudError::Other(format!(
"Unsupported URL scheme '{}'. Must be http or https.",
server_url.scheme()
)));
}
// Ensure the path has a trailing slash for reliable path joining
if !server_url.path().ends_with('/') {
let new_path = format!("{}/", server_url.path());
server_url.set_path(&new_path);
}
Ok(Self {
server_url,
credentials,
timeout: default_timeout(),
user_agent: DEFAULT_USER_AGENT.to_string(),
})
}
/// Set a custom request timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set a custom User-Agent.
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_config_creation() {
let config = ClientConfig::new(
"https://cloud.example.com",
Some(AccountCredentials::new("alice", "secret123")),
)
.expect("Valid URL should parse");
assert_eq!(config.server_url.as_str(), "https://cloud.example.com/");
assert_eq!(config.credentials.as_ref().unwrap().username, "alice");
}
#[test]
fn test_invalid_scheme() {
let result = ClientConfig::new("ftp://cloud.example.com", None);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,38 @@
use thiserror::Error;
/// Core error type for Nextcloud client operations.
#[derive(Error, Debug)]
pub enum NextcloudError {
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("HTTP request error: {0}")]
Http(#[from] reqwest::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Authentication failed for user '{username}': {message}")]
AuthenticationFailed { username: String, message: String },
#[error("Resource not found at '{path}'")]
NotFound { path: String },
#[error("Server error ({status}): {message}")]
ServerError { status: u16, message: String },
#[error("Nextcloud OCS API error (code {status_code}): {message}")]
OcsApiError {
status_code: i32,
message: String,
},
#[error("Invalid path '{path}': {reason}")]
InvalidPath { path: String, reason: String },
#[error("Unexpected error: {0}")]
Other(String),
}
/// Convenience alias for `Result<T, NextcloudError>`.
pub type Result<T> = std::result::Result<T, NextcloudError>;

View File

@@ -1,14 +1,15 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
//! # Nextcloud Client Library
//!
//! A high-performance, asynchronous Rust library for interacting with Nextcloud's
//! WebDAV file transfer APIs, OCS Sharing API, and credential storage.
#[cfg(test)]
mod tests {
use super::*;
pub mod client;
pub mod config;
pub mod error;
pub mod models;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
// Convenient top-level re-exports
pub use client::{NextcloudClient, ServerStatus};
pub use config::{AccountCredentials, ClientConfig};
pub use error::{NextcloudError, Result};
pub use models::{UploadOptions, UploadResult};

View File

@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
/// High-level options for uploading a file to Nextcloud.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadOptions {
/// Remote directory or full path on Nextcloud (e.g. `"Uploads/"` or `"Photos/sample.png"`).
pub remote_path: String,
/// Whether to automatically create a public share link after upload.
pub create_share: bool,
/// Optional password for the generated public share link.
pub share_password: Option<String>,
/// Whether to overwrite existing files at the target path.
pub overwrite: bool,
}
impl Default for UploadOptions {
fn default() -> Self {
Self {
remote_path: String::new(),
create_share: false,
share_password: None,
overwrite: true,
}
}
}
impl UploadOptions {
pub fn new(remote_path: impl Into<String>) -> Self {
Self {
remote_path: remote_path.into(),
..Default::default()
}
}
pub fn with_share(mut self, create_share: bool) -> Self {
self.create_share = create_share;
self
}
pub fn with_share_password(mut self, password: impl Into<String>) -> Self {
self.share_password = Some(password.into());
self.create_share = true;
self
}
}
/// Result returned from a successful file upload operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadResult {
/// The final remote path of the uploaded file on the Nextcloud instance.
pub remote_path: String,
/// Total number of bytes transferred.
pub bytes_uploaded: u64,
/// Public share URL if a share link was requested (e.g. `https://cloud.example.com/s/XyZ123`).
pub share_url: Option<String>,
/// Direct download link if a share link was requested (e.g. `https://cloud.example.com/index.php/s/XyZ123/download`).
pub direct_download_url: Option<String>,
}