diff --git a/Cargo.lock b/Cargo.lock
index cb53ee6..c530fc8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Tasks.md b/Tasks.md
index 8b67ccf..a1de89f 100644
--- a/Tasks.md
+++ b/Tasks.md
@@ -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
-
+
### 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: ``.
+- Anchor IDs are lowercase: ``.
- 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]`.
diff --git a/nextcloud_client/Cargo.toml b/nextcloud_client/Cargo.toml
index ba8f034..ccf391d 100644
--- a/nextcloud_client/Cargo.toml
+++ b/nextcloud_client/Cargo.toml
@@ -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"] }
diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs
new file mode 100644
index 0000000..e8c6923
--- /dev/null
+++ b/nextcloud_client/src/client.rs
@@ -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,
+}
+
+/// 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 {
+ 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:///remote.php/dav/files//`
+ pub fn webdav_url(&self, remote_path: &str) -> Result {
+ 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:///ocs/v2.php/apps/files_sharing/api/v1/shares`
+ pub fn ocs_shares_url(&self) -> Result {
+ 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:///index.php/s//download`
+ pub fn direct_download_url(&self, share_token: &str) -> Result {
+ 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 {
+ 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::().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"));
+ }
+}
diff --git a/nextcloud_client/src/config.rs b/nextcloud_client/src/config.rs
new file mode 100644
index 0000000..c3efb4a
--- /dev/null
+++ b/nextcloud_client/src/config.rs
@@ -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, app_password: impl Into) -> 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,
+
+ /// 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(duration: &Duration, serializer: S) -> std::result::Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_u64(duration.as_secs())
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result
+ 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,
+ ) -> Result {
+ 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) -> 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());
+ }
+}
diff --git a/nextcloud_client/src/error.rs b/nextcloud_client/src/error.rs
new file mode 100644
index 0000000..fa51b01
--- /dev/null
+++ b/nextcloud_client/src/error.rs
@@ -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`.
+pub type Result = std::result::Result;
diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs
index b93cf3f..44315c1 100644
--- a/nextcloud_client/src/lib.rs
+++ b/nextcloud_client/src/lib.rs
@@ -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};
diff --git a/nextcloud_client/src/models.rs b/nextcloud_client/src/models.rs
new file mode 100644
index 0000000..64cfb94
--- /dev/null
+++ b/nextcloud_client/src/models.rs
@@ -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,
+
+ /// 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) -> 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) -> 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,
+
+ /// 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,
+}