From e383c6a85c93384f0d8878306df9bf6ae978a681 Mon Sep 17 00:00:00 2001 From: roylaurie Date: Fri, 3 May 2024 14:58:32 -0700 Subject: [PATCH] error reporting and testing --- Cargo.toml | 13 ++- src/cli.rs | 46 +++++++-- src/lib.rs | 244 +++++++++++++++++++++++++++++++------------ src/main.rs | 4 +- tests/standard.rs | 257 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 82 deletions(-) create mode 100644 tests/standard.rs diff --git a/Cargo.toml b/Cargo.toml index 22d1847..2fab797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bak9" -version = "0.0.2" +version = "0.1.1" edition = "2021" description = "Creates a backup .bak copy of a file" authors = ["Asmov LLC "] @@ -10,6 +10,13 @@ keywords = ["backup", "copy"] categories = ["command-line-utilities", "filesystem"] [dependencies] -clap = { version = "^4", features = ["derive"] } -file_diff = "^1" +clap = { version = "4", features = ["derive"] } +colored = "2" +file_diff = "1" +strum = { version = "0", features = ["derive"] } +thiserror = "1" + +[dev-dependencies] +function_name = "0" +file_diff = "1" diff --git a/src/cli.rs b/src/cli.rs index 80638c1..e86eb03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use clap::Parser; +use crate::PathExt; + #[derive(Parser)] #[command(version, about)] pub struct Cli { @@ -13,7 +15,8 @@ pub struct Cli { #[arg(short, default_value_t = false, help = "Delete all backups of FILE")] pub delete: bool, - #[arg(short, default_value_t = 10, help = "Number of backups to keep before pruning")] + #[arg(short, value_parser = clap::value_parser!(u8).range(1..), + default_value_t = 10, help = "Number of backups to keep before pruning")] pub num: u8, } @@ -21,25 +24,48 @@ impl Cli { pub fn dir(&self) -> PathBuf { match &self.dir { Some(dir) => dir.clone(), - None => self.file.parent().unwrap().to_path_buf().clone(), + None => self.file.parent().expect("Expected parent directory").to_path_buf().clone(), } } } +fn validate_path(path: &str, filetype: &'static str) -> Result { + let path = PathBuf::from(path) + .canonicalize() + .map_err(|_| format!("{filetype} not found: {:?}", path))?; + + if !path.exists() { + return Err(format!("{filetype} not found: {:?}", path)) + } + + Ok(path) +} + fn validate_file(path: &str) -> Result { - let path = PathBuf::from(path); - if path.exists() { - Ok(path) + let path = validate_path(path, "File")?; + if !path.is_file() { + Err(format!("Source path is not a file: {:?}", path)) + } else if path.filename_str().is_none() { + return Err(format!("Invalid source file: {:?}", path)) } else { - Err(format!("File not found: {:?}", path)) + Ok(path) } } fn validate_dir(path: &str) -> Result { - let path = PathBuf::from(path); - if path.exists() { - Ok(path) + let path = validate_path(path, "Directory")?; + if !path.is_dir() { + return Err(format!("Destination path is not a directory: {:?}", path)) } else { - Err(format!("Directory not found: {:?}", path)) + Ok(path) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_verify_cli() { + use clap::CommandFactory; + super::Cli::command().debug_assert() } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a47ad21..e853fb9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,141 +24,239 @@ //! Creates at most **NUM** backup files. //! If not specified, defaults to 10 (0-9). -mod cli; +pub mod cli; use std::{fs, path::{Path, PathBuf}}; use clap::Parser; use file_diff; +use thiserror; +use colored::Colorize; const BAK: &str = "bak"; const BAK_DOT: &str = "bak."; const BAK_0: &str = "bak.0"; const BAK_1: &str = "bak.1"; +const E_STR: &str = "Expected string"; +const E_FILENAME: &str = "Expected filename"; + +/// Ergonomic methods for working with paths trait PathExt { fn append_extension(self, ext: &str) -> PathBuf; - fn filename_string(self) -> String; + fn filename_string(self) -> Option; + fn filename_str<'s>(&'s self) -> Option<&'s str>; } impl PathExt for PathBuf { fn append_extension(self, ext: &str) -> PathBuf { - self.with_extension(format!("{}.{}", self.extension().unwrap().to_str().unwrap(), ext)) + self.with_extension(format!("{}.{}", self.extension().unwrap_or_default().to_str().expect(E_STR), ext)) } - fn filename_string<'s>(self) -> String { - self.file_name().unwrap().to_str().unwrap().to_owned() + fn filename_string(self) -> Option { + match self.file_name() { + Some(filename) => match filename.to_str() { + Some(filename) => Some(filename.to_owned()), + None => None + }, + None => None + } + } + + fn filename_str<'s>(&'s self) -> Option<&'s str> { + match self.file_name() { + Some(filename) => filename.to_str(), + None => None + } } } impl PathExt for &Path { fn append_extension(self, ext: &str) -> PathBuf { - self.with_extension(format!("{}.{}", self.extension().unwrap().to_str().unwrap(), ext)) + self.with_extension(format!("{}.{}", self.extension().unwrap_or_default().to_str().expect(E_STR), ext)) + } + + fn filename_string(self) -> Option { + match self.file_name() { + Some(filename) => match filename.to_str() { + Some(filename) => Some(filename.to_owned()), + None => None + }, + None => None + } } - fn filename_string(self) -> String { - self.file_name().unwrap().to_str().unwrap().to_owned() + fn filename_str<'s>(&'s self) -> Option<&'s str> { + match self.file_name() { + Some(filename) => filename.to_str(), + None => None + } } } -pub fn run() { - let cli = cli::Cli::parse(); +#[derive(Debug, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum IoOp { + Read, + Write, + Delete, + Rename +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Unable to {op} {path}: {cause}")] + IO { op: IoOp, path: String, cause: String }, - if cli.delete { + #[error("Unable to copy {src} to {dest}: {cause}")] + Copy { src: String, dest: String, cause: String }, +} + +impl Error { + pub fn io(op: IoOp, path: PathBuf, cause: std::io::Error) -> Self { + Self::IO { op, path: path.to_str().expect(E_STR).cyan().to_string(), cause: cause.to_string() } + } + + pub fn copy(source: &Path, destination: &Path, cause: std::io::Error) -> Self { + Self::Copy { + src: source.to_str().expect(E_STR).cyan().to_string(), + dest: destination.to_str().expect(E_STR).cyan().to_string(), + cause: cause.to_string() } + } +} + +/// Entry point +pub fn run() -> std::process::ExitCode { + match run_with(cli::Cli::parse()) { + Ok(_) => std::process::ExitCode::SUCCESS, + Err(err) => { + eprintln!("{} {err}", "error:".red()); + std::process::ExitCode::FAILURE + } + } +} + +pub fn run_with(cli: cli::Cli) -> Result<(), Error> { + let result = if cli.delete { run_delete(&cli) } else { run_backup(&cli) + }; + + result +} + +/// Performs a wipe of all `.bak` files in the directory. +fn run_delete(cli: &cli::Cli) -> Result<(), Error> { + let bak_filepaths = list_bak_n_files(&cli.file, &cli.dir())?; + + for bak_filepath in bak_filepaths { + std::fs::remove_file(&bak_filepath) + .map_err(|e| Error::io(IoOp::Delete, bak_filepath, e))?; } + + // wipe the .bak if it exists as well + let bak_filepath = cli.dir() + .join(cli.file.filename_str().expect(E_FILENAME)) + .append_extension(BAK); + + if bak_filepath.exists() { + std::fs::remove_file(&bak_filepath) + .map_err(|e| Error::io(IoOp::Delete, bak_filepath, e))?; + } + + Ok(()) } -fn run_delete(cli: &cli::Cli) { - let bak_filename = cli.file.as_path().append_extension(BAK).filename_string(); - let bak_n_filename = cli.file.as_path().append_extension(BAK_DOT).filename_string(); +/// Retrieves a list of all `.bak.N` files in the directory. +fn list_bak_n_files(file: &Path, dir: &Path, ) -> Result, Error> { + let bak_n_file_pattern = file + .append_extension(BAK_DOT) + .filename_string().expect(E_FILENAME); - let bak_filepaths: Vec = cli.dir() - .read_dir().unwrap() - .map(|entry| entry.unwrap().path()) + let dir = dir.read_dir() + .map_err(|e| Error::io(IoOp::Read, dir.to_path_buf(), e))?; + let paths = dir + .filter_map(|entry| match entry { + Ok(entry) => Some(entry.path()), + Err(_) => None + }) .filter(|path| path.is_file()) .filter(|filepath| { - let filename = filepath.file_name().unwrap().to_str().unwrap(); - if filename == &bak_filename { - true - } else if filename.starts_with(&bak_n_filename) { - matches!(filename.trim_start_matches(&bak_n_filename).parse::(), Ok(_)) + let filename = match filepath.file_name() { + Some(filename) => filename.to_str().expect(E_STR), + None => return false + }; + + if filename.starts_with(&bak_n_file_pattern) { + matches!(filename.trim_start_matches(&bak_n_file_pattern).parse::(), Ok(_)) } else { false } }) .collect(); - for bak_filepath in bak_filepaths { - std::fs::remove_file(bak_filepath).unwrap(); - } + Ok(paths) } -fn run_backup(cli: &cli::Cli) { +/// Performs a copy +fn run_backup(cli: &cli::Cli) -> Result<(), Error> { + let source_filename = cli.file.filename_str().expect(E_FILENAME); let last_bak = find_last_bak(&cli.file, &cli.dir()); // check to see if a backup is necessary, using a file diff if let Some(last_bak_filepath) = &last_bak { - let mut file = std::fs::File::open(&cli.file).unwrap(); - let mut last_bak = std::fs::File::open(&last_bak_filepath).unwrap(); + let mut file = std::fs::File::open(&cli.file) + .map_err(|e| Error::io(IoOp::Read, cli.file.clone(), e))?; + let mut last_bak = std::fs::File::open(&last_bak_filepath) + .map_err(|e| Error::io(IoOp::Read, last_bak_filepath.clone(), e))?; + if file_diff::diff_files(&mut file, &mut last_bak) { - return; // skip + return Ok(()); } } let bak_filepath = if let Some(last_bak_filepath) = &last_bak { if cli.num == 1 { - run_delete(cli); + run_delete(cli)?; cli.dir() - .join(cli.file.file_name().unwrap()) + .join(&source_filename) .append_extension(BAK) - } else if last_bak_filepath.extension().unwrap() == BAK { - shift_bak_files(&cli.file, &cli.dir(), cli.num); + } else if last_bak_filepath.extension().expect("Expected .bak") == BAK { + shift_bak_files(&cli.file, &cli.dir(), cli.num)?; let bak1_filepath = cli.dir() - .join(cli.file.file_name().unwrap()) + .join(&source_filename) .append_extension(BAK_1); - fs::rename(last_bak_filepath, bak1_filepath).unwrap(); + + fs::rename(last_bak_filepath, &bak1_filepath) + .map_err(|e| Error::io(IoOp::Rename, bak1_filepath, e))?; cli.dir() - .join(cli.file.file_name().unwrap()) + .join(&source_filename) .append_extension(BAK_0) } else { - shift_bak_files(&cli.file, &cli.dir(), cli.num); + shift_bak_files(&cli.file, &cli.dir(), cli.num)?; cli.dir() - .join(cli.file.file_name().unwrap()) + .join(&source_filename) .append_extension(BAK_0) } } else { cli.dir() - .join(cli.file.file_name().unwrap()) + .join(&source_filename) .append_extension(BAK) }; - fs::copy(&cli.file, bak_filepath).unwrap(); + fs::copy(&cli.file, &bak_filepath) + .map_err(|e| Error::copy(&cli.file, &bak_filepath, e))?; + + Ok(()) } -fn shift_bak_files(file: &Path, dir: &Path, num: u8) { - let bak_n_file_pattern = file - .append_extension(BAK_DOT); - let bak_n_file_pattern = bak_n_file_pattern - .file_name().unwrap().to_str().unwrap(); - - let mut bak_filepaths: Vec = dir.read_dir().unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| path.is_file()) - .filter(|filepath| { - let filename = filepath.file_name().unwrap().to_str().unwrap(); - if filename.starts_with(bak_n_file_pattern) { - matches!(filename.trim_start_matches(bak_n_file_pattern).parse::(), Ok(_)) - } else { - false - } - }) - .collect(); +/// Increments the filename extension of all `.bak.N` files in the directory. +fn shift_bak_files(file: &Path, dir: &Path, num: u8) -> Result<(), Error> { + let mut bak_filepaths = list_bak_n_files(file, dir)?; // 0, 1, .. bak_filepaths.sort_by(|a, b| a.cmp(b)); @@ -167,30 +265,42 @@ fn shift_bak_files(file: &Path, dir: &Path, num: u8) { if bak_filepaths.len() >= num as usize { let prune_amount = bak_filepaths.len() - num as usize + 1; for _i in 0..prune_amount { - let bak_filepath = bak_filepaths.pop().unwrap(); - std::fs::remove_file(bak_filepath).unwrap(); + let bak_filepath = bak_filepaths.pop().expect("Expected array value"); + std::fs::remove_file(&bak_filepath) + .map_err(|e| Error::io(IoOp::Delete, bak_filepath, e))?; } } + let bak_n_file_pattern = file + .append_extension(BAK_DOT) + .filename_string().expect(E_FILENAME); + // shift each up by 1 + let source_filename = file.filename_string().expect(E_FILENAME); for bak_filepath in bak_filepaths.into_iter().rev() { - let n = bak_filepath.file_name().unwrap().to_str().unwrap() - .trim_start_matches(bak_n_file_pattern) - .parse::().unwrap(); - let bak_next_filepath = dir.join(file.file_name().unwrap()) + let n = bak_filepath.filename_str().expect(E_FILENAME) + .trim_start_matches(&bak_n_file_pattern) + .parse::() + .expect("Expected numeric extension"); + let bak_next_filepath = dir.join(&source_filename) .append_extension(BAK) .append_extension((n + 1).to_string().as_str()); - fs::rename(bak_filepath, bak_next_filepath).unwrap(); + fs::rename(bak_filepath, &bak_next_filepath) + .map_err(|e| Error::io(IoOp::Write, bak_next_filepath, e))?; } + + Ok(()) } +/// Returns either a `.bak` or `.bak.0` file if it exists. fn find_last_bak(file: &Path, dir: &Path) -> Option { - let bak_file = dir.join(file.file_name().unwrap()) + let bak_file = dir.join(file.filename_string().expect(E_FILENAME)) .append_extension(BAK); if bak_file.exists() { Some(bak_file) } else { - let bak0_file = dir.join(file.file_name().unwrap()).append_extension(BAK_0); + let bak0_file = dir.join(file.filename_string().expect(E_FILENAME)) + .append_extension(BAK_0); if bak0_file.exists() { Some(bak0_file) } else { diff --git a/src/main.rs b/src/main.rs index 9903d7c..678643a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,3 @@ -fn main() { - bak9::run(); +fn main() -> std::process::ExitCode { + bak9::run() } diff --git a/tests/standard.rs b/tests/standard.rs new file mode 100644 index 0000000..2e97c01 --- /dev/null +++ b/tests/standard.rs @@ -0,0 +1,257 @@ + +#[cfg(test)] +mod tests { + use bak9; + use std::path::PathBuf; + use function_name::named; + use file_diff; + + const TESTS: &str = "tests"; + + fn open_tmpdir(subdir: &str) -> PathBuf { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir); + + if dir.exists() { + std::fs::remove_dir_all(&dir) + .expect("Failed to remove existing directory"); + } + + std::fs::create_dir_all(&dir) + .expect("Failed to create directory"); + + dir + } + + fn open_tmpdir_topic(topic: &str, subdir: &str) -> PathBuf { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir) + .join(topic); + + if dir.exists() { + std::fs::remove_dir_all(&dir) + .expect("Failed to remove existing directory"); + } + + std::fs::create_dir_all(&dir) + .expect("Failed to create directory"); + + dir + } + + fn close_tmpdir_topic(topic: &str, subdir: &str) { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir) + .join(topic); + + if dir.exists() { + std::fs::remove_dir_all(&dir) + .expect("Failed to remove existing directory"); + } + } + + fn close_tmpdir(subdir: &str) { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir); + + if dir.exists() { + std::fs::remove_dir_all(&dir) + .expect("Failed to remove existing directory"); + } + } + + fn tmpfile_exists(filename: &str, subdir: &str) -> bool { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir) + .join(filename) + .exists() + } + + fn tmpfile_topic_exists(filename: &str, topic: &str, subdir: &str) -> bool { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir) + .join(topic) + .join(filename) + .exists() + } + + fn tmpfile_append(content: &str, filename: &str, subdir: &str) { + use std::io::prelude::*; + + let filepath = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir) + .join(filename); + + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(filepath) + .unwrap(); + + writeln!(file, "{}", content).unwrap(); + } + + /// Returns true if they are different + fn tmpfile_diff(filename_a: &str, filename_b: &str, subdir: &str) -> bool { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .join(TESTS) + .join(subdir); + + let mut file_a = std::fs::File::open(dir.join(filename_a)).unwrap(); + let mut file_b = std::fs::File::open(dir.join(filename_b)).unwrap(); + + !file_diff::diff_files(&mut file_a, &mut file_b) + } + + #[test] + #[named] + fn test_defaults() { + let tmpdir = open_tmpdir(function_name!()); + + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("noexist.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_err()); + + // source_1 + + std::fs::write(tmpdir.join("source_1.txt"), "LINE 1").unwrap(); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak", function_name!()), + "source_1.txt.bak should be created"); + + tmpfile_append("LINE 2", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + assert_eq!(false, tmpfile_diff("source_1.txt", "source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_diff("source_1.txt", "source_1.txt.bak.1", function_name!())); + + tmpfile_append("LINE 3", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.2", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + assert_eq!(false, tmpfile_diff("source_1.txt", "source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_diff("source_1.txt", "source_1.txt.bak.1", function_name!())); + assert_eq!(true, tmpfile_diff("source_1.txt", "source_1.txt.bak.2", function_name!())); + assert_eq!(true, tmpfile_diff("source_1.txt.bak.1", "source_1.txt.bak.2", function_name!())); + + tmpfile_append("LINE 4", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.2", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.3", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + + tmpfile_append("LINE 5", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 2, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.2", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.3", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + + tmpfile_append("LINE 6", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 1, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak", function_name!())); + assert_eq!(false, tmpfile_diff("source_1.txt", "source_1.txt.bak", function_name!())); + + tmpfile_append("LINE 7", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(true, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + + tmpfile_append("LINE 8", "source_1.txt", function_name!()); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_1.txt"), + dir: None, + delete: true, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.0", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak.1", function_name!())); + assert_eq!(false, tmpfile_exists("source_1.txt.bak", function_name!())); + + close_tmpdir(function_name!()); + } + + #[test] + #[named] + fn test_other_dir() { + let tmpdir = open_tmpdir(function_name!()); + + let topic_tmpdir = open_tmpdir_topic("source_2_dir", function_name!()); + std::fs::write(tmpdir.join("source_2.txt"), "LINE 1").unwrap(); + let result = bak9::run_with(bak9::cli::Cli { + file: tmpdir.join("source_2.txt"), + dir: Some(topic_tmpdir), + delete: false, + num: 3, + }); + assert_eq!(true, result.is_ok()); + assert_eq!(true, tmpfile_topic_exists("source_2.txt.bak", "source_2_dir", function_name!())); + close_tmpdir_topic("source_2_dir", function_name!()); + + close_tmpdir(function_name!()); + } +} \ No newline at end of file