Repository: pkolaczk/fclones (language: rust)

Issue:
Pluggable logging and progress reporting
Currently logging and progress bar implementations are tightly coupled to the fclones engine. These should be replaced by traits + adapters so they can be swapped to different implementations e.g. in a GUI app.


Discussion / hints:

Fix patch:
diff --git a/src/dedupe.rs b/src/dedupe.rs
index 7f2ce6f..ec8bd11 100644
--- a/src/dedupe.rs
+++ b/src/dedupe.rs
@@ -22,7 +22,7 @@ use crate::device::DiskDevices;
 use crate::file::{FileId, FileLen, FileMetadata};
 use crate::group::{FileGroup, FileSubGroup};
 use crate::lock::FileLock;
-use crate::log::Log;
+use crate::log::{Log, LogExt};
 use crate::path::Path;
 use crate::util::{max_result, min_result, try_sort_by_key};
 use crate::{Error, TIMESTAMP_FMT};
@@ -266,7 +266,7 @@ impl FsCommand {
     pub fn safe_remove<R>(
         path: &Path,
         f: impl FnOnce(&Path) -> io::Result<R>,
-        log: &Log,
+        log: &dyn Log,
     ) -> io::Result<R> {
         let tmp = Self::temp_file(path);
         Self::unsafe_rename(path, &tmp)?;
@@ -297,7 +297,7 @@ impl FsCommand {
     }
 
     /// Executes the command and returns the number of bytes reclaimed
-    pub fn execute(&self, should_lock: bool, log: &Log) -> io::Result<FileLen> {
+    pub fn execute(&self, should_lock: bool, log: &dyn Log) -> io::Result<FileLen> {
         match self {
             FsCommand::Remove { file } => {
                 let _ = Self::maybe_lock(&file.path, should_lock)?;
@@ -471,7 +471,7 @@ impl AddAssign for DedupeResult {
 
 /// Returns true if any of the files have been modified after the given timestamp.
 /// Also returns true if file timestamp could not be read.
-fn was_modified(files: &[PathAndMetadata], after: DateTime<FixedOffset>, log: &Log) -> bool {
+fn was_modified(files: &[PathAndMetadata], after: DateTime<FixedOffset>, log: &dyn Log) -> bool {
     let mut result = false;
     let after: DateTime<Local> = after.into();
     for PathAndMetadata {
@@ -728,7 +728,7 @@ 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<P>(group: FileGroup<P>, log: &Log) -> Option<Vec<PathAndMetadata>>
+fn files_metadata<P>(group: FileGroup<P>, log: &dyn Log) -> Option<Vec<PathAndMetadata>>
 where
     P: Into<Path>,
 {
@@ -757,7 +757,7 @@ where
 fn partition<P>(
     group: FileGroup<P>,
     config: &DedupeConfig,
-    log: &Log,
+    log: &dyn Log,
 ) -> Result<PartitionedFileGroup, Error>
 where
     P: Into<Path>,
@@ -881,7 +881,7 @@ pub fn dedupe<'a, I, P>(
     groups: I,
     op: DedupeOp,
     config: &'a DedupeConfig,
-    log: &'a Log,
+    log: &'a dyn Log,
 ) -> impl ParallelIterator<Item = (usize, Vec<FsCommand>)> + Send + 'a
 where
     I: IntoIterator<Item = FileGroup<P>> + 'a,
@@ -910,7 +910,7 @@ where
 /// On command execution failure, a warning is logged and the execution of remaining commands
 /// continues.
 /// Returns the number of files processed and the amount of disk space reclaimed.
-pub fn run_script<I>(script: I, should_lock: bool, log: &Log) -> DedupeResult
+pub fn run_script<I>(script: I, should_lock: bool, log: &dyn Log) -> DedupeResult
 where
     I: IntoParallelIterator<Item = (usize, Vec<FsCommand>)>,
 {
@@ -1044,6 +1044,7 @@ mod test {
     use crate::config::GroupConfig;
     use crate::file::FileHash;
     use crate::group_files;
+    use crate::log::StdLog;
     use crate::pattern::Pattern;
     use crate::util::test::{create_file, create_file_newer_than, read_file, with_dir, write_file};
 
@@ -1064,7 +1065,7 @@ mod test {
     #[test]
     fn test_remove_command_removes_file() {
         with_dir("dedupe/remove_cmd", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path = root.join("file");
             create_file(&file_path);
             let file = PathAndMetadata::new(Path::from(&file_path)).unwrap();
@@ -1077,7 +1078,7 @@ mod test {
     #[test]
     fn test_move_command_moves_file_by_rename() {
         with_dir("dedupe/move_rename_cmd", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path = root.join("file");
             let target = Path::from(root.join("target"));
             create_file(&file_path);
@@ -1096,7 +1097,7 @@ mod test {
     #[test]
     fn test_move_command_moves_file_by_copy() {
         with_dir("dedupe/move_copy_cmd", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path = root.join("file");
             let target = Path::from(root.join("target"));
             create_file(&file_path);
@@ -1115,7 +1116,7 @@ mod test {
     #[test]
     fn test_move_fails_if_target_exists() {
         with_dir("dedupe/move_target_exists", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path = root.join("file");
             let target = root.join("target");
             create_file(&file_path);
@@ -1133,7 +1134,7 @@ mod test {
     #[test]
     fn test_soft_link_command_replaces_file_with_a_link() {
         with_dir("dedupe/soft_link_cmd", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path_1 = root.join("file_1");
             let file_path_2 = root.join("file_2");
             write_file(&file_path_1, "foo");
@@ -1160,7 +1161,7 @@ mod test {
     #[test]
     fn test_hard_link_command_replaces_file_with_a_link() {
         with_dir("dedupe/hard_link_cmd", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path_1 = root.join("file_1");
             let file_path_2 = root.join("file_2");
 
@@ -1209,7 +1210,7 @@ mod test {
         with_dir("dedupe/partition/basic", |root| {
             let group = make_group(root, FileHash::from_str("00").unwrap());
             let config = DedupeConfig::default();
-            let partitioned = partition(group, &config, &Log::new()).unwrap();
+            let partitioned = partition(group, &config, &StdLog::new()).unwrap();
             assert_eq!(partitioned.to_keep.len(), 1);
             assert_eq!(partitioned.to_drop.len(), 2);
         })
@@ -1221,7 +1222,7 @@ mod test {
             let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.modified_before = Some(DateTime::from(Local::now() - Duration::days(1)));
-            let partitioned = partition(group, &config, &Log::new());
+            let partitioned = partition(group, &config, &StdLog::new());
             assert!(partitioned.is_err());
         })
     }
@@ -1235,7 +1236,7 @@ mod test {
 
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::MostRecentlyModified];
-            let partitioned = partition(group, &config, &Log::new()).unwrap();
+            let partitioned = partition(group, &config, &StdLog::new()).unwrap();
             assert!(partitioned
                 .to_drop
                 .iter()
@@ -1264,9 +1265,9 @@ mod test {
             let group = make_group(root, FileHash::from_str("00").unwrap());
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::Newest];
-            let partitioned_1 = partition(group.clone(), &config, &Log::new()).unwrap();
+            let partitioned_1 = partition(group.clone(), &config, &StdLog::new()).unwrap();
             config.priority = vec![Priority::Oldest];
-            let partitioned_2 = partition(group.clone(), &config, &Log::new()).unwrap();
+            let partitioned_2 = partition(group.clone(), &config, &StdLog::new()).unwrap();
 
             assert_ne!(
                 path_set(&partitioned_1.to_keep),
@@ -1290,9 +1291,9 @@ mod test {
 
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::MostRecentlyModified];
-            let partitioned_1 = partition(group.clone(), &config, &Log::new()).unwrap();
+            let partitioned_1 = partition(group.clone(), &config, &StdLog::new()).unwrap();
             config.priority = vec![Priority::LeastRecentlyModified];
-            let partitioned_2 = partition(group.clone(), &config, &Log::new()).unwrap();
+            let partitioned_2 = partition(group.clone(), &config, &StdLog::new()).unwrap();
 
             assert_ne!(
                 path_set(&partitioned_1.to_keep),
@@ -1312,13 +1313,13 @@ mod test {
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::LeastRecentlyModified];
             config.keep_name_patterns = vec![Pattern::glob("*_1").unwrap()];
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
             assert_eq!(p.to_keep.len(), 1);
             assert_eq!(&p.to_keep[0].path, &group.files[0]);
 
             config.keep_name_patterns = vec![];
             config.keep_path_patterns = vec![Pattern::glob("**/file_1").unwrap()];
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
             assert_eq!(p.to_keep.len(), 1);
             assert_eq!(&p.to_keep[0].path, &group.files[0]);
         })
@@ -1331,13 +1332,13 @@ mod test {
             let mut config = DedupeConfig::default();
             config.priority = vec![Priority::LeastRecentlyModified];
             config.name_patterns = vec![Pattern::glob("*_3").unwrap()];
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
             assert_eq!(p.to_drop.len(), 1);
             assert_eq!(&p.to_drop[0].path, &group.files[2]);
 
             config.name_patterns = vec![];
             config.path_patterns = vec![Pattern::glob("**/file_3").unwrap()];
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
             assert_eq!(p.to_drop.len(), 1);
             assert_eq!(&p.to_drop[0].path, &group.files[2]);
         })
@@ -1362,7 +1363,7 @@ mod test {
             let mut config = DedupeConfig::default();
             config.isolated_roots = vec![Path::from(&root1), Path::from(&root2)];
 
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
             assert_eq!(p.to_drop.len(), 3);
             assert!(p
                 .to_drop
@@ -1406,7 +1407,7 @@ mod test {
             };
 
             let config = DedupeConfig::default();
-            let p = partition(group.clone(), &config, &Log::new()).unwrap();
+            let p = partition(group.clone(), &config, &StdLog::new()).unwrap();
 
             // drop A files because file_a2 appears after file_b1 in the files vector
             assert_eq!(p.to_drop.len(), 2);
@@ -1425,7 +1426,7 @@ mod test {
     #[test]
     fn test_run_dedupe_script() {
         with_dir("dedupe/partition/run_dedupe_script", |root| {
-            let mut log = Log::new();
+            let mut log = StdLog::new();
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
@@ -1444,7 +1445,7 @@ mod test {
     #[test]
     fn test_log_dedupe_script() {
         with_dir("dedupe/partition/log_dedupe_script", |root| {
-            let mut log = Log::new();
+            let mut log = StdLog::new();
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
@@ -1477,7 +1478,7 @@ mod test {
     #[test]
     fn test_hard_link_merges_subgroups_of_hard_links() {
         with_dir("dedupe/merge_subgroups_of_hardlinks", |root| {
-            let mut log = Log::new();
+            let mut log = StdLog::new();
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
@@ -1521,7 +1522,7 @@ mod test {
         use std::os::unix::fs;
 
         with_dir("dedupe/remove_subgroups_with_symlinks", |root| {
-            let mut log = Log::new();
+            let mut log = StdLog::new();
             log.no_progress = true;
             log.log_stderr_to_stdout = true;
 
diff --git a/src/file.rs b/src/file.rs
index b3cf3e8..619aef9 100644
--- a/src/file.rs
+++ b/src/file.rs
@@ -21,7 +21,7 @@ use smallvec::alloc::str::FromStr;
 
 use crate::device::DiskDevices;
 use crate::group::FileGroup;
-use crate::log::Log;
+use crate::log::{Log, LogExt};
 use crate::path::Path;
 
 /// Represents data position in the file, counted from the beginning of the file, in bytes.
@@ -405,7 +405,7 @@ impl From<FileInfo> for Path {
 pub(crate) fn file_info_or_log_err(
     file: Path,
     devices: &DiskDevices,
-    log: &Log,
+    log: &dyn Log,
 ) -> Option<FileInfo> {
     match FileInfo::new(file, devices) {
         Ok(info) => Some(info),
@@ -516,7 +516,7 @@ impl<'de> Deserialize<'de> for FileHash {
         D: Deserializer<'de>,
     {
         let s = String::deserialize(deserializer)?;
-        let h = FileHash::from_str(&s).map_err(serde::de::Error::custom)?;
+        let h = FileHash::from_str(&s).map_err(de::Error::custom)?;
         Ok(h)
     }
 }
diff --git a/src/group.rs b/src/group.rs
index 87875c7..51a10f9 100644
--- a/src/group.rs
+++ b/src/group.rs
@@ -12,6 +12,7 @@ use std::io;
 use std::io::BufWriter;
 use std::iter::FromIterator;
 use std::marker::PhantomData;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
 use std::sync::Arc;
 
@@ -31,7 +32,7 @@ use crate::device::{DiskDevice, DiskDevices};
 use crate::error::Error;
 use crate::file::*;
 use crate::hasher::FileHasher;
-use crate::log::Log;
+use crate::log::{Log, LogExt, ProgressBarLength};
 use crate::path::Path;
 use crate::phase::{Phase, Phases};
 use crate::report::{FileStats, ReportHeader, ReportWriter};
@@ -100,7 +101,7 @@ where
 /// Holds stuff needed globally by the whole application
 struct GroupCtx<'a> {
     pub config: &'a GroupConfig,
-    pub log: &'a Log,
+    pub log: &'a dyn Log,
     phases: Phases,
     group_filter: FileGroupFilter,
     devices: DiskDevices,
@@ -109,7 +110,7 @@ struct GroupCtx<'a> {
 }
 
 impl<'a> GroupCtx<'a> {
-    pub fn new(config: &'a GroupConfig, log: &'a Log) -> Result<GroupCtx<'a>, Error> {
+    pub fn new(config: &'a GroupConfig, log: &'a dyn Log) -> Result<GroupCtx<'a>, Error> {
         let phases = if config.transform.is_some() {
             Phases::new(vec![
                 Phase::Walk,
@@ -614,8 +615,14 @@ where
 /// Walks the directory tree and collects matching files in parallel into a vector
 fn scan_files(ctx: &GroupCtx<'_>) -> Vec<Vec<FileInfo>> {
     let file_collector = ThreadLocal::new();
-    let spinner = ctx.log.spinner(&ctx.phases.format(Phase::Walk));
-    let spinner_tick = &|_: &Path| spinner.tick();
+    let file_count = AtomicU64::new(0);
+    let spinner = ctx
+        .log
+        .progress_bar(&ctx.phases.format(Phase::Walk), ProgressBarLength::Unknown);
+    let spinner_tick = &|_: &Path| {
+        file_count.fetch_add(1, Ordering::Relaxed);
+        spinner.inc(1);
+    };
 
     let config = &ctx.config;
     let min_size = config.min_size;
@@ -643,8 +650,10 @@ fn scan_files(ctx: &GroupCtx<'_>) -> Vec<Vec<FileInfo>> {
             });
     });
 
-    ctx.log
-        .info(format!("Scanned {} file entries", spinner.position()));
+    ctx.log.info(format!(
+        "Scanned {} file entries",
+        file_count.load(Ordering::Relaxed)
+    ));
 
     let files: Vec<_> = file_collector.into_iter().map(|r| r.into_inner()).collect();
 
@@ -709,14 +718,15 @@ fn stage_stats(groups: &[FileGroup<FileInfo>], filter: &FileGroupFilter) -> (usi
 
 fn group_by_size(ctx: &GroupCtx<'_>, files: Vec<Vec<FileInfo>>) -> Vec<FileGroup<FileInfo>> {
     let file_count: usize = files.iter().map(|v| v.len()).sum();
-    let progress = ctx
-        .log
-        .progress_bar(&ctx.phases.format(Phase::GroupBySize), file_count as u64);
+    let progress = ctx.log.progress_bar(
+        &ctx.phases.format(Phase::GroupBySize),
+        ProgressBarLength::Items(file_count as u64),
+    );
 
     let mut groups = GroupMap::new(|info: FileInfo| (info.len, info));
     for files in files.into_iter() {
         for file in files.into_iter() {
-            progress.tick();
+            progress.inc(1);
             groups.add(file);
         }
     }
@@ -793,7 +803,9 @@ fn atomic_counter_vec(len: usize) -> Vec<std::sync::atomic::AtomicU32> {
 #[cfg(target_os = "linux")]
 fn update_file_locations(ctx: &GroupCtx<'_>, groups: &mut (impl FileCollection + ?Sized)) {
     let count = groups.count();
-    let progress = ctx.log.progress_bar("Fetching extents", count as u64);
+    let progress = ctx
+        .log
+        .progress_bar("Fetching extents", ProgressBarLength::Items(count as u64));
 
     let err_counters = atomic_counter_vec(ctx.devices.len());
     groups.for_each_mut(|fi| {
@@ -803,7 +815,7 @@ fn update_file_locations(ctx: &GroupCtx<'_>, groups: &mut (impl FileCollection +
                 handle_fetch_physical_location_err(ctx, &err_counters, fi, e)
             }
         }
-        progress.tick()
+        progress.inc(1)
     });
 }
 
@@ -822,8 +834,6 @@ fn handle_fetch_physical_location_err(
     file_info: &FileInfo,
     error: io::Error,
 ) {
-    use std::sync::atomic::Ordering;
-
     const MAX_ERR_COUNT_TO_LOG: u32 = 10;
     let device = &ctx.devices[file_info.get_device_index()];
     let counter = &err_counters[device.index];
@@ -868,7 +878,7 @@ fn group_transformed(ctx: &GroupCtx<'_>, files: Vec<FileInfo>) -> Vec<FileGroup<
     }];
     let progress = ctx.log.progress_bar(
         &ctx.phases.format(Phase::TransformAndGroup),
-        unique_file_count(&groups) as u64,
+        ProgressBarLength::Items(unique_file_count(&groups) as u64),
     );
     let groups = rehash(
         groups,
@@ -885,7 +895,7 @@ fn group_transformed(ctx: &GroupCtx<'_>, files: Vec<FileInfo>) -> Vec<FileGroup<
                         fi.len = len;
                         hash
                     });
-            progress.tick();
+            progress.inc(1);
             result
         },
     );
@@ -935,9 +945,10 @@ fn group_by_prefix(
 
     let pre_filter = |g: &FileGroup<FileInfo>| g.unique_count() > 1;
     let file_count = unique_file_count(groups.iter().filter(|g| pre_filter(g)));
-    let progress = ctx
-        .log
-        .progress_bar(&ctx.phases.format(Phase::GroupByPrefix), file_count as u64);
+    let progress = ctx.log.progress_bar(
+        &ctx.phases.format(Phase::GroupByPrefix),
+        ProgressBarLength::Items(file_count as u64),
+    );
 
     let groups = rehash(
         groups,
@@ -946,7 +957,7 @@ fn group_by_prefix(
         &ctx.devices,
         FileAccess::Random,
         |(fi, _)| {
-            progress.tick();
+            progress.inc(1);
             let prefix_len = if fi.len <= prefix_len {
                 prefix_len
             } else {
@@ -994,9 +1005,10 @@ fn group_by_suffix(
     let pre_filter =
         |g: &FileGroup<FileInfo>| g.file_len >= suffix_threshold && g.unique_count() > 1;
     let file_count = unique_file_count(groups.iter().filter(|g| pre_filter(g)));
-    let progress = ctx
-        .log
-        .progress_bar(&ctx.phases.format(Phase::GroupBySuffix), file_count as u64);
+    let progress = ctx.log.progress_bar(
+        &ctx.phases.format(Phase::GroupBySuffix),
+        ProgressBarLength::Items(file_count as u64),
+    );
 
     let groups = rehash(
         groups,
@@ -1005,7 +1017,7 @@ fn group_by_suffix(
         &ctx.devices,
         FileAccess::Random,
         |(fi, old_hash)| {
-            progress.tick();
+            progress.inc(1);
             let chunk = FileChunk::new(&fi.path, fi.len.as_pos() - suffix_len, suffix_len);
             ctx.hasher
                 .hash_file_or_log_err(&chunk, |_| {})
@@ -1031,9 +1043,10 @@ fn group_by_contents(
 
     let pre_filter = |g: &FileGroup<FileInfo>| g.unique_count() > 1 && g.file_len >= min_file_len;
     let bytes_to_scan = unique_file_size(groups.iter().filter(|g| pre_filter(g)));
-    let progress = &ctx
-        .log
-        .bytes_progress_bar(&ctx.phases.format(Phase::GroupByContents), bytes_to_scan.0);
+    let progress = &ctx.log.progress_bar(
+        &ctx.phases.format(Phase::GroupByContents),
+        ProgressBarLength::Bytes(bytes_to_scan.0),
+    );
 
     let groups = rehash(
         groups,
@@ -1044,7 +1057,7 @@ fn group_by_contents(
         |(fi, _)| {
             let chunk = FileChunk::new(&fi.path, FilePos(0), fi.len);
             ctx.hasher
-                .hash_file_or_log_err(&chunk, |bytes_read| progress.inc(bytes_read))
+                .hash_file_or_log_err(&chunk, |bytes_read| progress.inc(bytes_read as u64))
         },
     );
 
@@ -1108,12 +1121,12 @@ fn group_by_contents(
 ///
 /// # Example
 /// ```
-/// use fclones::log::Log;
+/// use fclones::log::StdLog;
 /// use fclones::config::GroupConfig;
 /// use fclones::path::Path;
 /// use fclones::{group_files, write_report};
 ///
-/// let log = Log::new();
+/// let log = StdLog::new();
 /// let mut config = GroupConfig::default();
 /// config.paths = vec![Path::from("/path/to/a/dir")];
 ///
@@ -1123,8 +1136,8 @@ fn group_by_contents(
 /// // print standard fclones report to stdout:
 /// write_report(&config, &log, &groups).unwrap();
 /// ```
-pub fn group_files(config: &GroupConfig, log: &Log) -> Result<Vec<FileGroup<FileInfo>>, Error> {
-    let spinner = log.spinner("Initializing");
+pub fn group_files(config: &GroupConfig, log: &dyn Log) -> Result<Vec<FileGroup<FileInfo>>, Error> {
+    let spinner = log.progress_bar("Initializing", ProgressBarLength::Unknown);
     let ctx = GroupCtx::new(config, log)?;
 
     drop(spinner);
@@ -1166,7 +1179,7 @@ pub fn group_files(config: &GroupConfig, log: &Log) -> Result<Vec<FileGroup<File
 /// Returns [`io::Error`] on I/O write error or if the output file cannot be created.
 pub fn write_report(
     config: &GroupConfig,
-    log: &Log,
+    log: &dyn Log,
     groups: &[FileGroup<FileInfo>],
 ) -> io::Result<()> {
     let now = Local::now();
@@ -1201,8 +1214,11 @@ pub fn write_report(
 
     match &config.output {
         Some(path) => {
-            let progress = log.progress_bar("Writing report", groups.len() as u64);
-            let iter = groups.iter().inspect(|_g| progress.tick());
+            let progress = log.progress_bar(
+                "Writing report",
+                ProgressBarLength::Items(groups.len() as u64),
+            );
+            let iter = groups.iter().inspect(|_g| progress.inc(1));
             let file = BufWriter::new(File::create(path)?);
             let mut reporter = ReportWriter::new(file, false);
             reporter.write(config.format, &header, iter)
@@ -1225,6 +1241,7 @@ mod test {
     use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
     use std::sync::Mutex;
 
+    use crate::log::StdLog;
     use rand::seq::SliceRandom;
     use sysinfo::DiskType;
 
@@ -1890,8 +1907,8 @@ mod test {
         file.write(suffix).unwrap();
     }
 
-    fn test_log() -> Log {
-        let mut log = Log::new();
+    fn test_log() -> StdLog {
+        let mut log = StdLog::new();
         log.no_progress = true;
         log
     }
diff --git a/src/hasher.rs b/src/hasher.rs
index 2c6126c..61f9bb6 100644
--- a/src/hasher.rs
+++ b/src/hasher.rs
@@ -3,7 +3,7 @@ use std::cmp::{max, min};
 use std::fs::{File, OpenOptions};
 use std::hash::Hasher;
 use std::io;
-use std::io::{ErrorKind, Read, Seek};
+use std::io::{Read, Seek};
 use std::str::FromStr;
 
 use metrohash::MetroHash128;
@@ -17,7 +17,7 @@ use xxhash_rust::xxh3::Xxh3;
 
 use crate::cache::{HashCache, Key};
 use crate::file::{FileAccess, FileChunk, FileHash, FileLen, FileMetadata, FilePos};
-use crate::log::Log;
+use crate::log::{Log, LogExt};
 use crate::path::Path;
 use crate::transform::Transform;
 use crate::Error;
@@ -209,12 +209,12 @@ pub struct FileHasher<'a> {
     pub(crate) buf_len: usize,
     pub(crate) cache: Option<HashCache>,
     pub(crate) transform: Option<Transform>,
-    pub(crate) log: &'a Log,
+    pub(crate) log: &'a dyn Log,
 }
 
 impl FileHasher<'_> {
     /// Creates a hasher with no caching
-    pub fn new(algorithm: HashFn, transform: Option<Transform>, log: &Log) -> FileHasher<'_> {
+    pub fn new(algorithm: HashFn, transform: Option<Transform>, log: &dyn Log) -> FileHasher<'_> {
         FileHasher {
             algorithm,
             buf_len: 65536,
@@ -228,7 +228,7 @@ impl FileHasher<'_> {
     pub fn new_cached(
         algorithm: HashFn,
         transform: Option<Transform>,
-        log: &Log,
+        log: &dyn Log,
     ) -> Result<FileHasher<'_>, Error> {
         let transform_command_str = transform.as_ref().map(|t| t.command_str.as_str());
         let cache = HashCache::open_default(transform_command_str, algorithm)?;
@@ -286,7 +286,7 @@ impl FileHasher<'_> {
     ) -> Option<FileHash> {
         match self.hash_file(chunk, progress) {
             Ok(hash) => Some(hash),
-            Err(e) if e.kind() == ErrorKind::NotFound => None,
+            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
             Err(e) => {
                 self.log.warn(format!(
                     "Failed to compute hash of file {}: {}",
@@ -381,7 +381,7 @@ impl FileHasher<'_> {
     ) -> Option<(FileLen, FileHash)> {
         match self.hash_transformed(chunk, progress) {
             Ok(hash) => Some(hash),
-            Err(e) if e.kind() == ErrorKind::NotFound => None,
+            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
             Err(e) => {
                 self.log.warn(format!(
                     "Failed to compute hash of file {}: {}",
diff --git a/src/log.rs b/src/log.rs
index 250e647..6803755 100644
--- a/src/log.rs
+++ b/src/log.rs
@@ -3,22 +3,74 @@
 use std::sync::{Arc, Mutex, Weak};
 
 use console::style;
-use indicatif::ProgressDrawTarget;
 use nom::lib::std::fmt::Display;
 
-use crate::progress::FastProgressBar;
+use crate::progress::{FastProgressBar, ProgressTracker};
 use chrono::Local;
 
-pub struct Log {
+/// Determines the size of the task tracked by ProgressTracker.
+#[derive(Debug, Clone, Copy)]
+pub enum ProgressBarLength {
+    Items(u64),
+    Bytes(u64),
+    Unknown,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum LogLevel {
+    Info,
+    Warn,
+    Error,
+}
+
+/// Common interface for logging diagnostics and progress.
+pub trait Log: Sync + Send {
+    /// Clears any previous progress bar or spinner and installs a new progress bar.
+    fn progress_bar(&self, msg: &str, len: ProgressBarLength) -> Arc<dyn ProgressTracker>;
+
+    /// Logs a message.
+    fn log(&self, level: LogLevel, msg: String);
+}
+
+/// Additional convenience methods for logging.
+pub trait LogExt {
+    /// Logs an info message.
+    fn info(&self, msg: impl Display);
+    /// Logs an warning.
+    fn warn(&self, msg: impl Display);
+    /// Logs an error.
+    fn err(&self, msg: impl Display);
+}
+
+/// Additional convenience methods for logging.
+impl<L: Log + ?Sized> LogExt for L {
+    /// Logs an info message.
+    fn info(&self, msg: impl Display) {
+        self.log(LogLevel::Info, msg.to_string())
+    }
+
+    /// Logs an warning.
+    fn warn(&self, msg: impl Display) {
+        self.log(LogLevel::Warn, msg.to_string())
+    }
+
+    /// Logs an error.
+    fn err(&self, msg: impl Display) {
+        self.log(LogLevel::Error, msg.to_string())
+    }
+}
+
+/// A logger that uses standard error stream to communicate with the user.
+pub struct StdLog {
     program_name: String,
     progress_bar: Mutex<Weak<FastProgressBar>>,
     pub log_stderr_to_stdout: bool,
     pub no_progress: bool,
 }
 
-impl Log {
-    pub fn new() -> Log {
-        Log {
+impl StdLog {
+    pub fn new() -> StdLog {
+        StdLog {
             progress_bar: Mutex::new(Weak::default()),
             program_name: std::env::current_exe()
                 .unwrap()
@@ -78,22 +130,9 @@ impl Log {
         result
     }
 
-    /// Prints a message to stdout.
-    /// Does not interfere with progress bar.
-    pub fn println<I: Display>(&self, msg: I) {
-        match self.progress_bar.lock().unwrap().upgrade() {
-            Some(pb) if pb.is_visible() => {
-                pb.set_draw_target(ProgressDrawTarget::hidden());
-                println!("{}", msg);
-                pb.set_draw_target(ProgressDrawTarget::stderr());
-            }
-            _ => println!("{}", msg),
-        }
-    }
-
     /// Prints a message to stderr.
     /// Does not interfere with progress bar.
-    pub fn eprintln<I: Display>(&self, msg: I) {
+    fn eprintln<I: Display>(&self, msg: I) {
         match self.progress_bar.lock().unwrap().upgrade() {
             Some(pb) if pb.is_visible() => pb.println(format!("{}", msg)),
             _ if self.log_stderr_to_stdout => println!("{}", msg),
@@ -102,39 +141,24 @@ impl Log {
     }
 
     const TIMESTAMP_FMT: &'static str = "[%Y-%m-%d %H:%M:%S.%3f]";
+}
 
-    pub fn info<I: Display>(&self, msg: I) {
-        let timestamp = Local::now();
-        let msg = format!(
-            "{} {}: {} {}",
-            style(timestamp.format(Self::TIMESTAMP_FMT))
-                .for_stderr()
-                .dim()
-                .white(),
-            style(&self.program_name).for_stderr().yellow(),
-            style(" info:").for_stderr().green(),
-            msg
-        );
-        self.eprintln(msg);
-    }
-
-    pub fn warn<I: Display>(&self, msg: I) {
-        let timestamp = Local::now();
-        let msg = format!(
-            "{} {}: {} {}",
-            style(timestamp.format(Self::TIMESTAMP_FMT))
-                .for_stderr()
-                .dim()
-                .white(),
-            style(&self.program_name).for_stderr().yellow(),
-            style(" warn:").for_stderr().yellow(),
-            msg
-        );
-        self.eprintln(msg);
+impl Log for StdLog {
+    fn progress_bar(&self, msg: &str, len: ProgressBarLength) -> Arc<dyn ProgressTracker> {
+        match len {
+            ProgressBarLength::Items(count) => self.progress_bar(msg, count),
+            ProgressBarLength::Bytes(count) => self.bytes_progress_bar(msg, count),
+            ProgressBarLength::Unknown => self.spinner(msg),
+        }
     }
 
-    pub fn err<I: Display>(&self, msg: I) {
+    fn log(&self, level: LogLevel, msg: String) {
         let timestamp = Local::now();
+        let level = match level {
+            LogLevel::Info => style(" info:").for_stderr().green(),
+            LogLevel::Warn => style("warn:").for_stderr().yellow(),
+            LogLevel::Error => style("error:").for_stderr().red(),
+        };
         let msg = format!(
             "{} {}: {} {}",
             style(timestamp.format(Self::TIMESTAMP_FMT))
@@ -142,15 +166,15 @@ impl Log {
                 .dim()
                 .white(),
             style(&self.program_name).for_stderr().yellow(),
-            style("error:").for_stderr().red(),
+            level,
             msg
         );
         self.eprintln(msg);
     }
 }
 
-impl Default for Log {
+impl Default for StdLog {
     fn default() -> Self {
-        Log::new()
+        StdLog::new()
     }
 }
diff --git a/src/main.rs b/src/main.rs
index 6502865..5176db4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,7 +13,8 @@ use regex::Regex;
 use structopt::StructOpt;
 
 use fclones::config::{Command, Config, DedupeConfig, GroupConfig, Parallelism};
-use fclones::log::Log;
+use fclones::log::{Log, LogExt, ProgressBarLength, StdLog};
+use fclones::progress::{NoProgressBar, ProgressTracker};
 use fclones::report::{open_report, ReportHeader};
 use fclones::{dedupe, log_script, run_script, DedupeOp};
 use fclones::{group_files, write_report, Error};
@@ -35,7 +36,7 @@ fn extract_error_cause(message: &str) -> String {
 }
 
 /// 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> {
+fn check_input_paths_exist(config: &GroupConfig, log: &dyn 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.
@@ -109,7 +110,7 @@ fn configure_main_thread_pool(pool_sizes: &HashMap<OsString, Parallelism>) {
         .unwrap();
 }
 
-fn run_group(mut config: GroupConfig, log: &mut Log) -> Result<(), Error> {
+fn run_group(mut config: GroupConfig, log: &dyn Log) -> Result<(), Error> {
     config.resolve_base_dir().map_err(|e| e.to_string())?;
     check_input_paths_exist(&config, log)?;
     check_can_create_output_file(&config)?;
@@ -151,7 +152,7 @@ fn get_command_config(header: &ReportHeader) -> Result<Config, Error> {
     Ok(command)
 }
 
-pub fn run_dedupe(op: DedupeOp, config: DedupeConfig, log: &mut Log) -> Result<(), Error> {
+pub fn run_dedupe(op: DedupeOp, config: DedupeConfig, log: &dyn 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)?;
@@ -192,10 +193,12 @@ pub fn run_dedupe(op: DedupeOp, config: DedupeConfig, log: &mut Log) -> Result<(
 
     let mut result: Result<(), io::Error> = Ok(());
     let group_count = header.stats.map(|s| s.group_count as u64);
-    let progress = match group_count {
-        _ if dedupe_config.dry_run && dedupe_config.output.is_none() => log.hidden(),
-        Some(group_count) => log.progress_bar("Deduplicating", group_count),
-        None => log.spinner("Deduplicating"),
+    let progress: Arc<dyn ProgressTracker> = match group_count {
+        _ if dedupe_config.dry_run && dedupe_config.output.is_none() => Arc::new(NoProgressBar),
+        Some(group_count) => {
+            log.progress_bar("Deduplicating", ProgressBarLength::Items(group_count))
+        }
+        None => log.progress_bar("Deduplicating", ProgressBarLength::Unknown),
     };
 
     let groups = reader.read_groups();
@@ -212,7 +215,7 @@ pub fn run_dedupe(op: DedupeOp, config: DedupeConfig, log: &mut Log) -> Result<(
         })
         .take_while(|g| g.is_some())
         .map(|g| g.unwrap())
-        .inspect(|_| progress.tick());
+        .inspect(|_| progress.inc(1));
 
     let upto = if op == DedupeOp::RefLink {
         // Can't be sure because any previous deduplications are not
@@ -247,7 +250,7 @@ fn main() {
         exit(1);
     }
 
-    let mut log = Log::new();
+    let mut log = StdLog::new();
     if config.quiet {
         log.no_progress = true;
     }
@@ -261,24 +264,24 @@ fn main() {
     };
 
     let result = match config.command {
-        Command::Group(config) => run_group(config, &mut log),
-        Command::Remove(config) => run_dedupe(DedupeOp::Remove, config, &mut log),
-        Command::Link { config, soft: true } => run_dedupe(DedupeOp::SoftLink, config, &mut log),
+        Command::Group(config) => run_group(config, &log),
+        Command::Remove(config) => run_dedupe(DedupeOp::Remove, config, &log),
+        Command::Link { config, soft: true } => run_dedupe(DedupeOp::SoftLink, config, &log),
         Command::Link {
             config,
             soft: false,
-        } => run_dedupe(DedupeOp::HardLink, config, &mut log),
+        } => run_dedupe(DedupeOp::HardLink, config, &log),
         Command::Dedupe { config, .. } => {
             if cfg!(windows) {
                 log.err("Command \"dedupe\" is unsupported on Windows");
                 exit(1);
             }
-            run_dedupe(DedupeOp::RefLink, config, &mut log)
+            run_dedupe(DedupeOp::RefLink, config, &log)
         }
         Command::Move { config, target } => {
             let target = fclones::path::Path::from(target);
             let target = Arc::new(fclones::path::Path::from(cwd)).resolve(target);
-            run_dedupe(DedupeOp::Move(Arc::new(target)), config, &mut log)
+            run_dedupe(DedupeOp::Move(Arc::new(target)), config, &log)
         }
     };
 
diff --git a/src/progress.rs b/src/progress.rs
index 4d7b065..ca3ac53 100644
--- a/src/progress.rs
+++ b/src/progress.rs
@@ -7,7 +7,24 @@ use std::sync::Arc;
 use std::thread;
 use std::time::Duration;
 
-/// A wrapper over `indicatif::ProgressBar` that makes updating its progress lockless.
+/// Common interface for components that can show progress of a task. E.g. progress bars.
+pub trait ProgressTracker: Sync + Send {
+    fn inc(&self, delta: u64);
+}
+
+/// A progress bar that doesn't display itself and does nothing.
+/// This exists purely because sometimes there is an operation that needs to report progress,
+/// but we don't want to show it to the user.
+pub struct NoProgressBar;
+
+impl ProgressTracker for NoProgressBar {
+    fn inc(&self, _delta: u64) {}
+}
+
+/// Console-based progress bar that renders to standard error.
+///
+/// Implemented as a wrapper over `indicatif::ProgressBar` that makes updating its progress
+/// lockless.
 /// Unfortunately `indicatif::ProgressBar` wraps state in a `Mutex`, so updates are slow
 /// and can become a bottleneck in multithreaded context.
 /// This wrapper uses `atomic_counter::RelaxedCounter` to keep shared state without ever blocking
@@ -145,10 +162,6 @@ impl FastProgressBar {
         self.counter.inc();
     }
 
-    pub fn inc(&self, delta: usize) {
-        self.counter.add(delta);
-    }
-
     pub fn position(&self) -> usize {
         self.counter.get()
     }
@@ -167,11 +180,6 @@ impl FastProgressBar {
         self.progress_bar.finish_and_clear();
     }
 
-    pub fn finish_with_msg(&self, message: &str) {
-        self.update_progress();
-        self.progress_bar.finish_with_message(message);
-    }
-
     pub fn abandon(&self) {
         self.update_progress();
         self.progress_bar.abandon();
@@ -182,6 +190,12 @@ impl FastProgressBar {
     }
 }
 
+impl ProgressTracker for FastProgressBar {
+    fn inc(&self, delta: u64) {
+        self.counter.add(delta as usize);
+    }
+}
+
 impl Drop for FastProgressBar {
     fn drop(&mut self) {
         if !self.is_finished() {
diff --git a/src/reflink.rs b/src/reflink.rs
index 555baac..5403b63 100644
--- a/src/reflink.rs
+++ b/src/reflink.rs
@@ -5,7 +5,7 @@ use std::io;
 use filetime::FileTime;
 
 use crate::dedupe::{FsCommand, PathAndMetadata};
-use crate::log::Log;
+use crate::log::{Log, LogExt};
 
 #[cfg(unix)]
 #[cfg(any(not(any(target_os = "linux", target_os = "android")), test))]
@@ -17,7 +17,7 @@ struct XAttr {
 /// Calls OS-specific reflink implementations with an option to call the more generic
 /// one during testing one on Linux ("crosstesting").
 /// The destination file is allowed to exist.
-pub fn reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &Log) -> io::Result<()> {
+pub fn reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &dyn Log) -> io::Result<()> {
     // Remember original metadata of the parent directory:
     let dest_parent = dest.path.parent();
     let dest_parent_metadata = dest_parent.map(|p| p.to_path_buf().metadata());
@@ -57,14 +57,18 @@ pub fn reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &Log) -> io::
 
 // Dummy function so tests compile
 #[cfg(not(any(target_os = "linux", target_os = "android")))]
-fn linux_reflink(_target: &PathAndMetadata, _link: &PathAndMetadata, _log: &Log) -> io::Result<()> {
+fn linux_reflink(
+    _target: &PathAndMetadata,
+    _link: &PathAndMetadata,
+    _log: &StdLog,
+) -> io::Result<()> {
     unreachable!()
 }
 
 // First reflink (not move) the target file out of the way (this also checks for
 // reflink support), then overwrite the existing file to preserve metadata.
 #[cfg(any(target_os = "linux", target_os = "android"))]
-fn linux_reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &Log) -> io::Result<()> {
+fn linux_reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &dyn Log) -> io::Result<()> {
     let tmp = FsCommand::temp_file(&dest.path);
     let std_tmp = tmp.to_path_buf();
 
@@ -112,10 +116,10 @@ fn reflink_overwrite(target: &std::path::Path, link: &std::path::Path) -> io::Re
     use nix::request_code_write;
     use std::os::unix::prelude::AsRawFd;
 
-    let src = std::fs::File::open(&target)?;
+    let src = fs::File::open(&target)?;
 
     // This operation does not require `.truncate(true)` because the files are already of the same size.
-    let dest = std::fs::OpenOptions::new()
+    let dest = fs::OpenOptions::new()
         .create(true)
         .write(true)
         .open(&link)?;
@@ -286,7 +290,7 @@ fn copy_by_reflink(src: &crate::path::Path, dest: &crate::path::Path) -> io::Res
 // After successful copy, attempts to restore the metadata of the file.
 // If reflink or metadata restoration fails, moves the original file back to its original place.
 #[cfg(any(not(any(target_os = "linux", target_os = "android")), test))]
-fn safe_reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &Log) -> io::Result<()> {
+fn safe_reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &dyn Log) -> io::Result<()> {
     let dest_path_buf = dest.path.to_path_buf();
     #[cfg(unix)]
     let dest_xattrs = get_xattrs(&dest_path_buf)?;
@@ -307,7 +311,7 @@ fn safe_reflink(src: &PathAndMetadata, dest: &PathAndMetadata, log: &Log) -> io:
 
 // Dummy function so non-test cfg compiles
 #[cfg(not(any(not(any(target_os = "linux", target_os = "android")), test)))]
-fn safe_reflink(_src: &PathAndMetadata, _dest: &PathAndMetadata, _log: &Log) -> io::Result<()> {
+fn safe_reflink(_src: &PathAndMetadata, _dest: &PathAndMetadata, _log: &dyn Log) -> io::Result<()> {
     unreachable!()
 }
 
@@ -323,6 +327,7 @@ pub fn crosstest() -> bool {
 
 #[cfg(test)]
 pub mod test {
+
     pub mod cfg {
         // Helpers to switch reflink implementations when running tests
         // and to ensure only one reflink test runs at a time.
@@ -356,6 +361,7 @@ pub mod test {
         }
     }
 
+    use crate::log::StdLog;
     use std::sync::Arc;
 
     use crate::util::test::{cached_reflink_supported, read_file, with_dir, write_file};
@@ -381,12 +387,12 @@ pub mod test {
             struct CleanupGuard<'a>(&'a str);
             impl<'a> Drop for CleanupGuard<'a> {
                 fn drop(&mut self) {
-                    std::fs::remove_dir_all(&self.0).unwrap();
+                    fs::remove_dir_all(&self.0).unwrap();
                 }
             }
             let _guard = CleanupGuard(&test_root);
 
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path_1 = root.join("file_1");
             let file_path_2 = root.join("file_2");
 
@@ -432,7 +438,7 @@ pub mod test {
         }
 
         with_dir("dedupe/reflink_too_large", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path_1 = root.join("file_1");
             let file_path_2 = root.join("file_2");
 
@@ -485,7 +491,7 @@ pub mod test {
             return;
         }
         with_dir("dedupe/reflink_test", |root| {
-            let log = Log::new();
+            let log = StdLog::new();
             let file_path_1 = root.join("file_1");
             let file_path_2 = root.join("file_2");
 
diff --git a/src/transform.rs b/src/transform.rs
index b7e7a6b..6aee5d3 100644
--- a/src/transform.rs
+++ b/src/transform.rs
@@ -445,7 +445,7 @@ mod test {
 
     use crate::file::{FileChunk, FileLen, FilePos};
     use crate::hasher::{FileHasher, HashFn};
-    use crate::log::Log;
+    use crate::log::StdLog;
     use crate::util::test::with_dir;
 
     use super::*;
@@ -466,7 +466,7 @@ mod test {
             input.write(content).unwrap();
             drop(input);
 
-            let log = Log::default();
+            let log = StdLog::default();
             let hasher = FileHasher::new(HashFn::default(), Some(transform), &log);
             let input_path = Path::from(input_path);
             let chunk = FileChunk::new(&input_path, FilePos(0), FileLen::MAX);
@@ -488,7 +488,7 @@ mod test {
             input.write(content).unwrap();
             drop(input);
 
-            let log = Log::default();
+            let log = StdLog::default();
             let hasher = FileHasher::new(HashFn::default(), Some(transform), &log);
             let input_path = Path::from(input_path);
 
diff --git a/src/walk.rs b/src/walk.rs
index 2518c9d..8794809 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -8,7 +8,7 @@ use dashmap::DashSet;
 use ignore::gitignore::{Gitignore, GitignoreBuilder};
 use rayon::Scope;
 
-use crate::log::Log;
+use crate::log::{Log, LogExt};
 use crate::path::Path;
 use crate::selector::PathSelector;
 
@@ -65,7 +65,7 @@ struct IgnoreStack(Arc<Vec<Gitignore>>);
 
 impl IgnoreStack {
     /// Returns ignore stack initialized with global gitignore settings.
-    fn new(log: Option<&Log>) -> Self {
+    fn new(log: Option<&dyn Log>) -> Self {
         let gitignore = GitignoreBuilder::new("/").build_global();
         if let Some(err) = gitignore.1 {
             if let Some(log) = log {
@@ -82,7 +82,7 @@ impl IgnoreStack {
 
     /// If .gitignore file exists in given dir, creates a `Gitignore` struct for it
     /// and returns the stack with the new `Gitignore` appended. Otherwise returns a cloned self.
-    pub fn push(&self, dir: &Path, log: Option<&Log>) -> IgnoreStack {
+    pub fn push(&self, dir: &Path, log: Option<&dyn Log>) -> IgnoreStack {
         let mut path = Arc::new(dir.clone()).resolve(Path::from(".gitignore"));
         let mut path_buf = path.to_path_buf();
         if !path_buf.is_file() {
@@ -141,7 +141,7 @@ pub struct Walk<'a> {
     /// The function to call for each visited file. The directories are not reported.
     pub on_visit: &'a (dyn Fn(&Path) + Sync + Send),
     /// Warnings about inaccessible files or dirs are logged here, if defined.
-    pub log: Option<&'a Log>,
+    pub log: Option<&'a dyn Log>,
 }
 
 /// Private shared state scoped to a single `run` invocation.


Test patch: