prefix_file_tree/
lib.rs

1#![warn(clippy::all, clippy::pedantic, clippy::nursery, rust_2018_idioms)]
2#![allow(clippy::missing_errors_doc)]
3#![forbid(unsafe_code)]
4use std::fs::File;
5use std::path::{Path, PathBuf};
6
7pub mod builder;
8pub mod constraint;
9pub mod iter;
10pub mod scheme;
11
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    #[error("I/O error")]
15    Io(#[from] std::io::Error),
16    #[error("Expected file")]
17    ExpectedFile(PathBuf),
18    #[error("Expected directory")]
19    ExpectedDirectory(PathBuf),
20    #[error("Invalid directory")]
21    InvalidDirectory(PathBuf),
22    #[error("Invalid name")]
23    InvalidName(String),
24}
25
26#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
27pub struct Entry<N> {
28    pub name: N,
29    pub path: PathBuf,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct Tree<S> {
34    base: PathBuf,
35    length_constraint: Option<constraint::Length>,
36    extension_constraint: Option<constraint::Extension>,
37    prefix_part_lengths: Vec<usize>,
38    scheme: S,
39}
40
41impl<S: scheme::Scheme> Tree<S> {
42    pub fn path(&self, name: &S::Name) -> Result<PathBuf, String> {
43        let name_string = self.scheme.name_to_string(name);
44
45        if name_string.len() >= self.prefix_part_lengths_total() {
46            let mut name_remaining = name_string.as_ref();
47            let mut path = self.base.clone();
48
49            for prefix_part_length in &self.prefix_part_lengths {
50                let next = &name_remaining[0..*prefix_part_length];
51                name_remaining = &name_remaining[*prefix_part_length..];
52
53                path.push(next);
54            }
55
56            path.push(name_string.as_ref());
57
58            Ok(path)
59        } else {
60            Err(name_string.to_string())
61        }
62    }
63
64    fn prefix_part_lengths_total(&self) -> usize {
65        self.prefix_part_lengths.iter().sum()
66    }
67
68    pub fn open_file(&self, name: &S::Name) -> Result<Option<File>, Error> {
69        let path = self.path(name).map_err(Error::InvalidName)?;
70
71        match File::open(&path) {
72            Ok(file) => {
73                if path.is_file() {
74                    Ok(Some(file))
75                } else {
76                    Err(Error::ExpectedFile(path))
77                }
78            }
79            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
80            Err(error) => Err(error.into()),
81        }
82    }
83
84    pub fn create_file(&self, name: &S::Name) -> Result<Option<File>, Error> {
85        let path = self.path(name).map_err(Error::InvalidName)?;
86
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent)?;
89        }
90
91        match File::create_new(path) {
92            Ok(file) => {
93                file.lock()?;
94
95                Ok(Some(file))
96            }
97            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
98            Err(other) => Err(other.into()),
99        }
100    }
101
102    #[must_use]
103    pub fn entries(&self) -> iter::Entries<'_, S> {
104        iter::Entries::new(self)
105    }
106}
107
108impl Tree<scheme::Identity> {
109    pub fn builder<P: AsRef<Path>>(base: P) -> builder::TreeBuilder<scheme::Identity> {
110        builder::TreeBuilder::new(base.as_ref().to_path_buf())
111    }
112
113    /// Infer the prefix part lengths used to create a store.
114    ///
115    /// The result will be empty if and only if the store has no files (even if there are directories).
116    ///
117    /// If this function returns a result, it is guaranteed to be correct if the store is valid, but the validity is not checked.
118    pub fn infer_prefix_part_lengths<P: AsRef<Path>>(base: P) -> Result<Option<Vec<usize>>, Error> {
119        if base.as_ref().is_dir() {
120            let first = std::fs::read_dir(base)?
121                .next()
122                .map_or(Ok(None), |entry| entry.map(|entry| Some(entry.path())))?;
123
124            let mut acc = vec![];
125
126            let is_empty = first
127                .map(|first| Self::infer_prefix_part_lengths_rec(&first, &mut acc))
128                .map_or(Ok(true), |value| value)?;
129
130            Ok(if is_empty { None } else { Some(acc) })
131        } else {
132            Err(Error::ExpectedDirectory(base.as_ref().to_path_buf()))
133        }
134    }
135
136    // Return value indicates whether the store has no files.
137    fn infer_prefix_part_lengths_rec<P: AsRef<Path>>(
138        current: P,
139        acc: &mut Vec<usize>,
140    ) -> Result<bool, Error> {
141        if current.as_ref().is_file() {
142            Ok(false)
143        } else {
144            let directory_name = current
145                .as_ref()
146                .file_name()
147                .ok_or_else(|| Error::InvalidDirectory(current.as_ref().to_path_buf()))?;
148
149            acc.push(directory_name.len());
150
151            let next = std::fs::read_dir(current)?
152                .next()
153                .map_or(Ok(None), |entry| entry.map(|entry| Some(entry.path())))?;
154
155            next.map_or(Ok(true), |next| {
156                Self::infer_prefix_part_lengths_rec(next, acc)
157            })
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::io::Write;
166
167    #[test]
168    fn test_path_with_valid_prefix_lengths() -> Result<(), Box<dyn std::error::Error>> {
169        let temp_dir = tempfile::tempdir()?;
170        let tree = Tree::builder(temp_dir.path())
171            .with_scheme(scheme::Utf8)
172            .with_prefix_part_lengths([2, 2])
173            .build()?;
174
175        let path = tree.path(&"abcdef".to_string())?;
176        assert!(path.to_string_lossy().ends_with("/ab/cd/abcdef"));
177
178        Ok(())
179    }
180
181    #[test]
182    fn test_path_boundary_case_equal_length() -> Result<(), Box<dyn std::error::Error>> {
183        let temp_dir = tempfile::tempdir()?;
184        let tree = Tree::builder(temp_dir.path())
185            .with_scheme(scheme::Utf8)
186            .with_prefix_part_lengths([2, 1])
187            .build()?;
188
189        // Name length (3) equals prefix total (3).
190        let path = tree.path(&"abc".to_string())?;
191        assert!(path.to_string_lossy().ends_with("/ab/c/abc"));
192
193        Ok(())
194    }
195
196    #[test]
197    fn test_path_too_short_name() {
198        let temp_dir = tempfile::tempdir().unwrap();
199        let tree = Tree::builder(temp_dir.path())
200            .with_scheme(scheme::Utf8)
201            .with_prefix_part_lengths([2, 2])
202            .build()
203            .unwrap();
204
205        // Name length (3) is less than prefix total (4).
206        let result = tree.path(&"abc".to_string());
207        assert!(result.is_err());
208        assert_eq!(result.unwrap_err(), "abc");
209    }
210
211    #[test]
212    fn test_open_file_nonexistent() -> Result<(), Box<dyn std::error::Error>> {
213        let temp_dir = tempfile::tempdir()?;
214        let tree = Tree::builder(temp_dir.path())
215            .with_scheme(scheme::Utf8)
216            .build()?;
217
218        let result = tree.open_file(&"nonexistent".to_string())?;
219        assert!(
220            result.is_none(),
221            "Should return `Ok(None)` for nonexistent file"
222        );
223
224        Ok(())
225    }
226
227    #[test]
228    fn test_open_file_exists() -> Result<(), Box<dyn std::error::Error>> {
229        let temp_dir = tempfile::tempdir()?;
230        let tree = Tree::builder(temp_dir.path())
231            .with_scheme(scheme::Utf8)
232            .build()?;
233
234        // Create a file.
235        let test_name = "testfile".to_string();
236        let mut file = tree
237            .create_file(&test_name)?
238            .expect("Failed to create file");
239        file.write_all(b"test content")?;
240        drop(file);
241
242        // Try to open it.
243        let opened = tree.open_file(&test_name)?;
244        assert!(
245            opened.is_some(),
246            "Should return `Ok(Some(file))` for existing file"
247        );
248
249        Ok(())
250    }
251
252    #[test]
253    fn test_open_file_directory_instead_of_file() -> Result<(), Box<dyn std::error::Error>> {
254        let temp_dir = tempfile::tempdir()?;
255        let tree = Tree::builder(temp_dir.path())
256            .with_scheme(scheme::Utf8)
257            .with_prefix_part_lengths([2])
258            .build()?;
259
260        // Create a file, which will create directory `ab`.
261        let mut file = tree
262            .create_file(&"abcd".to_string())?
263            .expect("Failed to create");
264        file.write_all(b"test")?;
265        drop(file);
266
267        let dir_name = "zz".to_string();
268        let dir_path = tree.path(&dir_name)?;
269        std::fs::create_dir_all(&dir_path)?;
270
271        // Now try to open `zz` which exists as a directory.
272        let result = tree.open_file(&dir_name);
273
274        match result {
275            Err(Error::ExpectedFile(_)) | Ok(None) => {
276                // Expected behavior (detected it's a directory or couldn't open it)
277            }
278            Ok(Some(_)) => {
279                panic!("Should not return `Ok(Some)` for a directory")
280            }
281            other => {
282                panic!("Unexpected result: {other:?}")
283            }
284        }
285
286        Ok(())
287    }
288
289    #[test]
290    fn test_open_file_symlink_to_directory() -> Result<(), Box<dyn std::error::Error>> {
291        #[cfg(unix)]
292        {
293            use std::os::unix::fs::symlink;
294
295            let temp_dir = tempfile::tempdir()?;
296            let tree = Tree::builder(temp_dir.path())
297                .with_scheme(scheme::Utf8)
298                .build()?;
299
300            // Create a directory and a symlink to it.
301            let dir_path = temp_dir.path().join("somedir");
302            std::fs::create_dir(&dir_path)?;
303
304            let link_name = "symlink".to_string();
305            let link_path = temp_dir.path().join(&link_name);
306            symlink(&dir_path, &link_path)?;
307
308            // Try to open the symlink (which points to a directory).
309            let result = tree.open_file(&link_name);
310
311            match result {
312                Err(Error::ExpectedFile(_)) | Ok(None) => {
313                    // Should return Err(ExpectedFile) because target is a directory.
314                }
315                other => panic!("Expected Err or Ok(None), got {other:?}"),
316            }
317        }
318
319        Ok(())
320    }
321
322    #[test]
323    fn test_create_file_idempotent() -> Result<(), Box<dyn std::error::Error>> {
324        let temp_dir = tempfile::tempdir()?;
325        let tree = Tree::builder(temp_dir.path())
326            .with_scheme(scheme::Utf8)
327            .build()?;
328
329        let name = "testfile".to_string();
330
331        // First creation should succeed.
332        let first = tree.create_file(&name)?;
333        assert!(first.is_some(), "First creation should return Some(file)");
334        drop(first);
335
336        // Second creation should return `None` (file exists).
337        let second = tree.create_file(&name)?;
338        assert!(second.is_none(), "Second creation should return None");
339
340        Ok(())
341    }
342
343    #[test]
344    fn test_create_file_with_nested_dirs() -> Result<(), Box<dyn std::error::Error>> {
345        let temp_dir = tempfile::tempdir()?;
346        let tree = Tree::builder(temp_dir.path())
347            .with_scheme(scheme::Utf8)
348            .with_prefix_part_lengths([2, 2, 2])
349            .build()?;
350
351        let name = "abcdefgh".to_string();
352        let mut file = tree.create_file(&name)?.expect("Failed to create file");
353        file.write_all(b"nested")?;
354        drop(file);
355
356        // Verify the directory structure was created.
357        let path = tree.path(&name)?;
358        assert!(path.exists());
359        assert!(path.is_file());
360        assert!(path.to_string_lossy().contains("/ab/cd/ef/"));
361
362        Ok(())
363    }
364
365    #[test]
366    fn test_infer_empty_directory() -> Result<(), Box<dyn std::error::Error>> {
367        let temp_dir = tempfile::tempdir()?;
368
369        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
370        assert_eq!(result, None, "Empty directory should return None");
371
372        Ok(())
373    }
374
375    #[test]
376    fn test_infer_with_files_no_subdirs() -> Result<(), Box<dyn std::error::Error>> {
377        let temp_dir = tempfile::tempdir()?;
378
379        // Create a file directly in the base directory.
380        std::fs::File::create(temp_dir.path().join("file.txt"))?;
381
382        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
383        assert_eq!(
384            result,
385            Some(vec![]),
386            "File in root should give empty prefix list"
387        );
388
389        Ok(())
390    }
391
392    #[test]
393    fn test_infer_with_nested_structure() -> Result<(), Box<dyn std::error::Error>> {
394        let temp_dir = tempfile::tempdir()?;
395
396        // Create structure: `ab/cd/file.txt`.
397        let dir1 = temp_dir.path().join("ab");
398        let dir2 = dir1.join("cd");
399        std::fs::create_dir_all(&dir2)?;
400        std::fs::File::create(dir2.join("file.txt"))?;
401
402        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
403        assert_eq!(
404            result,
405            Some(vec![2, 2]),
406            "Should infer `[2, 2]` from `ab/cd/`"
407        );
408
409        Ok(())
410    }
411
412    #[test]
413    fn test_infer_on_file_instead_of_directory() {
414        let temp_file = tempfile::NamedTempFile::new().unwrap();
415
416        let result = Tree::infer_prefix_part_lengths(temp_file.path());
417        assert!(
418            result.is_err(),
419            "Should return error when given a file path"
420        );
421        match result {
422            Err(Error::ExpectedDirectory(_)) => (),
423            other => panic!("Expected `Err(ExpectedDirectory)`, got {other:?}"),
424        }
425    }
426
427    #[test]
428    fn test_path_with_empty_prefix_parts() -> Result<(), Box<dyn std::error::Error>> {
429        let temp_dir = tempfile::tempdir()?;
430        let tree = Tree::builder(temp_dir.path())
431            .with_scheme(scheme::Utf8)
432            .with_prefix_part_lengths([])
433            .build()?;
434
435        let path = tree.path(&"filename".to_string())?;
436        assert!(path.to_string_lossy().ends_with("/filename"));
437        assert!(!path.to_string_lossy().contains("//"));
438
439        Ok(())
440    }
441
442    #[test]
443    fn test_entries_iteration() -> Result<(), Box<dyn std::error::Error>> {
444        let temp_dir = tempfile::tempdir()?;
445        let tree = Tree::builder(temp_dir.path())
446            .with_scheme(scheme::Utf8)
447            .with_prefix_part_lengths([1])
448            .build()?;
449
450        // Create some files.
451        let names = vec!["aaa", "abc", "bcd", "bbb"];
452        for name in &names {
453            let mut file = tree
454                .create_file(&(*name).to_string())?
455                .expect("create failed");
456            file.write_all(name.as_bytes())?;
457            drop(file);
458        }
459
460        // Collect all entries.
461        let entries: Vec<_> = tree.entries().collect::<Result<Vec<_>, _>>()?;
462        assert_eq!(entries.len(), 4, "Should find all four files");
463
464        // Check that we got the files (order depends on scheme sorting).
465        let entry_names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
466        for name in &names {
467            assert!(
468                entry_names.contains(&(*name).to_string()),
469                "Should contain {name}"
470            );
471        }
472
473        Ok(())
474    }
475}