diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs index 71f86dd..cbe2555 100644 --- a/src/commands/monitor.rs +++ b/src/commands/monitor.rs @@ -142,7 +142,7 @@ pub fn execute( let mut pending_events = BTreeSet::new(); 'outer: while let Some(event) = context.monitor.get_event() { - if crate::timemachine::is_time_machine_running() && !context.is_timemachine_running { + if is_time_machine_running_logged() && !context.is_timemachine_running { info!("Time Machine backup started"); context.monitor.start_timemachine_monitoring(); context.is_timemachine_running = true; @@ -294,6 +294,20 @@ impl Drop for Monitor { } } +fn is_time_machine_running_logged() -> bool { + log_status(crate::timemachine::is_time_machine_running()) +} + +fn log_status(status: anyhow::Result) -> bool { + match status { + Ok(running) => running, + Err(error) => { + warn!("Failed to query Time Machine status: {error}"); + false + } + } +} + mod monitor_details { use std::{ collections::BTreeSet, @@ -308,7 +322,7 @@ mod monitor_details { use notify::Watcher; use super::EVENT_QUEUE_SIZE; - use crate::{git, timemachine}; + use crate::git; pub fn spawn_signals_thread( event_sender: Sender, @@ -582,7 +596,7 @@ mod monitor_details { TimeMachineControl::ResumeMonitoring => { const TIMEOUT: Duration = Duration::from_secs(1); debug!("Start monitoring tmutil status"); - while timemachine::is_time_machine_running() { + while super::is_time_machine_running_logged() { if let Ok(TimeMachineControl::Shutdown) = control_receiver.recv_timeout(TIMEOUT) { @@ -725,11 +739,20 @@ mod monitor_details { mod tests { use std::time::Duration; + use rstest::rstest; use serial_test::serial; use temp_dir_builder::TempDirectoryBuilder; use crate::{cache::Cache, json::save_json_file}; + #[rstest] + #[case(Ok(true), true)] + #[case(Ok(false), false)] + #[case(Err(anyhow::anyhow!("can't determine")), false)] + fn test_log_status(#[case] status: anyhow::Result, #[case] expected: bool) { + assert_eq!(expected, super::log_status(status)); + } + /// Test the behavior in case of a missing configuration file. /// It should not return an error, it should create the default configuration file. #[test] diff --git a/src/commands/stats.rs b/src/commands/stats.rs index 5b68357..43ff47d 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -105,6 +105,52 @@ mod tests { assert_eq!(3, size); } + #[test] + fn test_execute_size_humanize() { + let temp_dir = TempDirectoryBuilder::default() + .add_text_file("a", "abc") + .build() + .unwrap(); + let mut cache = Cache::open_in_memory().unwrap(); + cache + .add_paths([temp_dir.path().join("a")].into_iter()) + .unwrap(); + let mut buffer = vec![]; + + super::execute(&cache, &mut buffer, Stats::Size { humanize: true }).unwrap(); + + assert_eq!("3 B", String::from_utf8(buffer).unwrap().trim()); + } + + #[test] + fn test_execute_size_nested_directory() { + let temp_dir = TempDirectoryBuilder::default() + .add_text_file("top/sub/deep", "xyz") + .add_text_file("top/shallow", "ab") + .build() + .unwrap(); + let mut cache = Cache::open_in_memory().unwrap(); + cache + .add_paths([temp_dir.path().join("top")].into_iter()) + .unwrap(); + let mut buffer = vec![]; + + super::execute(&cache, &mut buffer, Stats::Size { humanize: false }).unwrap(); + + let size: u64 = String::from_utf8(buffer).unwrap().trim().parse().unwrap(); + assert_eq!(5, size); + } + + #[test] + fn test_execute_last_update() { + let cache = Cache::open_in_memory().unwrap(); + let mut buffer = vec![]; + + super::execute(&cache, &mut buffer, Stats::LastUpdate).unwrap(); + + assert!(!String::from_utf8(buffer).unwrap().trim().is_empty()); + } + #[test] fn test_fetch_total_size_does_not_follow_symlinks_outside_directory() { let temp_dir = TempDirectoryBuilder::default() diff --git a/src/git.rs b/src/git.rs index c7802f1..5724eea 100644 --- a/src/git.rs +++ b/src/git.rs @@ -294,6 +294,15 @@ mod tests { ); } + #[test] + fn test_find_ignored_files_path_does_not_exist() { + assert!( + super::find_ignored_files(Path::new("/this/path/does/not/exist")) + .unwrap() + .is_empty() + ); + } + #[test] fn test_worktree() { let temp_dir = TempDirectoryBuilder::default() diff --git a/src/main.rs b/src/main.rs index a4c419e..6ac33fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,7 +116,7 @@ fn program(cli: Cli, redirect_log_to_console: bool) -> anyhow::Result<()> { import_legacy_cache_file(&cli.legacy_cache, &cli.cache)?; match cli.command { Commands::Run { dry_run, details } => { - check_for_ongoing_time_machine_backup(dry_run)?; + check_for_ongoing_time_machine_backup::(dry_run)?; let mut cache = Cache::open_or_create(&cli.cache)?; let config = Config::load_or_create_file(&cli.config)?; @@ -130,7 +130,7 @@ fn program(cli: Cli, redirect_log_to_console: bool) -> anyhow::Result<()> { commands::list::execute(&cache, &mut std::io::stdout(), separator) } Commands::Reset { dry_run, details } => { - check_for_ongoing_time_machine_backup(dry_run)?; + check_for_ongoing_time_machine_backup::(dry_run)?; let mut cache = Cache::open_or_create(&cli.cache)?; @@ -161,8 +161,10 @@ fn program(cli: Cli, redirect_log_to_console: bool) -> anyhow::Result<()> { Ok(()) } -fn check_for_ongoing_time_machine_backup(dry_run: bool) -> anyhow::Result<()> { - if !dry_run && crate::timemachine::is_time_machine_running() { +fn check_for_ongoing_time_machine_backup( + dry_run: bool, +) -> anyhow::Result<()> { + if !dry_run && crate::timemachine::is_time_machine_running_impl::()? { return Err(anyhow!("Time Machine is currently doing a backup.")); } @@ -276,10 +278,31 @@ mod tests { use temp_dir_builder::{TempDirectory, TempDirectoryBuilder}; use crate::{ - Cli, cache::Cache, config::Config, import_legacy_cache_file, import_legacy_config_file, - json::save_json_file, program, + Cli, cache::Cache, check_for_ongoing_time_machine_backup, config::Config, + import_legacy_cache_file, import_legacy_config_file, json::save_json_file, program, + timemachine::tests::{NotRunning, Running, StatusError}, }; + #[test] + fn test_check_for_ongoing_time_machine_backup_running() { + assert!(check_for_ongoing_time_machine_backup::(false).is_err()); + } + + #[test] + fn test_check_for_ongoing_time_machine_backup_not_running() { + assert!(check_for_ongoing_time_machine_backup::(false).is_ok()); + } + + #[test] + fn test_check_for_ongoing_time_machine_backup_status_error_aborts() { + assert!(check_for_ongoing_time_machine_backup::(false).is_err()); + } + + #[test] + fn test_check_for_ongoing_time_machine_backup_dry_run_skips_status() { + assert!(check_for_ongoing_time_machine_backup::(true).is_ok()); + } + #[test] fn test_import_legacy_config_file_dont_exist() { import_legacy_config_file("don't exist", "don't exist").unwrap(); diff --git a/src/timemachine.rs b/src/timemachine.rs index 39bae89..cadf435 100644 --- a/src/timemachine.rs +++ b/src/timemachine.rs @@ -88,20 +88,138 @@ pub fn remove_exclusions<'a>(paths: impl Iterator) -> Vec bool { - std::process::Command::new("/usr/bin/tmutil") - .arg("status") - .output() - .is_ok_and(|output| String::from_utf8_lossy(&output.stdout).contains("Running = 1")) +pub(crate) trait TmUtilsTrait { + fn status() -> anyhow::Result; +} + +pub(crate) struct TmUtils; + +impl TmUtilsTrait for TmUtils { + fn status() -> anyhow::Result { + use anyhow::Context; + + let output = std::process::Command::new("/usr/bin/tmutil") + .arg("status") + .output() + .context("Failed to execute tmutil status")?; + + status_from_output(&output) + } +} + +fn status_from_output(output: &std::process::Output) -> anyhow::Result { + if !output.status.success() { + return Err(anyhow::anyhow!( + "tmutil status exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +pub fn is_time_machine_running() -> anyhow::Result { + is_time_machine_running_impl::() +} + +pub(crate) fn is_time_machine_running_impl() -> anyhow::Result { + Ok(parse_running(&T::status()?)) +} + +fn parse_running(tmutil_status_stdout: &str) -> bool { + tmutil_status_stdout.contains("Running = 1") } #[cfg(test)] pub(crate) mod tests { use std::{path::Path, process::Command}; + use rstest::rstest; use temp_dir_builder::TempDirectoryBuilder; - use crate::timemachine::{add_exclusions, remove_exclusions}; + use crate::timemachine::{TmUtilsTrait, add_exclusions, remove_exclusions}; + + pub(crate) struct Running; + impl TmUtilsTrait for Running { + fn status() -> anyhow::Result { + Ok("Backup session status:\n{\n Running = 1;\n}".to_string()) + } + } + + pub(crate) struct NotRunning; + impl TmUtilsTrait for NotRunning { + fn status() -> anyhow::Result { + Ok("Backup session status:\n{\n Running = 0;\n}".to_string()) + } + } + + pub(crate) struct StatusError; + impl TmUtilsTrait for StatusError { + fn status() -> anyhow::Result { + Err(anyhow::anyhow!("can't determine the Time Machine status")) + } + } + + #[rstest] + #[case("Backup session status:\n{\n Running = 1;\n}", true)] + #[case("Backup session status:\n{\n Running = 0;\n}", false)] + #[case("", false)] + fn test_parse_running(#[case] stdout: &str, #[case] expected: bool) { + assert_eq!(expected, super::parse_running(stdout)); + } + + #[test] + fn test_is_time_machine_running_true() { + assert_eq!( + true, + super::is_time_machine_running_impl::().unwrap() + ); + } + + #[test] + fn test_is_time_machine_running_false() { + assert_eq!( + false, + super::is_time_machine_running_impl::().unwrap() + ); + } + + #[test] + fn test_is_time_machine_running_error_propagates() { + assert!(super::is_time_machine_running_impl::().is_err()); + } + + fn exited_with(code: i32) -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + + std::process::ExitStatus::from_raw(code << 8) + } + + #[test] + fn test_status_from_output_success() { + let output = std::process::Output { + status: exited_with(0), + stdout: b" Running = 1;".to_vec(), + stderr: Vec::new(), + }; + + assert_eq!( + " Running = 1;", + super::status_from_output(&output).unwrap() + ); + } + + #[test] + fn test_status_from_output_non_zero_exit() { + let output = std::process::Output { + status: exited_with(1), + stdout: Vec::new(), + stderr: b"boom".to_vec(), + }; + + assert!(super::status_from_output(&output).is_err()); + } // Be careful, this test is not very reliable even if it uses the offical way (tmutil isexcluded) to // know if a file is excluded. For example, as soon as you add the extended attribute