file-declutter 0.1.1

Reorganizes files into nested folders based on their filenames.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! # File Declutter
//!
//! [![badge github]][url github]
//! [![badge crates.io]][url crates.io]
//! [![badge docs.rs]][url docs.rs]
//! [![badge license]][url license]
//!
//! [badge github]: https://img.shields.io/badge/github-FloGa%2Ffile--declutter-green
//! [badge crates.io]: https://img.shields.io/crates/v/file-declutter
//! [badge docs.rs]: https://img.shields.io/docsrs/file-declutter
//! [badge license]: https://img.shields.io/crates/l/file-declutter
//!
//! [url github]: https://github.com/FloGa/file-declutter
//! [url crates.io]: https://crates.io/crates/file-declutter
//! [url docs.rs]: https://docs.rs/file-declutter
//! [url license]: https://github.com/FloGa/file-declutter/blob/develop/LICENSE
//!
//! > Reorganizes files into nested folders based on their filenames.
//!
//! *File Declutter* is a little command line tool that helps you bring order to
//! your large directories. It can "declutter" a flat list of files by
//! redistributing them into nested subdirectories based on their filenames. It's
//! particularly useful for organizing large numbers of files in a single
//! directory (for example images, documents, etc.) into a more manageable
//! structure.
//!
//! This crate is split into an [Application](#application) part and a
//! [Library](#library) part.
//!
//! ## Motivation
//!
//! The need for this little tool derived from a situation where I was confronted
//! with a flat directory of 500k files and more. Well, to be frank, it was one of
//! my other creations, namely [*DedupeFS*][dedupefs github], which presents files
//! as 1MB chunks, named after their checksums. By using this over my media hard
//! drive, I ended up with so many files in one directory that my file manager
//! just refused to work with it.
//!
//! Since the file names are SHA-1 hashes, they consist of a somewhat evenly
//! distributed sequence of the hexadecimal numbers 0 to f. So, by putting all
//! files that start with 0 into a separate folder, and all that start with 1 into
//! a different folder, and so on, I can already split the number of files per
//! subdirectory by 16. If I repeat this step in each subdirectory, I can split
//! them again by 16.
//!
//! This is one possible scenario where *File Declutter* really comes in handy.
//!
//! [dedupefs github]: https://github.com/FloGa/dedupefs
//!
//! ## Application
//!
//! ### Installation
//!
//! This tool can be installed easily through Cargo via `crates.io`:
//!
//! ```shell
//! cargo install --locked file-declutter
//! ```
//!
//! Please note that the `--locked` flag is necessary here to have the exact same
//! dependencies as when the application was tagged and tested. Without it, you
//! might get more up-to-date versions of dependencies, but you have the risk of
//! undefined and unexpected behavior if the dependencies changed some
//! functionalities. The application might even fail to build if the public API of
//! a dependency changed too much.
//!
//! Alternatively, pre-built binaries can be downloaded from the [GitHub
//! releases][gh-releases] page.
//!
//! [gh-releases]: https://github.com/FloGa/file-declutter/releases
//!
//! ### Usage
//!
//! ```text
//! Usage: file-declutter [OPTIONS] <PATH>
//!
//! Arguments:
//!   <PATH>  Directory to declutter
//!
//! Options:
//!   -l, --levels <LEVELS>           Number of nested subdirectory levels [default: 3]
//!   -r, --remove-empty-directories  Remove empty directories after moving files
//!   -h, --help                      Print help
//!   -V, --version                   Print version
//! ```
//!
//! To declutter a directory into three levels, you would go with:
//!
//! ```shell
//! file-declutter --levels 3 path/to/large-directory
//! ```
//!
//! To "restore" the directory, or rather, flatten a list of files in
//! subdirectories, you can use:
//!
//! ```shell
//! file-declutter --levels 0 --remove-empty-directories path/to/decluttered-directory
//! ```
//!
//! **Warning:** Please note that flattening a directory tree in this way will
//! result in **data loss** when there are files with the same names! So if you
//! have two files `dir/a/123.txt` and `dir/b/123.txt` and you run the above
//! command over `dir`, you will end up with `dir/123.txt`, where the last file
//! move has overwritten the previous ones. The file listing could be in arbitrary
//! order, so you cannot even tell beforehand, which file will "win".
//!
//! ## Library
//!
//! ### Installation
//!
//! To add the `file-clutter` library to your project, you can use:
//!
//! ```shell
//! cargo add file-declutter
//! ```
//!
//! ### Usage
//!
//! The following is a short summary of how this library is intended to be used.
//! The actual functionality lies in a custom Iterator object
//! `FileDeclutterIterator`, but it is not intended to be instantiated directly.
//! Instead, the wrapper `FileDeclutter` should be used.
//!
//! Once the Iterator is created, you can either iterate over its items to receive
//! a list of `(source, target)` tuples, with `source` being the original filename
//! and `target` the "decluttered" one. You can use this to do your own logic over
//! them. Or you can use the `declutter_files` method to do the actual moving of
//! the files.
//!
//! #### Create Iterator from Path
//!
//! This method can be used if you have an actual directory that you want to
//! completely declutter recursively.
//!
//! ```rust no_run
//! use std::path::PathBuf;
//!
//! fn main() {
//!     let files_decluttered = file_declutter::FileDeclutter::new_from_path("/tmp/path")
//!         .levels(1)
//!         .collect::<Vec<_>>();
//!
//!     // If the specified directory contains the files 13.txt and 23.txt, the following tuples
//!     // will be produced:
//!     let files_expected = vec![
//!         (PathBuf::from("13.txt"), PathBuf::from("1/13.txt")),
//!         (PathBuf::from("23.txt"), PathBuf::from("2/23.txt")),
//!     ];
//!
//!     assert_eq!(files_expected, files_decluttered);
//! }
//! ```
//!
//! #### Create Iterator from Existing Iterator
//!
//! This method can be used if you have a specific list of files you want to
//! process.
//!
//! ```rust
//! use std::path::PathBuf;
//!
//! fn main() {
//!     let files = vec!["13.txt", "23.txt"];
//!     let files_decluttered = file_declutter::FileDeclutter::new_from_iter(files.into_iter())
//!         .levels(1)
//!         .collect::<Vec<_>>();
//!
//!     let files_expected = vec![
//!         (PathBuf::from("13.txt"), PathBuf::from("1/13.txt")),
//!         (PathBuf::from("23.txt"), PathBuf::from("2/23.txt")),
//!     ];
//!
//!     assert_eq!(files_expected, files_decluttered);
//! }
//! ```
//!
//! #### Oneshot
//!
//! This method can be used if you have a single file which you want to have a
//! decluttered name for.
//!
//! ```rust
//! use std::path::PathBuf;
//!
//! fn main() {
//!     let file = "123456.txt";
//!     let file_decluttered = file_declutter::FileDeclutter::oneshot(file, 3);
//!
//!     let file_expected = PathBuf::from("1/2/3/123456.txt");
//!
//!     assert_eq!(file_expected, file_decluttered);
//! }
//! ```

use std::path::PathBuf;

/// An iterator that transforms a list of file paths into (source, target) pairs, where the target
/// path is a decluttered version based on the filename's characters.
pub struct FileDeclutterIterator<I> {
    inner: I,
    base: PathBuf,
    levels: usize,
}

impl<I> FileDeclutterIterator<I>
where
    I: Iterator<Item = PathBuf>,
{
    /// Sets the base directory into which files will be moved.
    pub fn base<P: Into<PathBuf>>(mut self, base: P) -> Self {
        self.base = base.into();
        self
    }

    /// Sets the number of directory levels to create based on the filename.
    ///
    /// For example, with `levels = 2` and a file named `abcdef.txt`, the target path would include
    /// two subdirectories: `a/b/abcdef.txt`.
    pub fn levels(mut self, levels: usize) -> Self {
        self.levels = levels;
        self
    }

    /// Moves all files to their decluttered target paths.
    ///
    /// If `remove_empty_directories` is `true`, the function will attempt to remove any now-empty
    /// directories after the move operation.
    ///
    /// # Errors
    ///
    /// Returns an error if directory creation or file renaming fails.
    pub fn declutter_files(self, remove_empty_directories: bool) -> anyhow::Result<()> {
        let base = self.base.clone();

        for (source, target) in self {
            std::fs::create_dir_all(&target.parent().unwrap())?;
            std::fs::rename(source, target)?;
        }

        if remove_empty_directories {
            for dir in walkdir::WalkDir::new(base)
                .min_depth(1)
                .contents_first(true)
                .into_iter()
                .filter_entry(|f| f.file_type().is_dir())
                .flatten()
            {
                let dir = dir.into_path();

                if dir.read_dir()?.count() == 0 {
                    // Ignore result, it doesn't matter if deletion fails.
                    let _ = std::fs::remove_dir(dir);
                }
            }
        }

        Ok(())
    }
}

impl<I> Iterator for FileDeclutterIterator<I>
where
    I: Iterator<Item = PathBuf>,
{
    type Item = (PathBuf, PathBuf);

    /// Returns the next `(source, target)` file path pair.
    ///
    /// The target path is derived from the file name by taking the first `levels` characters and
    /// using them as nested directories.
    fn next(&mut self) -> Option<Self::Item> {
        self.inner.next().map(move |entry| {
            let sub_dirs = entry.file_name().unwrap().to_string_lossy();
            let sub_dirs = sub_dirs.chars().take(self.levels).map(String::from);

            let mut target_path = self.base.clone();
            for sub_dir in sub_dirs {
                target_path.push(sub_dir);
            }
            target_path.push(entry.file_name().unwrap());

            (entry, target_path)
        })
    }
}

/// Entry point for creating decluttering iterators or computing decluttered paths.
pub struct FileDeclutter;

impl FileDeclutter {
    /// Creates a `FileDeclutterIterator` from an arbitrary iterator over file paths.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use std::path::PathBuf;
    /// let files = vec!["13.txt", "23.txt"];
    /// let files_decluttered = file_declutter::FileDeclutter::new_from_iter(files.into_iter())
    ///     .levels(1)
    ///     .collect::<Vec<_>>();
    ///
    /// let files_expected = vec![
    ///     (PathBuf::from("13.txt"), PathBuf::from("1/13.txt")),
    ///     (PathBuf::from("23.txt"), PathBuf::from("2/23.txt")),
    /// ];
    ///
    /// assert_eq!(files_expected, files_decluttered);
    /// ```
    pub fn new_from_iter(
        iter: impl Iterator<Item = impl Into<PathBuf>>,
    ) -> FileDeclutterIterator<impl Iterator<Item = PathBuf>> {
        FileDeclutterIterator {
            inner: iter.map(Into::into),
            base: Default::default(),
            levels: Default::default(),
        }
    }

    /// Creates a `FileDeclutterIterator` by recursively collecting all files under a given
    /// directory and setting this directory as the base.
    ///
    /// # Examples
    ///
    /// ```rust no_run
    /// # use std::path::PathBuf;
    /// let files_decluttered = file_declutter::FileDeclutter::new_from_path("/tmp/path")
    ///     .levels(1)
    ///     .collect::<Vec<_>>();
    ///
    /// // If the specified directory contains the files 13.txt and 23.txt, the following tuples
    /// // will be produced:
    /// let files_expected = vec![
    ///     (PathBuf::from("13.txt"), PathBuf::from("1/13.txt")),
    ///     (PathBuf::from("23.txt"), PathBuf::from("2/23.txt")),
    /// ];
    ///
    /// assert_eq!(files_expected, files_decluttered);
    /// ```
    pub fn new_from_path(
        base: impl Into<PathBuf>,
    ) -> FileDeclutterIterator<impl Iterator<Item = PathBuf>> {
        let base = base.into();

        let iter = walkdir::WalkDir::new(&base)
            .min_depth(1)
            .into_iter()
            .flatten()
            .filter(|f| f.file_type().is_file())
            .map(|entry| entry.into_path());

        FileDeclutter::new_from_iter(iter).base(base)
    }

    /// Computes the decluttered path of a single file without moving it.
    ///
    /// # Arguments
    ///
    /// - `file`: Path to the input file.
    /// - `levels`: Number of subdirectory levels to use.
    ///
    /// # Returns
    ///
    /// A `PathBuf` representing the target decluttered location.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # use std::path::PathBuf;
    /// let file = "123456.txt";
    /// let file_decluttered = file_declutter::FileDeclutter::oneshot(file, 3);
    ///
    /// let file_expected = PathBuf::from("1/2/3/123456.txt");
    ///
    /// assert_eq!(file_expected, file_decluttered);
    /// ```
    pub fn oneshot(file: impl Into<PathBuf>, levels: usize) -> PathBuf {
        let iter = std::iter::once(file.into());
        FileDeclutter::new_from_iter(iter)
            .levels(levels)
            .next()
            .unwrap()
            .1
    }
}

#[cfg(test)]
mod tests {
    use assert_fs::TempDir;
    use assert_fs::prelude::*;
    use rand::RngExt;

    use super::*;

    #[test]
    fn decluttered_from_path_file_names_same() -> anyhow::Result<()> {
        let temp_dir = TempDir::new()?;

        let mut rng = rand::rng();
        for _ in 0..100 {
            let mut file_name = rng
                .random_range(1_000_000_000u64..10_000_000_000u64)
                .to_string();

            if rng.random_bool(0.25) {
                file_name = format!("subdir/{file_name}");
            }

            let child = temp_dir.child(file_name);
            child.touch()?;
        }

        for (source, target) in FileDeclutter::new_from_path(temp_dir.to_path_buf()).levels(1) {
            assert_ne!(source.parent(), target.parent());
            assert_eq!(source.file_name(), target.file_name());
        }

        Ok(())
    }

    #[test]
    fn decluttered_from_iter_file_names_same() -> anyhow::Result<()> {
        let mut rng = rand::rng();
        let files = (0..100).map(move |_| {
            let mut file_name = rng
                .random_range(1_000_000_000u64..10_000_000_000u64)
                .to_string();

            if rng.random_bool(0.25) {
                file_name = format!("subdir/{file_name}");
            }

            file_name
        });

        for (source, target) in FileDeclutter::new_from_iter(files).levels(1) {
            assert_ne!(source.parent(), target.parent());
            assert_eq!(source.file_name(), target.file_name());
        }

        Ok(())
    }

    #[test]
    fn oneshot() -> anyhow::Result<()> {
        let source = PathBuf::from("123456");
        let target_expected = PathBuf::from("1/2/3/123456");

        let target_actual = FileDeclutter::oneshot(&source, 3);

        assert_eq!(target_actual, target_expected);

        Ok(())
    }
}