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 file")]
21    InvalidFile(PathBuf),
22    #[error("Invalid directory")]
23    InvalidDirectory(PathBuf),
24    #[error("Invalid name")]
25    InvalidName(String),
26}
27
28#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
29pub struct Entry<N> {
30    pub name: N,
31    pub path: PathBuf,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct Tree<S> {
36    base: PathBuf,
37    length_constraint: Option<constraint::Length>,
38    extension_constraint: Option<constraint::Extension>,
39    prefix_part_lengths: Vec<usize>,
40    scheme: S,
41}
42
43impl<S> Tree<S> {
44    /// Open a tree with an inferred prefix part structure and extension constraint.
45    ///
46    /// The result will be empty if and only if the store has no files (even if there are directories).
47    ///
48    /// See the `infer_prefix_part_lengths` and `infer_extension_constraint` functions for
49    /// important qualifications.
50    pub fn open_inferred<P: AsRef<Path>>(base: P, scheme: S) -> Result<Option<Self>, Error> {
51        let prefix_part_lengths = Tree::infer_prefix_part_lengths(&base)?;
52        let extension_constraint = Tree::infer_extension_constraint(&base)?;
53
54        Ok(prefix_part_lengths.zip(extension_constraint).map(
55            |(prefix_part_lengths, extension_constraint)| Self {
56                base: base.as_ref().to_path_buf(),
57                length_constraint: None,
58                extension_constraint: Some(extension_constraint),
59                prefix_part_lengths,
60                scheme,
61            },
62        ))
63    }
64}
65
66impl<S: scheme::Scheme> Tree<S> {
67    /// Return the path through the tree for the given name.
68    ///
69    /// Note that this function ignores any configured extension constraint, or any extension at
70    /// for a file with this file stem at the specified directory.
71    fn name_path(&self, name: S::NameRef<'_>) -> Result<PathBuf, String> {
72        let name_string = self.scheme.name_to_string(name);
73
74        if name_string.len() >= self.prefix_part_lengths_total().max(1) {
75            let mut name_remaining = name_string.as_ref();
76            let mut path = self.base.clone();
77
78            for prefix_part_length in &self.prefix_part_lengths {
79                let next = &name_remaining[0..*prefix_part_length];
80                name_remaining = &name_remaining[*prefix_part_length..];
81
82                path.push(next);
83            }
84
85            path.push(name_string.as_ref());
86
87            Ok(path)
88        } else {
89            Err(name_string.to_string())
90        }
91    }
92
93    /// Return the path through the tree for the given name, including any fixed extension.
94    pub fn path(&self, name: S::NameRef<'_>) -> Result<PathBuf, String> {
95        let mut name_path = self.name_path(name)?;
96
97        if let Some(constraint::Extension::Fixed(extension)) = &self.extension_constraint {
98            name_path.add_extension(extension);
99        }
100
101        Ok(name_path)
102    }
103
104    fn prefix_part_lengths_total(&self) -> usize {
105        self.prefix_part_lengths.iter().sum()
106    }
107
108    /// Try to open a file for reading for the given name, including any fixed extension.
109    ///
110    /// Note that this function will probably not do the right thing for any extension
111    /// configuration that does not either prohibit extensions or require a fixed extension.
112    pub fn open_file(&self, name: S::NameRef<'_>) -> Result<Option<File>, Error> {
113        let path = self.path(name).map_err(Error::InvalidName)?;
114
115        match File::open(&path) {
116            Ok(file) => {
117                if path.is_file() {
118                    Ok(Some(file))
119                } else {
120                    Err(Error::ExpectedFile(path))
121                }
122            }
123            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
124            Err(error) => Err(error.into()),
125        }
126    }
127
128    /// Try to create a file for writing for the given name, including any fixed extension.
129    ///
130    /// Note that this function will probably not do the right thing for any extension
131    /// configuration that does not either prohibit extensions or require a fixed extension.
132    pub fn create_file(&self, name: S::NameRef<'_>) -> Result<Option<File>, Error> {
133        let path = self.path(name).map_err(Error::InvalidName)?;
134
135        if let Some(parent) = path.parent() {
136            std::fs::create_dir_all(parent)?;
137        }
138
139        match File::create_new(path) {
140            Ok(file) => {
141                file.lock()?;
142
143                Ok(Some(file))
144            }
145            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
146            Err(other) => Err(other.into()),
147        }
148    }
149
150    #[must_use]
151    pub fn entries(&self) -> iter::Entries<'_, S> {
152        iter::Entries::new(self)
153    }
154}
155
156impl Tree<scheme::Identity> {
157    pub fn builder<P: AsRef<Path>>(base: P) -> builder::TreeBuilder<scheme::Identity> {
158        builder::TreeBuilder::new(base.as_ref().to_path_buf())
159    }
160
161    /// Infer the prefix part lengths used to create a store.
162    ///
163    /// The result will be empty if and only if the store has no files (even if there are directories).
164    ///
165    /// If this function returns a result, it is guaranteed to be correct if the store is valid, but the validity is not checked.
166    pub fn infer_prefix_part_lengths<P: AsRef<Path>>(base: P) -> Result<Option<Vec<usize>>, Error> {
167        if base.as_ref().is_dir() {
168            let first = std::fs::read_dir(base)?
169                .next()
170                .map_or(Ok(None), |entry| entry.map(|entry| Some(entry.path())))?;
171
172            let mut acc = vec![];
173
174            let is_empty = first
175                .map(|first| Self::infer_prefix_part_lengths_rec(&first, &mut acc))
176                .map_or(Ok(true), |value| value)?;
177
178            Ok(if is_empty { None } else { Some(acc) })
179        } else {
180            Err(Error::ExpectedDirectory(base.as_ref().to_path_buf()))
181        }
182    }
183
184    // Return value indicates whether the store has no files.
185    fn infer_prefix_part_lengths_rec<P: AsRef<Path>>(
186        current: P,
187        acc: &mut Vec<usize>,
188    ) -> Result<bool, Error> {
189        if current.as_ref().is_file() {
190            Ok(false)
191        } else {
192            let directory_name = current
193                .as_ref()
194                .file_name()
195                .ok_or_else(|| Error::InvalidDirectory(current.as_ref().to_path_buf()))?;
196
197            acc.push(directory_name.len());
198
199            let next = std::fs::read_dir(current)?
200                .next()
201                .map_or(Ok(None), |entry| entry.map(|entry| Some(entry.path())))?;
202
203            next.map_or(Ok(true), |next| {
204                Self::infer_prefix_part_lengths_rec(next, acc)
205            })
206        }
207    }
208
209    /// Infer the extension constraint used to create a store.
210    ///
211    /// The result will be empty if and only if the store has no files (even if there are directories).
212    ///
213    /// This function can only infer the extension constraint if it either prohibits extensions or
214    /// requires all files to have the same extension.
215    pub fn infer_extension_constraint<P: AsRef<Path>>(
216        base: P,
217    ) -> Result<Option<constraint::Extension>, Error> {
218        Self::infer_extension_constraint_rec(base)
219    }
220
221    pub fn infer_extension_constraint_rec<P: AsRef<Path>>(
222        current: P,
223    ) -> Result<Option<constraint::Extension>, Error> {
224        if current.as_ref().is_file() {
225            match current.as_ref().extension() {
226                None => Ok(Some(constraint::Extension::None)),
227                Some(extension) => {
228                    let extension = extension
229                        .to_str()
230                        .ok_or_else(|| Error::InvalidFile(current.as_ref().to_path_buf()))?;
231
232                    Ok(Some(constraint::Extension::Fixed(extension.to_string())))
233                }
234            }
235        } else {
236            for result in std::fs::read_dir(current)? {
237                let entry = result?;
238
239                if let Some(constraint) = Self::infer_extension_constraint(entry.path())? {
240                    return Ok(Some(constraint));
241                }
242            }
243
244            Ok(None)
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::io::Write;
253
254    #[test]
255    fn test_path_with_valid_prefix_lengths() -> Result<(), Box<dyn std::error::Error>> {
256        let temp_dir = tempfile::tempdir()?;
257        let tree = Tree::builder(temp_dir.path())
258            .with_scheme(scheme::Utf8)
259            .with_prefix_part_lengths([2, 2])
260            .build()?;
261
262        let path = tree.path("abcdef")?;
263        assert!(path.to_string_lossy().ends_with("/ab/cd/abcdef"));
264
265        Ok(())
266    }
267
268    #[test]
269    fn test_path_boundary_case_equal_length() -> Result<(), Box<dyn std::error::Error>> {
270        let temp_dir = tempfile::tempdir()?;
271        let tree = Tree::builder(temp_dir.path())
272            .with_scheme(scheme::Utf8)
273            .with_prefix_part_lengths([2, 1])
274            .build()?;
275
276        // Name length (3) equals prefix total (3).
277        let path = tree.path("abc")?;
278        assert!(path.to_string_lossy().ends_with("/ab/c/abc"));
279
280        Ok(())
281    }
282
283    #[test]
284    fn test_path_too_short_name() {
285        let temp_dir = tempfile::tempdir().unwrap();
286        let tree = Tree::builder(temp_dir.path())
287            .with_scheme(scheme::Utf8)
288            .with_prefix_part_lengths([2, 2])
289            .build()
290            .unwrap();
291
292        // Name length (3) is less than prefix total (4).
293        let result = tree.path("abc");
294        assert!(result.is_err());
295        assert_eq!(result.unwrap_err(), "abc");
296    }
297
298    #[test]
299    fn test_open_file_nonexistent() -> Result<(), Box<dyn std::error::Error>> {
300        let temp_dir = tempfile::tempdir()?;
301        let tree = Tree::builder(temp_dir.path())
302            .with_scheme(scheme::Utf8)
303            .build()?;
304
305        let result = tree.open_file("nonexistent")?;
306        assert!(
307            result.is_none(),
308            "Should return `Ok(None)` for nonexistent file"
309        );
310
311        Ok(())
312    }
313
314    #[test]
315    fn test_open_file_exists() -> Result<(), Box<dyn std::error::Error>> {
316        let temp_dir = tempfile::tempdir()?;
317        let tree = Tree::builder(temp_dir.path())
318            .with_scheme(scheme::Utf8)
319            .build()?;
320
321        // Create a file.
322        let test_name = "testfile".to_string();
323        let mut file = tree
324            .create_file(&test_name)?
325            .expect("Failed to create file");
326        file.write_all(b"test content")?;
327        drop(file);
328
329        // Try to open it.
330        let opened = tree.open_file(&test_name)?;
331        assert!(
332            opened.is_some(),
333            "Should return `Ok(Some(file))` for existing file"
334        );
335
336        Ok(())
337    }
338
339    #[test]
340    fn test_open_file_directory_instead_of_file() -> Result<(), Box<dyn std::error::Error>> {
341        let temp_dir = tempfile::tempdir()?;
342        let tree = Tree::builder(temp_dir.path())
343            .with_scheme(scheme::Utf8)
344            .with_prefix_part_lengths([2])
345            .build()?;
346
347        // Create a file, which will create directory `ab`.
348        let mut file = tree.create_file("abcd")?.expect("Failed to create");
349        file.write_all(b"test")?;
350        drop(file);
351
352        let dir_name = "zz".to_string();
353        let dir_path = tree.path(&dir_name)?;
354        std::fs::create_dir_all(&dir_path)?;
355
356        // Now try to open `zz` which exists as a directory.
357        let result = tree.open_file(&dir_name);
358
359        match result {
360            Err(Error::ExpectedFile(_)) | Ok(None) => {
361                // Expected behavior (detected it's a directory or couldn't open it)
362            }
363            Ok(Some(_)) => {
364                panic!("Should not return `Ok(Some)` for a directory")
365            }
366            other => {
367                panic!("Unexpected result: {other:?}")
368            }
369        }
370
371        Ok(())
372    }
373
374    #[test]
375    fn test_open_file_symlink_to_directory() -> Result<(), Box<dyn std::error::Error>> {
376        #[cfg(unix)]
377        {
378            use std::os::unix::fs::symlink;
379
380            let temp_dir = tempfile::tempdir()?;
381            let tree = Tree::builder(temp_dir.path())
382                .with_scheme(scheme::Utf8)
383                .build()?;
384
385            // Create a directory and a symlink to it.
386            let dir_path = temp_dir.path().join("somedir");
387            std::fs::create_dir(&dir_path)?;
388
389            let link_name = "symlink".to_string();
390            let link_path = temp_dir.path().join(&link_name);
391            symlink(&dir_path, &link_path)?;
392
393            // Try to open the symlink (which points to a directory).
394            let result = tree.open_file(&link_name);
395
396            match result {
397                Err(Error::ExpectedFile(_)) | Ok(None) => {
398                    // Should return Err(ExpectedFile) because target is a directory.
399                }
400                other => panic!("Expected Err or Ok(None), got {other:?}"),
401            }
402        }
403
404        Ok(())
405    }
406
407    #[test]
408    fn test_create_file_idempotent() -> Result<(), Box<dyn std::error::Error>> {
409        let temp_dir = tempfile::tempdir()?;
410        let tree = Tree::builder(temp_dir.path())
411            .with_scheme(scheme::Utf8)
412            .build()?;
413
414        let name = "testfile".to_string();
415
416        // First creation should succeed.
417        let first = tree.create_file(&name)?;
418        assert!(first.is_some(), "First creation should return Some(file)");
419        drop(first);
420
421        // Second creation should return `None` (file exists).
422        let second = tree.create_file(&name)?;
423        assert!(second.is_none(), "Second creation should return None");
424
425        Ok(())
426    }
427
428    #[test]
429    fn test_create_file_with_nested_dirs() -> Result<(), Box<dyn std::error::Error>> {
430        let temp_dir = tempfile::tempdir()?;
431        let tree = Tree::builder(temp_dir.path())
432            .with_scheme(scheme::Utf8)
433            .with_prefix_part_lengths([2, 2, 2])
434            .build()?;
435
436        let name = "abcdefgh".to_string();
437        let mut file = tree.create_file(&name)?.expect("Failed to create file");
438        file.write_all(b"nested")?;
439        drop(file);
440
441        // Verify the directory structure was created.
442        let path = tree.path(&name)?;
443        assert!(path.exists());
444        assert!(path.is_file());
445        assert!(path.to_string_lossy().contains("/ab/cd/ef/"));
446
447        Ok(())
448    }
449
450    #[test]
451    fn test_infer_empty_directory() -> Result<(), Box<dyn std::error::Error>> {
452        let temp_dir = tempfile::tempdir()?;
453
454        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
455        assert_eq!(result, None, "Empty directory should return None");
456
457        Ok(())
458    }
459
460    #[test]
461    fn test_infer_with_files_no_subdirs() -> Result<(), Box<dyn std::error::Error>> {
462        let temp_dir = tempfile::tempdir()?;
463
464        // Create a file directly in the base directory.
465        std::fs::File::create(temp_dir.path().join("file.txt"))?;
466
467        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
468        assert_eq!(
469            result,
470            Some(vec![]),
471            "File in root should give empty prefix list"
472        );
473
474        Ok(())
475    }
476
477    #[test]
478    fn test_infer_with_nested_structure() -> Result<(), Box<dyn std::error::Error>> {
479        let temp_dir = tempfile::tempdir()?;
480
481        // Create structure: `ab/cd/file.txt`.
482        let dir1 = temp_dir.path().join("ab");
483        let dir2 = dir1.join("cd");
484        std::fs::create_dir_all(&dir2)?;
485        std::fs::File::create(dir2.join("file.txt"))?;
486
487        let result = Tree::infer_prefix_part_lengths(temp_dir.path())?;
488        assert_eq!(
489            result,
490            Some(vec![2, 2]),
491            "Should infer `[2, 2]` from `ab/cd/`"
492        );
493
494        Ok(())
495    }
496
497    #[test]
498    fn test_infer_on_file_instead_of_directory() {
499        let temp_file = tempfile::NamedTempFile::new().unwrap();
500
501        let result = Tree::infer_prefix_part_lengths(temp_file.path());
502        assert!(
503            result.is_err(),
504            "Should return error when given a file path"
505        );
506        match result {
507            Err(Error::ExpectedDirectory(_)) => (),
508            other => panic!("Expected `Err(ExpectedDirectory)`, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn test_path_with_empty_prefix_parts() -> Result<(), Box<dyn std::error::Error>> {
514        let temp_dir = tempfile::tempdir()?;
515        let tree = Tree::builder(temp_dir.path())
516            .with_scheme(scheme::Utf8)
517            .with_prefix_part_lengths([])
518            .build()?;
519
520        let path = tree.path("filename")?;
521        assert!(path.to_string_lossy().ends_with("/filename"));
522        assert!(!path.to_string_lossy().contains("//"));
523
524        Ok(())
525    }
526
527    #[test]
528    fn test_entries_iteration() -> Result<(), Box<dyn std::error::Error>> {
529        let temp_dir = tempfile::tempdir()?;
530        let tree = Tree::builder(temp_dir.path())
531            .with_scheme(scheme::Utf8)
532            .with_prefix_part_lengths([1])
533            .build()?;
534
535        // Create some files.
536        let names = vec!["aaa", "abc", "bcd", "bbb"];
537        for name in &names {
538            let mut file = tree.create_file(name)?.expect("create failed");
539            file.write_all(name.as_bytes())?;
540            drop(file);
541        }
542
543        // Collect all entries.
544        let entries: Vec<_> = tree.entries().collect::<Result<Vec<_>, _>>()?;
545        assert_eq!(entries.len(), 4, "Should find all four files");
546
547        // Check that we got the files (order depends on scheme sorting).
548        let entry_names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
549        for name in &names {
550            assert!(
551                entry_names.contains(&(*name).to_string()),
552                "Should contain {name}"
553            );
554        }
555
556        Ok(())
557    }
558
559    #[test]
560    fn test_example_with_fixed_extension() -> Result<(), Box<dyn std::error::Error>> {
561        let tree = Tree::builder("examples/extensions/fixed-01/")
562            .with_scheme(scheme::Utf8)
563            .with_prefix_part_lengths([2, 2, 2])
564            .with_length(8)
565            .with_extension("txt")
566            .build()?;
567
568        let file = tree.open_file("01234567")?;
569        assert!(file.is_some());
570
571        let file = tree.open_file("98765432")?;
572        assert!(file.is_some());
573
574        let entries: Vec<_> = tree.entries().collect::<Result<Vec<_>, _>>()?;
575        assert_eq!(entries.len(), 2, "Should find both files");
576
577        Ok(())
578    }
579
580    #[test]
581    fn test_example_with_mixed_extensions_and_no_constraint()
582    -> Result<(), Box<dyn std::error::Error>> {
583        let tree = Tree::builder("examples/extensions/mixed-01/")
584            .with_scheme(scheme::Utf8)
585            .with_prefix_part_lengths([2, 2, 2])
586            .with_length(8)
587            .build()?;
588
589        let entries: Vec<_> = tree.entries().collect::<Result<Vec<_>, _>>()?;
590        assert_eq!(entries.len(), 2, "Should find both files");
591
592        Ok(())
593    }
594
595    #[test]
596    fn test_example_with_mixed_extensions_and_any_constraint_fails()
597    -> Result<(), Box<dyn std::error::Error>> {
598        let tree = Tree::builder("examples/extensions/mixed-01/")
599            .with_scheme(scheme::Utf8)
600            .with_prefix_part_lengths([2, 2, 2])
601            .with_length(8)
602            .with_any_extension()
603            .build()?;
604
605        let entries = tree.entries().collect::<Result<Vec<_>, _>>();
606
607        match entries {
608            Err(super::iter::Error::InvalidExtension(None)) => {}
609            Err(error) => {
610                panic!("Unexpected error: {error:?}");
611            }
612            Ok(_) => {
613                panic!("Expected error on missing extension");
614            }
615        }
616
617        Ok(())
618    }
619
620    #[test]
621    fn test_example_with_mixed_extensions_and_fixed_constraint_fails()
622    -> Result<(), Box<dyn std::error::Error>> {
623        let tree = Tree::builder("examples/extensions/mixed-01/")
624            .with_scheme(scheme::Utf8)
625            .with_prefix_part_lengths([2, 2, 2])
626            .with_length(8)
627            .with_extension("txt")
628            .build()?;
629
630        let file = tree.open_file("01234567")?;
631        assert!(file.is_some());
632
633        let file = tree.open_file("98765432")?;
634        assert!(file.is_none());
635
636        let entries = tree.entries().collect::<Result<Vec<_>, _>>();
637
638        match entries {
639            Err(super::iter::Error::InvalidExtension(None)) => {}
640            Err(error) => {
641                panic!("Unexpected error: {error:?}");
642            }
643            Ok(_) => {
644                panic!("Expected error on missing extension");
645            }
646        }
647
648        Ok(())
649    }
650
651    #[test]
652    fn test_open_inferred_with_fixed_extension() -> Result<(), Box<dyn std::error::Error>> {
653        let tree = Tree::open_inferred("examples/extensions/fixed-01/", scheme::Utf8)?
654            .expect("Expected at least one file");
655
656        assert_eq!(tree.prefix_part_lengths, vec![2, 2, 2]);
657
658        assert_eq!(
659            tree.extension_constraint,
660            Some(crate::constraint::Extension::Fixed("txt".to_string()))
661        );
662
663        Ok(())
664    }
665
666    #[test]
667    fn test_open_inferred_with_no_extension() -> Result<(), Box<dyn std::error::Error>> {
668        let tree = Tree::open_inferred("examples/extensions/none-01/", scheme::Utf8)?
669            .expect("Expected at least one file");
670
671        assert_eq!(tree.prefix_part_lengths, vec![2, 2, 2]);
672
673        assert_eq!(
674            tree.extension_constraint,
675            Some(crate::constraint::Extension::None)
676        );
677
678        Ok(())
679    }
680}