Serialized file API
This creates a module for serializing and deserializing data to and from
files on the filesystem, with TOML as the initially supported format.
Change-Id: I57c8b397507334329d2b1ea8a17e69d645bc4706
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/119992
Reviewed-by: Erik Gilling <konkers@google.com>
Commit-Queue: Alexei Frolov <frolv@google.com>
diff --git a/Cargo.lock b/Cargo.lock
index 51ba387..2532395 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -525,6 +525,15 @@
checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193"
[[package]]
+name = "fastrand"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499"
+dependencies = [
+ "instant",
+]
+
+[[package]]
name = "fd-lock"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -665,6 +674,15 @@
]
[[package]]
+name = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "io-lifetimes"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1253,6 +1271,10 @@
version = "0.1.0"
dependencies = [
"rustpython",
+ "serde",
+ "tempfile",
+ "thiserror",
+ "toml",
]
[[package]]
@@ -1364,6 +1386,15 @@
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "result-like"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1964,6 +1995,20 @@
]
[[package]]
+name = "tempfile"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "libc",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
+[[package]]
name = "term"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/qg/Cargo.toml b/qg/Cargo.toml
index 91ed2a8..75d2068 100644
--- a/qg/Cargo.toml
+++ b/qg/Cargo.toml
@@ -8,8 +8,16 @@
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
-rustpython = {git="https://github.com/RustPython/RustPython", features=["freeze-stdlib"], optional = true }
+rustpython = { git = "https://github.com/RustPython/RustPython", features = [
+ "freeze-stdlib",
+], optional = true }
+serde = { version = "1.0.147", features = ["derive"] }
+thiserror = "1.0.37"
+toml = "0.5.9"
[features]
default = []
python = ["dep:rustpython"]
+
+[dev-dependencies]
+tempfile = "3.3.0"
diff --git a/qg/src/lib.rs b/qg/src/lib.rs
index 25e8584..2ec3aec 100644
--- a/qg/src/lib.rs
+++ b/qg/src/lib.rs
@@ -12,9 +12,47 @@
// License for the specific language governing permissions and limitations under
// the License.
+use std::path::{Path, PathBuf};
+
+pub mod project;
+
#[cfg(feature = "python")]
mod py;
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(thiserror::Error, Debug)]
+#[non_exhaustive]
+pub enum Error {
+ #[error("I/O error")]
+ Io(#[from] std::io::Error),
+
+ /// Error occurred when attempting to modify a file.
+ #[error("{0}: {1}")]
+ File(PathBuf, std::io::Error),
+
+ #[error("failed to serialize data: {0}")]
+ Serialization(String),
+}
+
+impl Error {
+ pub(crate) fn file(path: impl AsRef<Path>) -> impl FnOnce(std::io::Error) -> Self {
+ move |e| Self::File(path.as_ref().to_path_buf(), e)
+ }
+}
+
+impl From<toml::ser::Error> for Error {
+ fn from(e: toml::ser::Error) -> Self {
+ Self::Serialization(e.to_string())
+ }
+}
+
+impl From<toml::de::Error> for Error {
+ fn from(e: toml::de::Error) -> Self {
+ Self::Serialization(e.to_string())
+ }
+}
+
#[must_use]
pub fn name() -> &'static str {
"qg"
diff --git a/qg/src/project/file.rs b/qg/src/project/file.rs
new file mode 100644
index 0000000..db34bd1
--- /dev/null
+++ b/qg/src/project/file.rs
@@ -0,0 +1,187 @@
+// Copyright 2022 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+use std::{
+ io::Write,
+ path::{Path, PathBuf},
+};
+
+use serde::{de::DeserializeOwned, Serialize};
+
+use crate::{Error, Result};
+
+/// A file storing data in some serialized format.
+pub struct File {
+ path: PathBuf,
+}
+
+impl File {
+ pub(super) fn new(path: impl AsRef<Path>) -> Self {
+ Self {
+ path: path.as_ref().to_path_buf(),
+ }
+ }
+
+ /// Returns the full path to the file.
+ #[must_use]
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ /// Writes a serializable struct to the file as TOML.
+ ///
+ /// Creates the file if it does not exist, or overwrites existing contents.
+ ///
+ /// # Errors
+ /// Returns an error if the file could not be written, or the data is not
+ /// serializable.
+ pub fn serialize_toml<T: Serialize>(&self, value: &T) -> Result<()> {
+ let mut f = std::fs::File::create(&self.path).map_err(Error::file(&self.path))?;
+ let string = toml::to_string(value)?;
+ f.write_all(string.as_bytes())
+ .map_err(Error::file(&self.path))?;
+ Ok(())
+ }
+
+ /// Reads TOML data from the file into a struct.
+ ///
+ /// # Errors
+ /// Returns an error if the file could not be read, or the data is not
+ /// valid TOML.
+ pub fn deserialize_toml<T: DeserializeOwned>(&self) -> Result<T> {
+ let string = std::fs::read_to_string(&self.path).map_err(Error::file(&self.path))?;
+ let value = toml::from_str(&string)?;
+ Ok(value)
+ }
+
+ /// Modifies TOML data stored in the file, writing the updated values back
+ /// to disk.
+ ///
+ /// # Errors
+ /// Returns an error if the file could not be accessed, or data could not be
+ /// serialized.
+ pub fn modify_toml<T: Serialize + DeserializeOwned>(
+ &self,
+ func: impl FnOnce(&mut T) -> Result<()>,
+ ) -> Result<()> {
+ let mut value = self.deserialize_toml()?;
+ func(&mut value)?;
+ self.serialize_toml(&value)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde::Deserialize;
+
+ use super::*;
+
+ #[derive(Debug, Deserialize, PartialEq, Serialize)]
+ struct Foo {
+ value: String,
+ number: u8,
+ bar: Option<Bar>,
+ }
+
+ #[derive(Debug, Deserialize, PartialEq, Serialize)]
+ struct Bar {
+ path: PathBuf,
+ }
+
+ const SERIALIZED_DATA: &str = r#"value = "encodeme"
+number = 199
+
+[bar]
+path = "/usr/bin"
+"#;
+
+ #[test]
+ fn serialize_success() {
+ let tmpfile = tempfile::NamedTempFile::new().unwrap();
+ let file = File::new(tmpfile.path());
+
+ let foo = Foo {
+ value: "encodeme".into(),
+ number: 199,
+ bar: Some(Bar {
+ path: PathBuf::from("/usr/bin"),
+ }),
+ };
+
+ file.serialize_toml(&foo).unwrap();
+ let string = std::fs::read_to_string(file.path()).unwrap();
+ assert_eq!(string, SERIALIZED_DATA);
+ }
+
+ #[test]
+ fn deserialize_success() {
+ let tmpfile = tempfile::NamedTempFile::new().unwrap();
+ let file = File::new(tmpfile.path());
+
+ std::fs::write(file.path(), SERIALIZED_DATA).unwrap();
+ let foo = file.deserialize_toml::<Foo>().unwrap();
+ assert_eq!(foo.value, "encodeme".to_string());
+ assert_eq!(foo.number, 199);
+ assert_eq!(
+ foo.bar,
+ Some(Bar {
+ path: PathBuf::from("/usr/bin")
+ })
+ );
+ }
+
+ #[test]
+ fn deserialize_nonexisting_file() {
+ let file = File::new("./non-existing-file.xyz");
+ let e = file.deserialize_toml::<Foo>().unwrap_err();
+ assert!(matches!(e, Error::File(_, _)));
+ }
+
+ #[test]
+ fn deserialize_invalid_data() {
+ let tmpfile = tempfile::NamedTempFile::new().unwrap();
+ let file = File::new(tmpfile.path());
+
+ std::fs::write(file.path(), "invalid data").unwrap();
+ let e = file.deserialize_toml::<Foo>().unwrap_err();
+ assert!(matches!(e, Error::Serialization(_)));
+ }
+
+ #[test]
+ fn modify_success() {
+ let tmpfile = tempfile::NamedTempFile::new().unwrap();
+ let file = File::new(tmpfile.path());
+
+ let foo = Foo {
+ value: "encodeme".into(),
+ number: 199,
+ bar: Some(Bar {
+ path: PathBuf::from("/usr/bin"),
+ }),
+ };
+
+ file.serialize_toml(&foo).unwrap();
+ file.modify_toml::<Foo>(|value| {
+ value.bar = None;
+ value.number += 2;
+ Ok(())
+ })
+ .unwrap();
+
+ let result = file.deserialize_toml::<Foo>().unwrap();
+ assert_eq!(result.value, foo.value);
+ assert_eq!(result.number, 201);
+ assert_eq!(result.bar, None);
+ }
+}
diff --git a/qg/src/project/mod.rs b/qg/src/project/mod.rs
new file mode 100644
index 0000000..2585fd1
--- /dev/null
+++ b/qg/src/project/mod.rs
@@ -0,0 +1,17 @@
+// Copyright 2022 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+pub mod file;
+
+pub use file::File;