use chrono::prelude::*;
use maildir::Maildir;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StateError {
#[error("IO error: {0}")]
IOError(#[from] io::Error),
#[error("error parsing TOML: {0}")]
LoadTOMLError(#[from] toml::de::Error),
#[error("error writing TOML: {0}")]
SaveTOMLError(#[from] toml::ser::Error),
#[error("conflicting changes on both sides for UID {0}")]
ConflictError(u32),
#[error("error: {0}")]
Error(&'static str),
}
#[derive(Debug)]
pub(crate) struct LocalAddition {
id: String,
flags: String,
}
impl LocalAddition {
pub fn new(id: String, flags: String) -> Self {
LocalAddition { id, flags }
}
pub fn id(&self) -> &str {
&self.id
}
}
type LocalAdditions = Vec<LocalAddition>;
#[derive(Debug)]
pub(crate) struct LocalDeletion {}
type LocalDeletions = BTreeMap<u32, LocalDeletion>;
#[derive(Debug)]
pub(crate) struct LocalModification {
id: String,
new_flags: String,
}
impl LocalModification {
pub fn new_flags(&self) -> &str {
&self.new_flags
}
}
type LocalModifications = BTreeMap<u32, LocalModification>;
pub(crate) enum LocalChange {
Addition(LocalAddition),
Deletion(LocalDeletion),
Modification(LocalModification),
}
#[derive(Debug)]
pub(crate) struct LocalChanges {
pub added: LocalAdditions,
pub deleted: LocalDeletions,
pub modified: LocalModifications,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct LastSeen {
pub uid: u32,
pub uid_validity: u32,
pub highest_mod_seq: u64,
pub localtime: Option<i64>,
}
type MailState = (String, String);
#[derive(Debug)]
pub(crate) struct SyncState {
dir: PathBuf,
uids: BTreeMap<u32, MailState>,
pub last_seen: LastSeen,
pub local_changes: LocalChanges,
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RootState {
#[serde(skip)]
dir: PathBuf,
pub subdirs: BTreeSet<String>,
}
type MailStateOnDisk = (u32, String);
#[derive(Debug, Deserialize, Serialize)]
struct SyncStateOnDisk {
last_seen: LastSeen,
uids: BTreeMap<String, MailStateOnDisk>,
}
impl SyncStateOnDisk {
const FNAME: &str = ".vmtsyncstate";
fn new() -> SyncStateOnDisk {
SyncStateOnDisk {
uids: BTreeMap::new(),
last_seen: LastSeen {
uid: 1,
uid_validity: 0,
highest_mod_seq: 0,
localtime: Some(0),
},
}
}
fn load(dir: &impl AsRef<Path>) -> Result<Self, StateError> {
let dir: &Path = dir.as_ref();
let name = Path::new(SyncStateOnDisk::FNAME);
let path: PathBuf = [dir, name].iter().collect();
match fs::read_to_string(&path) {
Ok(s) => Ok(toml::from_str(&s)?),
Err(e) => {
if e.kind() != io::ErrorKind::NotFound {
eprintln!("error accessing state file {:?}", &path);
return Err(StateError::IOError(e));
}
Ok(SyncStateOnDisk::new())
}
}
}
fn save(&self, dir: &impl AsRef<Path>) -> Result<(), StateError> {
let dir: &Path = dir.as_ref();
let name = Path::new(SyncStateOnDisk::FNAME);
let path: PathBuf = [dir, name].iter().collect();
let toml = toml::to_string(self)?;
fs::write(path, toml)?;
Ok(())
}
}
impl RootState {
const FNAME: &str = ".vmtdirstate";
pub(crate) fn load(dir: &impl AsRef<Path>) -> Result<Self, StateError> {
let dir: &Path = dir.as_ref();
let name = Path::new(RootState::FNAME);
let path: PathBuf = [dir, name].iter().collect();
match fs::read_to_string(&path) {
Ok(s) => {
let mut r: Self = toml::from_str(&s)?;
r.dir = path;
Ok(r)
}
Err(e) => {
if e.kind() != io::ErrorKind::NotFound {
eprintln!("error accessing state file {:?}", &path);
return Err(StateError::IOError(e));
}
Ok(RootState {
dir: path,
subdirs: BTreeSet::new(),
})
}
}
}
pub fn save(&mut self) -> Result<(), StateError> {
let toml = toml::to_string(self)?;
fs::write(&self.dir, toml)?;
Ok(())
}
}
impl SyncState {
pub fn is_empty(&self) -> bool {
self.last_seen.highest_mod_seq == 0 && self.uids.len() == 0
}
pub fn clear(&mut self) {
self.uids.clear()
}
pub fn get(&mut self, uid: &u32) -> Option<&(String, String)> {
self.uids.get(uid)
}
pub fn insert(&mut self, uid: u32, value: (String, String)) -> Option<(String, String)> {
self.uids.insert(uid, value)
}
pub fn remove(&mut self, uid: &u32) -> Option<(String, String)> {
self.uids.remove(uid)
}
pub fn uids(&self) -> BTreeSet<u32> {
self.uids.keys().cloned().collect()
}
pub fn load(dir: &impl AsRef<Path>) -> Result<SyncState, StateError> {
let mut ssod = SyncStateOnDisk::load(dir)?;
let changes = SyncState::local_changes(dir.as_ref(), &ssod.uids);
ssod.last_seen.localtime = Some(Utc::now().timestamp_millis());
Ok(SyncState {
dir: PathBuf::from(dir.as_ref()),
uids: SyncState::invert_state_on_disk(ssod.uids),
last_seen: ssod.last_seen,
local_changes: changes,
})
}
pub fn save(&self) -> Result<(), StateError> {
let ssod = SyncStateOnDisk {
uids: SyncState::invert_state(self.uids.clone()),
last_seen: self.last_seen.clone(),
};
ssod.save(&self.dir)
}
pub fn has_local_changes(&self) -> bool {
(self.local_changes.added.len()
+ self.local_changes.modified.len()
+ self.local_changes.deleted.len())
> 0
}
pub fn discard_local_changes(&mut self) -> Result<bool, StateError> {
let mut needs_refresh = false;
let maildir = Maildir::from(self.dir.clone());
while let Some((uid, _)) = self.local_changes.modified.pop_first() {
let (id, flags) = self.uids.get(&uid).expect("inconsistent state");
maildir.set_flags(id, flags)?;
}
while let Some((uid, _)) = self.local_changes.deleted.pop_first() {
needs_refresh = true;
self.uids.remove(&uid);
}
while let Some(id) = self.local_changes.added.pop() {
if let Err(e) = maildir.delete(&id.id) {
eprintln!(
"Error deleting {} during local state discard: {}",
&id.id, e
);
}
}
Ok(needs_refresh)
}
pub fn safe_delete(&mut self, uid: &u32) -> Result<Option<String>, StateError> {
if self.local_changes.modified.contains_key(uid) {
return Err(StateError::ConflictError(*uid));
}
Ok(self.uids.remove(uid).map(|(id, _)| id))
}
pub fn safe_update(&mut self, uid: u32, id: &str, flags: &str) -> Result<(), StateError> {
if self.local_changes.modified.contains_key(&uid)
|| self.local_changes.deleted.contains_key(&uid)
{
return Err(StateError::ConflictError(uid));
}
self.uids
.insert(uid, (String::from(id), String::from(flags)));
Ok(())
}
fn local_changes(dir: &Path, ids: &BTreeMap<String, MailStateOnDisk>) -> LocalChanges {
let maildir = Maildir::from(PathBuf::from(dir));
let mut shadow_map = ids.clone();
let mut added = Vec::new();
let mut modified = BTreeMap::new();
let it_cur = maildir.list_cur();
let it_new = maildir.list_new();
for mail in it_cur.chain(it_new) {
let mail = mail.expect("Error during state reconciliation");
let uid = shadow_map.remove(mail.id());
match uid {
Some((uid, flags)) => {
if flags != mail.flags() {
modified.insert(
uid,
LocalModification {
id: String::from(mail.id()),
new_flags: String::from(mail.flags()),
},
);
}
}
None => {
added.push(LocalAddition {
id: String::from(mail.id()),
flags: String::from(mail.flags()),
});
}
}
}
let deleted: BTreeMap<_, _> = shadow_map
.into_values()
.map(|(uid, _flags)| (uid, LocalDeletion {}))
.collect();
LocalChanges {
added,
deleted,
modified,
}
}
pub fn apply(&mut self, uid: u32, change: LocalChange) {
match change {
LocalChange::Addition(added) => {
self.uids.insert(uid, (added.id, added.flags));
}
LocalChange::Deletion(_) => {
self.uids.remove(&uid);
}
LocalChange::Modification(modified) => {
self.uids.insert(uid, (modified.id, modified.new_flags));
}
}
}
fn invert_state(state: BTreeMap<u32, MailState>) -> BTreeMap<String, MailStateOnDisk> {
state
.into_iter()
.map(|(uid, (id, flags))| (id, (uid, flags)))
.collect()
}
fn invert_state_on_disk(ssod: BTreeMap<String, MailStateOnDisk>) -> BTreeMap<u32, MailState> {
ssod.into_iter()
.map(|(id, (uid, flags))| (uid, (id, flags)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use tempfile::{tempdir, TempDir};
fn copy_recursively(source: impl AsRef<Path>, destination: impl AsRef<Path>) -> io::Result<()> {
fs::create_dir_all(&destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let filetype = entry.file_type()?;
if filetype.is_dir() {
copy_recursively(entry.path(), destination.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), destination.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
fn setup(dir: &TempDir) {
let testdir = env::var("CARGO_MANIFEST_DIR").unwrap();
let maildir = format!("{}/resources/test/maildir", testdir);
copy_recursively(&maildir, dir.path()).unwrap();
}
#[test]
fn test_invert_state() {
let state: BTreeMap<_, _> = vec![
(23, (String::from("foo"), String::from("a"))),
(42, (String::from("bar"), String::from("x"))),
]
.into_iter()
.collect();
let inverted = SyncState::invert_state(state);
let mut i = inverted.into_iter();
assert_eq!(
i.next().unwrap(),
(String::from("bar"), (42, String::from("x")))
);
assert_eq!(
i.next().unwrap(),
(String::from("foo"), (23, String::from("a")))
);
assert!(i.next().is_none());
}
#[test]
fn test_invert_state_on_disk() {
let ssod: BTreeMap<_, _> = vec![
(String::from("foo"), (23, String::from("a"))),
(String::from("bar"), (42, String::from("x"))),
]
.into_iter()
.collect();
let inverted = SyncState::invert_state_on_disk(ssod);
let mut i = inverted.into_iter();
assert_eq!(
i.next().unwrap(),
(23, (String::from("foo"), String::from("a")))
);
assert_eq!(
i.next().unwrap(),
(42, (String::from("bar"), String::from("x")))
);
assert!(i.next().is_none());
}
#[test]
fn test_load_and_save_immutable() {
let dir = tempdir().unwrap();
setup(&dir);
let state1 = SyncState::load(&dir).unwrap();
assert!(state1.has_local_changes());
state1.save().unwrap();
let state2 = SyncState::load(&dir).unwrap();
assert!(state2.has_local_changes());
assert_eq!(state1.uids, state2.uids);
}
}