This commit is contained in:
Tristan Michael 2026-03-17 06:51:15 -07:00
parent b602f2cbd1
commit 087617b47f
12 changed files with 3557 additions and 7 deletions

1478
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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"

View file

@ -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};

View 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())
}

View file

@ -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(())

View file

@ -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 }

View file

@ -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());
}
} }

View file

@ -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>;

View file

@ -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;

File diff suppressed because it is too large Load diff

View 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(&current).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");
}
}