webdav
This commit is contained in:
parent
b602f2cbd1
commit
087617b47f
1478
Cargo.lock
generated
1478
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,3 +12,6 @@ uuid = { version = "1.0", features = ["serde", "v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
quick-xml = "0.36"
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,5 @@ anyhow = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
fs_extra = "1.3"
|
fs_extra = "1.3"
|
||||||
|
tokio = { workspace = true }
|
||||||
|
rpassword = "5.0"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ pub mod workspace;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod group;
|
pub mod group;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
use bevy_tasks_core::{AppConfig, TaskRepository};
|
use bevy_tasks_core::{AppConfig, TaskRepository};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
|
||||||
229
crates/bevy-tasks-cli/src/commands/sync.rs
Normal file
229
crates/bevy-tasks-cli/src/commands/sync.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use colored::Colorize;
|
||||||
|
use bevy_tasks_core::sync::{SyncMode, sync_workspace, get_sync_status};
|
||||||
|
use bevy_tasks_core::webdav::{WebDavClient, store_credentials, load_credentials};
|
||||||
|
use crate::output;
|
||||||
|
use super::{load_config, save_config};
|
||||||
|
|
||||||
|
/// Run sync setup: prompt for URL, username, password, test connection, store credentials.
|
||||||
|
pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
||||||
|
let mut config = load_config()?;
|
||||||
|
|
||||||
|
let (name, workspace) = if let Some(name) = workspace_name {
|
||||||
|
let ws = config.get_workspace(&name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
(name, ws)
|
||||||
|
} else {
|
||||||
|
let (n, ws) = config.get_current_workspace()
|
||||||
|
.context("No workspace set. Use 'bevy-tasks init' to create one.")?;
|
||||||
|
(n.clone(), ws.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prompt for WebDAV URL
|
||||||
|
println!("WebDAV sync setup for workspace \"{}\"", name.green());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let url = prompt("WebDAV URL: ")?;
|
||||||
|
if url.is_empty() {
|
||||||
|
output::error("URL cannot be empty");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = prompt("Username: ")?;
|
||||||
|
let password = rpassword::read_password_from_tty(Some("Password: "))
|
||||||
|
.context("Failed to read password")?;
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
println!();
|
||||||
|
println!("Testing connection...");
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
|
||||||
|
let client = WebDavClient::new(&url, &username, &password);
|
||||||
|
|
||||||
|
match rt.block_on(client.test_connection()) {
|
||||||
|
Ok(()) => {
|
||||||
|
output::success("Connection successful!");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
output::error(&format!("Connection failed: {}", e));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store credentials in keychain
|
||||||
|
let domain = extract_domain(&url);
|
||||||
|
match store_credentials(&domain, &username, &password) {
|
||||||
|
Ok(()) => output::info("Credentials stored in system keychain"),
|
||||||
|
Err(e) => {
|
||||||
|
output::warning(&format!(
|
||||||
|
"Could not store in keychain ({}). Set BEVY_TASKS_WEBDAV_USER and BEVY_TASKS_WEBDAV_PASS env vars instead.",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workspace config with WebDAV URL
|
||||||
|
let mut ws = workspace;
|
||||||
|
ws.webdav_url = Some(url);
|
||||||
|
config.add_workspace(name, ws);
|
||||||
|
save_config(&config)?;
|
||||||
|
|
||||||
|
output::success("Sync setup complete. Run 'bevy-tasks sync' to sync.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a sync operation.
|
||||||
|
pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
|
||||||
|
let config = load_config()?;
|
||||||
|
|
||||||
|
let (name, workspace) = if let Some(name) = workspace_name {
|
||||||
|
let ws = config.get_workspace(&name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
(name, ws)
|
||||||
|
} else {
|
||||||
|
let (n, ws) = config.get_current_workspace()
|
||||||
|
.context("No workspace set. Use 'bevy-tasks init' to create one.")?;
|
||||||
|
(n.clone(), ws.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = workspace.webdav_url.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"No WebDAV URL configured for workspace '{}'. Run 'bevy-tasks sync --setup' first.", name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let domain = extract_domain(url);
|
||||||
|
let (username, password) = load_credentials(&domain)
|
||||||
|
.context("Failed to load credentials")?;
|
||||||
|
|
||||||
|
let mode_str = match mode {
|
||||||
|
SyncMode::Full => "Syncing",
|
||||||
|
SyncMode::Push => "Pushing",
|
||||||
|
SyncMode::Pull => "Pulling",
|
||||||
|
};
|
||||||
|
println!("{} workspace \"{}\"...", mode_str, name.green());
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
|
||||||
|
let result = rt.block_on(sync_workspace(
|
||||||
|
&workspace.path,
|
||||||
|
url,
|
||||||
|
&username,
|
||||||
|
&password,
|
||||||
|
mode,
|
||||||
|
Some(Box::new(|msg: &str| { println!("{}", msg); })),
|
||||||
|
)).context("Sync failed")?;
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if result.uploaded > 0 { parts.push(format!("{} uploaded", result.uploaded)); }
|
||||||
|
if result.downloaded > 0 { parts.push(format!("{} downloaded", result.downloaded)); }
|
||||||
|
if result.deleted_local > 0 { parts.push(format!("{} deleted locally", result.deleted_local)); }
|
||||||
|
if result.deleted_remote > 0 { parts.push(format!("{} deleted remotely", result.deleted_remote)); }
|
||||||
|
if result.conflicts > 0 { parts.push(format!("{} conflicts", result.conflicts)); }
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
output::success("Already in sync, nothing to do.");
|
||||||
|
} else {
|
||||||
|
let summary = parts.join(", ");
|
||||||
|
if result.errors.is_empty() {
|
||||||
|
output::success(&format!("Sync complete: {}", summary));
|
||||||
|
} else {
|
||||||
|
output::warning(&format!("Sync complete with errors: {}", summary));
|
||||||
|
for err in &result.errors {
|
||||||
|
output::error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show sync status for a workspace.
|
||||||
|
pub fn status(workspace_name: Option<String>, all: bool) -> Result<()> {
|
||||||
|
let config = load_config()?;
|
||||||
|
|
||||||
|
if all {
|
||||||
|
// Show status for all workspaces that have sync configured
|
||||||
|
let mut found_any = false;
|
||||||
|
let mut names: Vec<_> = config.workspaces.keys().cloned().collect();
|
||||||
|
names.sort();
|
||||||
|
for name in names {
|
||||||
|
let ws = config.get_workspace(&name).unwrap();
|
||||||
|
if ws.webdav_url.is_some() {
|
||||||
|
found_any = true;
|
||||||
|
print_workspace_status(&name, &ws.path, ws.webdav_url.as_deref())?;
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found_any {
|
||||||
|
output::info("No workspaces have sync configured. Run 'bevy-tasks sync --setup' to set up.");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (name, workspace) = if let Some(name) = workspace_name {
|
||||||
|
let ws = config.get_workspace(&name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
(name, ws)
|
||||||
|
} else {
|
||||||
|
let (n, ws) = config.get_current_workspace()
|
||||||
|
.context("No workspace set.")?;
|
||||||
|
(n.clone(), ws.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
print_workspace_status(&name, &workspace.path, workspace.webdav_url.as_deref())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option<&str>) -> Result<()> {
|
||||||
|
println!("Workspace: {}", name.green());
|
||||||
|
|
||||||
|
if let Some(url) = webdav_url {
|
||||||
|
println!(" WebDAV URL: {}", url);
|
||||||
|
} else {
|
||||||
|
println!(" WebDAV: {}", "not configured".dimmed());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = get_sync_status(path)?;
|
||||||
|
|
||||||
|
if let Some(last) = info.last_sync {
|
||||||
|
println!(" Last sync: {}", last.format("%Y-%m-%d %H:%M:%S UTC"));
|
||||||
|
} else {
|
||||||
|
println!(" Last sync: {}", "never".dimmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Tracked files: {}", info.tracked_files);
|
||||||
|
println!(" Pending changes: {}", info.pending_changes);
|
||||||
|
if info.queued_operations > 0 {
|
||||||
|
println!(" Queued operations: {}", format!("{}", info.queued_operations).yellow());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract domain from a URL for credential storage.
|
||||||
|
fn extract_domain(url: &str) -> String {
|
||||||
|
url.split("://")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or(url)
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(url)
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(url)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt the user for text input.
|
||||||
|
fn prompt(message: &str) -> Result<String> {
|
||||||
|
use std::io::Write;
|
||||||
|
print!("{}", message);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
Ok(input.trim().to_string())
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,28 @@ enum Commands {
|
||||||
/// Toggle group-by-due-date for a list
|
/// Toggle group-by-due-date for a list
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Group(GroupCommands),
|
Group(GroupCommands),
|
||||||
|
|
||||||
|
/// Sync workspace with WebDAV server
|
||||||
|
Sync {
|
||||||
|
/// Run initial setup (URL, credentials)
|
||||||
|
#[arg(long)]
|
||||||
|
setup: bool,
|
||||||
|
/// Push-only sync (upload local changes)
|
||||||
|
#[arg(long, conflicts_with_all = ["pull", "setup", "status"])]
|
||||||
|
push: bool,
|
||||||
|
/// Pull-only sync (download remote changes)
|
||||||
|
#[arg(long, conflicts_with_all = ["push", "setup", "status"])]
|
||||||
|
pull: bool,
|
||||||
|
/// Show sync status
|
||||||
|
#[arg(long, conflicts_with_all = ["push", "pull", "setup"])]
|
||||||
|
status: bool,
|
||||||
|
/// Show status for all workspaces (with --status)
|
||||||
|
#[arg(long, requires = "status")]
|
||||||
|
all: bool,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
|
|
@ -233,6 +255,22 @@ fn main() -> Result<()> {
|
||||||
group::disable(list, workspace)?;
|
group::disable(list, workspace)?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Commands::Sync { setup, push, pull, status, all, workspace } => {
|
||||||
|
if setup {
|
||||||
|
sync::setup(workspace)?;
|
||||||
|
} else if status {
|
||||||
|
sync::status(workspace, all)?;
|
||||||
|
} else {
|
||||||
|
let mode = if push {
|
||||||
|
bevy_tasks_core::sync::SyncMode::Push
|
||||||
|
} else if pull {
|
||||||
|
bevy_tasks_core::sync::SyncMode::Pull
|
||||||
|
} else {
|
||||||
|
bevy_tasks_core::sync::SyncMode::Full
|
||||||
|
};
|
||||||
|
sync::execute(mode, workspace)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ serde_yaml = "0.9"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
directories = "5.0"
|
directories = "5.0"
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
quick-xml = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.0"
|
tempfile = "3.0"
|
||||||
|
wiremock = "0.6"
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ use crate::error::{Error, Result};
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkspaceConfig {
|
pub struct WorkspaceConfig {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub webdav_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceConfig {
|
impl WorkspaceConfig {
|
||||||
pub fn new(path: PathBuf) -> Self {
|
pub fn new(path: PathBuf) -> Self {
|
||||||
Self { path }
|
Self { path, webdav_url: None, last_sync: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,4 +205,43 @@ mod tests {
|
||||||
assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new"));
|
assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new"));
|
||||||
assert_eq!(config.workspaces.len(), 1);
|
assert_eq!(config.workspaces.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_workspace_config_with_webdav_fields_roundtrip() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.json");
|
||||||
|
|
||||||
|
let mut config = AppConfig::new();
|
||||||
|
let mut ws = WorkspaceConfig::new(PathBuf::from("/tasks"));
|
||||||
|
ws.webdav_url = Some("https://dav.example.com/tasks".to_string());
|
||||||
|
ws.last_sync = Some(chrono::Utc::now());
|
||||||
|
config.add_workspace("synced".to_string(), ws);
|
||||||
|
config.save_to_file(&config_path).unwrap();
|
||||||
|
|
||||||
|
let loaded = AppConfig::load_from_file(&config_path).unwrap();
|
||||||
|
let ws = loaded.get_workspace("synced").unwrap();
|
||||||
|
assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks"));
|
||||||
|
assert!(ws.last_sync.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backwards_compat_loading_old_format() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let config_path = temp_dir.path().join("config.json");
|
||||||
|
|
||||||
|
// Write old-format JSON without webdav_url or last_sync fields
|
||||||
|
let old_json = r#"{
|
||||||
|
"workspaces": {
|
||||||
|
"personal": { "path": "/home/user/tasks" }
|
||||||
|
},
|
||||||
|
"current_workspace": "personal"
|
||||||
|
}"#;
|
||||||
|
std::fs::write(&config_path, old_json).unwrap();
|
||||||
|
|
||||||
|
let loaded = AppConfig::load_from_file(&config_path).unwrap();
|
||||||
|
let ws = loaded.get_workspace("personal").unwrap();
|
||||||
|
assert_eq!(ws.path, PathBuf::from("/home/user/tasks"));
|
||||||
|
assert!(ws.webdav_url.is_none());
|
||||||
|
assert!(ws.last_sync.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ pub enum Error {
|
||||||
WorkspaceNotFound(String),
|
WorkspaceNotFound(String),
|
||||||
ListNotFound(String),
|
ListNotFound(String),
|
||||||
TaskNotFound(String),
|
TaskNotFound(String),
|
||||||
|
WebDav(String),
|
||||||
|
Sync(String),
|
||||||
|
Credential(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Error {
|
impl fmt::Display for Error {
|
||||||
|
|
@ -22,6 +25,9 @@ impl fmt::Display for Error {
|
||||||
Error::WorkspaceNotFound(name) => write!(f, "Workspace not found: {}", name),
|
Error::WorkspaceNotFound(name) => write!(f, "Workspace not found: {}", name),
|
||||||
Error::ListNotFound(id) => write!(f, "List not found: {}", id),
|
Error::ListNotFound(id) => write!(f, "List not found: {}", id),
|
||||||
Error::TaskNotFound(id) => write!(f, "Task not found: {}", id),
|
Error::TaskNotFound(id) => write!(f, "Task not found: {}", id),
|
||||||
|
Error::WebDav(msg) => write!(f, "WebDAV error: {}", msg),
|
||||||
|
Error::Sync(msg) => write!(f, "Sync error: {}", msg),
|
||||||
|
Error::Credential(msg) => write!(f, "Credential error: {}", msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,4 +52,10 @@ impl From<serde_yaml::Error> for Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for Error {
|
||||||
|
fn from(err: reqwest::Error) -> Self {
|
||||||
|
Error::WebDav(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ pub mod storage;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod webdav;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
pub use models::{Task, TaskStatus, TaskList};
|
pub use models::{Task, TaskStatus, TaskList};
|
||||||
pub use repository::TaskRepository;
|
pub use repository::TaskRepository;
|
||||||
|
|
|
||||||
1107
crates/bevy-tasks-core/src/sync.rs
Normal file
1107
crates/bevy-tasks-core/src/sync.rs
Normal file
File diff suppressed because it is too large
Load diff
640
crates/bevy-tasks-core/src/webdav.rs
Normal file
640
crates/bevy-tasks-core/src/webdav.rs
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
use reqwest::Client;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Information about a file on the remote WebDAV server.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteFileInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub content_length: u64,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV client wrapping reqwest with basic auth.
|
||||||
|
pub struct WebDavClient {
|
||||||
|
_client: Client,
|
||||||
|
_base_url: String,
|
||||||
|
_username: String,
|
||||||
|
_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebDavClient {
|
||||||
|
pub fn new(base_url: &str, username: &str, password: &str) -> Self {
|
||||||
|
let base_url = base_url.trim_end_matches('/').to_string();
|
||||||
|
Self {
|
||||||
|
_client: Client::new(),
|
||||||
|
_base_url: base_url,
|
||||||
|
_username: username.to_string(),
|
||||||
|
_password: password.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_url(&self, path: &str) -> String {
|
||||||
|
let path = path.trim_start_matches('/');
|
||||||
|
if path.is_empty() {
|
||||||
|
self._base_url.clone()
|
||||||
|
} else {
|
||||||
|
// Percent-encode path segments while preserving '/'
|
||||||
|
let encoded: String = path
|
||||||
|
.split('/')
|
||||||
|
.map(|seg| percent_encode(seg))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
format!("{}/{}", self._base_url, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection by issuing a PROPFIND depth 0 on the root.
|
||||||
|
pub async fn test_connection(&self) -> Result<()> {
|
||||||
|
let resp = self._client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.header("Depth", "0")
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(PROPFIND_BODY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status == 207 || status == 200 {
|
||||||
|
Ok(())
|
||||||
|
} else if status == 401 || status == 403 {
|
||||||
|
Err(Error::Credential("Authentication failed".to_string()))
|
||||||
|
} else {
|
||||||
|
Err(Error::WebDav(format!("Unexpected status {}", status)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List files at a given path using PROPFIND depth 1.
|
||||||
|
pub async fn list_files(&self, path: &str) -> Result<Vec<RemoteFileInfo>> {
|
||||||
|
let url = self.full_url(path);
|
||||||
|
let resp = self._client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.header("Depth", "1")
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(PROPFIND_BODY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status != 207 {
|
||||||
|
return Err(Error::WebDav(format!("PROPFIND failed with status {}", status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = resp.text().await?;
|
||||||
|
parse_propfind_response(&body, &self._base_url, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a file's contents.
|
||||||
|
pub async fn get_file(&self, path: &str) -> Result<Vec<u8>> {
|
||||||
|
let url = self.full_url(path);
|
||||||
|
let resp = self._client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status == 404 {
|
||||||
|
return Err(Error::NotFound(format!("Remote file not found: {}", path)));
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
return Err(Error::WebDav(format!("GET failed with status {}", status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp.bytes().await?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file.
|
||||||
|
pub async fn put_file(&self, path: &str, content: Vec<u8>) -> Result<()> {
|
||||||
|
let url = self.full_url(path);
|
||||||
|
let resp = self._client
|
||||||
|
.put(&url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.body(content)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if !(200..=299).contains(&status) {
|
||||||
|
return Err(Error::WebDav(format!("PUT failed with status {}", status)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a remote file.
|
||||||
|
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
||||||
|
let url = self.full_url(path);
|
||||||
|
let resp = self._client
|
||||||
|
.delete(&url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status == 404 {
|
||||||
|
return Ok(()); // Already gone
|
||||||
|
}
|
||||||
|
if !(200..=299).contains(&status) {
|
||||||
|
return Err(Error::WebDav(format!("DELETE failed with status {}", status)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a directory via MKCOL.
|
||||||
|
pub async fn create_dir(&self, path: &str) -> Result<()> {
|
||||||
|
let url = self.full_url(path);
|
||||||
|
let resp = self._client
|
||||||
|
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url)
|
||||||
|
.basic_auth(&self._username, Some(&self._password))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if status == 405 {
|
||||||
|
return Ok(()); // Already exists
|
||||||
|
}
|
||||||
|
if !(200..=299).contains(&status) {
|
||||||
|
return Err(Error::WebDav(format!("MKCOL failed with status {}", status)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a directory exists, creating it and parents as needed.
|
||||||
|
pub async fn ensure_dir(&self, path: &str) -> Result<()> {
|
||||||
|
let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
let mut current = String::new();
|
||||||
|
for part in parts {
|
||||||
|
current = if current.is_empty() {
|
||||||
|
part.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", current, part)
|
||||||
|
};
|
||||||
|
self.create_dir(¤t).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:getcontentlength/>
|
||||||
|
<D:getlastmodified/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"#;
|
||||||
|
|
||||||
|
/// Percent-encode a single path segment (not the whole path).
|
||||||
|
fn percent_encode(segment: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for byte in segment.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
result.push(byte as char);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
result.push_str(&format!("%{:02X}", byte));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-decode a string.
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
if let Ok(val) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||||
|
result.push(val);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&result).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PROPFIND multistatus XML response into RemoteFileInfo entries.
|
||||||
|
/// Handles namespace prefix variations (d:, D:, no prefix).
|
||||||
|
fn parse_propfind_response(xml: &str, base_url: &str, request_path: &str) -> Result<Vec<RemoteFileInfo>> {
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::Reader;
|
||||||
|
|
||||||
|
let mut reader = Reader::from_str(xml);
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// State machine for parsing
|
||||||
|
let mut in_response = false;
|
||||||
|
let mut in_propstat = false;
|
||||||
|
let mut in_prop = false;
|
||||||
|
let mut current_href: Option<String> = None;
|
||||||
|
let mut current_is_dir = false;
|
||||||
|
let mut current_content_length: u64 = 0;
|
||||||
|
let mut current_last_modified: Option<String> = None;
|
||||||
|
let mut reading_href = false;
|
||||||
|
let mut reading_content_length = false;
|
||||||
|
let mut reading_last_modified = false;
|
||||||
|
let mut in_resourcetype = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event() {
|
||||||
|
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
|
||||||
|
let name_bytes = e.name().as_ref().to_vec();
|
||||||
|
let local = local_name(&name_bytes);
|
||||||
|
match local {
|
||||||
|
"response" => {
|
||||||
|
in_response = true;
|
||||||
|
current_href = None;
|
||||||
|
current_is_dir = false;
|
||||||
|
current_content_length = 0;
|
||||||
|
current_last_modified = None;
|
||||||
|
}
|
||||||
|
"propstat" => in_propstat = true,
|
||||||
|
"prop" if in_propstat => in_prop = true,
|
||||||
|
"href" if in_response => reading_href = true,
|
||||||
|
"resourcetype" if in_prop => in_resourcetype = true,
|
||||||
|
"collection" if in_resourcetype => current_is_dir = true,
|
||||||
|
"getcontentlength" if in_prop => reading_content_length = true,
|
||||||
|
"getlastmodified" if in_prop => reading_last_modified = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(ref e)) => {
|
||||||
|
let name_bytes = e.name().as_ref().to_vec();
|
||||||
|
let local = local_name(&name_bytes);
|
||||||
|
match local {
|
||||||
|
"response" => {
|
||||||
|
if let Some(href) = current_href.take() {
|
||||||
|
let path = extract_relative_path(&href, base_url, request_path);
|
||||||
|
if !path.is_empty() {
|
||||||
|
results.push(RemoteFileInfo {
|
||||||
|
path,
|
||||||
|
is_dir: current_is_dir,
|
||||||
|
content_length: current_content_length,
|
||||||
|
last_modified: current_last_modified.take(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_response = false;
|
||||||
|
}
|
||||||
|
"propstat" => in_propstat = false,
|
||||||
|
"prop" => in_prop = false,
|
||||||
|
"resourcetype" => in_resourcetype = false,
|
||||||
|
"href" => reading_href = false,
|
||||||
|
"getcontentlength" => reading_content_length = false,
|
||||||
|
"getlastmodified" => reading_last_modified = false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Text(ref e)) => {
|
||||||
|
if let Ok(text) = e.unescape() {
|
||||||
|
let text = text.to_string();
|
||||||
|
if reading_href {
|
||||||
|
current_href = Some(text);
|
||||||
|
} else if reading_content_length {
|
||||||
|
current_content_length = text.trim().parse().unwrap_or(0);
|
||||||
|
} else if reading_last_modified {
|
||||||
|
current_last_modified = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Err(e) => return Err(Error::WebDav(format!("XML parse error: {}", e))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get local name from a potentially namespaced XML tag name.
|
||||||
|
fn local_name(name: &[u8]) -> &str {
|
||||||
|
let s = std::str::from_utf8(name).unwrap_or("");
|
||||||
|
// Handle both "D:href" and "href" and "{DAV:}href" forms
|
||||||
|
if let Some(pos) = s.rfind(':') {
|
||||||
|
&s[pos + 1..]
|
||||||
|
} else if let Some(pos) = s.rfind('}') {
|
||||||
|
&s[pos + 1..]
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a relative path from an href, stripping the base URL prefix and the request path.
|
||||||
|
fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> String {
|
||||||
|
let decoded = percent_decode(href);
|
||||||
|
// Strip scheme + host if present
|
||||||
|
let path = if let Some(pos) = decoded.find("://") {
|
||||||
|
let after_scheme = &decoded[pos + 3..];
|
||||||
|
if let Some(slash) = after_scheme.find('/') {
|
||||||
|
&after_scheme[slash..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decoded.as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract the base path from base_url
|
||||||
|
let base_path = if let Some(pos) = base_url.find("://") {
|
||||||
|
let after_scheme = &base_url[pos + 3..];
|
||||||
|
if let Some(slash) = after_scheme.find('/') {
|
||||||
|
&after_scheme[slash..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut relative = path.to_string();
|
||||||
|
// Strip base path prefix
|
||||||
|
if !base_path.is_empty() {
|
||||||
|
let bp = base_path.trim_end_matches('/');
|
||||||
|
if let Some(stripped) = relative.strip_prefix(bp) {
|
||||||
|
relative = stripped.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip request path prefix
|
||||||
|
let req = request_path.trim_matches('/');
|
||||||
|
if !req.is_empty() {
|
||||||
|
let prefixed = format!("/{}", req);
|
||||||
|
if let Some(stripped) = relative.strip_prefix(&prefixed) {
|
||||||
|
relative = stripped.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up leading/trailing slashes
|
||||||
|
let relative = relative.trim_matches('/').to_string();
|
||||||
|
relative
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Credential Storage ---
|
||||||
|
|
||||||
|
/// Store WebDAV credentials in the platform keychain.
|
||||||
|
pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> {
|
||||||
|
let service = format!("com.bevy-tasks.webdav.{}", domain);
|
||||||
|
|
||||||
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
|
user_entry.set_password(username)
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
||||||
|
|
||||||
|
let pass_entry = keyring::Entry::new(&service, "password")
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
|
pass_entry.set_password(password)
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load WebDAV credentials from the platform keychain, falling back to env vars.
|
||||||
|
pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
||||||
|
let service = format!("com.bevy-tasks.webdav.{}", domain);
|
||||||
|
|
||||||
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
|
let pass_entry = keyring::Entry::new(&service, "password")
|
||||||
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
|
|
||||||
|
match (user_entry.get_password(), pass_entry.get_password()) {
|
||||||
|
(Ok(user), Ok(pass)) => return Ok((user, pass)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to env vars for headless/CI environments
|
||||||
|
if let (Ok(user), Ok(pass)) = (
|
||||||
|
std::env::var("BEVY_TASKS_WEBDAV_USER"),
|
||||||
|
std::env::var("BEVY_TASKS_WEBDAV_PASS"),
|
||||||
|
) {
|
||||||
|
return Ok((user, pass));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::Credential(format!(
|
||||||
|
"No credentials found for '{}'. Run 'bevy-tasks sync --setup' or set BEVY_TASKS_WEBDAV_USER and BEVY_TASKS_WEBDAV_PASS.",
|
||||||
|
domain
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete WebDAV credentials from the platform keychain.
|
||||||
|
pub fn delete_credentials(domain: &str) -> Result<()> {
|
||||||
|
let service = format!("com.bevy-tasks.webdav.{}", domain);
|
||||||
|
|
||||||
|
if let Ok(entry) = keyring::Entry::new(&service, "username") {
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
}
|
||||||
|
if let Ok(entry) = keyring::Entry::new(&service, "password") {
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// --- URL encoding tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_encode_simple() {
|
||||||
|
assert_eq!(percent_encode("hello"), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_encode_spaces() {
|
||||||
|
assert_eq!(percent_encode("Buy groceries"), "Buy%20groceries");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_encode_special_chars() {
|
||||||
|
assert_eq!(percent_encode("task (1)"), "task%20%281%29");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_percent_decode_roundtrip() {
|
||||||
|
let original = "Buy groceries (urgent)";
|
||||||
|
let encoded = percent_encode(original);
|
||||||
|
let decoded = percent_decode(&encoded);
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PROPFIND XML parsing tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_propfind_with_d_prefix() {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:getcontentlength>0</d:getcontentlength>
|
||||||
|
</d:prop>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote/My%20Tasks/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<d:getcontentlength>0</d:getcontentlength>
|
||||||
|
</d:prop>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote/My%20Tasks/Buy%20groceries.md</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getcontentlength>150</d:getcontentlength>
|
||||||
|
<d:getlastmodified>Mon, 01 Jan 2026 00:00:00 GMT</d:getlastmodified>
|
||||||
|
</d:prop>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>"#;
|
||||||
|
|
||||||
|
let results = parse_propfind_response(xml, "http://example.com/remote", "").unwrap();
|
||||||
|
assert_eq!(results.len(), 2); // Root directory itself is empty path -> skipped
|
||||||
|
assert_eq!(results[0].path, "My Tasks");
|
||||||
|
assert!(results[0].is_dir);
|
||||||
|
assert_eq!(results[1].path, "My Tasks/Buy groceries.md");
|
||||||
|
assert!(!results[1].is_dir);
|
||||||
|
assert_eq!(results[1].content_length, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_propfind_with_uppercase_d_prefix() {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/dav/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype><D:collection/></D:resourcetype>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/dav/notes.md</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:getcontentlength>42</D:getcontentlength>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"#;
|
||||||
|
|
||||||
|
let results = parse_propfind_response(xml, "http://example.com/dav", "").unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "notes.md");
|
||||||
|
assert!(!results[0].is_dir);
|
||||||
|
assert_eq!(results[0].content_length, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_propfind_no_prefix() {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<multistatus xmlns="DAV:">
|
||||||
|
<response>
|
||||||
|
<href>/files/</href>
|
||||||
|
<propstat>
|
||||||
|
<prop>
|
||||||
|
<resourcetype><collection/></resourcetype>
|
||||||
|
</prop>
|
||||||
|
</propstat>
|
||||||
|
</response>
|
||||||
|
<response>
|
||||||
|
<href>/files/test.md</href>
|
||||||
|
<propstat>
|
||||||
|
<prop>
|
||||||
|
<resourcetype/>
|
||||||
|
<getcontentlength>100</getcontentlength>
|
||||||
|
<getlastmodified>Tue, 15 Mar 2026 10:30:00 GMT</getlastmodified>
|
||||||
|
</prop>
|
||||||
|
</propstat>
|
||||||
|
</response>
|
||||||
|
</multistatus>"#;
|
||||||
|
|
||||||
|
let results = parse_propfind_response(xml, "http://example.com/files", "").unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "test.md");
|
||||||
|
assert_eq!(results[0].last_modified.as_deref(), Some("Tue, 15 Mar 2026 10:30:00 GMT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_propfind_with_subpath() {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote/My%20Tasks/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
</d:prop>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote/My%20Tasks/task1.md</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getcontentlength>50</d:getcontentlength>
|
||||||
|
</d:prop>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>"#;
|
||||||
|
|
||||||
|
let results = parse_propfind_response(xml, "http://example.com/remote", "My Tasks").unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].path, "task1.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebDavClient URL building ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_url_building() {
|
||||||
|
let client = WebDavClient::new("http://example.com/dav/", "user", "pass");
|
||||||
|
assert_eq!(client.full_url(""), "http://example.com/dav");
|
||||||
|
assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md");
|
||||||
|
assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_url_strips_leading_slash() {
|
||||||
|
let client = WebDavClient::new("http://example.com/dav", "user", "pass");
|
||||||
|
assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- extract_relative_path ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_relative_path_full_url_href() {
|
||||||
|
let path = extract_relative_path(
|
||||||
|
"http://example.com/dav/My%20Tasks/file.md",
|
||||||
|
"http://example.com/dav",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert_eq!(path, "My Tasks/file.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_relative_path_absolute_href() {
|
||||||
|
let path = extract_relative_path(
|
||||||
|
"/dav/Work/meeting.md",
|
||||||
|
"http://example.com/dav",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert_eq!(path, "Work/meeting.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue