use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use log::warn;
use serde::{Deserialize, Serialize};
use crate::config::AccountMode;
const LAYOUT: u32 = 1;
const FILE: &str = "neverest.json";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ModeStamp {
#[serde(default)]
pub sources: usize,
#[serde(default)]
pub targets: usize,
#[serde(default)]
pub one_way: bool,
#[serde(default)]
pub retain: bool,
}
impl From<&AccountMode> for ModeStamp {
fn from(mode: &AccountMode) -> Self {
Self {
sources: mode.sources.len(),
targets: mode.targets.len(),
one_way: mode.one_way,
retain: mode.retain,
}
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct StoreState {
pub layout: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<ModeStamp>,
#[serde(default, skip_serializing_if = "is_false")]
pub mode_accepted: bool,
}
fn is_false(value: &bool) -> bool {
!value
}
impl StoreState {
pub fn load(dir: &Path) -> Result<Self> {
let path = Self::path(dir);
if !path.exists() {
if dir.join("pimdir.db").exists() {
bail!(
"The store at {} was written before collection ids carried their \
namespace, and is not read. Drop it with `neverest sync --reset` and let it \
resync.",
dir.display(),
);
}
return Ok(Self {
layout: LAYOUT,
..Default::default()
});
}
let raw =
fs::read_to_string(&path).with_context(|| format!("Read {} error", path.display()))?;
let state: Self = serde_json::from_str(&raw)
.with_context(|| format!("Parse {} error", path.display()))?;
if state.layout != LAYOUT {
bail!(
"The store at {} uses collection-id layout {} and this neverest writes {LAYOUT}; \
drop it with `neverest sync --reset` and let it resync.",
dir.display(),
state.layout,
);
}
Ok(state)
}
pub fn stamp(dir: &Path, mode: Option<&AccountMode>) -> Result<()> {
Self {
layout: LAYOUT,
mode: mode.map(ModeStamp::from),
mode_accepted: false,
}
.save(dir)
}
pub fn save(&mut self, dir: &Path) -> Result<()> {
self.layout = LAYOUT;
let path = Self::path(dir);
let raw = serde_json::to_string_pretty(self).context("Serialize store state error")?;
fs::write(&path, raw).with_context(|| format!("Write {} error", path.display()))
}
pub fn check_mode(&self, mode: &AccountMode) -> Result<()> {
let Some(previous) = self.mode else {
return Ok(());
};
let current = ModeStamp::from(mode);
if previous == current {
return Ok(());
}
if !previous.one_way && current.one_way && !self.mode_accepted {
bail!(
"This account synced both ways until now, and `one-way = true` makes the \
sources authoritative: the next run discards whatever the other side changed \
on its own, rather than merging it. Re-run with `--accept-mode` once you are \
sure, and neverest will remember."
);
}
if previous.retain && !current.retain {
warn!(
"the store no longer keeps bodies; the ones already stored stay, unreferenced, \
until `pimdir gc`"
);
}
if previous.sources != current.sources || previous.targets != current.targets {
warn!(
"endpoints changed, {} source(s) and {} target(s) where the last run had {} and {}",
current.sources, current.targets, previous.sources, previous.targets,
);
}
Ok(())
}
pub fn record_mode(&mut self, mode: &AccountMode, accepted: bool) {
self.mode = Some(ModeStamp::from(mode));
self.mode_accepted = accepted;
}
fn path(dir: &Path) -> PathBuf {
dir.join(FILE)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mode(sources: usize, targets: usize, one_way: bool, retain: bool) -> AccountMode {
AccountMode {
sources: (0..sources).map(|i| format!("s{i}")).collect(),
targets: (0..targets).map(|i| format!("t{i}")).collect(),
one_way,
retain,
}
}
#[test]
fn a_store_written_before_namespaced_ids_is_refused_with_its_remedy() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("pimdir.db"), b"").unwrap();
let err = StoreState::load(dir.path()).unwrap_err().to_string();
assert!(err.contains("--reset"), "got {err}");
}
#[test]
fn a_store_this_version_created_is_not_taken_for_the_ancestor() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("pimdir.db"), b"").unwrap();
StoreState::stamp(dir.path(), None).unwrap();
let state = StoreState::load(dir.path()).unwrap();
assert_eq!(state.layout, LAYOUT);
}
#[test]
fn an_empty_directory_starts_at_the_current_layout() {
let dir = tempfile::tempdir().unwrap();
let state = StoreState::load(dir.path()).unwrap();
assert_eq!(state.layout, LAYOUT);
assert!(state.mode.is_none());
}
#[test]
fn turning_one_way_on_refuses_the_run_that_would_discard() {
let dir = tempfile::tempdir().unwrap();
let mut state = StoreState::load(dir.path()).unwrap();
state.record_mode(&mode(1, 1, false, false), false);
state.save(dir.path()).unwrap();
let state = StoreState::load(dir.path()).unwrap();
let err = state
.check_mode(&mode(1, 1, true, false))
.unwrap_err()
.to_string();
assert!(err.contains("--accept-mode"), "got {err}");
}
#[test]
fn an_accepted_mode_stops_refusing() {
let dir = tempfile::tempdir().unwrap();
let mut state = StoreState::load(dir.path()).unwrap();
state.record_mode(&mode(1, 1, false, false), false);
state.record_mode(&mode(1, 1, true, false), true);
state.save(dir.path()).unwrap();
let state = StoreState::load(dir.path()).unwrap();
state.check_mode(&mode(1, 1, true, false)).unwrap();
}
#[test]
fn turning_one_way_off_is_not_gated() {
let dir = tempfile::tempdir().unwrap();
let mut state = StoreState::load(dir.path()).unwrap();
state.record_mode(&mode(1, 1, true, false), false);
state.save(dir.path()).unwrap();
let state = StoreState::load(dir.path()).unwrap();
state.check_mode(&mode(1, 1, false, false)).unwrap();
}
#[test]
fn an_unstamped_store_is_not_gated() {
let dir = tempfile::tempdir().unwrap();
let state = StoreState::load(dir.path()).unwrap();
state.check_mode(&mode(1, 1, true, false)).unwrap();
}
#[test]
fn a_softer_change_reports_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let mut state = StoreState::load(dir.path()).unwrap();
state.record_mode(&mode(1, 0, false, true), false);
state.save(dir.path()).unwrap();
let state = StoreState::load(dir.path()).unwrap();
state.check_mode(&mode(2, 0, false, false)).unwrap();
}
}