file_database/lib.rs
1//! # Database
2//! Local file database utilities.
3//!
4//! This crate gives you a simple way to manage files and folders inside one database directory.
5//! The main type is **`DatabaseManager`**, and items are addressed with **`ItemId`**.
6//!
7//! ## How `ItemId` works
8//! - **`ItemId`** has a `name` and an `index`.
9//! - `name` is the shared key (for example `"test_file.txt"`).
10//! - `index` picks which path you want when that name exists more than once.
11//! - `ItemId::id("name")` always means index `0`.
12//! - `ItemId::database_id()` is the root ID for the database itself.
13//!
14//! This means duplicate names are allowed, and you can still target one exact item by index.
15//!
16//! # Example: Build `ItemId` values
17//! ```
18//! use file_database::ItemId;
19//!
20//! let first = ItemId::id("test_file.txt");
21//! let second = ItemId::with_index("test_file.txt", 1);
22//! let root = ItemId::database_id();
23//!
24//! assert_eq!(first.get_name(), "test_file.txt");
25//! assert_eq!(first.get_index(), 0);
26//! assert_eq!(second.get_index(), 1);
27//! assert_eq!(root.get_name(), "");
28//! assert_eq!(root.get_index(), 0);
29//! ```
30//!
31//! ## How to use **`GenPath`**
32//! **`GenPath`** is used to generate paths from a ruleset. This is primarily used as the root directory for **`DatabaseManager`**.
33//! Pick the method based on where your app starts:
34//! - `GenPath::from_working_dir(steps)` when your process starts in a useful working directory.
35//! - `GenPath::from_exe(steps)` when paths should be anchored to the executable location.
36//! - `GenPath::from_closest_match("name")` when you want to find the nearest matching folder
37//! while walking upward from the executable.
38//!
39//! # Example: Build base paths with `GenPath`
40//! ```no_run
41//! use file_database::{DatabaseError, GenPath};
42//!
43//! fn main() -> Result<(), DatabaseError> {
44//! let from_cwd = GenPath::from_working_dir(0)?;
45//! let from_exe = GenPath::from_exe(0)?;
46//! assert!(from_cwd.is_absolute() || from_cwd.is_relative());
47//! assert!(from_exe.is_absolute() || from_exe.is_relative());
48//! Ok(())
49//! }
50//! ```
51//!
52//! # Example: Find a folder by name with `GenPath`
53//! ```no_run
54//! use file_database::{DatabaseError, GenPath};
55//!
56//! fn main() -> Result<(), DatabaseError> {
57//! let project_root = GenPath::from_closest_match("src")?;
58//! assert!(project_root.ends_with("src"));
59//! Ok(())
60//! }
61//! ```
62//!
63//! # Example: Create and overwrite a file
64//! ```no_run
65//! use file_database::{DatabaseError, DatabaseManager, ItemId};
66//!
67//! fn main() -> Result<(), DatabaseError> {
68//! let mut manager = DatabaseManager::new(".", "database")?;
69//! manager.write_new(ItemId::id("example.txt"), ItemId::database_id())?;
70//! manager.overwrite_existing(ItemId::id("example.txt"), b"hello")?;
71//! Ok(())
72//! }
73//! ```
74//!
75//! # Example: Duplicate names with indexes
76//! ```no_run
77//! use file_database::{DatabaseError, DatabaseManager, ItemId};
78//!
79//! fn main() -> Result<(), DatabaseError> {
80//! let mut manager = DatabaseManager::new(".", "database")?;
81//!
82//! manager.write_new(ItemId::id("folder_a"), ItemId::database_id())?;
83//! manager.write_new(ItemId::id("folder_b"), ItemId::database_id())?;
84//! manager.write_new(ItemId::id("test.txt"), ItemId::id("folder_a"))?;
85//! manager.write_new(ItemId::id("test.txt"), ItemId::id("folder_b"))?;
86//!
87//! // First match for "test.txt"
88//! let first = ItemId::id("test.txt");
89//! // Second match for "test.txt"
90//! let second = ItemId::with_index("test.txt", 1);
91//!
92//! let _first_path = manager.locate_absolute(first)?;
93//! let _second_path = manager.locate_absolute(second)?;
94//! Ok(())
95//! }
96//! ```
97//!
98//! # Example: Get all IDs for one shared name
99//! ```no_run
100//! use file_database::{DatabaseError, DatabaseManager, ItemId};
101//!
102//! fn main() -> Result<(), DatabaseError> {
103//! let mut manager = DatabaseManager::new(".", "database")?;
104//! manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
105//! manager.write_new(ItemId::id("folder"), ItemId::database_id())?;
106//! manager.write_new(ItemId::id("a.txt"), ItemId::id("folder"))?;
107//!
108//! let ids = manager.get_ids_from_shared_id(ItemId::id("a.txt"))?;
109//! assert_eq!(ids.len(), 2);
110//! assert_eq!(ids[0].get_index(), 0);
111//! assert_eq!(ids[1].get_index(), 1);
112//! Ok(())
113//! }
114//! ```
115
116use std::{
117 collections::{HashMap, HashSet},
118 env::{current_dir, current_exe},
119 ffi::OsStr,
120 fs::{self, File, create_dir, remove_dir, remove_dir_all, remove_file},
121 hash::Hash,
122 io::{self, Write},
123 path::{Path, PathBuf},
124 time::{SystemTime, UNIX_EPOCH},
125};
126use thiserror::Error;
127
128// Constants
129const ZERO: u64 = 0;
130const THOUSAND: u64 = 1_000;
131const MILLION: u64 = 1_000_000;
132const BILLION: u64 = 1_000_000_000;
133const TRILLION: u64 = 1_000_000_000_000;
134const QUADRILLION: u64 = 1_000_000_000_000_000;
135
136// -------- Enums --------
137#[derive(Debug, Error)]
138/// Errors returned by this library.
139pub enum DatabaseError {
140 /// Returned when requested path-step trimming exceeds the available path depth.
141 #[error("Steps '{0}' greater than path length '{1}'")]
142 PathStepOverflow(i32, i32),
143 /// Returned when no matching directory name can be found while walking upward.
144 #[error("Directory '{0}' not found along path to executable")]
145 NoClosestDir(String),
146 /// Returned when an `ItemId` name has no tracked entries in the index.
147 #[error("ID '{0}' doesn't point to a known path")]
148 NoMatchingID(String),
149 /// Returned when creating or renaming to an ID that already exists at the target path.
150 #[error("ID '{0}' already exists")]
151 IdAlreadyExists(String),
152 /// Returned when source and destination resolve to the same filesystem path.
153 #[error("Source and destination are identical: '{0}'")]
154 IdenticalSourceDestination(PathBuf),
155 /// Returned when an export destination points inside the managed database root.
156 #[error("Export destination is inside the database: '{0}'")]
157 ExportDestinationInsideDatabase(PathBuf),
158 /// Returned when an import source path points inside the managed database root.
159 #[error("Import source is inside the database: '{0}'")]
160 ImportSourceInsideDatabase(PathBuf),
161 /// Returned when the requested `index` is outside the bounds of the ID match list.
162 #[error("Index {index} out of bounds for ID '{id}' (len: {len})")]
163 IndexOutOfBounds {
164 id: String,
165 index: usize,
166 len: usize,
167 },
168 /// Returned when an operation does not allow `ItemId::database_id()` as input.
169 #[error("Root database ID cannot be used for this operation")]
170 RootIdUnsupported,
171 /// Returned when a path was expected to be a directory but is not.
172 #[error("Path '{0}' doesn't point to a directory")]
173 NotADirectory(PathBuf),
174 /// Returned when a path was expected to be a file but is not.
175 #[error("Path '{0}' doesn't point to a file")]
176 NotAFile(PathBuf),
177 /// Returned when converting an OS string/path segment into UTF-8 text fails.
178 #[error("Couldn't convert OsString to String")]
179 OsStringConversion,
180 /// Returned when an item has no parent inside the tracked database tree.
181 #[error("ID '{0}' doesn't have a parent")]
182 NoParent(String),
183 /// Returned when an underlying filesystem I/O operation fails.
184 #[error(transparent)]
185 Io(#[from] std::io::Error),
186 /// Returned when JSON serialization or deserialization fails.
187 #[error(transparent)]
188 SerdeJson(#[from] serde_json::Error),
189 /// Returned when bincode serialization or deserialization fails.
190 #[error(transparent)]
191 Bincode(#[from] bincode::Error),
192 /// Returned when converting an absolute path into a database-relative path fails.
193 #[error(transparent)]
194 PathBufConversion(#[from] std::path::StripPrefixError),
195}
196
197#[derive(Debug, PartialEq, Clone, Default)]
198/// Controls whether directory deletion is forced.
199pub enum ForceDeletion {
200 #[default]
201 Force,
202 NoForce,
203}
204
205impl Into<bool> for ForceDeletion {
206 /// Converts **`ForceDeletion`** into its boolean form.
207 fn into(self) -> bool {
208 match self {
209 ForceDeletion::Force => true,
210 ForceDeletion::NoForce => false,
211 }
212 }
213}
214
215impl From<bool> for ForceDeletion {
216 /// Converts a boolean into **`ForceDeletion`**.
217 fn from(value: bool) -> Self {
218 match value {
219 true => ForceDeletion::Force,
220 false => ForceDeletion::NoForce,
221 }
222 }
223}
224
225#[derive(Debug, PartialEq, Clone, Default)]
226/// Controls whether list results are sorted.
227pub enum ShouldSort {
228 #[default]
229 Sort,
230 NoSort,
231}
232
233impl Into<bool> for ShouldSort {
234 /// Converts **`ShouldSort`** into its boolean form.
235 fn into(self) -> bool {
236 match self {
237 ShouldSort::Sort => true,
238 ShouldSort::NoSort => false,
239 }
240 }
241}
242
243impl From<bool> for ShouldSort {
244 /// Converts a boolean into **`ShouldSort`**.
245 fn from(value: bool) -> Self {
246 match value {
247 true => ShouldSort::Sort,
248 false => ShouldSort::NoSort,
249 }
250 }
251}
252
253#[derive(Debug, PartialEq, Clone, Default)]
254/// Controls whether APIs should serialize values.
255pub enum Serialize {
256 #[default]
257 Serialize,
258 NoSerialize,
259}
260
261impl Into<bool> for Serialize {
262 /// Converts **`Serialize`** into its boolean form.
263 fn into(self) -> bool {
264 match self {
265 Serialize::Serialize => true,
266 Serialize::NoSerialize => false,
267 }
268 }
269}
270
271impl From<bool> for Serialize {
272 /// Converts a boolean into **`Serialize`**.
273 fn from(value: bool) -> Self {
274 match value {
275 true => Serialize::Serialize,
276 false => Serialize::NoSerialize,
277 }
278 }
279}
280
281#[derive(Debug, PartialEq, Clone, Default)]
282/// Controls whether export copies or moves the source.
283pub enum ExportMode {
284 #[default]
285 Copy,
286 Move,
287}
288
289#[derive(Debug, PartialEq, Clone, Default)]
290/// Controls how `scan_for_changes` handles newly found files.
291pub enum ScanPolicy {
292 DetectOnly,
293 RemoveNew,
294 #[default]
295 AddNew,
296}
297
298#[derive(Debug, Default, PartialEq, PartialOrd, Eq, Ord, Clone, Copy)]
299/// Units used by **`FileSize`**.
300pub enum FileSizeUnit {
301 #[default]
302 Byte,
303 Kilobyte,
304 Megabyte,
305 Gigabyte,
306 Terabyte,
307 Petabyte,
308}
309
310impl FileSizeUnit {
311 /// Internal numeric rank used for unit conversion math.
312 fn variant_integer_id(&self) -> u8 {
313 match self {
314 Self::Byte => 0,
315 Self::Kilobyte => 1,
316 Self::Megabyte => 2,
317 Self::Gigabyte => 3,
318 Self::Terabyte => 4,
319 Self::Petabyte => 5,
320 }
321 }
322}
323
324// -------- Structs --------
325#[derive(PartialEq, Debug, Clone, Default)]
326/// Helper for building paths from the current process location.
327pub struct GenPath;
328
329impl GenPath {
330 /// Returns the working directory, with `steps` parts removed from the end.
331 ///
332 /// # Parameters
333 /// - `steps`: number of path parts at the end to remove.
334 ///
335 /// # Errors
336 /// Returns an error if:
337 /// - the current directory cannot be read,
338 /// - `steps` is greater than or equal to the number of removable segments.
339 ///
340 /// # Examples
341 /// ```no_run
342 /// use file_database::{DatabaseError, GenPath};
343 ///
344 /// fn main() -> Result<(), DatabaseError> {
345 /// let _cwd = GenPath::from_working_dir(0)?;
346 /// Ok(())
347 /// }
348 /// ```
349 pub fn from_working_dir(steps: i32) -> Result<PathBuf, DatabaseError> {
350 let working_dir = truncate(current_dir()?, steps)?;
351
352 Ok(working_dir)
353 }
354
355 /// Returns the executable directory, with `steps` parts removed from the end.
356 ///
357 /// # Parameters
358 /// - `steps`: number of path parts at the end to remove from the executable directory.
359 ///
360 /// # Errors
361 /// Returns an error if:
362 /// - the executable path cannot be read,
363 /// - `steps` is too large for the path depth.
364 ///
365 /// # Examples
366 /// ```no_run
367 /// use file_database::{DatabaseError, GenPath};
368 ///
369 /// fn main() -> Result<(), DatabaseError> {
370 /// let _exe_dir = GenPath::from_exe(0)?;
371 /// Ok(())
372 /// }
373 /// ```
374 pub fn from_exe(steps: i32) -> Result<PathBuf, DatabaseError> {
375 let exe = truncate(current_exe()?, steps + 1)?;
376
377 Ok(exe)
378 }
379
380 /// Looks for the nearest matching folder name while walking up from the executable.
381 ///
382 /// At each level, this checks:
383 /// - the folder name itself,
384 /// - child folders one level down.
385 ///
386 /// File entries are ignored.
387 ///
388 /// # Parameters
389 /// - `name`: directory name to search for.
390 ///
391 /// # Errors
392 /// Returns an error if:
393 /// - no matching directory is found,
394 /// - the provided name cannot be converted from `OsStr` to `String`.
395 ///
396 /// # Examples
397 /// ```no_run
398 /// use file_database::{DatabaseError, GenPath};
399 ///
400 /// fn main() -> Result<(), DatabaseError> {
401 /// let _path = GenPath::from_closest_match("src")?;
402 /// Ok(())
403 /// }
404 /// ```
405 pub fn from_closest_match(name: impl AsRef<Path>) -> Result<PathBuf, DatabaseError> {
406 let exe = current_exe()?;
407 let target = name.as_ref();
408 let target_name = target.as_os_str();
409
410 for path in exe.ancestors() {
411 if !path.is_dir() {
412 continue;
413 }
414
415 if path
416 .file_name()
417 .is_some_and(|dir_name| dir_name == target_name)
418 {
419 return Ok(path.to_path_buf());
420 }
421
422 if let Ok(entries) = fs::read_dir(path) {
423 for entry in entries {
424 let entry = match entry {
425 Ok(entry) => entry,
426 Err(_) => continue,
427 };
428
429 let child_path = entry.path();
430 if !child_path.is_dir() {
431 continue;
432 }
433
434 if entry.file_name() == target_name {
435 return Ok(child_path);
436 }
437 }
438 }
439 }
440
441 let name_as_string = match target.to_owned().into_os_string().into_string() {
442 Ok(string) => string,
443 Err(_) => return Err(DatabaseError::OsStringConversion),
444 };
445
446 Err(DatabaseError::NoClosestDir(name_as_string))
447 }
448}
449
450#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
451/// Identifier used to select a tracked item by `name` and `index`.
452///
453/// Use this when:
454/// - you know the shared `name` and want the first match (`ItemId::id("name")`),
455/// - or when you need a specific duplicate (`ItemId::with_index("name", i)`).
456///
457/// `ItemId::database_id()` is special and points to the database root itself.
458///
459/// # Examples
460/// ```
461/// use file_database::ItemId;
462///
463/// let first = ItemId::id("report.txt");
464/// let second = ItemId::with_index("report.txt", 1);
465/// let root = ItemId::database_id();
466///
467/// assert_eq!(first.get_name(), "report.txt");
468/// assert_eq!(first.get_index(), 0);
469/// assert_eq!(second.get_index(), 1);
470/// assert_eq!(root.get_name(), "");
471/// ```
472pub struct ItemId {
473 name: String,
474 index: usize,
475}
476
477impl<T> From<T> for ItemId
478where
479 T: Into<String>,
480{
481 /// Creates an **`ItemId`** from a name, defaulting `index` to `0`.
482 fn from(value: T) -> Self {
483 Self {
484 name: value.into(),
485 index: 0,
486 }
487 }
488}
489
490impl From<&ItemId> for ItemId {
491 /// Clones an **`ItemId`** from a reference.
492 fn from(value: &ItemId) -> Self {
493 value.clone()
494 }
495}
496
497impl ItemId {
498 /// Returns the `ItemId::database_id()` for the database itself.
499 ///
500 /// # Examples
501 /// ```
502 /// use file_database::ItemId;
503 ///
504 /// let root = ItemId::database_id();
505 /// assert_eq!(root.get_name(), "");
506 /// assert_eq!(root.get_index(), 0);
507 /// ```
508 pub fn database_id() -> Self {
509 Self {
510 name: String::new(),
511 index: 0,
512 }
513 }
514
515 /// Creates an **`ItemId`** with `index` `0`.
516 ///
517 /// # Parameters
518 /// - `id`: shared `name` key stored in the manager.
519 ///
520 /// # Examples
521 /// ```
522 /// use file_database::ItemId;
523 ///
524 /// let id = ItemId::id("file.txt");
525 /// assert_eq!(id.get_name(), "file.txt");
526 /// assert_eq!(id.get_index(), 0);
527 /// ```
528 pub fn id(id: impl Into<String>) -> Self {
529 Self {
530 name: id.into(),
531 index: 0,
532 }
533 }
534
535 /// Creates an **`ItemId`** with an explicit shared-name `index`.
536 ///
537 /// # Parameters
538 /// - `id`: shared `name` key.
539 /// - `index`: zero-based `index` within that key's stored path vector.
540 ///
541 /// # Examples
542 /// ```
543 /// use file_database::ItemId;
544 ///
545 /// let id = ItemId::with_index("file.txt", 2);
546 /// assert_eq!(id.get_name(), "file.txt");
547 /// assert_eq!(id.get_index(), 2);
548 /// ```
549 pub fn with_index(id: impl Into<String>, index: usize) -> Self {
550 Self {
551 name: id.into(),
552 index,
553 }
554 }
555
556 /// Returns the shared `name` of this **`ItemId`**.
557 pub fn get_name(&self) -> &str {
558 &self.name
559 }
560
561 /// Returns the zero-based `index` for this shared `name`.
562 pub fn get_index(&self) -> usize {
563 self.index
564 }
565
566 /// Returns the shared `name` as `&str`.
567 pub fn as_str(&self) -> &str {
568 self.get_name()
569 }
570
571 /// Returns an owned `String` containing this **`ItemId`**'s shared `name`.
572 pub fn as_string(&self) -> String {
573 self.name.clone()
574 }
575}
576
577#[derive(Debug, Default, PartialEq, PartialOrd, Clone, Copy)]
578/// File size value paired with a unit.
579pub struct FileSize {
580 size: u64,
581 unit: FileSizeUnit,
582}
583
584impl FileSize {
585 /// Returns the stored size value in the current unit.
586 pub fn get_size(&self) -> u64 {
587 self.size
588 }
589
590 /// Returns the stored unit.
591 pub fn get_unit(&self) -> FileSizeUnit {
592 self.unit
593 }
594
595 /// Returns a human-readable unit string, pluralized when needed.
596 ///
597 /// # Examples
598 /// ```
599 /// use file_database::FileSize;
600 ///
601 /// let size = FileSize::default();
602 /// assert_eq!(size.unit_as_string(), "Bytes");
603 /// ```
604 pub fn unit_as_string(&self) -> String {
605 let name = match self.unit {
606 FileSizeUnit::Byte => "Byte",
607 FileSizeUnit::Kilobyte => "Kilobyte",
608 FileSizeUnit::Megabyte => "Megabyte",
609 FileSizeUnit::Gigabyte => "Gigabyte",
610 FileSizeUnit::Terabyte => "Terabyte",
611 FileSizeUnit::Petabyte => "Petabyte",
612 };
613
614 let mut name_string = String::from(name);
615
616 // Push an s to the end of the string if not 1
617 match self.size {
618 1 => (),
619 _ => name_string.push('s'),
620 }
621
622 name_string
623 }
624
625 /// Returns a copy of this size converted to another unit.
626 ///
627 /// Conversion uses powers of 1000 between adjacent units.
628 ///
629 /// # Parameters
630 /// - `unit`: destination unit.
631 ///
632 /// # Examples
633 /// ```
634 /// use file_database::{FileSize, FileSizeUnit};
635 ///
636 /// let bytes = FileSize::default().as_unit(FileSizeUnit::Byte);
637 /// assert_eq!(bytes.get_unit(), FileSizeUnit::Byte);
638 /// ```
639 pub fn as_unit(&self, unit: FileSizeUnit) -> Self {
640 let difference = self.unit.variant_integer_id() as i8 - unit.variant_integer_id() as i8;
641
642 let mut size = self.size;
643
644 if difference > 0 {
645 let factor = THOUSAND.pow(difference as u32);
646 size = size.saturating_mul(factor);
647 } else if difference < 0 {
648 let factor = THOUSAND.pow((-difference) as u32);
649 size /= factor;
650 }
651
652 Self { size, unit }
653 }
654
655 /// Builds **`FileSize`** from raw bytes using automatic unit selection.
656 fn from(bytes: u64) -> Self {
657 let (size, unit) = match bytes {
658 ZERO..THOUSAND => (bytes, FileSizeUnit::Byte),
659 THOUSAND..MILLION => (bytes / THOUSAND, FileSizeUnit::Kilobyte),
660 MILLION..BILLION => (bytes / MILLION, FileSizeUnit::Megabyte),
661 BILLION..TRILLION => (bytes / BILLION, FileSizeUnit::Gigabyte),
662 TRILLION..QUADRILLION => (bytes / TRILLION, FileSizeUnit::Terabyte),
663 _ => (bytes / QUADRILLION, FileSizeUnit::Petabyte),
664 };
665
666 Self { size, unit }
667 }
668}
669
670#[derive(Debug, Default, PartialEq, PartialOrd, Clone)]
671/// Metadata returned by `get_file_information`.
672pub struct FileInformation {
673 name: Option<String>,
674 extension: Option<String>,
675 size: FileSize,
676 unix_created: Option<u64>,
677 time_since_created: Option<u64>,
678 unix_last_opened: Option<u64>,
679 time_since_last_opened: Option<u64>,
680 unix_last_modified: Option<u64>,
681 time_since_last_modified: Option<u64>,
682}
683
684impl FileInformation {
685 /// Returns file `name` without extension, or directory `name` for directories.
686 pub fn get_name(&self) -> Option<&str> {
687 self.name.as_deref()
688 }
689
690 /// Returns file extension for files, otherwise `None`.
691 pub fn get_extension(&self) -> Option<&str> {
692 self.extension.as_deref()
693 }
694
695 /// Returns normalized file size data.
696 pub fn get_size(&self) -> &FileSize {
697 &self.size
698 }
699
700 /// Returns created-at Unix timestamp (seconds), when available on this platform.
701 pub fn get_unix_created(&self) -> Option<&u64> {
702 self.unix_created.as_ref()
703 }
704
705 /// Returns age since creation in seconds, when available.
706 pub fn get_time_since_created(&self) -> Option<&u64> {
707 self.time_since_created.as_ref()
708 }
709
710 /// Returns last-accessed Unix timestamp (seconds), when available.
711 pub fn get_unix_last_opened(&self) -> Option<&u64> {
712 self.unix_last_opened.as_ref()
713 }
714
715 /// Returns age since last access in seconds, when available.
716 pub fn get_time_since_last_opened(&self) -> Option<&u64> {
717 self.time_since_last_opened.as_ref()
718 }
719
720 /// Returns last-modified Unix timestamp (seconds), when available.
721 pub fn get_unix_last_modified(&self) -> Option<&u64> {
722 self.unix_last_modified.as_ref()
723 }
724
725 /// Returns age since last modification in seconds, when available.
726 pub fn get_time_since_last_modified(&self) -> Option<&u64> {
727 self.time_since_last_modified.as_ref()
728 }
729}
730
731#[derive(Debug, PartialEq, Clone)]
732/// A file or folder change found by `scan_for_changes`.
733pub enum ExternalChange {
734 Added { id: ItemId, path: PathBuf },
735 Removed { id: ItemId, path: PathBuf },
736}
737
738#[derive(Debug, PartialEq, Clone)]
739/// Summary returned by `scan_for_changes`.
740pub struct ScanReport {
741 scanned_from: ItemId,
742 recursive: bool,
743 added: Vec<ExternalChange>,
744 removed: Vec<ExternalChange>,
745 unchanged_count: usize,
746 total_changed_count: usize,
747}
748
749impl ScanReport {
750 /// Returns the **`ItemId`** used as the scan root.
751 pub fn get_scan_from(&self) -> &ItemId {
752 &self.scanned_from
753 }
754
755 /// Returns all newly discovered items in the scanned scope.
756 pub fn get_added(&self) -> &Vec<ExternalChange> {
757 &self.added
758 }
759
760 /// Returns tracked **`ItemId`** values that were missing on disk.
761 pub fn get_removed(&self) -> &Vec<ExternalChange> {
762 &self.removed
763 }
764
765 /// Returns how many tracked **`ItemId`** values stayed the same in this scan area.
766 pub fn get_unchanged_count(&self) -> usize {
767 self.unchanged_count
768 }
769
770 /// Returns total number of changed items (`added + removed`).
771 pub fn get_total_changed_count(&self) -> usize {
772 self.total_changed_count
773 }
774}
775
776#[derive(Debug, PartialEq)]
777/// Main type that manages a database directory and its index.
778pub struct DatabaseManager {
779 path: PathBuf,
780 items: HashMap<String, Vec<PathBuf>>,
781}
782
783impl DatabaseManager {
784 /// Creates a new database directory and returns a manager for it.
785 ///
786 /// # Parameters
787 /// - `path`: parent directory where the database folder will be created.
788 /// - `name`: database directory name appended to `path`.
789 ///
790 /// # Errors
791 /// Returns an error if:
792 /// - the destination directory already exists,
793 /// - parent directories are missing,
794 /// - the process cannot create directories at the destination.
795 ///
796 /// # Examples
797 /// ```no_run
798 /// use file_database::{DatabaseError, DatabaseManager};
799 ///
800 /// fn main() -> Result<(), DatabaseError> {
801 /// let _manager = DatabaseManager::new(".", "database")?;
802 /// Ok(())
803 /// }
804 /// ```
805 pub fn new(path: impl AsRef<Path>, name: impl AsRef<Path>) -> Result<Self, DatabaseError> {
806 let mut path: PathBuf = path.as_ref().to_path_buf();
807
808 path.push(name);
809
810 create_dir(&path)?;
811
812 let manager = Self {
813 path: path.into(),
814 items: HashMap::new(),
815 };
816
817 Ok(manager)
818 }
819
820 /// Creates a new file or directory under `parent`.
821 ///
822 /// Name interpretation is extension-based:
823 /// - if `id.name` has an extension, a file is created,
824 /// - otherwise, a directory is created.
825 ///
826 /// # Parameters
827 /// - `id`: name key for the new item. Root **`ItemId`** is not allowed.
828 /// - `parent`: destination parent item. Use `ItemId::database_id()` for database root.
829 ///
830 /// # Errors
831 /// Returns an error if:
832 /// - `id` is the `ItemId::database_id()`,
833 /// - `parent` cannot be found,
834 /// - another item already exists at the target relative path,
835 /// - filesystem create operations fail.
836 ///
837 /// # Examples
838 /// ```no_run
839 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
840 ///
841 /// fn main() -> Result<(), DatabaseError> {
842 /// let mut manager = DatabaseManager::new(".", "database")?;
843 /// manager.write_new(ItemId::id("notes.txt"), ItemId::database_id())?;
844 /// Ok(())
845 /// }
846 /// ```
847 pub fn write_new(
848 &mut self,
849 id: impl Into<ItemId>,
850 parent: impl Into<ItemId>,
851 ) -> Result<(), DatabaseError> {
852 let id = id.into();
853 let parent = parent.into();
854
855 if id.get_name().is_empty() {
856 return Err(DatabaseError::RootIdUnsupported);
857 }
858
859 let absolute_parent_path = self.locate_absolute(&parent)?;
860 let relative_path = if parent.get_name().is_empty() {
861 PathBuf::from(id.get_name())
862 } else {
863 let mut path = self.locate_relative(parent)?.to_path_buf();
864 path.push(id.get_name());
865 path
866 };
867 let absolute_path = absolute_parent_path.join(id.get_name());
868
869 if self
870 .items
871 .get(id.get_name())
872 .is_some_and(|paths| paths.iter().any(|path| path == &relative_path))
873 {
874 return Err(DatabaseError::IdAlreadyExists(id.as_string()));
875 }
876
877 if relative_path.extension().is_none() {
878 create_dir(&absolute_path)?;
879 } else {
880 File::create_new(&absolute_path)?;
881 }
882
883 self.items
884 .entry(id.get_name().to_string())
885 .or_default()
886 .push(relative_path);
887 Ok(())
888 }
889
890 /// Overwrites an existing file with raw bytes in a safe way.
891 ///
892 /// It writes to a temp file first, then replaces the target file.
893 ///
894 /// # Parameters
895 /// - `id`: target file **`ItemId`**.
896 /// - `data`: raw bytes to write.
897 ///
898 /// # Errors
899 /// Returns an error if:
900 /// - `id` cannot be found,
901 /// - `id` points to a directory,
902 /// - writing, syncing, or renaming fails.
903 ///
904 /// # Examples
905 /// ```no_run
906 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
907 ///
908 /// fn main() -> Result<(), DatabaseError> {
909 /// let mut manager = DatabaseManager::new(".", "database")?;
910 /// manager.write_new(ItemId::id("blob.bin"), ItemId::database_id())?;
911 /// manager.overwrite_existing(ItemId::id("blob.bin"), [1_u8, 2, 3, 4])?;
912 /// Ok(())
913 /// }
914 /// ```
915 pub fn overwrite_existing<T>(&self, id: impl Into<ItemId>, data: T) -> Result<(), DatabaseError>
916 where
917 T: AsRef<[u8]>,
918 {
919 let id = id.into();
920 let bytes = data.as_ref();
921
922 let path = self.locate_absolute(id)?;
923
924 self.overwrite_path_atomic_with(&path, |file| {
925 file.write_all(bytes)?;
926 Ok(bytes.len() as u64)
927 })?;
928
929 Ok(())
930 }
931
932 /// Converts `value` to JSON and overwrites the target file.
933 ///
934 /// # Parameters
935 /// - `id`: target file **`ItemId`**.
936 /// - `value`: serializable value.
937 ///
938 /// # Errors
939 /// Returns an error if:
940 /// - JSON serialization fails,
941 /// - finding `id` or overwriting the file fails.
942 ///
943 /// # Examples
944 /// ```no_run
945 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
946 /// use serde::Serialize;
947 ///
948 /// #[derive(Serialize)]
949 /// struct Config {
950 /// retries: u8,
951 /// }
952 ///
953 /// fn main() -> Result<(), DatabaseError> {
954 /// let mut manager = DatabaseManager::new(".", "database")?;
955 /// manager.write_new(ItemId::id("config.json"), ItemId::database_id())?;
956 /// manager.overwrite_existing_json(ItemId::id("config.json"), &Config { retries: 3 })?;
957 /// Ok(())
958 /// }
959 /// ```
960 pub fn overwrite_existing_json<T: serde::Serialize>(
961 &self,
962 id: impl Into<ItemId>,
963 value: &T,
964 ) -> Result<(), DatabaseError> {
965 let data = serde_json::to_vec(value)?;
966 self.overwrite_existing(id, data)
967 }
968
969 /// Converts `value` to bincode and overwrites the target file.
970 ///
971 /// # Parameters
972 /// - `id`: target file **`ItemId`**.
973 /// - `value`: serializable value.
974 ///
975 /// # Errors
976 /// Returns an error if:
977 /// - bincode serialization fails,
978 /// - finding `id` or overwriting the file fails.
979 ///
980 /// # Examples
981 /// ```no_run
982 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
983 /// use serde::Serialize;
984 ///
985 /// #[derive(Serialize)]
986 /// enum State {
987 /// Ready,
988 /// }
989 ///
990 /// fn main() -> Result<(), DatabaseError> {
991 /// let mut manager = DatabaseManager::new(".", "database")?;
992 /// manager.write_new(ItemId::id("state.bin"), ItemId::database_id())?;
993 /// manager.overwrite_existing_binary(ItemId::id("state.bin"), &State::Ready)?;
994 /// Ok(())
995 /// }
996 /// ```
997 pub fn overwrite_existing_binary<T: serde::Serialize>(
998 &self,
999 id: impl Into<ItemId>,
1000 value: &T,
1001 ) -> Result<(), DatabaseError> {
1002 let data = bincode::serialize(value)?;
1003 self.overwrite_existing(id, data)
1004 }
1005
1006 /// Streams bytes from `reader` into the target file and returns bytes written.
1007 ///
1008 /// This uses chunked I/O and a safe replace step, so it works well for large payloads.
1009 ///
1010 /// # Parameters
1011 /// - `id`: target file **`ItemId`**.
1012 /// - `reader`: source stream consumed until EOF.
1013 ///
1014 /// # Errors
1015 /// Returns an error if:
1016 /// - `id` cannot be found,
1017 /// - target is not a file,
1018 /// - stream read/write/sync/rename fails.
1019 ///
1020 /// # Examples
1021 /// ```no_run
1022 /// use std::io::Cursor;
1023 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1024 ///
1025 /// fn main() -> Result<(), DatabaseError> {
1026 /// let mut manager = DatabaseManager::new(".", "database")?;
1027 /// manager.write_new(ItemId::id("stream.bin"), ItemId::database_id())?;
1028 /// let mut source = Cursor::new(vec![9_u8; 1024]);
1029 /// let _bytes = manager.overwrite_existing_from_reader(ItemId::id("stream.bin"), &mut source)?;
1030 /// Ok(())
1031 /// }
1032 /// ```
1033 pub fn overwrite_existing_from_reader<R: io::Read>(
1034 &self,
1035 id: impl Into<ItemId>,
1036 reader: &mut R,
1037 ) -> Result<u64, DatabaseError> {
1038 let id = id.into();
1039 let path = self.locate_absolute(id)?;
1040 self.overwrite_path_atomic_with(&path, |file| Ok(io::copy(reader, file)?))
1041 }
1042
1043 /// Reads a managed file and returns its raw bytes.
1044 ///
1045 /// # Parameters
1046 /// - `id`: target file **`ItemId`**.
1047 ///
1048 /// # Errors
1049 /// Returns an error if:
1050 /// - `id` cannot be found,
1051 /// - `id` points to a directory,
1052 /// - file reading fails.
1053 ///
1054 /// # Examples
1055 /// ```no_run
1056 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1057 ///
1058 /// fn main() -> Result<(), DatabaseError> {
1059 /// let mut manager = DatabaseManager::new(".", "database")?;
1060 /// manager.write_new(ItemId::id("data.bin"), ItemId::database_id())?;
1061 /// manager.overwrite_existing(ItemId::id("data.bin"), [1_u8, 2, 3])?;
1062 /// let _data = manager.read_existing(ItemId::id("data.bin"))?;
1063 /// Ok(())
1064 /// }
1065 /// ```
1066 pub fn read_existing(&self, id: impl Into<ItemId>) -> Result<Vec<u8>, DatabaseError> {
1067 let id = id.into();
1068 let path = self.locate_absolute(id)?;
1069
1070 if path.is_dir() {
1071 return Err(DatabaseError::NotAFile(path));
1072 }
1073
1074 Ok(fs::read(path)?)
1075 }
1076
1077 /// Reads a managed file and turns JSON into `T`.
1078 ///
1079 /// # Parameters
1080 /// - `id`: target file **`ItemId`**.
1081 ///
1082 /// # Errors
1083 /// Returns an error if:
1084 /// - finding `id` or reading the file fails,
1085 /// - JSON deserialization fails.
1086 ///
1087 /// # Examples
1088 /// ```no_run
1089 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1090 /// use serde::{Deserialize, Serialize};
1091 ///
1092 /// #[derive(Serialize, Deserialize)]
1093 /// struct Config {
1094 /// retries: u8,
1095 /// }
1096 ///
1097 /// fn main() -> Result<(), DatabaseError> {
1098 /// let mut manager = DatabaseManager::new(".", "database")?;
1099 /// manager.write_new(ItemId::id("config.json"), ItemId::database_id())?;
1100 /// manager.overwrite_existing_json(ItemId::id("config.json"), &Config { retries: 3 })?;
1101 /// let _loaded: Config = manager.read_existing_json(ItemId::id("config.json"))?;
1102 /// Ok(())
1103 /// }
1104 /// ```
1105 pub fn read_existing_json<T: serde::de::DeserializeOwned>(
1106 &self,
1107 id: impl Into<ItemId>,
1108 ) -> Result<T, DatabaseError> {
1109 let bytes = self.read_existing(id)?;
1110 Ok(serde_json::from_slice(&bytes)?)
1111 }
1112
1113 /// Reads a managed file and turns bincode into `T`.
1114 ///
1115 /// # Parameters
1116 /// - `id`: target file **`ItemId`**.
1117 ///
1118 /// # Errors
1119 /// Returns an error if:
1120 /// - finding `id` or reading the file fails,
1121 /// - bincode deserialization fails.
1122 ///
1123 /// # Examples
1124 /// ```no_run
1125 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1126 /// use serde::{Deserialize, Serialize};
1127 ///
1128 /// #[derive(Serialize, Deserialize)]
1129 /// enum State {
1130 /// Ready,
1131 /// }
1132 ///
1133 /// fn main() -> Result<(), DatabaseError> {
1134 /// let mut manager = DatabaseManager::new(".", "database")?;
1135 /// manager.write_new(ItemId::id("state.bin"), ItemId::database_id())?;
1136 /// manager.overwrite_existing_binary(ItemId::id("state.bin"), &State::Ready)?;
1137 /// let _loaded: State = manager.read_existing_binary(ItemId::id("state.bin"))?;
1138 /// Ok(())
1139 /// }
1140 /// ```
1141 pub fn read_existing_binary<T: serde::de::DeserializeOwned>(
1142 &self,
1143 id: impl Into<ItemId>,
1144 ) -> Result<T, DatabaseError> {
1145 let bytes = self.read_existing(id)?;
1146 Ok(bincode::deserialize(&bytes)?)
1147 }
1148
1149 /// Returns every tracked item in the database.
1150 ///
1151 /// # Parameters
1152 /// - `sorted`: whether output should be sorted by **`ItemId`** ordering.
1153 ///
1154 /// # Examples
1155 /// ```no_run
1156 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1157 ///
1158 /// fn main() -> Result<(), DatabaseError> {
1159 /// let mut manager = DatabaseManager::new(".", "database")?;
1160 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1161 /// let _all = manager.get_all(true);
1162 /// Ok(())
1163 /// }
1164 /// ```
1165 pub fn get_all(&self, sorted: impl Into<bool>) -> Vec<ItemId> {
1166 let sorted = sorted.into();
1167
1168 let mut list: Vec<ItemId> = self
1169 .items
1170 .iter()
1171 .flat_map(|(name, paths)| {
1172 paths
1173 .iter()
1174 .enumerate()
1175 .map(|(index, _)| ItemId::with_index(name.clone(), index))
1176 })
1177 .collect();
1178
1179 if sorted {
1180 list.sort();
1181 }
1182
1183 list
1184 }
1185
1186 /// Returns all tracked items that are direct children of `parent`.
1187 ///
1188 /// If `parent` is the `ItemId::database_id()`, this returns all top-level items.
1189 ///
1190 /// # Parameters
1191 /// - `parent`: parent directory item to query.
1192 /// - `sorted`: whether output should be sorted by **`ItemId`**.
1193 ///
1194 /// # Errors
1195 /// Returns an error if:
1196 /// - `parent` cannot be found,
1197 /// - `parent` points to a file instead of a directory.
1198 ///
1199 /// # Examples
1200 /// ```no_run
1201 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1202 ///
1203 /// fn main() -> Result<(), DatabaseError> {
1204 /// let mut manager = DatabaseManager::new(".", "database")?;
1205 /// manager.write_new(ItemId::id("folder"), ItemId::database_id())?;
1206 /// manager.write_new(ItemId::id("a.txt"), ItemId::id("folder"))?;
1207 /// let _children = manager.get_by_parent(ItemId::id("folder"), true)?;
1208 /// Ok(())
1209 /// }
1210 /// ```
1211 pub fn get_by_parent(
1212 &self,
1213 parent: impl Into<ItemId>,
1214 sorted: impl Into<bool>,
1215 ) -> Result<Vec<ItemId>, DatabaseError> {
1216 let parent = parent.into();
1217 let sorted = sorted.into();
1218
1219 let absolute_parent = self.locate_absolute(&parent)?;
1220
1221 if !absolute_parent.is_dir() {
1222 return Err(DatabaseError::NotADirectory(absolute_parent));
1223 }
1224
1225 let mut list: Vec<ItemId> = if parent.get_name().is_empty() {
1226 self.items
1227 .iter()
1228 .flat_map(|(name, paths)| {
1229 paths.iter().enumerate().filter_map(|(index, item_path)| {
1230 item_path
1231 .parent()
1232 .is_some_and(|parent| parent.as_os_str().is_empty())
1233 .then_some(ItemId::with_index(name.clone(), index))
1234 })
1235 })
1236 .collect()
1237 } else {
1238 let parent_path = self.locate_relative(parent)?;
1239 self.items
1240 .iter()
1241 .flat_map(|(name, paths)| {
1242 paths.iter().enumerate().filter_map(|(index, item_path)| {
1243 (item_path.parent() == Some(parent_path.as_path()))
1244 .then_some(ItemId::with_index(name.clone(), index))
1245 })
1246 })
1247 .collect()
1248 };
1249
1250 if sorted {
1251 list.sort();
1252 }
1253
1254 Ok(list)
1255 }
1256
1257 /// Returns the parent **`ItemId`** for an item.
1258 ///
1259 /// Top-level items return [`ItemId::database_id`].
1260 ///
1261 /// # Parameters
1262 /// - `id`: item whose parent should be looked up.
1263 ///
1264 /// # Errors
1265 /// Returns an error if:
1266 /// - `id` cannot be found,
1267 /// - parent path data cannot be converted to UTF-8 string.
1268 ///
1269 /// # Examples
1270 /// ```no_run
1271 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1272 ///
1273 /// fn main() -> Result<(), DatabaseError> {
1274 /// let mut manager = DatabaseManager::new(".", "database")?;
1275 /// manager.write_new(ItemId::id("folder"), ItemId::database_id())?;
1276 /// manager.write_new(ItemId::id("a.txt"), ItemId::id("folder"))?;
1277 /// let _parent = manager.get_parent(ItemId::id("a.txt"))?;
1278 /// Ok(())
1279 /// }
1280 /// ```
1281 pub fn get_parent(&self, id: impl Into<ItemId>) -> Result<ItemId, DatabaseError> {
1282 let id = id.into();
1283 let path = self.locate_relative(&id)?;
1284
1285 let parent = match path.parent() {
1286 Some(parent) => parent,
1287 None => return Ok(ItemId::database_id()),
1288 };
1289
1290 if parent.as_os_str().is_empty() {
1291 return Ok(ItemId::database_id());
1292 }
1293
1294 match parent.file_name() {
1295 Some(name) => Ok(ItemId::id(os_str_to_string(Some(name))?)),
1296 None => Err(DatabaseError::NoParent(id.as_string())),
1297 }
1298 }
1299
1300 /// Renames the chosen item to `to` in the same parent directory.
1301 ///
1302 /// # Parameters
1303 /// - `id`: source **`ItemId`** to rename.
1304 /// - `to`: new file or directory name.
1305 ///
1306 /// # Errors
1307 /// Returns an error if:
1308 /// - `id` is the `ItemId::database_id()`,
1309 /// - `id` cannot be found,
1310 /// - `id.index` is out of range for the list of paths under this `name`,
1311 /// - destination `name` already exists at the same relative `path`,
1312 /// - underlying filesystem rename fails.
1313 ///
1314 /// # Examples
1315 /// ```no_run
1316 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1317 ///
1318 /// fn main() -> Result<(), DatabaseError> {
1319 /// let mut manager = DatabaseManager::new(".", "database")?;
1320 /// manager.write_new(ItemId::id("old.txt"), ItemId::database_id())?;
1321 /// manager.rename(ItemId::id("old.txt"), "new.txt")?;
1322 /// Ok(())
1323 /// }
1324 /// ```
1325 pub fn rename(
1326 &mut self,
1327 id: impl Into<ItemId>,
1328 to: impl AsRef<str>,
1329 ) -> Result<(), DatabaseError> {
1330 let id = id.into();
1331 let name = to.as_ref().to_owned();
1332
1333 if id.get_name().is_empty() {
1334 return Err(DatabaseError::RootIdUnsupported);
1335 }
1336
1337 let path = self.locate_absolute(&id)?;
1338 let mut relative_path = self.locate_relative(&id)?.to_path_buf();
1339
1340 let renamed_path = path.with_file_name(&name);
1341 relative_path = match relative_path.pop() {
1342 true => {
1343 relative_path.push(&name);
1344 relative_path
1345 }
1346 false => PathBuf::from(&name),
1347 };
1348
1349 if self
1350 .items
1351 .get(&name)
1352 .is_some_and(|paths| paths.iter().any(|entry| entry == &relative_path))
1353 {
1354 return Err(DatabaseError::IdAlreadyExists(name));
1355 }
1356
1357 fs::rename(&path, renamed_path)?;
1358
1359 let old_name = id.get_name().to_string();
1360 let old_paths = self
1361 .items
1362 .get_mut(&old_name)
1363 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1364
1365 if id.get_index() >= old_paths.len() {
1366 return Err(DatabaseError::IndexOutOfBounds {
1367 id: id.as_string(),
1368 index: id.get_index(),
1369 len: old_paths.len(),
1370 });
1371 }
1372
1373 old_paths.remove(id.get_index());
1374 if old_paths.is_empty() {
1375 self.items.remove(&old_name);
1376 }
1377
1378 self.items.entry(name).or_default().push(relative_path);
1379
1380 Ok(())
1381 }
1382
1383 /// Deletes a file, directory, or the whole database root.
1384 ///
1385 /// # Parameters
1386 /// - `id`: item to delete. Use `ItemId::database_id()` to target the database folder itself.
1387 /// - `force`: when deleting directories, controls recursive vs empty-only behavior.
1388 ///
1389 /// # Errors
1390 /// Returns an error if:
1391 /// - `id` cannot be found,
1392 /// - `id.index` is out of range for the list of paths under this `name`,
1393 /// - directory deletion does not match `force` rules,
1394 /// - filesystem delete operations fail.
1395 ///
1396 /// # Examples
1397 /// ```no_run
1398 /// use file_database::{DatabaseError, DatabaseManager, ForceDeletion, ItemId};
1399 ///
1400 /// fn main() -> Result<(), DatabaseError> {
1401 /// let mut manager = DatabaseManager::new(".", "database")?;
1402 /// manager.write_new(ItemId::id("tmp.txt"), ItemId::database_id())?;
1403 /// manager.delete(ItemId::id("tmp.txt"), ForceDeletion::Force)?;
1404 /// Ok(())
1405 /// }
1406 /// ```
1407 pub fn delete(
1408 &mut self,
1409 id: impl Into<ItemId>,
1410 force: impl Into<bool>,
1411 ) -> Result<(), DatabaseError> {
1412 let id = id.into();
1413
1414 if id.get_name().is_empty() {
1415 match delete_directory(&self.locate_absolute(id)?, force) {
1416 Ok(_) => {
1417 self.path = PathBuf::new();
1418 self.items.drain();
1419 return Ok(());
1420 }
1421 Err(error) => return Err(error),
1422 }
1423 }
1424
1425 let path = self.locate_absolute(&id)?;
1426
1427 if path.is_dir() {
1428 delete_directory(&path, force)?;
1429 } else {
1430 remove_file(path)?;
1431 }
1432
1433 let key = id.get_name().to_string();
1434 let paths = self
1435 .items
1436 .get_mut(&key)
1437 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1438
1439 if id.get_index() >= paths.len() {
1440 return Err(DatabaseError::IndexOutOfBounds {
1441 id: id.as_string(),
1442 index: id.get_index(),
1443 len: paths.len(),
1444 });
1445 }
1446
1447 paths.remove(id.get_index());
1448 if paths.is_empty() {
1449 self.items.remove(&key);
1450 }
1451
1452 Ok(())
1453 }
1454
1455 /// Gets the absolute file path for an **`ItemId`**.
1456 ///
1457 /// For the `ItemId::database_id()`, this returns the database directory path.
1458 ///
1459 /// # Parameters
1460 /// - `id`: **`ItemId`** to look up.
1461 ///
1462 /// # Errors
1463 /// Returns an error if:
1464 /// - `id.name` does not exist,
1465 /// - `id.index` is out of bounds.
1466 ///
1467 /// # Examples
1468 /// ```no_run
1469 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1470 ///
1471 /// fn main() -> Result<(), DatabaseError> {
1472 /// let mut manager = DatabaseManager::new(".", "database")?;
1473 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1474 /// let _path = manager.locate_absolute(ItemId::id("a.txt"))?;
1475 /// Ok(())
1476 /// }
1477 /// ```
1478 pub fn locate_absolute(&self, id: impl Into<ItemId>) -> Result<PathBuf, DatabaseError> {
1479 let id = id.into();
1480
1481 if id.get_name().is_empty() {
1482 return Ok(self.path.to_path_buf());
1483 }
1484
1485 Ok(self.path.join(self.resolve_path_by_id(&id)?))
1486 }
1487
1488 /// Gets the stored relative path reference for an **`ItemId`**.
1489 ///
1490 /// For the `ItemId::database_id()`, this currently returns a reference to the manager root path.
1491 ///
1492 /// # Parameters
1493 /// - `id`: **`ItemId`** to look up.
1494 ///
1495 /// # Errors
1496 /// Returns an error if:
1497 /// - `id.name` does not exist,
1498 /// - `id.index` is out of bounds.
1499 ///
1500 /// # Examples
1501 /// ```no_run
1502 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1503 ///
1504 /// fn main() -> Result<(), DatabaseError> {
1505 /// let mut manager = DatabaseManager::new(".", "database")?;
1506 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1507 /// let _relative = manager.locate_relative(ItemId::id("a.txt"))?;
1508 /// Ok(())
1509 /// }
1510 /// ```
1511 pub fn locate_relative(&self, id: impl Into<ItemId>) -> Result<&PathBuf, DatabaseError> {
1512 let id = id.into();
1513 if id.get_name().is_empty() {
1514 return Ok(&self.path);
1515 }
1516
1517 self.resolve_path_by_id(&id)
1518 }
1519
1520 /// Returns all stored relative paths for a shared `name`.
1521 ///
1522 /// # Parameters
1523 /// - `id`: shared-name **`ItemId`**. `index` is ignored for lookup.
1524 ///
1525 /// # Errors
1526 /// Returns an error if:
1527 /// - `id` is the `ItemId::database_id()`,
1528 /// - no entry exists for `id.name`.
1529 ///
1530 /// # Examples
1531 /// ```no_run
1532 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1533 ///
1534 /// fn main() -> Result<(), DatabaseError> {
1535 /// let mut manager = DatabaseManager::new(".", "database")?;
1536 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1537 /// let _paths = manager.get_paths_for_id(ItemId::id("a.txt"))?;
1538 /// Ok(())
1539 /// }
1540 /// ```
1541 pub fn get_paths_for_id(&self, id: impl Into<ItemId>) -> Result<&Vec<PathBuf>, DatabaseError> {
1542 let id = id.into();
1543
1544 if id.get_name().is_empty() {
1545 return Err(DatabaseError::RootIdUnsupported);
1546 }
1547
1548 self.items
1549 .get(id.get_name())
1550 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))
1551 }
1552
1553 /// Returns all specific **`ItemId`** values for a shared `name`.
1554 ///
1555 /// # Parameters
1556 /// - `id`: shared-name **`ItemId`**. `index` is ignored for lookup.
1557 ///
1558 /// # Errors
1559 /// Returns an error if:
1560 /// - `ItemId::database_id()` is provided,
1561 /// - no entry exists for `id.name`.
1562 ///
1563 /// # Examples
1564 /// ```no_run
1565 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1566 ///
1567 /// fn main() -> Result<(), DatabaseError> {
1568 /// let mut manager = DatabaseManager::new(".", "database")?;
1569 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1570 /// let _ids = manager.get_ids_from_shared_id(ItemId::id("a.txt"))?;
1571 /// Ok(())
1572 /// }
1573 /// ```
1574 pub fn get_ids_from_shared_id(
1575 &self,
1576 id: impl Into<ItemId>,
1577 ) -> Result<Vec<ItemId>, DatabaseError> {
1578 let id = id.into();
1579
1580 let paths = self.get_paths_for_id(&id)?;
1581
1582 let ids = paths
1583 .iter()
1584 .enumerate()
1585 .map(|(index, _)| ItemId::with_index(id.get_name().to_string(), index))
1586 .collect();
1587
1588 Ok(ids)
1589 }
1590
1591 /// Scans files on disk and compares them to entries in this scan area.
1592 ///
1593 /// Missing tracked items are always removed from the `items` index kept in memory.
1594 ///
1595 /// Policy behavior for newly discovered external items:
1596 /// - `DetectOnly`: report only.
1597 /// - `AddNew`: report and add to the `index`.
1598 /// - `RemoveNew`: report and delete from disk.
1599 ///
1600 /// # Parameters
1601 /// - `scan_from`: root **`ItemId`** to scan from (`ItemId::database_id()` scans the full database).
1602 /// - `policy`: change handling policy.
1603 /// - `recursive`: `true` scans full subtree, `false` scans immediate children only.
1604 ///
1605 /// # Errors
1606 /// Returns an error if:
1607 /// - `scan_from` cannot be found,
1608 /// - `scan_from` points to a file,
1609 /// - path-to-string conversion fails for discovered entries,
1610 /// - filesystem read or delete operations fail.
1611 ///
1612 /// # Examples
1613 /// ```no_run
1614 /// use file_database::{DatabaseError, DatabaseManager, ItemId, ScanPolicy};
1615 ///
1616 /// fn main() -> Result<(), DatabaseError> {
1617 /// let mut manager = DatabaseManager::new(".", "database")?;
1618 /// let _report = manager.scan_for_changes(ItemId::database_id(), ScanPolicy::AddNew, true)?;
1619 /// Ok(())
1620 /// }
1621 /// ```
1622 pub fn scan_for_changes(
1623 &mut self,
1624 scan_from: impl Into<ItemId>,
1625 policy: ScanPolicy,
1626 recursive: bool,
1627 ) -> Result<ScanReport, DatabaseError> {
1628 let scan_from = scan_from.into();
1629 let scan_from_absolute = self.locate_absolute(&scan_from)?;
1630 if !scan_from_absolute.is_dir() {
1631 return Err(DatabaseError::NotADirectory(scan_from_absolute));
1632 }
1633
1634 let scope_relative = if scan_from.get_name().is_empty() {
1635 None
1636 } else {
1637 Some(self.locate_relative(&scan_from)?.clone())
1638 };
1639
1640 let discovered_paths = self.collect_paths_in_scope(&scan_from_absolute, recursive)?;
1641 let discovered_set: HashSet<PathBuf> = discovered_paths.iter().cloned().collect();
1642
1643 let mut existing_in_scope_set = HashSet::new();
1644 let mut removed = Vec::new();
1645 let mut unchanged_count = 0usize;
1646
1647 for (name, paths) in &self.items {
1648 for (index, path) in paths.iter().enumerate() {
1649 if !is_path_in_scope(path, scope_relative.as_deref(), recursive) {
1650 continue;
1651 }
1652
1653 existing_in_scope_set.insert(path.clone());
1654
1655 if discovered_set.contains(path) {
1656 unchanged_count += 1;
1657 } else {
1658 removed.push(ExternalChange::Removed {
1659 id: ItemId::with_index(name.clone(), index),
1660 path: path.clone(),
1661 });
1662 }
1663 }
1664 }
1665
1666 let mut added_paths: Vec<PathBuf> = discovered_paths
1667 .into_iter()
1668 .filter(|path| !existing_in_scope_set.contains(path))
1669 .collect();
1670
1671 let mut added = Vec::new();
1672 let mut add_offsets: HashMap<String, usize> = HashMap::new();
1673 for path in &added_paths {
1674 let name = path
1675 .file_name()
1676 .and_then(|name| name.to_str())
1677 .ok_or(DatabaseError::OsStringConversion)?
1678 .to_string();
1679 let base_len = self.items.get(&name).map(|paths| paths.len()).unwrap_or(0);
1680 let offset = add_offsets.entry(name.clone()).or_insert(0);
1681 let index = base_len + *offset;
1682 *offset += 1;
1683
1684 added.push(ExternalChange::Added {
1685 id: ItemId::with_index(name, index),
1686 path: path.clone(),
1687 });
1688 }
1689
1690 let mut empty_keys = Vec::new();
1691 for (name, paths) in self.items.iter_mut() {
1692 paths.retain(|path| {
1693 !is_path_in_scope(path, scope_relative.as_deref(), recursive)
1694 || discovered_set.contains(path)
1695 });
1696 if paths.is_empty() {
1697 empty_keys.push(name.clone());
1698 }
1699 }
1700 for key in empty_keys {
1701 self.items.remove(&key);
1702 }
1703
1704 match policy {
1705 ScanPolicy::DetectOnly => (),
1706 ScanPolicy::AddNew => {
1707 for path in &added_paths {
1708 let name = path
1709 .file_name()
1710 .and_then(|name| name.to_str())
1711 .ok_or(DatabaseError::OsStringConversion)?
1712 .to_string();
1713 self.items.entry(name).or_default().push(path.clone());
1714 }
1715 }
1716 ScanPolicy::RemoveNew => {
1717 added_paths.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
1718 for path in added_paths {
1719 let absolute = self.path.join(&path);
1720 if !absolute.exists() {
1721 continue;
1722 }
1723
1724 if absolute.is_dir() {
1725 remove_dir_all(&absolute)?;
1726 } else if absolute.is_file() {
1727 remove_file(&absolute)?;
1728 }
1729 }
1730 }
1731 }
1732
1733 let total_changed_count = added.len() + removed.len();
1734
1735 Ok(ScanReport {
1736 scanned_from: scan_from,
1737 recursive,
1738 added,
1739 removed,
1740 unchanged_count,
1741 total_changed_count,
1742 })
1743 }
1744
1745 /// Moves the entire database directory to a new parent directory.
1746 ///
1747 /// Existing destination database directory with the same name is removed first.
1748 ///
1749 /// # Parameters
1750 /// - `to`: destination parent directory.
1751 ///
1752 /// # Errors
1753 /// Returns an error if:
1754 /// - current database path is invalid,
1755 /// - destination cleanup fails,
1756 /// - recursive copy or source removal fails.
1757 ///
1758 /// # Examples
1759 /// ```no_run
1760 /// use file_database::{DatabaseError, DatabaseManager};
1761 ///
1762 /// fn main() -> Result<(), DatabaseError> {
1763 /// let mut manager = DatabaseManager::new(".", "database")?;
1764 /// manager.migrate_database("./new_parent")?;
1765 /// Ok(())
1766 /// }
1767 /// ```
1768 pub fn migrate_database(&mut self, to: impl AsRef<Path>) -> Result<(), DatabaseError> {
1769 let destination = to.as_ref().to_path_buf();
1770 let name = self
1771 .path
1772 .file_name()
1773 .ok_or_else(|| DatabaseError::NotADirectory(self.path.clone()))?;
1774 let destination_database_path = destination.join(name);
1775
1776 if destination_database_path.exists() {
1777 remove_dir_all(&destination_database_path)?;
1778 }
1779
1780 copy_directory_recursive(&self.path, &destination_database_path)?;
1781 remove_dir_all(&self.path)?;
1782
1783 self.path = destination_database_path;
1784
1785 Ok(())
1786 }
1787
1788 /// Moves a managed item to another directory inside the same database.
1789 ///
1790 /// # Parameters
1791 /// - `id`: source item to move.
1792 /// - `to`: destination directory item (or `ItemId::database_id()`).
1793 ///
1794 /// # Errors
1795 /// Returns an error if:
1796 /// - `id` is root or cannot be found,
1797 /// - destination is not a directory,
1798 /// - source and destination are identical,
1799 /// - `id.index` is out of bounds for the source `name` vector,
1800 /// - filesystem move fails.
1801 ///
1802 /// # Examples
1803 /// ```no_run
1804 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
1805 ///
1806 /// fn main() -> Result<(), DatabaseError> {
1807 /// let mut manager = DatabaseManager::new(".", "database")?;
1808 /// manager.write_new(ItemId::id("folder"), ItemId::database_id())?;
1809 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1810 /// manager.migrate_item(ItemId::id("a.txt"), ItemId::id("folder"))?;
1811 /// Ok(())
1812 /// }
1813 /// ```
1814 pub fn migrate_item(
1815 &mut self,
1816 id: impl Into<ItemId>,
1817 to: impl Into<ItemId>,
1818 ) -> Result<(), DatabaseError> {
1819 let id = id.into();
1820 let to = to.into();
1821
1822 if id.get_name().is_empty() {
1823 return Err(DatabaseError::RootIdUnsupported);
1824 }
1825
1826 let destination_dir = self.locate_absolute(&to)?;
1827 if !destination_dir.is_dir() {
1828 return Err(DatabaseError::NotADirectory(destination_dir));
1829 }
1830
1831 let source_absolute = self.locate_absolute(&id)?;
1832 let source_name = source_absolute
1833 .file_name()
1834 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1835 let destination_absolute = destination_dir.join(source_name);
1836
1837 if destination_absolute == source_absolute {
1838 return Err(DatabaseError::IdenticalSourceDestination(
1839 destination_absolute,
1840 ));
1841 }
1842
1843 if destination_absolute.exists() {
1844 if destination_absolute.is_dir() {
1845 remove_dir_all(&destination_absolute)?;
1846 } else {
1847 remove_file(&destination_absolute)?;
1848 }
1849 }
1850
1851 fs::rename(&source_absolute, &destination_absolute)?;
1852
1853 let old_name = id.get_name().to_string();
1854 let old_paths = self
1855 .items
1856 .get_mut(&old_name)
1857 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1858
1859 if id.get_index() >= old_paths.len() {
1860 return Err(DatabaseError::IndexOutOfBounds {
1861 id: id.as_string(),
1862 index: id.get_index(),
1863 len: old_paths.len(),
1864 });
1865 }
1866
1867 old_paths.remove(id.get_index());
1868 if old_paths.is_empty() {
1869 self.items.remove(&old_name);
1870 }
1871
1872 let relative_destination = destination_absolute.strip_prefix(&self.path)?.to_path_buf();
1873 let new_name = match relative_destination.file_name() {
1874 Some(name) => os_str_to_string(Some(name))?,
1875 None => old_name,
1876 };
1877
1878 self.items
1879 .entry(new_name)
1880 .or_default()
1881 .push(relative_destination);
1882
1883 Ok(())
1884 }
1885
1886 /// Exports a managed file or directory to an external destination directory.
1887 ///
1888 /// `Copy` keeps the item in the `index`. `Move` removes the moved entry from the `index`.
1889 ///
1890 /// # Parameters
1891 /// - `id`: source item to export.
1892 /// - `to`: external destination directory path.
1893 /// - `mode`: copy or move behavior.
1894 ///
1895 /// # Errors
1896 /// Returns an error if:
1897 /// - `id` is root or cannot be found,
1898 /// - destination is inside the database,
1899 /// - destination path cannot be created or used as a directory,
1900 /// - `id.index` is out of bounds when removing moved entries,
1901 /// - filesystem copy/move operations fail.
1902 ///
1903 /// # Examples
1904 /// ```no_run
1905 /// use file_database::{DatabaseError, DatabaseManager, ExportMode, ItemId};
1906 ///
1907 /// fn main() -> Result<(), DatabaseError> {
1908 /// let mut manager = DatabaseManager::new(".", "database")?;
1909 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
1910 /// manager.export_item(ItemId::id("a.txt"), "./exports", ExportMode::Copy)?;
1911 /// Ok(())
1912 /// }
1913 /// ```
1914 pub fn export_item(
1915 &mut self,
1916 id: impl Into<ItemId>,
1917 to: impl AsRef<Path>,
1918 mode: ExportMode,
1919 ) -> Result<(), DatabaseError> {
1920 let id = id.into();
1921 let destination_dir = {
1922 let to = to.as_ref();
1923 if to.is_absolute() {
1924 to.to_path_buf()
1925 } else {
1926 current_dir()?.join(to)
1927 }
1928 };
1929
1930 if id.get_name().is_empty() {
1931 return Err(DatabaseError::RootIdUnsupported);
1932 }
1933
1934 if destination_dir.starts_with(&self.path) {
1935 return Err(DatabaseError::ExportDestinationInsideDatabase(
1936 destination_dir,
1937 ));
1938 }
1939
1940 fs::create_dir_all(&destination_dir)?;
1941 if !destination_dir.is_dir() {
1942 return Err(DatabaseError::NotADirectory(destination_dir));
1943 }
1944
1945 let source_absolute = self.locate_absolute(&id)?;
1946 let source_name = source_absolute
1947 .file_name()
1948 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1949 let destination_absolute = destination_dir.join(source_name);
1950
1951 if destination_absolute == source_absolute {
1952 return Err(DatabaseError::IdenticalSourceDestination(
1953 destination_absolute,
1954 ));
1955 }
1956
1957 if destination_absolute.exists() {
1958 if destination_absolute.is_dir() {
1959 remove_dir_all(&destination_absolute)?;
1960 } else {
1961 remove_file(&destination_absolute)?;
1962 }
1963 }
1964
1965 match mode {
1966 ExportMode::Copy => {
1967 if source_absolute.is_dir() {
1968 copy_directory_recursive(&source_absolute, &destination_absolute)?;
1969 } else {
1970 fs::copy(&source_absolute, &destination_absolute)?;
1971 }
1972 }
1973 ExportMode::Move => {
1974 match fs::rename(&source_absolute, &destination_absolute) {
1975 Ok(_) => (),
1976 Err(_) => {
1977 if source_absolute.is_dir() {
1978 copy_directory_recursive(&source_absolute, &destination_absolute)?;
1979 remove_dir_all(&source_absolute)?;
1980 } else {
1981 fs::copy(&source_absolute, &destination_absolute)?;
1982 remove_file(&source_absolute)?;
1983 }
1984 }
1985 }
1986
1987 let key = id.get_name().to_string();
1988 let paths = self
1989 .items
1990 .get_mut(&key)
1991 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
1992
1993 if id.get_index() >= paths.len() {
1994 return Err(DatabaseError::IndexOutOfBounds {
1995 id: id.as_string(),
1996 index: id.get_index(),
1997 len: paths.len(),
1998 });
1999 }
2000
2001 paths.remove(id.get_index());
2002 if paths.is_empty() {
2003 self.items.remove(&key);
2004 }
2005 }
2006 }
2007
2008 Ok(())
2009 }
2010
2011 /// Imports an external file or directory into a database destination directory.
2012 ///
2013 /// The imported item keeps its original `name`.
2014 ///
2015 /// # Parameters
2016 /// - `from`: source path outside the database.
2017 /// - `to`: destination directory item in the database.
2018 ///
2019 /// # Errors
2020 /// Returns an error if:
2021 /// - source path points inside the database,
2022 /// - destination is not a directory,
2023 /// - destination `path`/`name` already exists,
2024 /// - source does not exist as file or directory,
2025 /// - filesystem copy operations fail.
2026 ///
2027 /// # Examples
2028 /// ```no_run
2029 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
2030 ///
2031 /// fn main() -> Result<(), DatabaseError> {
2032 /// let mut manager = DatabaseManager::new(".", "database")?;
2033 /// manager.write_new(ItemId::id("imports"), ItemId::database_id())?;
2034 /// manager.import_item("./outside/example.txt", ItemId::id("imports"))?;
2035 /// Ok(())
2036 /// }
2037 /// ```
2038 pub fn import_item(
2039 &mut self,
2040 from: impl AsRef<Path>,
2041 to: impl Into<ItemId>,
2042 ) -> Result<(), DatabaseError> {
2043 let source_path = {
2044 let from = from.as_ref();
2045 if from.is_absolute() {
2046 from.to_path_buf()
2047 } else {
2048 current_dir()?.join(from)
2049 }
2050 };
2051 let to = to.into();
2052
2053 if source_path.starts_with(&self.path) {
2054 return Err(DatabaseError::ImportSourceInsideDatabase(source_path));
2055 }
2056
2057 let destination_parent = self.locate_absolute(&to)?;
2058 if !destination_parent.is_dir() {
2059 return Err(DatabaseError::NotADirectory(destination_parent));
2060 }
2061
2062 let item_name = source_path
2063 .file_name()
2064 .ok_or_else(|| DatabaseError::NotAFile(source_path.clone()))?
2065 .to_string_lossy()
2066 .to_string();
2067
2068 let destination_absolute = destination_parent.join(&item_name);
2069 let destination_relative = if to.get_name().is_empty() {
2070 PathBuf::from(&item_name)
2071 } else {
2072 let mut relative = self.locate_relative(&to)?.to_path_buf();
2073 relative.push(&item_name);
2074 relative
2075 };
2076
2077 if destination_absolute.exists()
2078 || self
2079 .items
2080 .get(&item_name)
2081 .is_some_and(|paths| paths.iter().any(|path| path == &destination_relative))
2082 {
2083 return Err(DatabaseError::IdAlreadyExists(item_name));
2084 }
2085
2086 if source_path.is_dir() {
2087 copy_directory_recursive(&source_path, &destination_absolute)?;
2088 } else if source_path.is_file() {
2089 fs::copy(&source_path, &destination_absolute)?;
2090 } else {
2091 return Err(DatabaseError::NoMatchingID(
2092 source_path.display().to_string(),
2093 ));
2094 }
2095
2096 self.items
2097 .entry(item_name)
2098 .or_default()
2099 .push(destination_relative);
2100
2101 Ok(())
2102 }
2103
2104 /// Duplicates a managed item into `parent` using a caller-provided `name`.
2105 ///
2106 /// # Parameters
2107 /// - `id`: source item to duplicate.
2108 /// - `parent`: destination parent directory item (or `ItemId::database_id()`).
2109 /// - `name`: new name for the duplicate.
2110 ///
2111 /// # Errors
2112 /// Returns an error if:
2113 /// - `id` is root or cannot be found,
2114 /// - destination parent is not a directory,
2115 /// - destination `name` already exists in the target directory,
2116 /// - filesystem copy fails.
2117 ///
2118 /// # Examples
2119 /// ```no_run
2120 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
2121 ///
2122 /// fn main() -> Result<(), DatabaseError> {
2123 /// let mut manager = DatabaseManager::new(".", "database")?;
2124 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
2125 /// manager.duplicate_item(ItemId::id("a.txt"), ItemId::database_id(), "copy.txt")?;
2126 /// Ok(())
2127 /// }
2128 /// ```
2129 pub fn duplicate_item(
2130 &mut self,
2131 id: impl Into<ItemId>,
2132 parent: impl Into<ItemId>,
2133 name: impl AsRef<str>,
2134 ) -> Result<(), DatabaseError> {
2135 let id = id.into();
2136 let parent = parent.into();
2137 let name = name.as_ref().to_owned();
2138
2139 if id.get_name().is_empty() {
2140 return Err(DatabaseError::RootIdUnsupported);
2141 }
2142
2143 let source_absolute = self.locate_absolute(&id)?;
2144 let parent_absolute = self.locate_absolute(&parent)?;
2145 if !parent_absolute.is_dir() {
2146 return Err(DatabaseError::NotADirectory(parent_absolute));
2147 }
2148
2149 let destination_absolute = parent_absolute.join(&name);
2150 let destination_relative = if parent.get_name().is_empty() {
2151 PathBuf::from(&name)
2152 } else {
2153 let mut path = self.locate_relative(&parent)?.to_path_buf();
2154 path.push(&name);
2155 path
2156 };
2157
2158 if destination_absolute.exists()
2159 || self
2160 .items
2161 .get(&name)
2162 .is_some_and(|paths| paths.iter().any(|path| path == &destination_relative))
2163 {
2164 return Err(DatabaseError::IdAlreadyExists(name));
2165 }
2166
2167 if source_absolute.is_dir() {
2168 copy_directory_recursive(&source_absolute, &destination_absolute)?;
2169 } else {
2170 fs::copy(&source_absolute, &destination_absolute)?;
2171 }
2172
2173 self.items
2174 .entry(
2175 destination_relative
2176 .file_name()
2177 .map(|name| name.to_string_lossy().to_string())
2178 .unwrap_or_default(),
2179 )
2180 .or_default()
2181 .push(destination_relative);
2182
2183 Ok(())
2184 }
2185
2186 /// Returns filesystem metadata summary for a managed file or directory.
2187 ///
2188 /// Includes:
2189 /// - `name`/`extension`,
2190 /// - normalized size,
2191 /// - Unix timestamps and "time since" timestamps where available.
2192 ///
2193 /// # Parameters
2194 /// - `id`: item to inspect.
2195 ///
2196 /// # Errors
2197 /// Returns an error if:
2198 /// - `id` cannot be found,
2199 /// - metadata lookup fails.
2200 ///
2201 /// # Examples
2202 /// ```no_run
2203 /// use file_database::{DatabaseError, DatabaseManager, ItemId};
2204 ///
2205 /// fn main() -> Result<(), DatabaseError> {
2206 /// let mut manager = DatabaseManager::new(".", "database")?;
2207 /// manager.write_new(ItemId::id("a.txt"), ItemId::database_id())?;
2208 /// let _info = manager.get_file_information(ItemId::id("a.txt"))?;
2209 /// Ok(())
2210 /// }
2211 /// ```
2212 pub fn get_file_information(
2213 &self,
2214 id: impl Into<ItemId>,
2215 ) -> Result<FileInformation, DatabaseError> {
2216 let id = id.into();
2217
2218 let path = self.locate_absolute(id)?;
2219
2220 let metadata = fs::metadata(&path)?;
2221
2222 let name = {
2223 let os = if path.is_dir() {
2224 path.file_name()
2225 } else {
2226 path.file_stem()
2227 };
2228
2229 match os_str_to_string(os) {
2230 Ok(name) => Some(name),
2231 Err(_) => None,
2232 }
2233 };
2234
2235 let extension = {
2236 if path.is_dir() {
2237 None
2238 } else {
2239 match os_str_to_string(path.extension()) {
2240 Ok(extension) => Some(extension),
2241 Err(_) => None,
2242 }
2243 }
2244 };
2245
2246 let size = FileSize::from(metadata.len());
2247
2248 let unix_created = sys_time_to_unsigned_int(metadata.created());
2249 let time_since_created = sys_time_to_time_since(metadata.created());
2250
2251 let unix_last_opened = sys_time_to_unsigned_int(metadata.accessed());
2252 let time_since_last_opened = sys_time_to_time_since(metadata.accessed());
2253
2254 let unix_last_modified = sys_time_to_unsigned_int(metadata.modified());
2255 let time_since_last_modified = sys_time_to_time_since(metadata.modified());
2256
2257 Ok(FileInformation {
2258 name,
2259 extension,
2260 size,
2261 unix_created,
2262 time_since_created,
2263 unix_last_opened,
2264 time_since_last_opened,
2265 unix_last_modified,
2266 time_since_last_modified,
2267 })
2268 }
2269
2270 /// Gets one specific path from a shared `name` + `index`.
2271 ///
2272 /// # Errors
2273 /// Returns an error if:
2274 /// - the shared `name` key does not exist,
2275 /// - `id.index` is out of bounds.
2276 fn resolve_path_by_id(&self, id: &ItemId) -> Result<&PathBuf, DatabaseError> {
2277 let matches = self
2278 .items
2279 .get(id.get_name())
2280 .ok_or_else(|| DatabaseError::NoMatchingID(id.as_string()))?;
2281
2282 if id.get_index() >= matches.len() {
2283 return Err(DatabaseError::IndexOutOfBounds {
2284 id: id.as_string(),
2285 index: id.get_index(),
2286 len: matches.len(),
2287 });
2288 }
2289
2290 Ok(&matches[id.get_index()])
2291 }
2292
2293 /// Overwrites a file safely by using a temp file and rename.
2294 ///
2295 /// `write_fn` is responsible for writing bytes to the temporary file and returning
2296 /// the number of bytes written.
2297 ///
2298 /// # Errors
2299 /// Returns an error if:
2300 /// - `path` points to a directory,
2301 /// - temp create/write/sync/rename fails.
2302 fn overwrite_path_atomic_with<F>(&self, path: &Path, write_fn: F) -> Result<u64, DatabaseError>
2303 where
2304 F: FnOnce(&mut File) -> Result<u64, DatabaseError>,
2305 {
2306 if path.is_dir() {
2307 return Err(DatabaseError::NotAFile(path.to_path_buf()));
2308 }
2309
2310 let buffer = path.with_extension("tmp");
2311
2312 let result = (|| {
2313 let mut file = File::create(&buffer)?;
2314 let bytes_written = write_fn(&mut file)?;
2315 file.sync_all()?;
2316 fs::rename(&buffer, path)?;
2317 Ok(bytes_written)
2318 })();
2319
2320 if result.is_err() && buffer.exists() {
2321 let _ = remove_file(&buffer);
2322 }
2323
2324 result
2325 }
2326
2327 /// Collects relative file and folder paths in the scan area.
2328 ///
2329 /// # Parameters
2330 /// - `scope_absolute`: absolute root directory for collection.
2331 /// - `recursive`: whether to include descendants recursively.
2332 ///
2333 /// # Errors
2334 /// Returns an error if reading folders fails or converting to a relative prefix fails.
2335 fn collect_paths_in_scope(
2336 &self,
2337 scope_absolute: &Path,
2338 recursive: bool,
2339 ) -> Result<Vec<PathBuf>, DatabaseError> {
2340 let mut collected = Vec::new();
2341
2342 if recursive {
2343 let mut stack = vec![scope_absolute.to_path_buf()];
2344 while let Some(directory) = stack.pop() {
2345 for entry in fs::read_dir(&directory)? {
2346 let entry = entry?;
2347 let absolute_path = entry.path();
2348 let relative_path = absolute_path.strip_prefix(&self.path)?.to_path_buf();
2349
2350 if absolute_path.is_dir() {
2351 collected.push(relative_path);
2352 stack.push(absolute_path);
2353 } else if absolute_path.is_file() {
2354 collected.push(relative_path);
2355 }
2356 }
2357 }
2358 } else {
2359 for entry in fs::read_dir(scope_absolute)? {
2360 let entry = entry?;
2361 let absolute_path = entry.path();
2362 let relative_path = absolute_path.strip_prefix(&self.path)?.to_path_buf();
2363
2364 if absolute_path.is_dir() || absolute_path.is_file() {
2365 collected.push(relative_path);
2366 }
2367 }
2368 }
2369
2370 Ok(collected)
2371 }
2372}
2373
2374// -------- Functions --------
2375/// Removes `steps` trailing segments from `path`.
2376///
2377/// # Errors
2378/// Returns [`DatabaseError::PathStepOverflow`] when `steps` is too large for `path`.
2379fn truncate(mut path: PathBuf, steps: i32) -> Result<PathBuf, DatabaseError> {
2380 let parents = (path.ancestors().count() - 1) as i32;
2381
2382 if parents <= steps {
2383 return Err(DatabaseError::PathStepOverflow(steps, parents));
2384 }
2385
2386 for _ in 0..steps {
2387 path.pop();
2388 }
2389
2390 Ok(path)
2391}
2392
2393/// Converts an optional `OsStr` into an owned `String`.
2394///
2395/// # Errors
2396/// Returns [`DatabaseError::OsStringConversion`] if the value is `None` or invalid UTF-8.
2397fn os_str_to_string(os_str: Option<&OsStr>) -> Result<String, DatabaseError> {
2398 let os_str = match os_str {
2399 Some(os_str) => os_str,
2400 None => return Err(DatabaseError::OsStringConversion),
2401 };
2402
2403 match os_str.to_os_string().into_string() {
2404 Ok(string) => Ok(string),
2405 Err(_) => Err(DatabaseError::OsStringConversion),
2406 }
2407}
2408
2409/// Converts `SystemTime` to Unix timestamp seconds.
2410///
2411/// Returns `None` for platform or conversion failures.
2412fn sys_time_to_unsigned_int(time: io::Result<SystemTime>) -> Option<u64> {
2413 match time {
2414 Ok(time) => match time.duration_since(UNIX_EPOCH) {
2415 Ok(duration) => Some(duration.as_secs()),
2416 Err(_) => None,
2417 },
2418 Err(_) => None,
2419 }
2420}
2421
2422/// Converts `SystemTime` to "time since now" represented as Unix-seconds duration.
2423///
2424/// Returns `None` for platform or conversion failures.
2425fn sys_time_to_time_since(time: io::Result<SystemTime>) -> Option<u64> {
2426 let duration = match time {
2427 Ok(time) => match SystemTime::now().duration_since(time) {
2428 Ok(duration) => duration,
2429 Err(_) => return None,
2430 },
2431 Err(_) => return None,
2432 };
2433
2434 sys_time_to_unsigned_int(Ok(UNIX_EPOCH + duration))
2435}
2436
2437/// Recursively copies a directory tree from `from` to `to`.
2438///
2439/// # Errors
2440/// Returns **`DatabaseError`** if reading folders or copying files fails.
2441fn copy_directory_recursive(from: &Path, to: &Path) -> Result<(), DatabaseError> {
2442 fs::create_dir_all(to)?;
2443
2444 for entry in fs::read_dir(from)? {
2445 let entry = entry?;
2446 let source_path = entry.path();
2447 let destination_path = to.join(entry.file_name());
2448
2449 if source_path.is_dir() {
2450 copy_directory_recursive(&source_path, &destination_path)?;
2451 } else {
2452 fs::copy(&source_path, &destination_path)?;
2453 }
2454 }
2455
2456 Ok(())
2457}
2458
2459/// Returns whether `path` is inside the requested scan scope.
2460fn is_path_in_scope(path: &Path, scope_relative: Option<&Path>, recursive: bool) -> bool {
2461 match scope_relative {
2462 None => {
2463 if recursive {
2464 true
2465 } else {
2466 path.parent()
2467 .is_some_and(|parent| parent.as_os_str().is_empty())
2468 }
2469 }
2470 Some(scope_relative) => {
2471 if recursive {
2472 path.starts_with(scope_relative) && path != scope_relative
2473 } else {
2474 path.parent() == Some(scope_relative)
2475 }
2476 }
2477 }
2478}
2479
2480/// Deletes a directory `path` in forced or non-forced mode.
2481///
2482/// # Errors
2483/// Returns **`DatabaseError`** if the remove operation fails.
2484fn delete_directory<T>(path: &PathBuf, force: T) -> Result<(), DatabaseError>
2485where
2486 T: Into<bool>,
2487{
2488 if force.into() {
2489 return Ok(remove_dir_all(path)?);
2490 } else {
2491 return Ok(remove_dir(path)?);
2492 }
2493}