Implement basic data directory operations
This commit is contained in:
parent
58cd4d7517
commit
909399b276
8 changed files with 284 additions and 0 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1452,9 +1452,11 @@ dependencies = [
|
||||||
name = "gdn"
|
name = "gdn"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"directories",
|
"directories",
|
||||||
"rand 0.9.0",
|
"rand 0.9.0",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ version = { workspace = true }
|
||||||
edition = { workspace = true }
|
edition = { workspace = true }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
directories = { workspace = true }
|
directories = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
42
gdn/src/data.rs
Normal file
42
gdn/src/data.rs
Normal file
|
|
@ -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<UnlockedDataDir> {
|
||||||
|
let dir = UnlockedDataDir::new(path);
|
||||||
|
dir.require_version(VERSION)?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_and_migrate(path: PathBuf) -> anyhow::Result<LockedDataDir> {
|
||||||
|
let dir = UnlockedDataDir::new(path).lock()?;
|
||||||
|
migrate(&dir)?;
|
||||||
|
dir.require_version(VERSION)?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path() -> anyhow::Result<PathBuf> {
|
||||||
|
let dirs = ProjectDirs::from("de", "plugh", crate::TECHNICAL_NAME)
|
||||||
|
.context("failed to locate data dir")?;
|
||||||
|
Ok(dirs.data_dir().to_path_buf())
|
||||||
|
}
|
||||||
159
gdn/src/data/datadir.rs
Normal file
159
gdn/src/data/datadir.rs
Normal file
|
|
@ -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 `.<name>.<rand_suffix>~gdn`.
|
||||||
|
fn tmp_file_name(name: &OsStr) -> OsString {
|
||||||
|
let random_suffix = rand::rng()
|
||||||
|
.sample_iter(Alphanumeric)
|
||||||
|
.take(6)
|
||||||
|
.map(char::from)
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
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<LockedDataDir> {
|
||||||
|
Ok(LockedDataDir {
|
||||||
|
lockfile: LockFile::lock(self.lock_file())?,
|
||||||
|
unlocked: self,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_string(&self, path: &Path) -> anyhow::Result<String> {
|
||||||
|
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_string_optional(&self, path: &Path) -> anyhow::Result<Option<String>> {
|
||||||
|
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<T: DeserializeOwned>(&self, path: &Path) -> anyhow::Result<T> {
|
||||||
|
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<u32> {
|
||||||
|
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<UnlockedDataDir> {
|
||||||
|
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<T: Serialize>(&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
|
||||||
|
}
|
||||||
|
}
|
||||||
42
gdn/src/data/lockfile.rs
Normal file
42
gdn/src/data/lockfile.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io,
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct LockFile {
|
||||||
|
path: PathBuf,
|
||||||
|
file: Option<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LockFile {
|
||||||
|
pub fn lock(path: PathBuf) -> io::Result<Self> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
gdn/src/data/v0.rs
Normal file
12
gdn/src/data/v0.rs
Normal file
|
|
@ -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(())
|
||||||
|
}
|
||||||
24
gdn/src/data/v1.rs
Normal file
24
gdn/src/data/v1.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_file(dir: &UnlockedDataDir) -> PathBuf {
|
||||||
|
dir.path().join("state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_state(dir: &UnlockedDataDir) -> anyhow::Result<State> {
|
||||||
|
dir.read_json(&state_file(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_state(dir: &LockedDataDir, state: &State) -> anyhow::Result<()> {
|
||||||
|
dir.write_json(&state_file(dir), state)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod data;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue