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