Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/commands/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>) -> bool {
match status {
Ok(running) => running,
Err(error) => {
warn!("Failed to query Time Machine status: {error}");
false
}
}
}

mod monitor_details {
use std::{
collections::BTreeSet,
Expand All @@ -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<super::Event>,
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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<bool>, #[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]
Expand Down
46 changes: 46 additions & 0 deletions src/commands/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 29 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<crate::timemachine::TmUtils>(dry_run)?;

let mut cache = Cache::open_or_create(&cli.cache)?;
let config = Config::load_or_create_file(&cli.config)?;
Expand All @@ -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::<crate::timemachine::TmUtils>(dry_run)?;

let mut cache = Cache::open_or_create(&cli.cache)?;

Expand Down Expand Up @@ -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<T: crate::timemachine::TmUtilsTrait>(
dry_run: bool,
) -> anyhow::Result<()> {
if !dry_run && crate::timemachine::is_time_machine_running_impl::<T>()? {
return Err(anyhow!("Time Machine is currently doing a backup."));
}

Expand Down Expand Up @@ -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::<Running>(false).is_err());
}

#[test]
fn test_check_for_ongoing_time_machine_backup_not_running() {
assert!(check_for_ongoing_time_machine_backup::<NotRunning>(false).is_ok());
}

#[test]
fn test_check_for_ongoing_time_machine_backup_status_error_aborts() {
assert!(check_for_ongoing_time_machine_backup::<StatusError>(false).is_err());
}

#[test]
fn test_check_for_ongoing_time_machine_backup_dry_run_skips_status() {
assert!(check_for_ongoing_time_machine_backup::<StatusError>(true).is_ok());
}

#[test]
fn test_import_legacy_config_file_dont_exist() {
import_legacy_config_file("don't exist", "don't exist").unwrap();
Expand Down
130 changes: 124 additions & 6 deletions src/timemachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,138 @@ pub fn remove_exclusions<'a>(paths: impl Iterator<Item = &'a PathBuf>) -> Vec<Er
errors
}

pub fn is_time_machine_running() -> 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<String>;
}

pub(crate) struct TmUtils;

impl TmUtilsTrait for TmUtils {
fn status() -> anyhow::Result<String> {
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<String> {
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<bool> {
is_time_machine_running_impl::<TmUtils>()
}

pub(crate) fn is_time_machine_running_impl<T: TmUtilsTrait>() -> anyhow::Result<bool> {
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<String> {
Ok("Backup session status:\n{\n Running = 1;\n}".to_string())
}
}

pub(crate) struct NotRunning;
impl TmUtilsTrait for NotRunning {
fn status() -> anyhow::Result<String> {
Ok("Backup session status:\n{\n Running = 0;\n}".to_string())
}
}

pub(crate) struct StatusError;
impl TmUtilsTrait for StatusError {
fn status() -> anyhow::Result<String> {
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::<Running>().unwrap()
);
}

#[test]
fn test_is_time_machine_running_false() {
assert_eq!(
false,
super::is_time_machine_running_impl::<NotRunning>().unwrap()
);
}

#[test]
fn test_is_time_machine_running_error_propagates() {
assert!(super::is_time_machine_running_impl::<StatusError>().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
Expand Down