From 909399b276cf9915cdde9445e40fb74b4103c73c Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 18 Feb 2025 23:21:12 +0100 Subject: [PATCH] Implement basic data directory operations --- Cargo.lock | 2 + gdn/Cargo.toml | 2 + gdn/src/data.rs | 42 +++++++++++ gdn/src/data/datadir.rs | 159 +++++++++++++++++++++++++++++++++++++++ gdn/src/data/lockfile.rs | 42 +++++++++++ gdn/src/data/v0.rs | 12 +++ gdn/src/data/v1.rs | 24 ++++++ gdn/src/lib.rs | 1 + 8 files changed, 284 insertions(+) create mode 100644 gdn/src/data.rs create mode 100644 gdn/src/data/datadir.rs create mode 100644 gdn/src/data/lockfile.rs create mode 100644 gdn/src/data/v0.rs create mode 100644 gdn/src/data/v1.rs diff --git a/Cargo.lock b/Cargo.lock index 262a6ac..3727536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,9 +1452,11 @@ dependencies = [ name = "gdn" version = "0.0.0" dependencies = [ + "anyhow", "directories", "rand 0.9.0", "serde", + "serde_json", ] [[package]] diff --git a/gdn/Cargo.toml b/gdn/Cargo.toml index dcdd114..504d592 100644 --- a/gdn/Cargo.toml +++ b/gdn/Cargo.toml @@ -4,9 +4,11 @@ version = { workspace = true } edition = { workspace = true } [dependencies] +anyhow = { workspace = true } directories = { workspace = true } rand = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/gdn/src/data.rs b/gdn/src/data.rs new file mode 100644 index 0000000..29fcd14 --- /dev/null +++ b/gdn/src/data.rs @@ -0,0 +1,42 @@ +mod datadir; +mod lockfile; +mod v0; +mod v1; + +use std::path::PathBuf; + +use anyhow::Context; +use directories::ProjectDirs; + +pub use self::{ + datadir::{LockedDataDir, UnlockedDataDir}, + v1::{load_state, save_state, VERSION}, +}; + +fn migrate(dir: &LockedDataDir) -> anyhow::Result<()> { + loop { + match dir.read_version()? { + 0 => v0::migrate(dir)?, + _ => break Ok(()), + } + } +} + +pub fn open(path: PathBuf) -> anyhow::Result { + let dir = UnlockedDataDir::new(path); + dir.require_version(VERSION)?; + Ok(dir) +} + +pub fn open_and_migrate(path: PathBuf) -> anyhow::Result { + let dir = UnlockedDataDir::new(path).lock()?; + migrate(&dir)?; + dir.require_version(VERSION)?; + Ok(dir) +} + +pub fn path() -> anyhow::Result { + let dirs = ProjectDirs::from("de", "plugh", crate::TECHNICAL_NAME) + .context("failed to locate data dir")?; + Ok(dirs.data_dir().to_path_buf()) +} diff --git a/gdn/src/data/datadir.rs b/gdn/src/data/datadir.rs new file mode 100644 index 0000000..6b23866 --- /dev/null +++ b/gdn/src/data/datadir.rs @@ -0,0 +1,159 @@ +use std::{ + ffi::{OsStr, OsString}, + fs, + io::ErrorKind, + ops::Deref, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, bail, Context}; +use rand::{distr::Alphanumeric, Rng}; +use serde::{de::DeserializeOwned, Serialize}; + +use super::lockfile::LockFile; + +/// Create a temporary file name derived from an existing file name. +/// +/// The temporary name has the form `..~gdn`. +fn tmp_file_name(name: &OsStr) -> OsString { + let random_suffix = rand::rng() + .sample_iter(Alphanumeric) + .take(6) + .map(char::from) + .collect::(); + + let mut tmp_name = OsString::new(); + if !tmp_name.as_bytes().starts_with(b".") { + tmp_name.push("."); + } + tmp_name.push(name); + tmp_name.push("."); + tmp_name.push(random_suffix); + tmp_name.push("~"); + tmp_name.push(crate::ABBREVIATED_NAME); + tmp_name +} + +fn atomic_write(path: &Path, contents: impl AsRef<[u8]>) -> anyhow::Result<()> { + let name = path + .file_name() + .ok_or_else(|| anyhow!("path has no file name: {}", path.display()))?; + + let parent = path + .parent() + .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?; + + let tmp_path = path.with_file_name(tmp_file_name(name)); + + fs::create_dir_all(parent)?; + fs::write(&tmp_path, contents)?; + fs::rename(&tmp_path, path)?; + + Ok(()) +} + +pub struct UnlockedDataDir { + path: PathBuf, +} + +impl UnlockedDataDir { + pub(super) fn new(path: PathBuf) -> Self { + Self { path } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + fn version_file(&self) -> PathBuf { + self.path.join("VERSION") + } + + fn lock_file(&self) -> PathBuf { + self.path.join("LOCK") + } + + pub fn lock(self) -> anyhow::Result { + Ok(LockedDataDir { + lockfile: LockFile::lock(self.lock_file())?, + unlocked: self, + }) + } + + pub(super) fn read_string(&self, path: &Path) -> anyhow::Result { + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display())) + } + + pub(super) fn read_string_optional(&self, path: &Path) -> anyhow::Result> { + match fs::read_to_string(path) { + Ok(string) => Ok(Some(string)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + .with_context(|| format!("failed to read {}", path.display())) + } + + pub(super) fn read_json(&self, path: &Path) -> anyhow::Result { + let string = self.read_string(path)?; + let data = serde_json::from_str(&string) + .with_context(|| format!("failed to parse {} as json", path.display()))?; + Ok(data) + } + + pub(super) fn read_version(&self) -> anyhow::Result { + let path = self.version_file(); + match self.read_string_optional(&path)? { + None => Ok(0), + Some(string) => Ok(string.trim().parse().with_context(|| { + format!("failed to parse {} as version number", path.display()) + })?), + } + } + + pub(super) fn require_version(&self, expected: u32) -> anyhow::Result<()> { + let actual = self.read_version()?; + if actual != expected { + bail!( + "expected version {expected}, but found {actual} at {}", + self.version_file().display() + ); + } + Ok(()) + } +} + +pub struct LockedDataDir { + unlocked: UnlockedDataDir, + lockfile: LockFile, +} + +impl LockedDataDir { + pub fn unlock(self) -> anyhow::Result { + self.lockfile.unlock()?; + Ok(self.unlocked) + } + + pub(super) fn write_string(&self, path: &Path, string: &str) -> anyhow::Result<()> { + atomic_write(path, string).with_context(|| format!("failed to write {}", path.display())) + } + + pub(super) fn write_json(&self, path: &Path, contents: &T) -> anyhow::Result<()> { + let string = serde_json::to_string_pretty(contents) + .with_context(|| format!("failed to format json for {}", path.display()))?; + self.write_string(path, &string)?; + Ok(()) + } + + pub(super) fn write_version(&self, version: u32) -> anyhow::Result<()> { + self.write_string(&self.version_file(), &format!("{version}\n")) + } +} + +impl Deref for LockedDataDir { + type Target = UnlockedDataDir; + + fn deref(&self) -> &Self::Target { + &self.unlocked + } +} diff --git a/gdn/src/data/lockfile.rs b/gdn/src/data/lockfile.rs new file mode 100644 index 0000000..277562d --- /dev/null +++ b/gdn/src/data/lockfile.rs @@ -0,0 +1,42 @@ +use std::{ + fs::{self, File}, + io, + path::PathBuf, +}; + +pub struct LockFile { + path: PathBuf, + file: Option, +} + +impl LockFile { + pub fn lock(path: PathBuf) -> io::Result { + Ok(Self { + file: Some(File::create_new(&path)?), + path, + }) + } + + fn unlock_ref(&mut self) -> io::Result<()> { + let file = self + .file + .take() + .expect("can't unlock lockfile more than once"); + + drop(file); + fs::remove_file(&self.path)?; + Ok(()) + } + + pub fn unlock(mut self) -> io::Result<()> { + self.unlock_ref() + } +} + +impl Drop for LockFile { + fn drop(&mut self) { + if let Err(_err) = self.unlock_ref() { + // TODO Log error + } + } +} diff --git a/gdn/src/data/v0.rs b/gdn/src/data/v0.rs new file mode 100644 index 0000000..f8d81db --- /dev/null +++ b/gdn/src/data/v0.rs @@ -0,0 +1,12 @@ +// Just the empty directory + +use super::{v1, LockedDataDir}; + +pub const VERSION: u32 = 0; + +pub fn migrate(dir: &LockedDataDir) -> anyhow::Result<()> { + dir.require_version(VERSION)?; + v1::save_state(dir, &v1::State::default())?; + dir.write_version(v1::VERSION)?; + Ok(()) +} diff --git a/gdn/src/data/v1.rs b/gdn/src/data/v1.rs new file mode 100644 index 0000000..863426c --- /dev/null +++ b/gdn/src/data/v1.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::{LockedDataDir, UnlockedDataDir}; + +pub const VERSION: u32 = 1; + +#[derive(Default, Serialize, Deserialize)] +pub struct State { + pub name: Option, +} + +pub fn state_file(dir: &UnlockedDataDir) -> PathBuf { + dir.path().join("state.json") +} + +pub fn load_state(dir: &UnlockedDataDir) -> anyhow::Result { + dir.read_json(&state_file(dir)) +} + +pub fn save_state(dir: &LockedDataDir, state: &State) -> anyhow::Result<()> { + dir.write_json(&state_file(dir), state) +} diff --git a/gdn/src/lib.rs b/gdn/src/lib.rs index f6ff301..ef5df15 100644 --- a/gdn/src/lib.rs +++ b/gdn/src/lib.rs @@ -1,3 +1,4 @@ +pub mod data; pub mod ids; mod paths;