Skip to main content

loftspec_store/
file.rs

1//! The file store: the document held as the `spec/` tree under a directory, read through the
2//! codec and saved through the journal.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use loftspec::codec::{self, Files, SpecPath};
8use loftspec::document::Document;
9use loftspec::publish::{LOCK, Lock};
10use loftspec::trace::{TRACE, TestFile, TestFiles};
11
12use crate::error::io_error;
13use crate::fs::{SIDECAR, SPEC, hidden};
14use crate::journal::{self, Changes, RootPath};
15use crate::{DocumentStore, Saved, StoreError};
16
17/// The `spec/` tree under a directory.
18#[derive(Debug, Clone)]
19pub struct FileStore {
20    root: PathBuf,
21}
22
23impl FileStore {
24    /// The store of the tree under `root`, after finishing a committed save there that was
25    /// interrupted, or discarding an uncommitted one.
26    ///
27    /// # Errors
28    ///
29    /// An interrupted save that cannot be recovered.
30    pub fn open(root: &Path) -> Result<Self, StoreError> {
31        journal::recover(root)?;
32        Ok(Self {
33            root: root.to_owned(),
34        })
35    }
36
37    /// Every file of the tree, by its path under `spec/`.
38    ///
39    /// # Errors
40    ///
41    /// A missing `spec/` directory, a file that cannot be read, or a name that is not UTF-8.
42    pub fn read_files(&self) -> Result<Files, StoreError> {
43        let spec = self.root.join(SPEC);
44        if !spec.is_dir() {
45            return Err(StoreError::NoTree(spec));
46        }
47        let mut files = Files::new();
48        collect(&spec, "", &mut files)?;
49        Ok(files)
50    }
51
52    /// Writes the given files of the tree in one save and removes none.
53    ///
54    /// # Errors
55    ///
56    /// A file that cannot be staged or put in place.
57    pub fn write_files(&self, files: Files) -> Result<(), StoreError> {
58        journal::save(&self.root, &Changes::of_tree(files, &[]))
59    }
60
61    /// Writes an imported tree into an empty or missing `spec/`, and the sidecar beside it, in
62    /// one save.
63    ///
64    /// # Errors
65    ///
66    /// `spec/` holds a file or directory already (RQ-134), in which case nothing is written; or a
67    /// file cannot be written.
68    pub fn import(&self, files: Files, sidecar: &[u8]) -> Result<(), StoreError> {
69        let spec = self.root.join(SPEC);
70        if spec.exists() && holds_entries(&spec)? {
71            return Err(StoreError::NotEmpty(spec));
72        }
73        fs::create_dir_all(&spec).map_err(io_error(&spec))?;
74        self.write_with_beside(files, (SIDECAR, sidecar.to_vec()))
75    }
76
77    /// Writes a new tree into a `spec/` that does not exist yet, in one save.
78    ///
79    /// # Errors
80    ///
81    /// `spec/` exists already, in which case nothing is written; or a file cannot be written.
82    pub fn create(&self, files: Files) -> Result<(), StoreError> {
83        let spec = self.root.join(SPEC);
84        if spec.exists() {
85            return Err(StoreError::Exists(spec));
86        }
87        // The save creates `spec/` with its files, so an interrupted save leaves none behind.
88        self.write_files(files)
89    }
90
91    /// The lock beside `spec/`, or none.
92    ///
93    /// # Errors
94    ///
95    /// The lock is there but cannot be read, or is not a valid lock record.
96    pub fn lock(&self) -> Result<Option<Lock>, StoreError> {
97        let Some(bytes) = self.beside(LOCK)? else {
98            return Ok(None);
99        };
100        Lock::read(&bytes)
101            .map(Some)
102            .map_err(|error| StoreError::Lock(self.root.join(LOCK), error))
103    }
104
105    /// The bytes of the trace file beside `spec/`, or none.
106    ///
107    /// # Errors
108    ///
109    /// The trace file is there but cannot be read.
110    pub fn trace(&self) -> Result<Option<Vec<u8>>, StoreError> {
111        self.beside(TRACE)
112    }
113
114    /// Writes the trace file beside `spec/` in one save.
115    ///
116    /// # Errors
117    ///
118    /// The file cannot be staged or put in place.
119    pub fn write_trace(&self, text: String) -> Result<(), StoreError> {
120        self.write_with_beside(Files::new(), (TRACE, text.into_bytes()))
121    }
122
123    /// The files of the directory that holds `spec/`, where the reconciler finds the tests.
124    #[must_use]
125    pub fn test_files(&self) -> ProjectFiles {
126        ProjectFiles {
127            root: self.root.clone(),
128        }
129    }
130
131    /// The bytes of the file `name` beside `spec/`, or none.
132    fn beside(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
133        let path = self.root.join(name);
134        match fs::read(&path) {
135            Ok(bytes) => Ok(Some(bytes)),
136            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
137            Err(error) => Err(io_error(&path)(error)),
138        }
139    }
140
141    /// Writes what a publish gives in one save: the files under `spec/` it changes, and the lock
142    /// beside `spec/`; all of them or none (AC-463).
143    ///
144    /// # Errors
145    ///
146    /// A file cannot be staged or put in place.
147    pub fn publish(&self, files: Files, lock: &Lock) -> Result<(), StoreError> {
148        self.write_with_beside(files, (LOCK, lock.to_bytes()))
149    }
150
151    /// Writes files of the tree and one file beside it in one save, removing none.
152    fn write_with_beside(
153        &self,
154        files: Files,
155        (name, bytes): (&str, Vec<u8>),
156    ) -> Result<(), StoreError> {
157        let mut changes = Changes::of_tree(files, &[]);
158        changes.write.insert(RootPath::beside(name), bytes);
159        journal::save(&self.root, &changes)
160    }
161}
162
163/// The files of the directory that holds `spec/`, read as text by their relative paths.
164#[derive(Debug, Clone)]
165pub struct ProjectFiles {
166    root: PathBuf,
167}
168
169impl TestFiles for ProjectFiles {
170    fn read(&self, path: &str) -> TestFile {
171        match fs::read(self.root.join(path)) {
172            Ok(bytes) => String::from_utf8(bytes).map_or(TestFile::NotText, TestFile::Text),
173            Err(error) if error.kind() == std::io::ErrorKind::NotFound => TestFile::Missing,
174            Err(error) => TestFile::Unreadable(error.to_string()),
175        }
176    }
177}
178
179impl DocumentStore for FileStore {
180    fn load(&self) -> Result<Document, StoreError> {
181        codec::read(&self.read_files()?).map_err(StoreError::Read)
182    }
183
184    /// Writes each file whose bytes change and removes each file the document no longer has, in
185    /// one save; a file whose bytes would not change is left alone. A save of this store's that
186    /// failed part-way is finished first, so the tree it compares against is the one it left.
187    fn save(&mut self, document: &Document) -> Result<Saved, StoreError> {
188        journal::recover(&self.root)?;
189        let current = match self.read_files() {
190            Ok(files) => files,
191            Err(StoreError::NoTree(_)) => Files::new(),
192            Err(error) => return Err(error),
193        };
194        let mut next = codec::write(document);
195        let saved = Saved::between(&current, &next);
196        next.retain(|path, _| saved.written.binary_search(path).is_ok());
197        journal::save(&self.root, &Changes::of_tree(next, &saved.removed))?;
198        Ok(saved)
199    }
200}
201
202/// Whether `dir` holds an entry that is not hidden.
203fn holds_entries(dir: &Path) -> Result<bool, StoreError> {
204    for entry in fs::read_dir(dir).map_err(io_error(dir))? {
205        let entry = entry.map_err(io_error(dir))?;
206        if !entry.file_name().to_str().is_some_and(hidden) {
207            return Ok(true);
208        }
209    }
210    Ok(false)
211}
212
213fn collect(dir: &Path, prefix: &str, files: &mut Files) -> Result<(), StoreError> {
214    for entry in fs::read_dir(dir).map_err(io_error(dir))? {
215        let entry = entry.map_err(io_error(dir))?;
216        let path = entry.path();
217        let name = entry
218            .file_name()
219            .into_string()
220            .map_err(|_| StoreError::NotUtf8(path.clone()))?;
221        if hidden(&name) {
222            continue;
223        }
224        let relative = if prefix.is_empty() {
225            name
226        } else {
227            format!("{prefix}/{name}")
228        };
229        if path.is_dir() {
230            collect(&path, &relative, files)?;
231        } else {
232            let bytes = fs::read(&path).map_err(io_error(&path))?;
233            files.insert(SpecPath::new(&relative), bytes);
234        }
235    }
236    Ok(())
237}