use std::sync::{Arc, OnceLock};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum FolderError {
#[error("writable folder is read-only")]
ReadOnly,
#[error("file not found: {0}")]
NotFound(String),
#[error("writable folders are not supported on this platform")]
Unsupported,
#[error("{0}")]
Io(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FolderEntry {
pub name: String,
pub len: u64,
pub modified_millis: Option<u64>,
}
pub trait FolderReader: Send {
fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, FolderError>;
}
pub trait FolderWriter: Send {
fn write_chunk(&mut self, bytes: &[u8]) -> Result<(), FolderError>;
fn finish(self: Box<Self>) -> Result<(), FolderError>;
}
pub trait WritableFolderStore: Send + Sync {
fn write(&self, name: &str, contents: &[u8]) -> Result<(), FolderError>;
fn read(&self, name: &str) -> Result<Vec<u8>, FolderError>;
fn list(&self) -> Result<Vec<FolderEntry>, FolderError>;
fn remove(&self, name: &str) -> Result<(), FolderError>;
fn open_read(&self, name: &str) -> Result<Box<dyn FolderReader>, FolderError>;
fn open_write(&self, name: &str) -> Result<Box<dyn FolderWriter>, FolderError>;
fn is_writable(&self) -> bool;
fn handle(&self) -> String;
fn display_name(&self) -> String {
let handle = self.handle();
handle
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\', ':'])
.find(|segment| !segment.is_empty())
.map(ToString::to_string)
.unwrap_or(handle)
}
fn entry(&self, name: &str) -> Result<FolderEntry, FolderError> {
self.list()?
.into_iter()
.find(|entry| entry.name == name)
.ok_or_else(|| FolderError::NotFound(name.to_string()))
}
}
pub type WritableFolderStoreRef = Arc<dyn WritableFolderStore>;
type StoreFactory = Box<dyn Fn(&str) -> Option<WritableFolderStoreRef> + Send + Sync>;
static STORE_FACTORY: OnceLock<StoreFactory> = OnceLock::new();
pub fn set_writable_folder_store_factory(factory: StoreFactory) {
let _ = STORE_FACTORY.set(factory);
}
pub fn open_writable_folder(handle: &str) -> Option<WritableFolderStoreRef> {
if let Some(factory) = STORE_FACTORY.get() {
return factory(handle);
}
builtin_open(handle)
}
fn builtin_open(handle: &str) -> Option<WritableFolderStoreRef> {
#[cfg(all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
))]
{
return Some(desktop::open(handle));
}
#[allow(unreachable_code)]
{
let _ = handle;
None
}
}
#[cfg(all(
not(target_arch = "wasm32"),
not(target_os = "android"),
not(target_os = "ios"),
feature = "file-picker-native"
))]
mod desktop;
#[cfg(test)]
mod tests {
use super::*;
struct FlatStore {
handle: String,
}
impl WritableFolderStore for FlatStore {
fn write(&self, _name: &str, _contents: &[u8]) -> Result<(), FolderError> {
Ok(())
}
fn read(&self, _name: &str) -> Result<Vec<u8>, FolderError> {
Ok(Vec::new())
}
fn list(&self) -> Result<Vec<FolderEntry>, FolderError> {
Ok(vec![FolderEntry {
name: "sync.json".into(),
len: 12,
modified_millis: Some(5),
}])
}
fn remove(&self, _name: &str) -> Result<(), FolderError> {
Ok(())
}
fn open_read(&self, _name: &str) -> Result<Box<dyn FolderReader>, FolderError> {
Err(FolderError::Unsupported)
}
fn open_write(&self, _name: &str) -> Result<Box<dyn FolderWriter>, FolderError> {
Err(FolderError::Unsupported)
}
fn is_writable(&self) -> bool {
true
}
fn handle(&self) -> String {
self.handle.clone()
}
}
#[test]
fn folder_error_messages_are_distinct() {
assert_eq!(
FolderError::ReadOnly.to_string(),
"writable folder is read-only"
);
assert!(
FolderError::NotFound("a.txt".into())
.to_string()
.contains("a.txt")
);
}
#[test]
fn the_default_display_name_is_the_last_handle_segment() {
let store = FlatStore {
handle: "/home/user/Shared Sync/".into(),
};
assert_eq!(store.display_name(), "Shared Sync");
let tree = FlatStore {
handle: "content://com.android.externalstorage.documents/tree/primary%3ASync".into(),
};
assert_eq!(tree.display_name(), "primary%3ASync");
}
#[test]
fn the_default_entry_lookup_filters_the_listing() {
let store = FlatStore {
handle: "sync-root".into(),
};
assert_eq!(store.entry("sync.json").unwrap().len, 12);
assert_eq!(
store.entry("absent.json"),
Err(FolderError::NotFound("absent.json".into()))
);
}
}