Skip to main content

wowlab_fs/artifact/
mod.rs

1//! Generated-file comparison and persistence.
2
3use crate::{atomic, directory, file, path::Path};
4
5/// Failure while inspecting or persisting a generated artifact.
6#[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    /// Return the generated artifact path.
31    #[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
46/// Result returned by generated-artifact operations.
47pub type Result<T> = std::result::Result<T, Error>;
48
49/// Relationship between expected contents and the file currently on disk.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51#[non_exhaustive]
52pub enum Status {
53    /// Exact expected bytes are present.
54    Current,
55    /// No file exists.
56    Missing,
57    /// A file exists with different bytes.
58    Changed,
59}
60
61/// Expected bytes at one generated-file path.
62#[derive(Clone, Copy, Debug)]
63pub struct GeneratedFile<'a> {
64    path: &'a Path,
65    contents: &'a [u8],
66}
67
68impl<'a> GeneratedFile<'a> {
69    /// Bind expected bytes to an output path.
70    #[must_use]
71    pub const fn new(path: &'a Path, contents: &'a [u8]) -> Self {
72        Self { path, contents }
73    }
74
75    /// Return the output path.
76    #[must_use]
77    pub const fn path(&self) -> &'a Path {
78        self.path
79    }
80
81    /// Compare exact bytes with the current file.
82    ///
83    /// # Errors
84    ///
85    /// Returns a contextual error when an existing entry cannot be read.
86    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    /// Ensure parent directories and atomically replace the file.
94    ///
95    /// # Errors
96    ///
97    /// Returns a contextual error when parent creation or persistence fails.
98    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/// Expected UTF-8 text at one generated-file path.
110///
111/// Unlike a byte artifact, invalid UTF-8 is a read error.
112/// It is not treated as merely different content.
113#[derive(Clone, Copy, Debug)]
114pub struct GeneratedTextFile<'a> {
115    path: &'a Path,
116    contents: &'a str,
117}
118
119impl<'a> GeneratedTextFile<'a> {
120    /// Bind expected UTF-8 text to an output path.
121    #[must_use]
122    pub const fn new(path: &'a Path, contents: &'a str) -> Self {
123        Self { path, contents }
124    }
125
126    /// Return the output path.
127    #[must_use]
128    pub const fn path(&self) -> &'a Path {
129        self.path
130    }
131
132    /// Compare exact UTF-8 text with the current file.
133    ///
134    /// # Errors
135    ///
136    /// Returns a contextual error when an existing entry cannot be read as UTF-8 text.
137    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    /// Ensure parent directories and atomically replace the text file.
145    ///
146    /// # Errors
147    ///
148    /// Returns a contextual error when parent creation or persistence fails.
149    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}