diff --git a/README.md b/README.md index 019e846..fe98747 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - [x] use subcommand - [ ] backup subcommands - [ ] backup add - - [ ] test for backup add + - [?] test for backup add - [ ] backup list - [ ] backup done - [ ] no commit option diff --git a/src/backups.rs b/src/backups.rs index 923ea9d..3cb9565 100644 --- a/src/backups.rs +++ b/src/backups.rs @@ -1,17 +1,47 @@ -use std::path::PathBuf; +use core::panic; +use std::{ + collections::HashMap, + fs, io, + path::{Path, PathBuf}, +}; +use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Local}; use serde::{Deserialize, Serialize}; -use crate::storages::Storage; +use crate::{ + devices::Device, + storages::{self, Storage}, +}; + +/// Directory to store backup configs for each devices. +pub const BACKUPSDIR: &str = "backups"; + +/// File to store backups for the `device`. +/// Relative path from the config directory. +pub fn backups_file(device: &Device) -> PathBuf { + PathBuf::from(BACKUPSDIR).join(format!("{}.yml", device.name())) +} /// Targets for backup source or destination. #[derive(Debug, Serialize, Deserialize)] pub struct BackupTarget { - storage: Storage, + /// `name()` of [`Storage`]. + /// Use `String` for serialization/deserialization. + storage: String, + /// Relative path to the `storage`. path: PathBuf, } +impl BackupTarget { + pub fn new(storage_name: String, relative_path: PathBuf) -> Self { + BackupTarget { + storage: storage_name, + path: relative_path, + } + } +} + /// Type of backup commands. #[derive(Debug, Serialize, Deserialize)] pub enum BackupCommand { @@ -26,6 +56,12 @@ pub struct ExternallyInvoked { pub note: String, } +impl ExternallyInvoked { + pub fn new(name: String, note: String) -> Self { + ExternallyInvoked { name, note } + } +} + /// Backup execution log. #[derive(Debug, Serialize, Deserialize)] pub struct BackupLog { @@ -53,3 +89,86 @@ pub struct Backup { command: BackupCommand, logs: Vec, } + +impl Backup { + /// With empty logs. + pub fn new( + name: String, + device_name: String, + from: BackupTarget, + to: BackupTarget, + command: BackupCommand, + ) -> Self { + Backup { + name, + device: device_name, + from, + to, + command, + logs: Vec::new(), + } + } + + pub fn name(&self) -> &String { + &self.name + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Backups { + pub list: HashMap, +} + +impl Backups { + /// Empty [`Backups`]. + pub fn new() -> Backups { + Backups { + list: HashMap::new(), + } + } + + pub fn get(&self, name: &String) -> Option<&Backup> { + self.list.get(name) + } + + /// Add new [`Backup`]. + /// New `backup` must has new unique name. + pub fn add(&mut self, backup: Backup) -> Result<()> { + if self.list.keys().any(|name| name == &backup.name) { + return Err(anyhow::anyhow!(format!( + "Backup with name {} already exists", + backup.name + ))); + } + match self.list.insert(backup.name.clone(), backup) { + Some(v) => { + error!("Inserted backup with existing name: {}", v.name); + panic!("unexpected behavior (unreachable)") + } + None => Ok(()), + } + } + + pub fn read(config_dir: &Path, device: &Device) -> Result { + let backups_file = config_dir.join(backups_file(device)); + if !backups_file.exists() { + return Err(anyhow!("Couldn't find backups file: {:?}", backups_file)); + } + trace!("Reading {}", backups_file.display()); + let f = fs::File::open(backups_file)?; + let reader = io::BufReader::new(f); + let yaml: Backups = + serde_yaml::from_reader(reader).context("Failed to parse backups file")?; + Ok(yaml) + } + + pub fn write(self, config_dir: &Path, device: &Device) -> Result<()> { + let f = fs::File::create(config_dir.join(backups_file(device))) + .context("Failed to open backups file")?; + let writer = io::BufWriter::new(f); + serde_yaml::to_writer(writer, &self).context(format!( + "Failed writing to {}", + config_dir.join(backups_file(device)).display() + )) + } +} diff --git a/src/cmd_args.rs b/src/cmd_args.rs index 1e42485..63a6d3c 100644 --- a/src/cmd_args.rs +++ b/src/cmd_args.rs @@ -86,6 +86,10 @@ pub(crate) enum StorageCommands { #[arg(short, long)] path: path::PathBuf, }, + // /// Remove storage from the storage list + // Remove { + // storage: String, + // } } #[derive(Args, Debug)] diff --git a/src/cmd_backup.rs b/src/cmd_backup.rs new file mode 100644 index 0000000..91ffb01 --- /dev/null +++ b/src/cmd_backup.rs @@ -0,0 +1,81 @@ +use std::{io::stdout, path::{Path, PathBuf}}; + +use anyhow::{Context, Result}; +use git2::Repository; + +use crate::{ + add_and_commit, + backups::{self, Backup, BackupCommand, BackupTarget, Backups, ExternallyInvoked}, + cmd_args::BackupAddCommands, + devices::{self, Device}, + storages::{StorageExt, Storages}, + util, +}; + +pub(crate) fn cmd_backup_add( + name: String, + src: PathBuf, + dest: PathBuf, + cmd: BackupAddCommands, + repo: Repository, + config_dir: &PathBuf, + storages: &Storages, +) -> Result<()> { + let device = devices::get_device(&config_dir)?; + let new_backup = new_backup(name, src, dest, cmd, &device, storages)?; + let new_backup_name = new_backup.name().clone(); + let mut backups = Backups::read(&config_dir, &device)?; + println!("Backup config:"); + serde_yaml::to_writer(stdout(), &new_backup)?; + backups.add(new_backup)?; + backups.write(&config_dir, &device)?; + + add_and_commit( + &repo, + &backups::backups_file(&device), + &format!("Add new backup: {}", new_backup_name), + )?; + + println!("Added new backup."); + trace!("Finished adding backup"); + Ok(()) +} + +fn new_backup( + name: String, + src: PathBuf, + dest: PathBuf, + cmd: BackupAddCommands, + device: &Device, + storages: &Storages, +) -> Result { + let (src_parent, src_diff) = + util::min_parent_storage(&src, &storages, &device).context(format!( + "Coundn't find parent storage for src directory {}", + src.display() + ))?; + let (dest_parent, dest_diff) = + util::min_parent_storage(&dest, &storages, &device).context(format!( + "Couldn't find parent storage for dest directory: {}", + dest.display() + ))?; + let src_target = BackupTarget::new(src_parent.name().to_string(), src_diff); + trace!("Backup source target: {:?}", src_target); + let dest_target = BackupTarget::new(dest_parent.name().to_string(), dest_diff); + trace!("Backup destination target: {:?}", dest_target); + + let command: BackupCommand = match cmd { + BackupAddCommands::External { name, note } => { + BackupCommand::ExternallyInvoked(ExternallyInvoked::new(name, note)) + } + }; + trace!("Backup command: {:?}", command); + + Ok(Backup::new( + name, + device.name(), + src_target, + dest_target, + command, + )) +} diff --git a/src/cmd_init.rs b/src/cmd_init.rs index ab7f68a..585dcbf 100644 --- a/src/cmd_init.rs +++ b/src/cmd_init.rs @@ -1,14 +1,16 @@ //! Init subcommand. //! Initialize xdbm for the device. +use crate::backups::{backups_file, Backups}; use crate::storages::{Storages, STORAGESFILE}; -use crate::{add_and_commit, full_status, get_devices, write_devices, Device, DEVICESFILE}; +use crate::{ + add_and_commit, backups, full_status, get_devices, write_devices, Device, DEVICESFILE, +}; use anyhow::{anyhow, Context, Ok, Result}; use core::panic; use git2::{Cred, RemoteCallbacks, Repository}; use inquire::Password; -use std::env; -use std::fs::File; +use std::fs::{DirBuilder, File}; use std::io::{BufWriter, Write}; use std::path::{self, Path, PathBuf}; @@ -128,6 +130,9 @@ pub(crate) fn cmd_init( &format!("Initialize {}", STORAGESFILE), )?; + // set up directory for backups + DirBuilder::new().create(&config_dir.join(backups::BACKUPSDIR))?; + repo } }; @@ -153,6 +158,8 @@ pub(crate) fn cmd_init( let mut devices: Vec = get_devices(&config_dir)?; trace!("devices: {:?}", devices); if devices.iter().any(|x| x.name() == device.name()) { + error!("Device name `{}` is already used.", device.name()); + error!("Clear the config directory and try again with different name"); return Err(anyhow!("device name is already used.")); } devices.push(device.clone()); @@ -167,6 +174,18 @@ pub(crate) fn cmd_init( &Path::new(DEVICESFILE), &format!("Add new device: {}", &device.name()), )?; + + // backups/[device].yml + { + let backups = Backups::new(); + backups.write(&config_dir, &device)?; + } + add_and_commit( + &repo, + &backups::backups_file(&device), + &format!("Add new backups for device: {}", &device.name()), + )?; + println!("Device added"); full_status(&repo)?; Ok(()) diff --git a/src/cmd_storage.rs b/src/cmd_storage.rs index 53bb1d4..ce76d9f 100644 --- a/src/cmd_storage.rs +++ b/src/cmd_storage.rs @@ -1,17 +1,14 @@ //! Storage subcommands. use std::{ - collections::HashMap, io::{self, Write}, path::{Path, PathBuf}, - string, }; use anyhow::{anyhow, Context, Result}; use byte_unit::Byte; -use clap::{error::ErrorKind, CommandFactory}; use git2::Repository; -use inquire::{min_length, Confirm, CustomType, Select, Text}; +use inquire::{Confirm, CustomType, Text}; use unicode_width::{self, UnicodeWidthStr}; use crate::{ @@ -215,7 +212,7 @@ fn write_storages_list( |v| v.display().to_string(), ); let parent_name = if let Storage::SubDirectory(s) = storage { - s.parent(&storages)? + s.parent(&storages) .context(format!("Failed to get parent of storage {}", s))? .name() } else { diff --git a/src/main.rs b/src/main.rs index 3a951ed..445dd1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ use std::path::Path; use std::path::{self, PathBuf}; use storages::Storages; -use crate::cmd_args::{Cli, Commands, StorageCommands}; +use crate::cmd_args::{BackupSubCommands, Cli, Commands, StorageCommands}; use crate::storages::{ directory, local_info, online_storage, physical_drive_partition, Storage, StorageExt, StorageType, STORAGESFILE, @@ -28,12 +28,14 @@ use devices::{Device, DEVICESFILE, *}; mod backups; mod cmd_args; +mod cmd_backup; mod cmd_init; mod cmd_storage; mod cmd_sync; mod devices; mod inquire_filepath_completer; mod storages; +mod util; fn main() -> Result<()> { let cli = Cli::parse(); @@ -93,7 +95,27 @@ fn main() -> Result<()> { let _storages = Storages::read(&config_dir)?; todo!() } - Commands::Backup(_) => todo!(), + Commands::Backup(backup) => { + trace!("backup subcommand with args: {:?}", backup); + let repo = Repository::open(&config_dir).context( + "Repository doesn't exist on the config path. Please run init to initialize the repository.", + )?; + let storages = Storages::read(&config_dir)?; + match backup { + BackupSubCommands::Add { + name, + src, + dest, + cmd, + } => cmd_backup::cmd_backup_add(name, src, dest, cmd, repo, &config_dir, &storages)?, + BackupSubCommands::List {} => todo!(), + BackupSubCommands::Done { + name, + exit_status, + log, + } => todo!(), + } + } } full_status(&Repository::open(&config_dir)?)?; Ok(()) diff --git a/src/storages.rs b/src/storages.rs index 7019443..4371a23 100644 --- a/src/storages.rs +++ b/src/storages.rs @@ -97,7 +97,7 @@ impl StorageExt for Storage { } } - fn parent<'a>(&'a self, storages: &'a Storages) -> Result> { + fn parent<'a>(&'a self, storages: &'a Storages) -> Option<&'a Storage> { match self { Storage::PhysicalStorage(s) => s.parent(storages), Storage::SubDirectory(s) => s.parent(storages), @@ -146,7 +146,7 @@ pub trait StorageExt { ) -> Result<()>; /// Get parent - fn parent<'a>(&'a self, storages: &'a Storages) -> Result>; + fn parent<'a>(&'a self, storages: &'a Storages) -> Option<&Storage>; } pub mod directory; @@ -196,7 +196,6 @@ impl Storages { // dependency check if self.list.iter().any(|(_k, v)| { v.parent(&self) - .unwrap() .is_some_and(|parent| parent.name() == storage.name()) }) { return Err(anyhow!( diff --git a/src/storages/directory.rs b/src/storages/directory.rs index e2789b6..ebeaeee 100644 --- a/src/storages/directory.rs +++ b/src/storages/directory.rs @@ -9,6 +9,7 @@ use std::{ }; use crate::devices; +use crate::util; use super::{local_info::LocalInfo, Storage, StorageExt, Storages}; @@ -55,29 +56,13 @@ impl Directory { device: &devices::Device, storages: &Storages, ) -> Result { - let (parent_name, diff_path) = storages - .list - .iter() - .filter(|(_k, v)| v.has_alias(&device)) - .filter_map(|(k, v)| { - let diff = pathdiff::diff_paths(&path, v.mount_path(&device, &storages).unwrap())?; - trace!("Pathdiff: {:?}", diff); - if diff.components().any(|c| c == path::Component::ParentDir) { - None - } else { - Some((k, diff)) - } - }) - .min_by_key(|(_k, v)| { - let diff_paths: Vec> = v.components().collect(); - diff_paths.len() - }) - .context(format!("Failed to compare diff of paths"))?; - trace!("Selected parent: {}", parent_name); + let (parent, diff_path) = util::min_parent_storage(&path, storages, &device) + .context("Failed to compare diff of paths")?; + trace!("Selected parent: {}", parent.name()); let local_info = LocalInfo::new(alias, path); Ok(Directory::new( name, - parent_name.clone(), + parent.name().to_string(), diff_path, notes, HashMap::from([(device.name(), local_info)]), @@ -97,7 +82,7 @@ impl Directory { /// Resolve mount path of directory with current device. fn mount_path(&self, device: &devices::Device, storages: &Storages) -> Result { let parent_mount_path = self - .parent(&storages)? + .parent(&storages) .context("Can't find parent storage")? .mount_path(&device, &storages)?; Ok(parent_mount_path.join(self.relative_path.clone())) @@ -117,12 +102,12 @@ impl StorageExt for Directory { self.local_infos.get(&device.name()) } - fn mount_path( - &self, - device: &devices::Device, - storages: &Storages, - ) -> Result { - Ok(self.mount_path(device, storages)?) + fn mount_path(&self, device: &devices::Device, storages: &Storages) -> Result { + Ok(self + .local_infos + .get(&device.name()) + .context(format!("LocalInfo for storage: {} not found", &self.name()))? + .mount_path()) } /// This method doesn't use `mount_path`. @@ -141,14 +126,8 @@ impl StorageExt for Directory { } // Get parent `&Storage` of directory. - fn parent<'a>(&'a self, storages: &'a Storages) -> Result> { - match storages.get(&self.parent).context(format!( - "No parent {} exists for directory {}", - &self.parent, &self.name - )) { - Ok(s) => Ok(Some(s)), - Err(e) => Err(anyhow!(e)), - } + fn parent<'a>(&'a self, storages: &'a Storages) -> Option<&Storage> { + storages.get(&self.parent) } } @@ -205,10 +184,15 @@ mod test { local_infos, ); let mut storages = Storages::new(); - storages.add(storages::Storage::PhysicalStorage(physical)).unwrap(); + storages + .add(storages::Storage::PhysicalStorage(physical)) + .unwrap(); storages.add(Storage::SubDirectory(directory)).unwrap(); // assert_eq!(directory.name(), "test_name"); - assert_eq!(storages.get(&"test_name".to_string()).unwrap().name(), "test_name"); + assert_eq!( + storages.get(&"test_name".to_string()).unwrap().name(), + "test_name" + ); assert_eq!( storages .get(&"test_name".to_string()) diff --git a/src/storages/online_storage.rs b/src/storages/online_storage.rs index ff70bf2..392252b 100644 --- a/src/storages/online_storage.rs +++ b/src/storages/online_storage.rs @@ -11,7 +11,7 @@ use crate::devices; use super::{ local_info::{self, LocalInfo}, - StorageExt, Storages, + Storage, StorageExt, Storages, }; #[derive(Serialize, Deserialize, Debug)] @@ -88,11 +88,8 @@ impl StorageExt for OnlineStorage { Ok(()) } - fn parent( - &self, - storages: &Storages, - ) -> Result> { - Ok(None) + fn parent(&self, storages: &Storages) -> Option<&Storage> { + None } } diff --git a/src/storages/physical_drive_partition.rs b/src/storages/physical_drive_partition.rs index b61d9d5..7f06748 100644 --- a/src/storages/physical_drive_partition.rs +++ b/src/storages/physical_drive_partition.rs @@ -165,8 +165,8 @@ impl StorageExt for PhysicalDrivePartition { Ok(()) } - fn parent(&self, storages: &Storages) -> Result> { - Ok(None) + fn parent(&self, storages: &Storages) -> Option<&Storage> { + None } } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..7f7cd5b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,37 @@ +use std::path::{self, PathBuf}; + +use crate::{ + devices::Device, + storages::{Storage, StorageExt, Storages}, +}; + +/// Find the closest parent storage from the `path`. +pub fn min_parent_storage<'a>( + path: &PathBuf, + storages: &'a Storages, + device: &'a Device, +) -> Option<(&'a Storage, PathBuf)> { + let (name, pathdiff) = storages + .list + .iter() + .filter_map(|(k, storage)| { + let storage_path = match storage.mount_path(device, storages) { + Ok(path) => path, + Err(_) => return None, + }; + let diff = pathdiff::diff_paths(&path, storage_path)?; + if diff.components().any(|c| c == path::Component::ParentDir) { + None + } else { + Some((k, diff)) + } + }) + .min_by_key(|(k, pathdiff)| { + pathdiff + .components() + .collect::>() + .len() + })?; + let storage = storages.get(name)?; + Some((storage, pathdiff)) +} diff --git a/tests/cli.rs b/tests/cli.rs index 809fbe1..c78d6ec 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -181,7 +181,7 @@ mod cmd_init { .success() .stdout(predicate::str::contains("")); // Add storage (directory) - let sample_directory = sample_storage.join("foo").join("bar"); + let sample_directory = &sample_storage.join("foo").join("bar"); DirBuilder::new() .recursive(true) .create(&sample_directory)?; @@ -228,6 +228,71 @@ mod cmd_init { .success() .stdout(predicate::str::contains("")); + // storage 3 + let sample_storage_2 = assert_fs::TempDir::new()?; + Command::cargo_bin("xdbm")? + .arg("-c") + .arg(&config_dir_2.path()) + .arg("storage") + .arg("add") + .arg("online") + .arg("--provider") + .arg("me") + .arg("--capacity") + .arg("1000000000000") + .arg("--alias") + .arg("nas") + .arg("nas") + .arg(&sample_storage_2.path()) + .assert() + .success(); + + Command::cargo_bin("xdbm")? + .arg("-c") + .arg(&config_dir_2.path()) + .arg("storage") + .arg("list") + .arg("-l") + .assert() + .success(); + // backup add + let backup_src = &sample_storage_2.join("foo").join("bar"); + DirBuilder::new().recursive(true).create(&backup_src)?; + let backup_dest = &sample_directory.join("docs"); + DirBuilder::new().recursive(true).create(&backup_dest)?; + Command::cargo_bin("xdbm")? + .arg("-c") + .arg(&config_dir_2.path()) + .arg("backup") + .arg("add") + .arg("--src") + .arg(&backup_src) + .arg("--dest") + .arg(&backup_dest) + .arg("foodoc") + .arg("external") + .arg("rsync") + .arg("note: nonsense") + .assert() + .success(); + + Command::cargo_bin("xdbm")? + .arg("-c") + .arg(&config_dir_2.path()) + .arg("backup") + .arg("add") + .arg("--src") + .arg(&backup_src) + .arg("--dest") + .arg(&backup_dest) + .arg("foodoc") + .arg("external") + .arg("rsync") + .arg("note: nonsense") + .assert() + .failure() + .stderr(predicate::str::contains("already")); + Ok(()) } }