wowlab_fs/artifact/
mod.rs1use crate::{atomic, directory, file, path::Path};
4
5#[derive(Debug, thiserror::Error)]
7#[error("failed to {action} generated artifact {path}: {source}")]
8pub struct Error {
9 action: Action,
10 path: crate::path::PathBuf,
11 #[source]
12 source: crate::error::Error,
13}
14
15#[derive(Clone, Copy, Debug)]
16enum Action {
17 Read,
18 Persist,
19}
20
21impl Error {
22 fn new(action: Action, path: &Path, source: crate::error::Error) -> Self {
23 Self {
24 action,
25 path: path.to_path_buf(),
26 source,
27 }
28 }
29
30 #[must_use]
32 pub fn path(&self) -> &Path {
33 &self.path
34 }
35}
36
37impl std::fmt::Display for Action {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::Read => f.write_str("read"),
41 Self::Persist => f.write_str("persist"),
42 }
43 }
44}
45
46pub type Result<T> = std::result::Result<T, Error>;
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51#[non_exhaustive]
52pub enum Status {
53 Current,
55 Missing,
57 Changed,
59}
60
61#[derive(Clone, Copy, Debug)]
63pub struct GeneratedFile<'a> {
64 path: &'a Path,
65 contents: &'a [u8],
66}
67
68impl<'a> GeneratedFile<'a> {
69 #[must_use]
71 pub const fn new(path: &'a Path, contents: &'a [u8]) -> Self {
72 Self { path, contents }
73 }
74
75 #[must_use]
77 pub const fn path(&self) -> &'a Path {
78 self.path
79 }
80
81 pub fn status(&self) -> Result<Status> {
87 classify_status(
88 self.path,
89 file::read_bytes(self.path).map(|existing| existing == self.contents),
90 )
91 }
92
93 pub fn persist(&self) -> Result<()> {
99 if let Some(parent) = self.path.parent().filter(|path| !path.is_empty()) {
100 directory::ensure(parent)
101 .map_err(|source| Error::new(Action::Persist, self.path, source))?;
102 }
103
104 atomic::replace(self.path, self.contents)
105 .map_err(|source| Error::new(Action::Persist, self.path, source))
106 }
107}
108
109#[derive(Clone, Copy, Debug)]
114pub struct GeneratedTextFile<'a> {
115 path: &'a Path,
116 contents: &'a str,
117}
118
119impl<'a> GeneratedTextFile<'a> {
120 #[must_use]
122 pub const fn new(path: &'a Path, contents: &'a str) -> Self {
123 Self { path, contents }
124 }
125
126 #[must_use]
128 pub const fn path(&self) -> &'a Path {
129 self.path
130 }
131
132 pub fn status(&self) -> Result<Status> {
138 classify_status(
139 self.path,
140 file::read_text(self.path).map(|existing| existing == self.contents),
141 )
142 }
143
144 pub fn persist(&self) -> Result<()> {
150 GeneratedFile::new(self.path, self.contents.as_bytes()).persist()
151 }
152}
153
154fn classify_status(path: &Path, comparison: crate::error::Result<bool>) -> Result<Status> {
155 match comparison {
156 Ok(true) => Ok(Status::Current),
157 Ok(false) => Ok(Status::Changed),
158 Err(error) if error.is_not_found() => Ok(Status::Missing),
159 Err(source) => Err(Error::new(Action::Read, path, source)),
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use googletest::prelude::*;
166
167 use super::{GeneratedTextFile, Status};
168 use crate::{file, temporary::Directory};
169
170 #[gtest]
171 fn status_and_persistence_use_exact_bytes() -> Result<()> {
172 let directory = Directory::new().or_fail()?;
173 let path = directory.path().join("nested/generated.txt");
174 let artifact = GeneratedTextFile::new(&path, "expected\r\n");
175
176 verify_that!(artifact.status().or_fail()?, eq(Status::Missing))?;
177
178 artifact.persist().or_fail()?;
179 verify_that!(artifact.status().or_fail()?, eq(Status::Current))?;
180
181 file::write_text(&path, "changed\n").or_fail()?;
182
183 verify_that!(artifact.status().or_fail()?, eq(Status::Changed))
184 }
185
186 #[gtest]
187 fn text_status_rejects_invalid_utf8() -> Result<()> {
188 let directory = Directory::new().or_fail()?;
189 let path = directory.path().join("generated.txt");
190
191 file::write_bytes(&path, [0xff]).or_fail()?;
192
193 let error = GeneratedTextFile::new(&path, "expected")
194 .status()
195 .unwrap_err();
196
197 verify_that!(error.path(), eq(&*path))
198 }
199}