use serde_json::{Map, Value};
mod json;
use json::{default_root, descend, parse_or_default, string_array};
use std::collections::BTreeSet;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEBOUNCE: Duration = Duration::from_millis(250);
pub trait Clock {
fn now(&self) -> Instant;
}
#[derive(Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> Instant {
Instant::now()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeenKind {
Notify,
Stopped,
Budget,
Conflicted,
}
impl SeenKind {
fn key(self) -> &'static str {
match self {
SeenKind::Notify => "notify",
SeenKind::Stopped => "stopped",
SeenKind::Budget => "budget",
SeenKind::Conflicted => "conflicted",
}
}
}
pub fn content_hash(bytes: &[u8]) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
pub fn derive_startup_focus(roster: &[&str], attention: &[&str]) -> Option<String> {
for &w in roster {
if attention.contains(&w) {
return Some(w.to_string());
}
}
roster.first().map(|w| (*w).to_string())
}
pub struct UiState {
path: PathBuf,
root: Map<String, Value>,
clock: Arc<dyn Clock>,
dirty_since: Option<Instant>,
last_hash: Option<u64>,
}
impl UiState {
pub fn open(path: PathBuf) -> Self {
Self::with_clock(path, Arc::new(SystemClock))
}
}
impl UiState {
pub fn with_clock(path: PathBuf, clock: Arc<dyn Clock>) -> Self {
let (root, last_hash) = match fs::read(&path) {
Ok(bytes) => (parse_or_default(&bytes), Some(content_hash(&bytes))),
Err(_) => (default_root(), None),
};
Self {
path,
root,
clock,
dirty_since: None,
last_hash,
}
}
pub fn is_echo(&self, bytes: &[u8]) -> bool {
self.last_hash == Some(content_hash(bytes))
}
pub fn adopt(&mut self, bytes: &[u8]) {
self.root = parse_or_default(bytes);
self.last_hash = Some(content_hash(bytes));
self.dirty_since = None;
}
pub fn record_seen(&mut self, kind: SeenKind, ws: &str, agent: &str, oid: &str) {
let by_ws = descend(&mut self.root, "seen".to_string());
let by_agent = descend(by_ws, ws.to_string());
let marks = descend(by_agent, agent.to_string());
marks.insert(kind.key().to_string(), Value::String(oid.to_string()));
self.touch();
}
pub fn is_seen(&self, kind: SeenKind, ws: &str, agent: &str, oid: &str) -> bool {
self.root
.get("seen")
.and_then(Value::as_object)
.and_then(|m| m.get(ws))
.and_then(Value::as_object)
.and_then(|m| m.get(agent))
.and_then(Value::as_object)
.and_then(|m| m.get(kind.key()))
.and_then(Value::as_str)
== Some(oid)
}
pub fn pinned(&self) -> Vec<String> {
string_array(&self.root, "pinned")
}
pub fn set_pinned(&mut self, list: Vec<String>) {
let arr = list.into_iter().map(Value::String).collect();
self.root.insert("pinned".to_string(), Value::Array(arr));
self.touch();
}
pub fn is_collapsed(&self, key: &str) -> bool {
string_array(&self.root, "collapsed").contains(&key.to_string())
}
pub fn set_collapsed(&mut self, key: &str, collapsed: bool) {
let mut set: BTreeSet<String> = BTreeSet::new();
set.extend(string_array(&self.root, "collapsed"));
if collapsed {
set.insert(key.to_string());
} else {
set.remove(key);
}
let arr = set.into_iter().map(Value::String).collect();
self.root.insert("collapsed".to_string(), Value::Array(arr));
self.touch();
}
pub fn identity_last_used(&self) -> Option<String> {
self.root
.get("identity_last_used")
.and_then(Value::as_str)
.map(String::from)
}
pub fn set_identity(&mut self, identity: &str) {
self.root.insert(
"identity_last_used".to_string(),
Value::String(identity.to_string()),
);
self.touch();
}
pub fn show_internal(&self) -> bool {
self.root
.get("show_internal")
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub fn set_show_internal(&mut self, show: bool) {
self.root
.insert("show_internal".to_string(), Value::Bool(show));
self.touch();
}
pub fn flush(&mut self) -> io::Result<bool> {
if self.dirty_since.is_some() {
self.write()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn flush_if_due(&mut self) -> io::Result<bool> {
match self.dirty_since {
Some(since) if self.clock.now().saturating_duration_since(since) >= DEBOUNCE => {
self.write()?;
Ok(true)
}
_ => Ok(false),
}
}
fn touch(&mut self) {
self.dirty_since = Some(self.clock.now());
}
fn write(&mut self) -> io::Result<()> {
let bytes = self.serialize();
self.write_atomic(&bytes)?;
self.last_hash = Some(content_hash(&bytes));
self.dirty_since = None;
Ok(())
}
fn serialize(&self) -> Vec<u8> {
serde_json::to_vec_pretty(&self.root).unwrap_or_default()
}
fn write_atomic(&self, bytes: &[u8]) -> io::Result<()> {
let dir = self.path.parent().ok_or(io::Error::other("no parent"))?;
fs::create_dir_all(dir)?;
let name = self
.path
.file_name()
.ok_or(io::Error::other("no file name"))?;
let tmp_name = format!(".{}.yog-tmp-{}", name.to_string_lossy(), std::process::id());
let tmp = dir.join(tmp_name);
fs::write(&tmp, bytes)?;
fs::rename(&tmp, &self.path)
}
}
#[cfg(test)]
mod tests;