mago_database/
lib.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::path::PathBuf;
4
5use crate::change::Change;
6use crate::change::ChangeLog;
7use crate::error::DatabaseError;
8use crate::file::File;
9use crate::file::FileId;
10use crate::file::FileType;
11use crate::file::line_starts;
12
13mod utils;
14
15pub mod change;
16pub mod error;
17pub mod exclusion;
18pub mod file;
19pub mod loader;
20
21/// A mutable database for managing a collection of project files.
22///
23/// This struct acts as the primary "builder" for your file set. It is optimized
24/// for efficient additions, updates, and deletions. Once you have loaded all
25/// files and performed any initial modifications, you can create a high-performance,
26/// immutable snapshot for fast querying by calling [`read_only`](Self::read_only).
27#[derive(Debug, Default)]
28pub struct Database {
29    /// Maps a file's logical name to its `File` object for fast name-based access.
30    files: HashMap<String, File>,
31    /// Maps a file's stable ID back to its logical name for fast ID-based mutations.
32    id_to_name: HashMap<FileId, String>,
33}
34
35/// An immutable, read-optimized snapshot of a file database.
36///
37/// This structure is designed for high-performance lookups and iteration. It stores
38/// all files in a contiguous, sorted vector and uses multiple `HashMap` indices
39/// to provide $O(1)$ average-time access to files by their ID, name, or path.
40///
41/// A `ReadDatabase` is created via [`Database::read_only`].
42#[derive(Debug, Clone)]
43pub struct ReadDatabase {
44    /// A contiguous list of all files, sorted by `FileId` for deterministic iteration.
45    files: Vec<File>,
46    /// Maps a file's stable ID to its index in the `files` vector.
47    id_to_index: HashMap<FileId, usize>,
48    /// Maps a file's logical name to its index in the `files` vector.
49    name_to_index: HashMap<String, usize>,
50    /// Maps a file's absolute path to its index in the `files` vector.
51    path_to_index: HashMap<PathBuf, usize>,
52}
53
54impl Database {
55    /// Creates a new, empty `Database`.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Adds a file to the database, overwriting any existing file with the same name.
61    pub fn add(&mut self, file: File) {
62        let name = file.name.clone();
63        let id = file.id;
64
65        if let Some(old_file) = self.files.insert(name.clone(), file) {
66            self.id_to_name.remove(&old_file.id);
67        }
68        self.id_to_name.insert(id, name);
69    }
70
71    /// Updates a file's content in-place using its stable `FileId`.
72    ///
73    /// This recalculates derived data like file size, line endings, and `FileRevision`.
74    /// Returns `true` if a file with the given ID was found and updated.
75    pub fn update(&mut self, id: FileId, new_contents: String) -> bool {
76        if let Some(name) = self.id_to_name.get(&id)
77            && let Some(file) = self.files.get_mut(name)
78        {
79            file.contents = new_contents;
80            file.size = file.contents.len();
81            file.lines = line_starts(&file.contents).collect();
82            return true;
83        }
84        false
85    }
86
87    /// Deletes a file from the database using its stable `FileId`.
88    ///
89    /// Returns `true` if a file with the given ID was found and removed.
90    pub fn delete(&mut self, id: FileId) -> bool {
91        if let Some(name) = self.id_to_name.remove(&id) { self.files.remove(&name).is_some() } else { false }
92    }
93
94    /// Commits a [`ChangeLog`], applying all its recorded operations to the database.
95    ///
96    /// This method consumes the log and applies each `Change` sequentially.
97    /// It will fail if other references to the `ChangeLog` still exist.
98    ///
99    /// # Errors
100    ///
101    /// Returns a [`DatabaseError`] if the log cannot be consumed.
102    pub fn commit(&mut self, change_log: ChangeLog) -> Result<(), DatabaseError> {
103        for change in change_log.into_inner()? {
104            self.apply(change);
105        }
106        Ok(())
107    }
108
109    /// Applies a single `Change` operation to the database.
110    fn apply(&mut self, change: Change) {
111        match change {
112            Change::Add(file) => self.add(file),
113            Change::Update(id, contents) => {
114                self.update(id, contents);
115            }
116            Change::Delete(id) => {
117                self.delete(id);
118            }
119        }
120    }
121
122    /// Creates an independent, immutable snapshot of the database.
123    ///
124    /// This is a potentially expensive one-time operation as it **clones** all file
125    /// data. The resulting [`ReadDatabase`] is highly optimized for fast reads and
126    /// guarantees a deterministic iteration order. The original `Database` is not
127    /// consumed and can continue to be used.
128    pub fn read_only(&self) -> ReadDatabase {
129        let mut files_vec: Vec<File> = self.files.values().cloned().collect();
130        files_vec.sort_unstable_by_key(|f| f.id);
131
132        let mut id_to_index = HashMap::with_capacity(files_vec.len());
133        let mut name_to_index = HashMap::with_capacity(files_vec.len());
134        let mut path_to_index = HashMap::with_capacity(files_vec.len());
135
136        for (index, file) in files_vec.iter().enumerate() {
137            id_to_index.insert(file.id, index);
138            name_to_index.insert(file.name.clone(), index);
139            if let Some(path) = &file.path {
140                path_to_index.insert(path.clone(), index);
141            }
142        }
143
144        ReadDatabase { files: files_vec, id_to_index, name_to_index, path_to_index }
145    }
146}
147
148impl ReadDatabase {
149    /// Creates a new `ReadDatabase` containing only a single file.
150    ///
151    /// This is a convenience constructor for situations, such as testing or
152    /// single-file tools, where an operation requires a [`DatabaseReader`]
153    /// implementation but only needs to be aware of one file.
154    ///
155    /// # Arguments
156    ///
157    /// * `file`: The single `File` to include in the database.
158    pub fn single(file: File) -> Self {
159        let mut id_to_index = HashMap::with_capacity(1);
160        let mut name_to_index = HashMap::with_capacity(1);
161        let mut path_to_index = HashMap::with_capacity(1);
162
163        // The index for the single file will always be 0.
164        id_to_index.insert(file.id, 0);
165        name_to_index.insert(file.name.clone(), 0);
166        if let Some(path) = &file.path {
167            path_to_index.insert(path.clone(), 0);
168        }
169
170        Self { files: vec![file], id_to_index, name_to_index, path_to_index }
171    }
172}
173
174/// A universal interface for reading data from any database implementation.
175///
176/// This trait provides a common API for querying file data, abstracting over
177/// whether the underlying source is the mutable [`Database`] or the read-optimized
178/// [`ReadDatabase`]. This allows for writing generic code that can operate on either.
179pub trait DatabaseReader {
180    /// Retrieves a file's stable ID using its logical name.
181    fn get_id(&self, name: &str) -> Option<FileId>;
182
183    fn get_name(&self, id: &FileId) -> Option<&str> {
184        self.get_by_id(id).map(|file| file.name.as_str()).ok()
185    }
186
187    /// Retrieves a reference to a file using its stable `FileId`.
188    ///
189    /// # Errors
190    ///
191    /// Returns `DatabaseError::FileNotFound` if no file with the given ID exists.
192    fn get_by_id(&self, id: &FileId) -> Result<&File, DatabaseError>;
193
194    /// Retrieves a reference to a file using its logical name.
195    ///
196    /// # Errors
197    ///
198    /// Returns `DatabaseError::FileNotFound` if no file with the given name exists.
199    fn get_by_name(&self, name: &str) -> Result<&File, DatabaseError>;
200
201    /// Retrieves a reference to a file by its absolute filesystem path.
202    ///
203    /// # Errors
204    ///
205    /// Returns `DatabaseError::FileNotFound` if no file with the given path exists.
206    fn get_by_path(&self, path: &Path) -> Result<&File, DatabaseError>;
207
208    /// Returns an iterator over all files in the database.
209    ///
210    /// The order is not guaranteed for `Database`, but is sorted by `FileId`
211    /// for `ReadDatabase`, providing deterministic iteration.
212    fn files(&self) -> impl Iterator<Item = &File>;
213
214    /// Returns an iterator over all files of a specific `FileType`.
215    fn files_with_type(&self, file_type: FileType) -> impl Iterator<Item = &File> {
216        self.files().filter(move |file| file.file_type == file_type)
217    }
218
219    /// Returns an iterator over all files that do not match a specific `FileType`.
220    fn files_without_type(&self, file_type: FileType) -> impl Iterator<Item = &File> {
221        self.files().filter(move |file| file.file_type != file_type)
222    }
223
224    /// Returns an iterator over the stable IDs of all files in the database.
225    fn file_ids(&self) -> impl Iterator<Item = FileId> {
226        self.files().map(|file| file.id)
227    }
228
229    /// Returns an iterator over the stable IDs of all files of a specific `FileType`.
230    fn file_ids_with_type(&self, file_type: FileType) -> impl Iterator<Item = FileId> {
231        self.files_with_type(file_type).map(|file| file.id)
232    }
233
234    /// Returns an iterator over the stable IDs of all files that do not match a specific `FileType`.
235    fn file_ids_without_type(&self, file_type: FileType) -> impl Iterator<Item = FileId> {
236        self.files_without_type(file_type).map(|file| file.id)
237    }
238
239    /// Returns the total number of files in the database.
240    fn len(&self) -> usize;
241
242    /// Returns `true` if the database contains no files.
243    fn is_empty(&self) -> bool {
244        self.len() == 0
245    }
246}
247
248impl DatabaseReader for Database {
249    fn get_id(&self, name: &str) -> Option<FileId> {
250        self.files.get(name).map(|f| f.id)
251    }
252
253    fn get_by_id(&self, id: &FileId) -> Result<&File, DatabaseError> {
254        self.id_to_name.get(id).and_then(|name| self.files.get(name)).ok_or(DatabaseError::FileNotFound)
255    }
256
257    fn get_by_name(&self, name: &str) -> Result<&File, DatabaseError> {
258        self.files.get(name).ok_or(DatabaseError::FileNotFound)
259    }
260
261    fn get_by_path(&self, path: &Path) -> Result<&File, DatabaseError> {
262        self.files.values().find(|file| file.path.as_deref() == Some(path)).ok_or(DatabaseError::FileNotFound)
263    }
264
265    fn files(&self) -> impl Iterator<Item = &File> {
266        self.files.values()
267    }
268
269    fn len(&self) -> usize {
270        self.files.len()
271    }
272}
273
274impl DatabaseReader for ReadDatabase {
275    fn get_id(&self, name: &str) -> Option<FileId> {
276        self.name_to_index.get(name).and_then(|&i| self.files.get(i)).map(|f| f.id)
277    }
278
279    fn get_by_id(&self, id: &FileId) -> Result<&File, DatabaseError> {
280        self.id_to_index.get(id).and_then(|&i| self.files.get(i)).ok_or(DatabaseError::FileNotFound)
281    }
282
283    fn get_by_name(&self, name: &str) -> Result<&File, DatabaseError> {
284        self.name_to_index.get(name).and_then(|&i| self.files.get(i)).ok_or(DatabaseError::FileNotFound)
285    }
286
287    fn get_by_path(&self, path: &Path) -> Result<&File, DatabaseError> {
288        self.path_to_index.get(path).and_then(|&i| self.files.get(i)).ok_or(DatabaseError::FileNotFound)
289    }
290
291    fn files(&self) -> impl Iterator<Item = &File> {
292        self.files.iter()
293    }
294
295    fn len(&self) -> usize {
296        self.files.len()
297    }
298}