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"] }
|
||||
anyhow = "1.0"
|
||||
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 }
|
||||
uuid = { workspace = true }
|
||||
fs_extra = "1.3"
|
||||
tokio = { workspace = true }
|
||||
rpassword = "5.0"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod workspace;
|
|||
pub mod list;
|
||||
pub mod task;
|
||||
pub mod group;
|
||||
pub mod sync;
|
||||
|
||||
use bevy_tasks_core::{AppConfig, TaskRepository};
|
||||
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
|
||||
#[command(subcommand)]
|
||||
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)]
|
||||
|
|
@ -233,6 +255,22 @@ fn main() -> Result<()> {
|
|||
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(())
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ serde_yaml = "0.9"
|
|||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
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]
|
||||
tempfile = "3.0"
|
||||
wiremock = "0.6"
|
||||
tokio = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ use crate::error::{Error, Result};
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceConfig {
|
||||
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 {
|
||||
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.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),
|
||||
ListNotFound(String),
|
||||
TaskNotFound(String),
|
||||
WebDav(String),
|
||||
Sync(String),
|
||||
Credential(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
|
|
@ -22,6 +25,9 @@ impl fmt::Display for Error {
|
|||
Error::WorkspaceNotFound(name) => write!(f, "Workspace not found: {}", name),
|
||||
Error::ListNotFound(id) => write!(f, "List 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>;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ pub mod storage;
|
|||
pub mod repository;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod webdav;
|
||||
pub mod sync;
|
||||
|
||||
pub use models::{Task, TaskStatus, TaskList};
|
||||
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