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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = tree.open_file(&dir_name);
358
359 match result {
360 Err(Error::ExpectedFile(_)) | Ok(None) => {
361 }
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 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 let result = tree.open_file(&link_name);
395
396 match result {
397 Err(Error::ExpectedFile(_)) | Ok(None) => {
398 }
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 let first = tree.create_file(&name)?;
418 assert!(first.is_some(), "First creation should return Some(file)");
419 drop(first);
420
421 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 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 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 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 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 let entries: Vec<_> = tree.entries().collect::<Result<Vec<_>, _>>()?;
545 assert_eq!(entries.len(), 4, "Should find all four files");
546
547 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}