Repository: pkolaczk/fclones (language: rust)

Issue:
isolate behavior removes dupes same volume
when using --isolate for two directories, duplicate files within the directories are also being deleted. my understanding is the files w/i the volumes aren't considered for duplicate removal.

`  
cd /mnt/media
mkdir test-a
mkdir test-b
echo "test-one" > test-a/test-1
echo "test-one" > test-a/test-2
echo "test-one" > test-b/test-1
echo "test-one" > test-b/test-2
echo "test-two" > test-a/test-3
echo "test-two" > test-a/test-4
echo "test-two" > test-b/test-4
fclones group --isolate /mnt/media/test-a /mnt/media/test-b > test.txt
fclones remove --dry-run <test.txt 
[2022-02-15 12:53:15.823] fclones:  info: Started deduplicating (dry run)
rm /mnt/media/test-a/test-4
rm /mnt/media/test-a/test-2
rm /mnt/media/test-b/test-4
rm /mnt/media/test-b/test-1
rm /mnt/media/test-b/test-2
[2022-02-15 12:53:15.826] fclones:  info: Would process 5 files and reclaim 45 B space


`


Discussion / hints:
The --isolate option makes duplicate files within the same root counted as one. But in this case you have same files under different roots, so they are properly treated as duplicates. 
After thinking about it a bit more, indeed I think the removal behaviour is wrong. It should only remove files from one root in this case and leave the other root untouched.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 92244ab..cf80a00 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -339,7 +339,7 @@ checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
 
 [[package]]
 name = "fclones"
-version = "0.17.1"
+version = "0.18.0"
 dependencies = [
  "atomic-counter",
  "byte-unit",
diff --git a/Cargo.toml b/Cargo.toml
index ccde4ce..bcd6a3f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "fclones"
-version = "0.17.1"
+version = "0.18.0"
 description = "Finds duplicate, unique, under- or over-replicated files"
 authors = ["Piotr Kołaczkowski <pkolaczk@gmail.com>"]
 homepage = "https://github.com/pkolaczk/fclones"
diff --git a/src/config.rs b/src/config.rs
index 0b4f879..b9d37da 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -4,9 +4,10 @@ use std::collections::HashMap;
 use std::ffi::OsString;
 use std::fmt::{Display, Formatter};
 use std::io;
-use std::io::{stdin, BufRead, BufReader};
+use std::io::{stdin, BufRead, BufReader, ErrorKind};
 use std::path::PathBuf;
 use std::str::FromStr;
+use std::sync::Arc;
 
 use chrono::{DateTime, FixedOffset, Local};
 use clap::AppSettings;
@@ -71,7 +72,7 @@ fn parse_date_time(s: &str) -> Result<DateTime<FixedOffset>, String> {
             let local_offset = *Local::now().offset();
             Ok(DateTime::from_utc(dt, local_offset))
         }
-        Err(e) => Err(format!("Failed to parse {} as date: {}", s, e.to_string())),
+        Err(e) => Err(format!("Failed to parse {} as date: {}", s, e)),
     }
 }
 
@@ -263,6 +264,10 @@ pub struct GroupConfig {
       verbatim_doc_comment)]
     pub threads: Vec<(OsString, Parallelism)>,
 
+    /// Base directory to use when resolving relative input paths.
+    #[structopt(long, parse(from_os_str), default_value("."))]
+    pub base_dir: PathBuf,
+
     /// A list of input paths.
     ///
     /// Accepts files and directories.
@@ -367,17 +372,43 @@ impl GroupConfig {
         }
     }
 
-    /// Returns an iterator over the input paths.
+    /// Makes the base directory absolute.
+    /// Returns error if the base directory does not exist.
+    pub fn resolve_base_dir(&mut self) -> io::Result<&PathBuf> {
+        if self.base_dir.is_relative() {
+            self.base_dir = std::env::current_dir()?.join(&self.base_dir)
+        }
+        if !self.base_dir.is_dir() {
+            return Err(io::Error::new(
+                ErrorKind::NotFound,
+                format!("Directory not found: {}", self.base_dir.display()),
+            ));
+        }
+        self.base_dir = self.base_dir.canonicalize()?;
+        Ok(&self.base_dir)
+    }
+
+    /// Returns an iterator over the absolute input paths.
     /// Input paths may be provided as arguments or from standard input.
     pub fn input_paths(&self) -> Box<dyn Iterator<Item = Path> + Send> {
+        let base_dir = Arc::new(if self.base_dir.as_os_str().is_empty() {
+            Path::from(".")
+        } else {
+            Path::from(&self.base_dir)
+        });
         if self.stdin {
             Box::new(
                 BufReader::new(stdin())
                     .lines()
-                    .map(|s| Path::from(s.unwrap().as_str())),
+                    .map(move |s| base_dir.resolve(Path::from(s.unwrap().as_str()))),
             )
         } else {
-            Box::new(self.paths.clone().into_iter())
+            Box::new(
+                self.paths
+                    .clone()
+                    .into_iter()
+                    .map(move |p| base_dir.resolve(p)),
+            )
         }
     }
 
@@ -501,6 +532,16 @@ pub struct DedupeConfig {
     /// Keeps files with paths matching any given patterns untouched.
     #[structopt(long = "keep-path", value_name = "pattern")]
     pub keep_path_patterns: Vec<Pattern>,
+
+    /// Specifies a list of path prefixes.
+    /// If non-empty, all duplicates having the same path prefix (root) are treated as one.
+    /// This also means that the files sharing the same root can be either all
+    /// dropped or all retained.
+    ///
+    /// By default, it is set to the input paths given as arguments to the earlier
+    /// `fclones group` command, if `--isolate` option was present.
+    #[structopt(long = "isolate", value_name = "path", parse(from_os_str))]
+    pub isolated_roots: Vec<Path>,
 }
 
 #[derive(Debug, StructOpt)]
diff --git a/src/dedupe.rs b/src/dedupe.rs
index 352e9dd..cbb5972 100644
--- a/src/dedupe.rs
+++ b/src/dedupe.rs
@@ -21,9 +21,11 @@ use crate::files::FileLen;
 use crate::lock::FileLock;
 use crate::log::Log;
 use crate::path::Path;
-use crate::util::fallible_sort_by_key;
-use crate::{Error, FileGroup, TIMESTAMP_FMT};
+use crate::util::{max_result, min_result, try_sort_by_key};
+use crate::{root_of, Error, FileGroup, TIMESTAMP_FMT};
+use itertools::Itertools;
 use std::collections::HashMap;
+use std::time::SystemTime;
 
 /// Defines what to do with redundant files
 #[derive(Clone, PartialEq, Eq)]
@@ -470,57 +472,6 @@ fn was_modified(files: &[FileMetadata], after: DateTime<FixedOffset>, log: &Log)
     result
 }
 
-/// Sort files so that files with highest priority (newest, most recently updated,
-/// recently accessed, etc) are sorted last.
-/// In cases when metadata of a file cannot be accessed, an error message is pushed
-/// in the result vector and such file is placed at the beginning of the list.
-fn sort_by_priority(files: &mut Vec<FileMetadata>, priority: &Priority) -> Vec<Error> {
-    let errors = match priority {
-        Priority::Newest => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .created()
-                .map_err(|e| format!("Failed to read creation time of file {}: {}", m.path, e))
-        }),
-        Priority::Oldest => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .created()
-                .map(Reverse)
-                .map_err(|e| format!("Failed to read creation time of file {}: {}", m.path, e))
-        }),
-        Priority::MostRecentlyModified => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .modified()
-                .map_err(|e| format!("Failed to read modification time of file {}: {}", m.path, e))
-        }),
-        Priority::LeastRecentlyModified => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .modified()
-                .map(Reverse)
-                .map_err(|e| format!("Failed to read modification time of file {}: {}", m.path, e))
-        }),
-        Priority::MostRecentlyAccessed => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .accessed()
-                .map_err(|e| format!("Failed to read access time of file {}: {}", m.path, e))
-        }),
-        Priority::LeastRecentlyAccessed => fallible_sort_by_key(files, |m| {
-            m.metadata
-                .accessed()
-                .map(Reverse)
-                .map_err(|e| format!("Failed to read access time of file {}: {}", m.path, e))
-        }),
-        Priority::MostNested => {
-            files.sort_by_key(|m| m.path.component_count());
-            vec![]
-        }
-        Priority::LeastNested => {
-            files.sort_by_key(|m| Reverse(m.path.component_count()));
-            vec![]
-        }
-    };
-    errors.into_iter().map(Error::from).collect()
-}
-
 /// Returns true if given path matches any of the `keep` patterns
 fn should_keep(path: &Path, config: &DedupeConfig) -> bool {
     let matches_any_name = config
@@ -564,6 +515,108 @@ fn may_drop(path: &Path, config: &DedupeConfig) -> bool {
         || matches_any_path()
 }
 
+/// A sub-group of files. A sub-group is treated as one entity.
+/// All files in a subgroup must be either dropped or kept.
+struct FileSubGroup {
+    files: Vec<FileMetadata>,
+}
+
+impl FileSubGroup {
+    /// Splits a group of files into subgroups.
+    /// Files sharing the same prefix found in the roots array are placed in the same subgroup.
+    pub fn group(files: Vec<FileMetadata>, roots: &[Path]) -> Vec<FileSubGroup> {
+        assert!(!files.is_empty());
+        files
+            .into_iter()
+            .map(|f| (root_of(&f.path, roots).clone(), f))
+            .into_group_map()
+            .into_values()
+            .map(|files| FileSubGroup { files })
+            .collect()
+    }
+
+    /// Returns the time of the earliest creation of a file in the subgroup
+    pub fn created(&self) -> Result<SystemTime, Error> {
+        Ok(min_result(self.files.iter().map(|f| {
+            f.metadata
+                .created()
+                .map_err(|e| format!("Failed to read creation time of file {}: {}", f.path, e))
+        }))?
+        .unwrap())
+    }
+
+    /// Returns the time of the latest modification of a file in the subgroup
+    pub fn modified(&self) -> Result<SystemTime, Error> {
+        Ok(max_result(self.files.iter().map(|f| {
+            f.metadata
+                .modified()
+                .map_err(|e| format!("Failed to read modification time of file {}: {}", f.path, e))
+        }))?
+        .unwrap())
+    }
+
+    /// Returns the time of the latest access of a file in the subgroup
+    pub fn accessed(&self) -> Result<SystemTime, Error> {
+        Ok(max_result(self.files.iter().map(|f| {
+            f.metadata
+                .accessed()
+                .map_err(|e| format!("Failed to read access time of file {}: {}", f.path, e))
+        }))?
+        .unwrap())
+    }
+
+    /// Returns true if any of the files in the subgroup must be kept
+    pub fn should_keep(&self, config: &DedupeConfig) -> bool {
+        self.files.iter().any(|f| should_keep(&f.path, config))
+    }
+
+    /// Returns true if all files in the subgroup can be dropped
+    pub fn may_drop(&self, config: &DedupeConfig) -> bool {
+        self.files.iter().all(|f| may_drop(&f.path, config))
+    }
+
+    /// Returns the number of components of the least nested path
+    pub fn min_nesting(&self) -> usize {
+        self.files
+            .iter()
+            .map(|f| f.path.component_count())
+            .min()
+            .unwrap()
+    }
+
+    /// Returns the number of components of the most nested path
+    pub fn max_nesting(&self) -> usize {
+        self.files
+            .iter()
+            .map(|f| f.path.component_count())
+            .max()
+            .unwrap()
+    }
+}
+
+/// Sort files so that files with highest priority (newest, most recently updated,
+/// recently accessed, etc) are sorted last.
+/// In cases when metadata of a file cannot be accessed, an error message is pushed
+/// in the result vector and such file is placed at the beginning of the list.
+fn sort_by_priority(files: &mut [FileSubGroup], priority: &Priority) -> Vec<Error> {
+    match priority {
+        Priority::Newest => try_sort_by_key(files, |m| m.created()),
+        Priority::Oldest => try_sort_by_key(files, |m| m.created().map(Reverse)),
+        Priority::MostRecentlyModified => try_sort_by_key(files, |m| m.modified()),
+        Priority::LeastRecentlyModified => try_sort_by_key(files, |m| m.modified().map(Reverse)),
+        Priority::MostRecentlyAccessed => try_sort_by_key(files, |m| m.accessed()),
+        Priority::LeastRecentlyAccessed => try_sort_by_key(files, |m| m.accessed().map(Reverse)),
+        Priority::MostNested => {
+            files.sort_by_key(|m| m.max_nesting());
+            vec![]
+        }
+        Priority::LeastNested => {
+            files.sort_by_key(|m| Reverse(m.min_nesting()));
+            vec![]
+        }
+    }
+}
+
 struct PartitionedFileGroup {
     to_keep: Vec<FileMetadata>,
     to_drop: Vec<FileMetadata>,
@@ -576,8 +629,8 @@ impl PartitionedFileGroup {
         let root = source_path
             .root()
             .to_string()
-            .replace("/", "")
-            .replace(":", "");
+            .replace('/', "")
+            .replace(':', "");
         let suffix = source_path.strip_root();
         if root.is_empty() {
             target_dir.join(suffix)
@@ -641,6 +694,29 @@ impl PartitionedFileGroup {
     }
 }
 
+/// Attempts to retrieve the metadata of all the files in the file group.
+/// If metadata is inaccessible for a file, a warning is emitted to the log, and None gets returned.
+fn files_metadata(group: FileGroup<Path>, log: &Log) -> Option<Vec<FileMetadata>> {
+    let mut last_error: Option<io::Error> = None;
+    let files: Vec<_> = group
+        .files
+        .into_iter()
+        .filter_map(|p| {
+            FileMetadata::new(p)
+                .map_err(|e| {
+                    log.warn(&e);
+                    last_error = Some(e);
+                })
+                .ok()
+        })
+        .collect();
+
+    match last_error {
+        Some(_) => None,
+        None => Some(files),
+    }
+}
+
 /// Partitions a group of files into files to keep and files that can be safely dropped
 /// (or linked).
 fn partition(
@@ -657,30 +733,10 @@ fn partition(
         )))
     };
 
-    // Fetch metadata of the files
-    let mut metadata_err = false;
-    let mut files: Vec<_> = group
-        .files
-        .into_iter()
-        .map(|p| {
-            FileMetadata::new(p)
-                .map_err(|e| {
-                    log.warn(e);
-                    metadata_err = true;
-                })
-                .ok()
-        })
-        .flatten()
-        .collect();
-
-    // On metadata errors, we're just ignoring the group.
-    // We must not attempt removing any files from the group
-    // because we can't reliably determine which files to remove.
-    // What if the files we couldn't access should be removed and the ones we access
-    // should remain untouched?
-    if metadata_err {
-        return error("Metadata of some files could not be obtained");
-    }
+    let mut files = match files_metadata(group, log) {
+        Some(files) => files,
+        None => return error("Metadata of some files could not be obtained"),
+    };
 
     // We don't want to remove dirs or symlinks
     files.retain(|m| {
@@ -714,13 +770,15 @@ fn partition(
         }
     }
 
+    let mut file_sub_groups = FileSubGroup::group(files, &config.isolated_roots);
+
     // Sort files to remove in user selected order.
     // The priorities at the beginning of the argument list have precedence over
     // the priorities given at the end of the argument list, therefore we're applying
     // them in reversed order.
     let mut sort_errors = Vec::new();
     for priority in config.priority.iter().rev() {
-        sort_errors.extend(sort_by_priority(&mut files, priority));
+        sort_errors.extend(sort_by_priority(&mut file_sub_groups, priority));
     }
 
     if !sort_errors.is_empty() {
@@ -730,11 +788,11 @@ fn partition(
         return error("Metadata of some files could not be read.");
     }
 
-    // Split the set of files into two sets - a set that we want to keep intact and a set
+    // Split the set of file subgroups into two sets - a set that we want to keep intact and a set
     // that we can remove or replace with links:
-    let (mut to_retain, mut to_drop): (Vec<_>, Vec<_>) = files
+    let (mut to_retain, mut to_drop): (Vec<_>, Vec<_>) = file_sub_groups
         .into_iter()
-        .partition(|m| should_keep(&m.path, config) || !may_drop(&m.path, config));
+        .partition(|m| m.should_keep(config) || !m.may_drop(config));
 
     // If the set to retain is smaller than the number of files we must keep (rf), then
     // move some higher priority files from `to_drop` and append them to `to_retain`.
@@ -744,8 +802,8 @@ fn partition(
 
     assert!(to_retain.len() >= n || to_drop.is_empty());
     Ok(PartitionedFileGroup {
-        to_keep: to_retain,
-        to_drop,
+        to_keep: to_retain.into_iter().flat_map(|g| g.files).collect(),
+        to_drop: to_drop.into_iter().flat_map(|g| g.files).collect(),
     })
 }
 
@@ -853,6 +911,7 @@ pub fn log_script(
 #[cfg(test)]
 mod test {
     use std::collections::HashSet;
+    use std::fs::create_dir;
     use std::path::PathBuf;
     use std::{thread, time};
 
@@ -1157,6 +1216,40 @@ mod test {
         })
     }
 
+    #[test]
+    fn test_partition_respects_isolated_roots() {
+        with_dir("dedupe/partition/isolated_roots", |root| {
+            let root1 = root.join("root1");
+            let root2 = root.join("root2");
+            create_dir(&root1).unwrap();
+            create_dir(&root2).unwrap();
+
+            let group1 = make_group(&root1);
+            let group2 = make_group(&root2);
+            let group = FileGroup {
+                file_len: group1.file_len,
+                file_hash: group1.file_hash,
+                files: group1.files.into_iter().chain(group2.files).collect(),
+            };
+
+            let mut config = DedupeConfig::default();
+            config.priority = vec![Priority::Oldest];
+            config.isolated_roots = vec![Path::from(&root1), Path::from(&root2)];
+
+            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            assert_eq!(p.to_drop.len(), 3);
+            assert!(p
+                .to_drop
+                .iter()
+                .all(|f| f.path.to_path_buf().starts_with(&root1)));
+            assert_eq!(p.to_keep.len(), 3);
+            assert!(p
+                .to_keep
+                .iter()
+                .all(|f| f.path.to_path_buf().starts_with(&root2)));
+        })
+    }
+
     #[test]
     fn test_run_dedupe_script() {
         with_dir("dedupe/partition/dedupe_script", |root| {
diff --git a/src/lib.rs b/src/lib.rs
index 633ceea..0545f69 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -976,6 +976,7 @@ pub fn write_report(config: &GroupConfig, log: &Log, groups: &[FileGroup<Path>])
         timestamp: DateTime::from_utc(now.naive_utc(), *now.offset()),
         version: env!("CARGO_PKG_VERSION").to_owned(),
         command: args().collect(),
+        base_dir: config.base_dir.to_string_lossy().to_string(),
         stats: Some(FileStats {
             group_count: groups.len(),
             redundant_file_count: groups.selected_count(rf_over, usize::MAX),
diff --git a/src/main.rs b/src/main.rs
index 451fa03..314ff39 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
 use std::ffi::{OsStr, OsString};
 use std::fs::File;
 use std::io::{stdin, Write};
+use std::path::PathBuf;
 use std::process::exit;
 use std::sync::Arc;
 use std::{fs, io};
@@ -15,7 +16,7 @@ use structopt::StructOpt;
 use console::style;
 use fclones::config::{Command, Config, DedupeConfig, GroupConfig, Parallelism};
 use fclones::log::Log;
-use fclones::report::open_report;
+use fclones::report::{open_report, ReportHeader};
 use fclones::{dedupe, log_script, run_script, DedupeOp};
 use fclones::{group_files, write_report, Error};
 
@@ -35,6 +36,64 @@ fn extract_error_cause(message: &str) -> String {
         .join(" ")
 }
 
+/// Returns error if any of the input paths doesn't exist or if input paths list is empty.
+fn check_input_paths_exist(config: &GroupConfig, log: &mut Log) -> Result<(), Error> {
+    // Unfortunately we can't fail fast here when the list of files
+    // is streamed from the standard input, because we'd have to collect all paths into a vector
+    // list first, but we don't want to do this because there may be many.
+    // In that case, we just let the lower layers handle eventual
+    // problems and report as warnings.
+    if config.stdin {
+        return Ok(());
+    }
+
+    // If files aren't streamed on stdin, we can inspect all of them now
+    // and exit early on any access error. If depth is set to 0 (recursive scan disabled)
+    // we also want to filter out directories and terminate with an error if there are
+    // no files in the input.
+    let mut access_error = false;
+    let depth = config.depth;
+    let input_paths = config
+        .input_paths()
+        .filter(|p| match fs::metadata(&p.to_path_buf()) {
+            Ok(m) if m.is_dir() && depth == Some(0) => {
+                log.warn(format!(
+                    "Skipping directory {} because recursive scan is disabled.",
+                    p.display()
+                ));
+                false
+            }
+            Err(e) => {
+                log.err(format!("Can't access {}: {}", p.display(), e));
+                access_error = true;
+                false
+            }
+            Ok(_) => true,
+        })
+        .collect_vec();
+    if access_error {
+        return Err(Error::from("Some input paths could not be accessed."));
+    }
+    if input_paths.is_empty() {
+        return Err(Error::from("No input files."));
+    }
+    Ok(())
+}
+
+/// Attempts to create the output file and returns an error if it fails.
+fn check_can_create_output_file(config: &GroupConfig) -> Result<(), Error> {
+    if let Some(output) = &config.output {
+        if let Err(e) = File::create(output) {
+            return Err(Error::new(format!(
+                "Cannot create output file {}: {}",
+                output.display(),
+                e
+            )));
+        }
+    }
+    Ok(())
+}
+
 /// Configures global thread pool to use desired number of threads
 fn configure_main_thread_pool(pool_sizes: &HashMap<OsString, Parallelism>) {
     let parallelism = pool_sizes.get(OsStr::new("main")).unwrap_or_else(|| {
@@ -53,56 +112,10 @@ fn configure_main_thread_pool(pool_sizes: &HashMap<OsString, Parallelism>) {
 }
 
 fn run_group(mut config: GroupConfig, log: &mut Log) -> Result<(), Error> {
-    if !config.stdin {
-        // If files aren't streamed on stdin, we can inspect all of them now
-        // and exit early on any access error. If depth is set to 0 (recursive scan disabled)
-        // we also want to filter out directories and terminate with an error if there are
-        // no files in the input.
-        // Unfortunately we can't fail fast here when the list of files
-        // is streamed from the standard input, because we'd have to collect all paths into a vector
-        // list first, but we don't want to do this because there may be many.
-        // In that case, we just let the lower layers handle eventual
-        // problems and report as warnings.
-        let mut access_error = false;
-        let depth = config.depth;
-        config
-            .paths
-            .retain(|p| match fs::metadata(&p.to_path_buf()) {
-                Ok(m) if m.is_dir() && depth == Some(0) => {
-                    log.warn(format!(
-                        "Skipping directory {} because recursive scan is disabled.",
-                        p.display()
-                    ));
-                    false
-                }
-                Err(e) => {
-                    log.err(format!("Can't access {}: {}", p.display(), e));
-                    access_error = true;
-                    false
-                }
-                Ok(_) => true,
-            });
-        if access_error {
-            return Err(Error::from("Some input paths could not be accessed."));
-        }
-        if config.paths.is_empty() {
-            return Err(Error::from("No input files."));
-        }
-    }
-
+    config.resolve_base_dir().map_err(|e| e.to_string())?;
+    check_input_paths_exist(&config, log)?;
+    check_can_create_output_file(&config)?;
     configure_main_thread_pool(&config.thread_pool_sizes());
-    if let Some(output) = &config.output {
-        // Try to create the output file now and fail early so that
-        // the user doesn't waste time to only find that the report cannot be written at the end:
-        if let Err(e) = File::create(output) {
-            return Err(Error::new(format!(
-                "Cannot create output file {}: {}",
-                output.display(),
-                e
-            )));
-        }
-    }
-
     log.info("Started grouping");
     let results = group_files(&config, log).map_err(|e| Error::new(e.message))?;
 
@@ -124,27 +137,48 @@ fn get_output_writer(config: &DedupeConfig) -> Result<Box<dyn Write + Send>, Err
     }
 }
 
+/// Returns the configuration of a previously executed fclones command,
+/// stored in the report header.
+fn get_command_config(header: &ReportHeader) -> Result<Config, Error> {
+    let mut command: Config = Config::from_iter_safe(&header.command).map_err(|e| {
+        let message: String = extract_error_cause(&e.message);
+        format!("Unrecognized earlier fclones configuration: {}", message)
+    })?;
+
+    // Configure the same base directory as set when running the previous command.
+    // This is important to get the correct input paths.
+    if let Command::Group(ref mut group_config) = command.command {
+        group_config.base_dir = PathBuf::from(&header.base_dir)
+    }
+    Ok(command)
+}
+
 pub fn run_dedupe(op: DedupeOp, config: DedupeConfig, log: &mut Log) -> Result<(), Error> {
     let input_error = |e: io::Error| format!("Input error: {}", e);
     let mut dedupe_config = config;
     let mut reader = open_report(stdin()).map_err(input_error)?;
     let header = reader.read_header().map_err(input_error)?;
-    let find_config: Config = Config::from_iter_safe(&header.command).map_err(|e| {
-        let message: String = extract_error_cause(&e.message);
-        format!("Unrecognized earlier fclones configuration: {}", message)
-    })?;
+    let prev_command_config = get_command_config(&header)?;
 
-    let rf_over = match find_config.command {
-        Command::Group(c) => c.rf_over(),
-        _ if dedupe_config.rf_over.is_some() => dedupe_config.rf_over.unwrap(),
-        _ => {
-            return Err(Error::from(
-                "Could not extract --rf-over setting from the earlier fclones configuration.",
-            ))
+    if dedupe_config.rf_over.is_none() {
+        match &prev_command_config.command {
+            Command::Group(c) => dedupe_config.rf_over = Some(c.rf_over()),
+            _ => {
+                return Err(Error::from(
+                    "Could not extract --rf-over setting from the earlier fclones configuration.",
+                ))
+            }
         }
     };
 
-    dedupe_config.rf_over = Some(rf_over);
+    if dedupe_config.isolated_roots.is_empty() {
+        if let Command::Group(c) = &prev_command_config.command {
+            if c.isolate {
+                dedupe_config.isolated_roots = c.input_paths().collect();
+            }
+        }
+    }
+
     if dedupe_config.modified_before.is_none() {
         dedupe_config.modified_before = Some(header.timestamp);
     }
diff --git a/src/pattern.rs b/src/pattern.rs
index 7f85f35..cb6abab 100644
--- a/src/pattern.rs
+++ b/src/pattern.rs
@@ -48,6 +48,7 @@ impl FromStr for Pattern {
     }
 }
 
+#[derive(Default)]
 pub struct PatternOpts {
     case_insensitive: bool,
 }
@@ -60,14 +61,6 @@ impl PatternOpts {
     }
 }
 
-impl Default for PatternOpts {
-    fn default() -> PatternOpts {
-        PatternOpts {
-            case_insensitive: false,
-        }
-    }
-}
-
 #[derive(PartialEq, Debug)]
 enum Scope {
     TopLevel,
@@ -207,19 +200,19 @@ impl Pattern {
             |(_, list, _)| list,
         );
 
-        let p_ext_optional = map(tuple((tag("?"), |i| p_ext_glob(i))), |(_, g)| {
+        let p_ext_optional = map(tuple((tag("?"), &p_ext_glob)), |(_, g)| {
             mk_string(g, "(", "|", ")?")
         });
-        let p_ext_many = map(tuple((tag("*"), |i| p_ext_glob(i))), |(_, g)| {
+        let p_ext_many = map(tuple((tag("*"), &p_ext_glob)), |(_, g)| {
             mk_string(g, "(", "|", ")*")
         });
-        let p_ext_at_least_once = map(tuple((tag("+"), |i| p_ext_glob(i))), |(_, g)| {
+        let p_ext_at_least_once = map(tuple((tag("+"), &p_ext_glob)), |(_, g)| {
             mk_string(g, "(", "|", ")+")
         });
-        let p_ext_exactly_once = map(tuple((tag("@"), |i| p_ext_glob(i))), |(_, g)| {
+        let p_ext_exactly_once = map(tuple((tag("@"), &p_ext_glob)), |(_, g)| {
             mk_string(g, "(", "|", ")")
         });
-        let p_ext_never = map(tuple((tag("!"), |i| p_ext_glob(i))), |(_, g)| {
+        let p_ext_never = map(tuple((tag("!"), &p_ext_glob)), |(_, g)| {
             mk_string(g, "(?!", "|", ")")
         });
 
diff --git a/src/report.rs b/src/report.rs
index 365f9e9..2335b5a 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -39,6 +39,8 @@ pub struct ReportHeader {
     pub timestamp: DateTime<FixedOffset>,
     /// Full shell command containing arguments of the search run that produced the report
     pub command: Vec<String>,
+    /// Working directory where the fclones command was executed
+    pub base_dir: String,
     /// Information on the number of duplicate files reported.
     /// This is optional to allow streaming the report out before finding all files in the future.
     pub stats: Option<FileStats>,
@@ -96,6 +98,7 @@ impl<W: Write> ReportWriter<W> {
     /// # Report by fclones 0.12.0
     /// # Timestamp: Mon, 03 May 2021 13:22:51 +0000
     /// # Command: target/debug/fclones find . -o report.txt
+    /// # Base dir: /home/pkolaczk/Projekty/fclones
     /// # Found 553 file groups
     /// # 271.8 MB in 4266 redundant files can be removed
     /// 5649a555c131508c4a757d9e14c4aea6, 6626689 B (6.6 MB) * 5:
@@ -124,6 +127,7 @@ impl<W: Write> ReportWriter<W> {
             header.timestamp.format(TIMESTAMP_FMT)
         ))?;
         self.write_header_line(&format!("Command: {}", command))?;
+        self.write_header_line(&format!("Base dir: {}", header.base_dir))?;
         if let Some(stats) = &header.stats {
             self.write_header_line(&format!("Found {} file groups", stats.group_count))?;
             self.write_header_line(&format!(
@@ -463,6 +467,7 @@ impl<R: BufRead + Send + 'static> ReportReader for TextReportReader<R> {
                 Regex::new(r"^# Report by fclones ([0-9]+\.[0-9]+\.[0-9]+)").unwrap();
             static ref TIMESTAMP_RE: Regex = Regex::new(r"^# Timestamp: (.*)").unwrap();
             static ref COMMAND_RE: Regex = Regex::new(r"^# Command: (.*)").unwrap();
+            static ref BASE_DIR_RE: Regex = Regex::new(r"^# Base dir: (.*)").unwrap();
             static ref GROUP_COUNT_RE: Regex =
                 Regex::new(r"^# Found ([0-9]+) file groups").unwrap();
             static ref STATS_RE: Regex =
@@ -491,6 +496,9 @@ impl<R: BufRead + Send + 'static> ReportReader for TextReportReader<R> {
                 format!("Malformed header: Failed to parse command arguments: {}", e),
             )
         })?;
+        let base_dir = self
+            .read_extract(&BASE_DIR_RE, "Malformed header: Missing base dir")?
+            .swap_remove(0);
         let group_count = self
             .read_extract(&GROUP_COUNT_RE, "Malformed header: Missing group count")?
             .swap_remove(0);
@@ -525,6 +533,7 @@ impl<R: BufRead + Send + 'static> ReportReader for TextReportReader<R> {
             version,
             timestamp,
             command,
+            base_dir,
             stats: Some(FileStats {
                 group_count,
                 redundant_file_count,
@@ -601,6 +610,8 @@ pub fn open_report(r: impl Read + Send + 'static) -> io::Result<Box<dyn ReportRe
 
 #[cfg(test)]
 mod test {
+    use std::env::current_dir;
+
     use tempfile::NamedTempFile;
 
     use crate::files::{FileHash, FileLen};
@@ -611,6 +622,7 @@ mod test {
     fn dummy_report_header() -> ReportHeader {
         ReportHeader {
             command: vec!["fclones".to_owned(), "find".to_owned(), ".".to_owned()],
+            base_dir: current_dir().unwrap().to_string_lossy().to_string(),
             version: env!("CARGO_PKG_VERSION").to_owned(),
             timestamp: DateTime::parse_from_str("2021-08-27 12:11:23.456 +0000", TIMESTAMP_FMT)
                 .unwrap(),
diff --git a/src/selector.rs b/src/selector.rs
index 2902ebc..2b82258 100644
--- a/src/selector.rs
+++ b/src/selector.rs
@@ -107,7 +107,7 @@ impl PathSelector {
             pattern
         } else {
             let base_dir_pat = base_dir.to_string_lossy();
-            let base_dir_pat = base_dir_pat.replace("\u{FFFD}", "?");
+            let base_dir_pat = base_dir_pat.replace('\u{FFFD}', "?");
             let base_dir_pat = Pattern::literal(Self::append_sep(base_dir_pat).as_str());
             base_dir_pat + pattern
         }
diff --git a/src/util.rs b/src/util.rs
index 4eacd45..adbe82b 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -1,3 +1,4 @@
+use itertools::Itertools;
 use serde::{Serialize, Serializer};
 use std::cell::Cell;
 
@@ -17,7 +18,7 @@ where
 /// Sorts an array using a key generation function that can fail.
 /// Items for which the key could not be obtained are sorted last.
 /// Returns vector of errors encountered when obtaining the keys.
-pub fn fallible_sort_by_key<T, K, E>(v: &mut Vec<T>, f: impl Fn(&T) -> Result<K, E>) -> Vec<E>
+pub fn try_sort_by_key<T, K, E>(v: &mut [T], f: impl Fn(&T) -> Result<K, E>) -> Vec<E>
 where
     K: Ord,
 {
@@ -26,6 +27,42 @@ where
     errors
 }
 
+/// Reduces the elements to a single one, by repeatedly applying a reducing operation.
+/// If the iterator is empty, returns `Ok(None)`; otherwise, returns the result of the reduction.
+/// If any of the elements is `Err`, returns the first `Err`.
+pub fn reduce_results<I, T, E, F>(mut iter: I, f: F) -> Result<Option<T>, E>
+where
+    I: Iterator<Item = Result<T, E>>,
+    F: Fn(T, T) -> T,
+{
+    iter.fold_results(None, |res, item| match res {
+        Some(res) => Some(f(res, item)),
+        None => Some(item),
+    })
+}
+
+/// Finds the minimum value.
+/// If any of the values is `Err`, returns the first `Err`.
+/// If the input iterator is empty, returns `Ok(None)`.
+pub fn min_result<I, T, E>(iter: I) -> Result<Option<T>, E>
+where
+    I: Iterator<Item = Result<T, E>>,
+    T: Ord,
+{
+    reduce_results(iter, T::min)
+}
+
+/// Finds the maximum value.
+/// If any of the values is `Err`, returns the first `Err`.
+/// If the input iterator is empty, returns `Ok(None)`.
+pub fn max_result<I, T, E>(iter: I) -> Result<Option<T>, E>
+where
+    I: Iterator<Item = Result<T, E>>,
+    T: Ord,
+{
+    reduce_results(iter, T::max)
+}
+
 /// Utility functions intended for testing.
 /// Beware they typically panic instead of returning `Err`.
 #[cfg(test)]
@@ -37,6 +74,7 @@ pub mod test {
     use std::time::SystemTime;
     use std::{fs, thread};
 
+    use super::*;
     use lazy_static::lazy_static;
 
     #[derive(Debug, PartialEq, Eq)]
@@ -143,4 +181,28 @@ pub mod test {
             FsSupportsReflink::Supported(val) => val,
         }
     }
+
+    #[test]
+    fn min_result_should_return_none_if_no_elements() {
+        let elements: Vec<Result<i64, &str>> = vec![];
+        assert_eq!(min_result(elements.into_iter()), Ok(None));
+    }
+
+    #[test]
+    fn min_result_should_return_min_if_all_ok() {
+        let elements: Vec<Result<i64, &str>> = vec![Ok(1), Ok(3), Ok(2)];
+        assert_eq!(min_result(elements.into_iter()), Ok(Some(1)));
+    }
+
+    #[test]
+    fn min_result_should_return_err_if_at_least_one_err() {
+        let elements: Vec<Result<i64, &str>> = vec![Ok(1), Ok(3), Err("error"), Ok(2)];
+        assert_eq!(min_result(elements.into_iter()), Err("error"));
+    }
+
+    #[test]
+    fn max_result_should_return_max_if_all_ok() {
+        let elements: Vec<Result<i64, &str>> = vec![Ok(1), Ok(3), Ok(2)];
+        assert_eq!(max_result(elements.into_iter()), Ok(Some(3)));
+    }
 }


Test patch: