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;