Improve CLI
+ Add repository URL as valid input for dotfiles + Add regex, chrono crates + Add custom error types for Kot + Add uninstallation of dotfiles to revert changes when error is encountered + Update README, help text
This commit is contained in:
288
src/kot.rs
288
src/kot.rs
@@ -6,15 +6,31 @@
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use crate::kot::kfs::check_collisions;
|
||||
|
||||
pub mod kcli;
|
||||
pub mod kfs;
|
||||
pub mod kio;
|
||||
pub mod kgit;
|
||||
pub mod kerror;
|
||||
|
||||
use kerror::Error;
|
||||
/// Result alias to return result with Error of various types
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
pub type Result<T> = std::result::Result<T, kerror::Error>;
|
||||
|
||||
macro_rules! err {
|
||||
($type:expr, $msg:expr) => {
|
||||
return Err(Error::new($type, $msg))
|
||||
};
|
||||
|
||||
($msg:expr) => {
|
||||
return Err(Error::new(ErrorKind::Other("Unclassified kot error"), $msg))
|
||||
};
|
||||
}
|
||||
pub (crate) use err;
|
||||
use crate::ErrorKind::Other;
|
||||
use crate::kot::kfs::get_target_paths;
|
||||
|
||||
// =============================================================================
|
||||
// IMPLEMENTATION
|
||||
@@ -22,111 +38,199 @@ pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Creates symbolic links to the configurations we're installing
|
||||
// TODO: On error, revert to last good state
|
||||
// TODO: User script to execute after installing configs successfully
|
||||
pub fn install_configs(args: & kcli::Cli) -> Result<()> {
|
||||
// Get the configurations and their target installation paths
|
||||
// + Checks for conflicts and prompts user to abort or continue
|
||||
let config_map = kfs::get_target_paths(&args)?;
|
||||
|
||||
// Check if there are any existing files in the install directory that are also within the dotfiles to install
|
||||
handle_collisions(&args, &config_map)?;
|
||||
|
||||
// At this point there are either no conflicts or the user agreed to them
|
||||
println!("Installing configs:");
|
||||
for (config_path, target_path) in &config_map {
|
||||
println!(" + {:?}", target_path);
|
||||
match std::os::unix::fs::symlink(config_path, target_path) {
|
||||
Ok(()) => (), // Configuration installed successfully
|
||||
Err(_e) => {
|
||||
// Attempt to remove the file or directory first, and then symlink the new config
|
||||
match target_path.is_dir() {
|
||||
true => fs_extra::dir::remove(target_path)
|
||||
.expect(&format!("Error: Unable to remove directory: {:?}", target_path)),
|
||||
false => fs_extra::file::remove(target_path)
|
||||
.expect(&format!("Error: Unable to remove file: {:?}", target_path)),
|
||||
};
|
||||
// Try to symlink the config again, if failure exit with error
|
||||
std::os::unix::fs::symlink(config_path, target_path)?;
|
||||
},
|
||||
}
|
||||
pub fn handle_args(args: &mut kcli::Cli) -> Result<()> {
|
||||
if args.is_repo {
|
||||
// Attempt to install dotfiles from a dotfiles repository
|
||||
// + No specific configuration required on behalf of dotfiles repo
|
||||
kgit::clone(&args.dotfiles.to_str().unwrap(),
|
||||
&args.clone_dir.as_ref().unwrap())?;
|
||||
}
|
||||
return match install_configs(args) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
// If we reach an error, use our backup_dir to restore configs
|
||||
// + Remove configs we applied that weren't previously on the system
|
||||
uninstall_configs(args)?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Creates symbolic links to the configurations we're installing
|
||||
pub fn install_configs(args: &mut kcli::Cli) -> Result<()> {
|
||||
//
|
||||
// Find path that points us to the dotfiles we are installing
|
||||
let dotfiles = match args.is_repo {
|
||||
// If the dotfiles were provided as a system path, use it
|
||||
false => args.dotfiles.to_owned(),
|
||||
// If the dotfiles to install was a repository, find the path we cloned to
|
||||
true => args.clone_dir.as_ref().unwrap().to_path_buf()
|
||||
};
|
||||
|
||||
//
|
||||
// Check if there are any existing files in the install directory that are also within the dotfiles to install
|
||||
|
||||
// Get the configurations and their target installation paths in a hashmap<config, target_path>
|
||||
// + Using target_path, check for conflicts and prompts user to abort or continue
|
||||
let config_map = kfs::get_target_paths(&args.install_dir, &dotfiles)?;
|
||||
handle_collisions(args, &config_map)?;
|
||||
|
||||
//
|
||||
// Install the dotfiles configurations
|
||||
|
||||
// At this point there are either no conflicts or the user agreed to them
|
||||
println!("Installing configs:");
|
||||
for (config_path, target_path) in &config_map {
|
||||
println!(" + {:?}", target_path);
|
||||
std::os::unix::fs::symlink(config_path, target_path)
|
||||
.or_else(|err| -> Result<()> {
|
||||
eprintln!("Error: Unable to create symlink {:?} -> {:?} ({:?})",
|
||||
target_path, config_path, err);
|
||||
|
||||
// Attempt to remove the file or directory first, and then symlink the new config
|
||||
match target_path.is_dir() {
|
||||
true => fs_extra::dir::remove(target_path)
|
||||
.expect(&format!("Error: Unable to remove directory: {:?}", target_path)),
|
||||
false => fs_extra::file::remove(target_path)
|
||||
.expect(&format!("Error: Unable to remove file: {:?}", target_path)),
|
||||
};
|
||||
// Try to symlink the config again, if failure exit with error
|
||||
std::os::unix::fs::symlink(config_path, target_path).or_else(|err| {
|
||||
eprintln!("Error: Unable to symlink config: {:?} -> {:?}",
|
||||
target_path, config_path);
|
||||
return Err(err);
|
||||
})?;
|
||||
|
||||
return Ok(());
|
||||
})?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// Handles collisions between existing files and dotfiles we're installing
|
||||
fn handle_collisions(args : & kcli::Cli,
|
||||
config_map : & kfs::HashMap<PathBuf, PathBuf>) -> Result<()> {
|
||||
// Check if we found any collisions in the configurations
|
||||
match check_collisions(&config_map) {
|
||||
None => {
|
||||
return Ok(()) // There were no collisions, configurations pass pre-install checks
|
||||
/// + If --force is not set, prompt user to continue based on conflicts found
|
||||
/// + If --force is set or user chooses to continue,
|
||||
/// move conflicting files to a backup directory
|
||||
fn handle_collisions(args: &mut kcli::Cli,
|
||||
config_map: &kfs::HashMap<PathBuf, PathBuf>) -> Result<()> {
|
||||
// Check if we found any collisions in the configurations
|
||||
return match check_collisions(&config_map) {
|
||||
None => Ok(()), // There were no collisions, configurations pass pre-install checks
|
||||
Some(conflicts) => {
|
||||
args.conflicts = conflicts.to_owned();
|
||||
// Ask client if they would like to abort given the config collisions
|
||||
let mut msg = format!("The following configurations already exist:");
|
||||
for config in conflicts.iter() {
|
||||
msg += format!("\n {:?}", config).as_str();
|
||||
}
|
||||
msg += format!("\nIf you continue, backups will be made in {:?}. \
|
||||
Any configurations there will be overwritten.\
|
||||
\nContinue? Enter Y/y or N/n: ",
|
||||
args.backup_dir.as_ref().unwrap()).as_str();
|
||||
|
||||
// If the --force flag is set, short-circuit boolean and skip prompt
|
||||
match args.force || kio::prompt(msg) {
|
||||
true => {
|
||||
// Backup each conflicting config at the install location
|
||||
for backup_target in conflicts.iter() {
|
||||
backup_config(backup_target, &args)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
Some(conflicts) => {
|
||||
// Ask client if they would like to abort given the config collisions
|
||||
let mut msg = format!("The following configurations already exist:");
|
||||
for config in conflicts.iter() {
|
||||
msg += format!("\n {:?}", config).as_str();
|
||||
}
|
||||
msg += format!("\nIf you continue, backups will be made in {:?}. \
|
||||
Any configurations there will be overwritten.\
|
||||
\nAbort? Enter y/n or Y/N: ", &args.backup_dir).as_str();
|
||||
false => err!(Other("User aborted installation".to_string()), "Aborted".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If we abort, exit; If we continue, back up the configs
|
||||
// TODO: Group this in with the --force flag?; Or make a new --adopt flag?
|
||||
match kio::prompt(msg) {
|
||||
true => return Ok(()),
|
||||
false => {
|
||||
// Backup each conflicting config at the install location
|
||||
for backup_target in conflicts.iter() {
|
||||
backup_config(backup_target, &args)?;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
Ok(())
|
||||
/// Checks if any config to install collides with existing files or directories
|
||||
/// + Returns a list of collisions within Some(), else returns None
|
||||
pub fn check_collisions(config_map: &HashMap<PathBuf, PathBuf>)
|
||||
-> Option<Vec<PathBuf>> {
|
||||
let mut config_conflicts = vec![];
|
||||
for (_path, target_config) in config_map.iter() {
|
||||
// If the target configuration file or directory already exists
|
||||
if target_config.exists() {
|
||||
config_conflicts.push(target_config.to_owned());
|
||||
}
|
||||
}
|
||||
if !config_conflicts.is_empty() {
|
||||
return Some(config_conflicts);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Creates a backup of configurations that conflict
|
||||
// + Backup directory location is specified by CLI --backup-dir
|
||||
// TODO: .kotignore in dotfiles repo to specify files to not install / backup
|
||||
// TODO: .kotrc in dotfiles repo or home dir to set backup-dir and install-dir?
|
||||
fn backup_config(config_path: & kfs::PathBuf, args: & kcli::Cli) -> Result<()> {
|
||||
let mut backup_path = args.backup_dir.to_owned();
|
||||
backup_path.push(config_path.file_name().unwrap());
|
||||
fn backup_config(config_path: &kfs::PathBuf, args: &kcli::Cli) -> Result<()> {
|
||||
let mut backup_path = args.backup_dir.as_ref().unwrap().to_owned();
|
||||
|
||||
// Check if the configuration we're backing up is a directory or a single file
|
||||
match config_path.is_dir() {
|
||||
true => {
|
||||
// Copy directory with recursion using move_dir() wrapper function
|
||||
let mut options = kfs::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = args.force;
|
||||
if let Err(e) = kfs::move_dir(config_path, &backup_path, Some(&options))
|
||||
.map_err(|e| e.into()) {
|
||||
return Err(e)
|
||||
}
|
||||
},
|
||||
false => {
|
||||
// Copy single configuration file
|
||||
let mut options = fs_extra::file::CopyOptions::new();
|
||||
options.overwrite = args.force;
|
||||
if let Err(e) = kfs::move_file(config_path, &backup_path, Some(&options))
|
||||
.map_err(|e| e.into()) {
|
||||
return Err(e)
|
||||
}
|
||||
},
|
||||
// Check if the configuration we're backing up is a directory or a single file
|
||||
match config_path.is_dir() {
|
||||
true => {
|
||||
// Copy directory with recursion using move_dir() wrapper function
|
||||
let mut options = fs_extra::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = args.force;
|
||||
kfs::move_dir(config_path, &backup_path, Some(&options))?;
|
||||
}
|
||||
Ok(())
|
||||
false => {
|
||||
backup_path.push(config_path.file_name().unwrap());
|
||||
// Copy single configuration file
|
||||
kfs::move_file(config_path, &backup_path)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// TODO: Function to uninstall configs.
|
||||
// + Loops through dotfiles and restore backup files or delete configs
|
||||
fn _uninstall_configs() -> Result<()> {
|
||||
Ok(())
|
||||
// Loops through dotfiles to restore backup files or delete unused configs
|
||||
pub fn uninstall_configs(args: &kcli::Cli) -> Result<()> {
|
||||
//
|
||||
// Replace previous configs we stored in backup_dir
|
||||
for config in args.backup_dir.as_ref().unwrap().read_dir()? {
|
||||
match config.as_ref().unwrap().path().is_dir() {
|
||||
true => {
|
||||
let mut options = fs_extra::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = args.force;
|
||||
kfs::move_dir(&config.as_ref().unwrap().path(), &args.install_dir,
|
||||
Some(&options)
|
||||
)?;
|
||||
},
|
||||
false => {
|
||||
kfs::move_file(&config.unwrap().path(), &args.install_dir)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Remove configurations only required by the dotfiles we attempted to install
|
||||
|
||||
// Check each config in the dotfiles we want to uninstall
|
||||
let dotfile_path = match args.is_repo {
|
||||
true => args.clone_dir.as_ref().unwrap(),
|
||||
false => &args.dotfiles
|
||||
};
|
||||
|
||||
for dotfile in dotfile_path.read_dir()? {
|
||||
let path = dotfile.unwrap().path();
|
||||
// If the configuration was not a conflict initially
|
||||
// then we didn't have it before we installed; It is not being used
|
||||
if !args.conflicts.contains(&path) {
|
||||
let mut unused_config: PathBuf = args.install_dir.to_owned();
|
||||
unused_config.push(std::path::Path::new(&path.file_name().unwrap()));
|
||||
// Verify the file was already installed before we hit an error
|
||||
if !unused_config.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove the unused config from install_dir
|
||||
std::fs::remove_file(unused_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
228
src/kot/kcli.rs
228
src/kot/kcli.rs
@@ -6,8 +6,16 @@
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
use std::path::Path;
|
||||
use std::borrow::Borrow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use regex::Regex;
|
||||
use structopt::StructOpt;
|
||||
use crate::kot::kerror::{Error, ErrorKind};
|
||||
use crate::kot::err;
|
||||
use crate::kot::kfs::create_dir_all;
|
||||
|
||||
use chrono;
|
||||
use super::kfs;
|
||||
|
||||
// =============================================================================
|
||||
// STRUCTS
|
||||
@@ -15,43 +23,86 @@ use structopt::StructOpt;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Struct to outline behavior and features of kot CLI
|
||||
/// CLI for managing Linux user configurations
|
||||
#[derive(Debug, StructOpt)]
|
||||
#[structopt(
|
||||
name="kot",
|
||||
about="CLI for managing Linux user configurations"
|
||||
)]
|
||||
#[structopt(name = "kot")]
|
||||
pub struct Cli {
|
||||
#[structopt(
|
||||
help="Local or full path to user configurations to install",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub dotfiles_dir: std::path::PathBuf,
|
||||
/// Local or full path to user configurations to install. Can also be a git repository.
|
||||
///
|
||||
/// System path or repository URL for dotfiles we want to install.
|
||||
/// If a path is used, it can either be local to CWD or absolute.
|
||||
/// If a URL is used for a dotfiles repository, the repo is cloned into $HOME/.local/shared/kot/dotfiles/
|
||||
#[structopt(parse(from_os_str))]
|
||||
pub dotfiles: PathBuf,
|
||||
|
||||
#[structopt(
|
||||
help="The location to attempt installation of user configurations",
|
||||
default_value="dry-runs/kapper", // TODO: Remove temp default value after tests
|
||||
// env = "HOME", // Default value to env variable $HOME
|
||||
name="install-dir",
|
||||
short, long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub install_dir: std::path::PathBuf,
|
||||
/// The location to attempt installation of user configurations
|
||||
///
|
||||
/// The desired installation directory for user configurations.
|
||||
/// By default this is your $HOME directory
|
||||
/// This could optionally point to some other directory to perform a dry run, or the --dry-run flag could be set
|
||||
#[structopt(
|
||||
env = "HOME", // Default value to env variable $HOME
|
||||
name = "install",
|
||||
short, long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub install_dir: PathBuf,
|
||||
|
||||
#[structopt(
|
||||
help="The location to store backups for this user",
|
||||
default_value="backups/kapper",
|
||||
name="backup-dir",
|
||||
short, long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub backup_dir: std::path::PathBuf,
|
||||
/// The location to store backups for this user
|
||||
///
|
||||
/// If no backup-dir is provided, we create one within the default kot data directory:
|
||||
/// $HOME/.local/share/kot/backups/
|
||||
#[structopt(
|
||||
name = "backup-dir",
|
||||
short, long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub backup_dir: Option<PathBuf>,
|
||||
|
||||
#[structopt(
|
||||
help="Overwrites existing backups",
|
||||
short, long
|
||||
)]
|
||||
pub force: bool,
|
||||
/// An alternate path to clone a dotfiles repository to
|
||||
///
|
||||
/// If the clone-dir option is provided to the CLI, kot will clone the dotfiles repository into this directory.
|
||||
/// If clone-dir is not provided, the repository is cloned into $HOME/.local/share/kot/dotfiles
|
||||
/// Custom clone-dir will be used literally, and no subdirectory is created to store the cloned repository
|
||||
/// For example, clone-dir of $HOME/clonedir for repo named Dotfiles
|
||||
/// We will clone into $HOME/clonedir, and NOT $HOME/clonedir/Dotfiles
|
||||
/// The default path for cloned repos is $HOME/.local/share/kot/dotfiles/
|
||||
#[structopt(
|
||||
name = "clone-dir",
|
||||
short, long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
pub clone_dir: Option<PathBuf>,
|
||||
|
||||
/// Overwrites existing backups
|
||||
///
|
||||
/// This flag will replace existing backups if during installation we encounter conflicts
|
||||
/// and the backup-dir provided already contains previous backups.
|
||||
#[structopt(
|
||||
name = "force",
|
||||
short, long
|
||||
)]
|
||||
pub force: bool,
|
||||
|
||||
/// Installs configurations to $HOME/.local/shared/kot/dry-runs
|
||||
///
|
||||
/// Useful flag to set when testing what an install would do to your home directory.
|
||||
/// This is synonymous with setting --install $HOME/.local/shared/kot/dry-runs/$USER.
|
||||
/// Subsequent runs with this flag set will not delete the contents of this directory.
|
||||
#[structopt(
|
||||
name = "dry-run",
|
||||
short, long
|
||||
)]
|
||||
pub dry_run: bool,
|
||||
|
||||
// Indicates if dotfiles is a git repository URL; Not used by CLI directly
|
||||
// + Initialized with result of regex pattern matching
|
||||
#[structopt(skip)]
|
||||
pub is_repo: bool,
|
||||
|
||||
// Not used by CLI, used to uninstall dotfiles when error is hit
|
||||
#[structopt(skip)]
|
||||
pub conflicts: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -64,50 +115,79 @@ pub struct Cli {
|
||||
/// + Also enforces use of Cli::normalize()
|
||||
/// + https://docs.rs/structopt/0.3.23/src/structopt/lib.rs.html#1121-1126
|
||||
pub fn from_args() -> super::Result<Cli> {
|
||||
let s = Cli::from_clap(&Cli::clap().get_matches());
|
||||
s.normalize()
|
||||
let s = Cli::from_clap(&Cli::clap().get_matches());
|
||||
s.normalize()
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Helper function to normalize arguments passed to program
|
||||
pub fn normalize(mut self) -> super::Result<Self> {
|
||||
// If the path to the dotfiles doesn't exist, exit with error
|
||||
if !&self.dotfiles_dir.exists() {
|
||||
panic!("Error: Dotfiles configuration at {:?} does not exist", self.dotfiles_dir);
|
||||
}
|
||||
self.dotfiles_dir = self.dotfiles_dir.canonicalize()?;
|
||||
/// Helper function to normalize arguments passed to program
|
||||
/// + Checks if dotfiles path is a repository URL
|
||||
/// + If dotfiles path is not a repo URL, checks the path exists on the system
|
||||
/// + Verifies install directory exists
|
||||
/// + Verifies backup directory exists and does not already contain backups
|
||||
pub fn normalize(mut self) -> super::Result<Self> {
|
||||
// Determine if the dotfiles were provided as a github repository URL
|
||||
let re_git = Regex::new(
|
||||
r"^(([A-Za-z0-9]+@|http(|s)://)|(http(|s)://[A-Za-z0-9]+@))([A-Za-z0-9.]+(:\d+)?)(?::|/)([\d/\w.-]+?)(\.git){1}$"
|
||||
);
|
||||
self.is_repo = re_git.unwrap().is_match(&self.dotfiles.to_str().unwrap());
|
||||
|
||||
// If either the install or backup dir don't exist, create them
|
||||
std::fs::create_dir_all(&self.install_dir)?;
|
||||
self.install_dir = self.install_dir.canonicalize()?;
|
||||
std::fs::create_dir_all(&self.backup_dir)?;
|
||||
self.backup_dir = self.backup_dir.canonicalize()?;
|
||||
|
||||
// + To enforce the correction when error is encountered
|
||||
// Get the number of configs currently in backup directory
|
||||
// + An empty backup directory returns a count of 1
|
||||
let current_backups = self.backup_dir.read_dir()?.count();
|
||||
// If there are files in the backup directory already
|
||||
if current_backups > 1 {
|
||||
// If the --force flag is not set, warn and abort
|
||||
if !self.force {
|
||||
panic!("\n Error: Backups already exist at {:?}\
|
||||
\n Set the --force flag to overwrite configurations stored here" , self.backup_dir)
|
||||
}
|
||||
// If the --force flag is set, remove backups and create new
|
||||
// + Move backups to /tmp/<BACKUP_DIRNAME>
|
||||
// + If we encounter an error, we can move these temp files back to args.backup_dir
|
||||
// + On success we can delete them since new backups will have been created at args.backup_dir
|
||||
let mut options = fs_extra::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = true;
|
||||
let mut temp_path = Path::new("/tmp/").to_path_buf();
|
||||
temp_path.push(self.backup_dir.file_name().unwrap());
|
||||
// Move the old backups to /tmp/ and create a new empty backup directory
|
||||
super::kfs::move_dir(&self.backup_dir, &temp_path, Some(&options))?;
|
||||
std::fs::create_dir_all(&self.backup_dir)?;
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
if self.is_repo {
|
||||
// If the dotfiles were provided as a repository URL initialize clone_dir
|
||||
self.clone_dir = match &self.clone_dir {
|
||||
Some(d) => {
|
||||
kfs::create_dir_all(d)?;
|
||||
Some(kfs::abs(d)?)
|
||||
},
|
||||
None => Some(kfs::get_repo_path(self.dotfiles.to_str().unwrap()))
|
||||
};
|
||||
}
|
||||
else {
|
||||
// If the dotfiles were provided as a path, canonicalize it
|
||||
self.dotfiles = kfs::abs(&self.dotfiles)?;
|
||||
}
|
||||
|
||||
//
|
||||
// If either the install, backup, or clone dir does not exist, create them
|
||||
|
||||
if self.dry_run {
|
||||
self.install_dir = Path::new(
|
||||
&(env!("HOME").to_owned() + &"/.local/share/kot/dry-runs/" + env!("USER"))
|
||||
).to_path_buf();
|
||||
}
|
||||
self.install_dir = kfs::create_dir_all(&self.install_dir)?;
|
||||
// If the CLI was not provided a backup_dir, use default naming convention
|
||||
match self.backup_dir {
|
||||
None => {
|
||||
let mut backup_dir = kfs::get_data_dir();
|
||||
backup_dir.push("backups/");
|
||||
backup_dir.push(self.dotfiles.file_name().unwrap().to_str().unwrap().to_owned()
|
||||
+ ":" + &*chrono::offset::Local::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
);
|
||||
self.backup_dir = Some(kfs::create_dir_all(&backup_dir)?);
|
||||
}
|
||||
Some(dir) => {
|
||||
// If a backup_dir was given to CLI, use it instead of default
|
||||
self.backup_dir = Some(kfs::create_dir_all(&dir)?);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Check if the backup directory provided is empty
|
||||
|
||||
// If there are files and the --force flag is not set, warn and abort
|
||||
if !self.force && kfs::dir_entries(&self.backup_dir.as_ref().unwrap())? > 1 {
|
||||
return err!(
|
||||
ErrorKind::ConfigError(format!("Backups already exist at: {:?}", &self.backup_dir)),
|
||||
"Set the --force flag to overwrite configurations stored here".to_owned()
|
||||
);
|
||||
}
|
||||
// If the --force flag is set, stash backup files in /tmp/ and create new
|
||||
kfs::stash_dir(&self.backup_dir.as_ref().unwrap())?;
|
||||
|
||||
// Available CLI options pass initial checks; Return them to caller
|
||||
return Ok(self);
|
||||
}
|
||||
}
|
||||
|
||||
78
src/kot/kerror.rs
Normal file
78
src/kot/kerror.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
/*##############################################################################
|
||||
## Author: Shaun Reed ##
|
||||
## Legal: All Content (c) 2021 Shaun Reed, all rights reserved ##
|
||||
## About: Error module for dotfiles manager kot ##
|
||||
## This module supports converting errors to custom types using ? operator ##
|
||||
## ##
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
||||
// Error types for kot application
|
||||
#[derive(Debug)]
|
||||
pub enum ErrorKind {
|
||||
ConfigError(String),
|
||||
GitError(String),
|
||||
IOError(String),
|
||||
FileError(String),
|
||||
DirError(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMPLEMENTATION
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub kind: ErrorKind,
|
||||
message: String,
|
||||
}
|
||||
|
||||
// Implement Display trait for printing found errors
|
||||
impl std::fmt::Display for ErrorKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Kot {:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error { }
|
||||
|
||||
impl Error {
|
||||
pub fn new(kind: ErrorKind, message: String) -> Error {
|
||||
Error {
|
||||
kind: kind,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement From<T> for each error type T that we want to handle
|
||||
// These implementations handle converting from T to kot::kerror::Error using ?
|
||||
|
||||
// Converting from std::io::Error to kot::kerror::Error::GitError
|
||||
impl std::convert::From<std::io::Error> for Error {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
return Error::new(ErrorKind::IOError(error.to_string()),
|
||||
"(std::io error)".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
// Converting from fs_extra::error::Error to kot::kerror::Error::GitError
|
||||
impl std::convert::From<fs_extra::error::Error> for Error {
|
||||
fn from(error: fs_extra::error::Error) -> Self {
|
||||
return Error::new(ErrorKind::FileError(error.to_string()),
|
||||
"(fs_extra error)".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
185
src/kot/kfs.rs
185
src/kot/kfs.rs
@@ -6,12 +6,15 @@
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
// Allow the use of kot::fs::Path and kot::fs::PathBuf from std::path::
|
||||
pub use std::path::{Path, PathBuf};
|
||||
pub use std::collections::HashMap;
|
||||
pub use fs_extra::dir;
|
||||
|
||||
use std::fs;
|
||||
use crate::kot::err;
|
||||
use crate::kot::kerror::{Error, ErrorKind};
|
||||
|
||||
use super::kgit;
|
||||
|
||||
// =============================================================================
|
||||
// IMPLEMENTATION
|
||||
@@ -19,72 +22,146 @@ use std::fs;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
pub fn abs(dir: &PathBuf) -> super::Result<PathBuf> {
|
||||
return match dir.canonicalize() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => {
|
||||
err!(
|
||||
ErrorKind::IOError(e.to_string()),
|
||||
format!("Unable to canonicalize dir: {:?}", dir)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize and return a HashMap<config_dir, config_install_location>
|
||||
/// + Later used to check each install location for conflicts before installing
|
||||
/// + This function does not create or modify any files or directories
|
||||
pub fn get_target_paths(args: & super::kcli::Cli) -> super::Result<HashMap<PathBuf, PathBuf>> {
|
||||
let mut config_map = HashMap::new();
|
||||
pub fn get_target_paths(install_dir: &PathBuf, dotfiles: &PathBuf)
|
||||
-> super::Result<HashMap<PathBuf, PathBuf>> {
|
||||
let mut config_map = HashMap::new();
|
||||
|
||||
// Local variable for the installation directory as an absolute path
|
||||
let mut config_target = args.install_dir.to_owned();
|
||||
// For each file or directory within the dotfiles we're installing
|
||||
for config_entry in fs::read_dir(&args.dotfiles_dir)? {
|
||||
let entry = config_entry?;
|
||||
// Create full path to target config file (or directory) by push onto install path
|
||||
config_target.push(entry.file_name());
|
||||
// Local variable for the installation directory as an absolute path
|
||||
let mut config_target = install_dir.to_owned();
|
||||
// For each file or directory within the dotfiles we're installing
|
||||
for config_entry in fs::read_dir(&dotfiles)? {
|
||||
let entry = config_entry?;
|
||||
// Create full path to target config file (or directory) by push onto install path
|
||||
config_target.push(entry.file_name());
|
||||
|
||||
// If the entry doesn't already exist, insert it into the config_map
|
||||
// + Key is full path to source config from dotfiles repo we're installing
|
||||
// + Value is desired full path to config at final install location
|
||||
// TODO: If the entry does exist, should there be an exception?
|
||||
config_map.entry(entry.path().to_owned())
|
||||
.or_insert(config_target.to_owned());
|
||||
// If the entry doesn't already exist, insert it into the config_map
|
||||
// + Key is full path to source config from dotfiles repo we're installing
|
||||
// + Value is desired full path to config at final install location
|
||||
config_map.entry(entry.path().to_owned())
|
||||
.or_insert(config_target.to_owned());
|
||||
|
||||
// Reset config_target to be equal to requested install_dir
|
||||
config_target.pop();
|
||||
}
|
||||
Ok(config_map)
|
||||
}
|
||||
|
||||
/// Checks if any config to install collides with existing files or directories
|
||||
/// + Returns a count of collisions within Some(), else returns None
|
||||
pub fn check_collisions(config_map : & HashMap<PathBuf, PathBuf>) -> Option<Vec<PathBuf>> {
|
||||
let mut config_conflicts = vec![];
|
||||
for (_path, target_config) in config_map.iter() {
|
||||
// If the target configuration file or directory already exists
|
||||
if target_config.exists() {
|
||||
config_conflicts.push(target_config.to_owned());
|
||||
}
|
||||
}
|
||||
if config_conflicts.len() > 0 {
|
||||
return Some(config_conflicts)
|
||||
}
|
||||
return None
|
||||
// Reset config_target to be equal to requested install_dir
|
||||
config_target.pop();
|
||||
}
|
||||
return Ok(config_map);
|
||||
}
|
||||
|
||||
/// Moves a single file from one location to another; Can be used to rename files
|
||||
/// + Overwrites file at the dst location with the src file
|
||||
/// + To specify options such as overwrite for the copy operation, a custom CopyOptions can be provided
|
||||
pub fn move_file(src: & PathBuf, dst: & PathBuf,
|
||||
options: Option< & fs_extra::file::CopyOptions>) -> super::Result<()> {
|
||||
if options.is_none() {
|
||||
// Default CopyOptions for moving files
|
||||
let mut options = fs_extra::file::CopyOptions::new();
|
||||
options.overwrite = false;
|
||||
}
|
||||
fs_extra::file::move_file(src, dst, options.unwrap())?;
|
||||
Ok(())
|
||||
pub fn move_file(src: &PathBuf, dst: &PathBuf) -> super::Result<()> {
|
||||
std::fs::copy(src, dst)?;
|
||||
std::fs::remove_file(src)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// Moves a directory and all of it's contents recursively
|
||||
/// + To specify options such as overwrite for the copy operation, a custom CopyOptions can be provided
|
||||
pub fn move_dir(src: & PathBuf, dst: & PathBuf,
|
||||
options: Option< & fs_extra::dir::CopyOptions>) -> super::Result<()> {
|
||||
if options.is_none() {
|
||||
// Default CopyOptions for moving directories
|
||||
let mut options = fs_extra::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = false;
|
||||
/// TODO: Implement this using std::fs to remove fs_extra dependency
|
||||
pub fn move_dir(src: &PathBuf, dst: &PathBuf,
|
||||
options: Option<&fs_extra::dir::CopyOptions>)
|
||||
-> super::Result<()> {
|
||||
let copy_options = match options {
|
||||
Some(opts) => opts.to_owned(),
|
||||
None => {
|
||||
// Default CopyOptions for moving directories
|
||||
let mut opts = fs_extra::dir::CopyOptions::new();
|
||||
opts.copy_inside = true;
|
||||
opts.overwrite = false;
|
||||
opts
|
||||
}
|
||||
fs_extra::dir::move_dir(src, dst, options.unwrap())?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Err(e) = fs_extra::dir::move_dir(src, dst, ©_options) {
|
||||
return err!(
|
||||
ErrorKind::DirError(e.to_string()),
|
||||
format!("Cannot move directory from {:?} to {:?}", src, dst)
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// Recursively creates a directory
|
||||
/// Returns a result that contains the absolute path to the new directory
|
||||
pub fn create_dir_all(dir: &PathBuf) -> super::Result<PathBuf> {
|
||||
return match fs::create_dir_all(dir) {
|
||||
Ok(_) => {
|
||||
Ok(dir.to_owned())
|
||||
},
|
||||
Err(e) => {
|
||||
err!(
|
||||
ErrorKind::IOError(e.to_string()),
|
||||
format!("Unable to create directory: {:?}", dir)
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the total number of entries within a directory
|
||||
/// + Returns 1 for empty directories
|
||||
pub fn dir_entries(dir: &PathBuf) -> super::Result<usize> {
|
||||
if !dir.exists() {
|
||||
return Ok(0)
|
||||
}
|
||||
let count = dir.read_dir().and_then(|dir| Ok(dir.count()))?;
|
||||
return Ok(count);
|
||||
}
|
||||
|
||||
/// Stash a directory in the temp folder, staging it for deletion
|
||||
/// + We stash first instead of delete to allow recovery of these files if we run into an error
|
||||
pub fn stash_dir(dir: &PathBuf) -> super::Result<()> {
|
||||
// Get the number of configs currently in backup directory
|
||||
// + An empty backup directory returns a count of 1
|
||||
if dir_entries(&dir)? > 1 {
|
||||
// Move backups to /tmp/<BACKUP_DIRNAME>
|
||||
// + If we encounter an error, we can move these temp files back to args.backup_dir
|
||||
// + On success we can delete them since new backups will have been created at args.backup_dir
|
||||
let mut options = fs_extra::dir::CopyOptions::new();
|
||||
options.copy_inside = true;
|
||||
options.overwrite = true;
|
||||
let mut temp_path = get_temp_dir();
|
||||
temp_path.push(dir.file_name().unwrap());
|
||||
// Move the old backups to /tmp/ and create a new empty backup directory
|
||||
super::kfs::move_dir(&dir, &temp_path, Some(&options))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// Gets the root temp directory used by kot to store expired files as an owned PathBuf
|
||||
pub fn get_temp_dir() -> PathBuf {
|
||||
// Get temp directory from current user environment
|
||||
let mut temp = std::env::temp_dir();
|
||||
temp.push("kot/expired/");
|
||||
return temp;
|
||||
}
|
||||
|
||||
/// Constructs a new PathBuf pointing to the default data directory used by kot
|
||||
pub fn get_data_dir() -> PathBuf {
|
||||
let mut data_dir = std::path::Path::new(env!("HOME")).to_path_buf();
|
||||
data_dir.push(".local/share/kot/");
|
||||
return data_dir;
|
||||
}
|
||||
|
||||
/// Constructs a new PathBuf pointing to the default clone directory used by kot
|
||||
pub fn get_repo_path(repo_url: &str) -> PathBuf {
|
||||
let mut repo_path = get_data_dir();
|
||||
// Store the new dotfiles repo in a subdirectory using it's name
|
||||
repo_path.push("dotfiles/".to_owned() + &kgit::repo_name(repo_url) + "/");
|
||||
return repo_path;
|
||||
}
|
||||
|
||||
47
src/kot/kgit.rs
Normal file
47
src/kot/kgit.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
/*##############################################################################
|
||||
## Author: Shaun Reed ##
|
||||
## Legal: All Content (c) 2021 Shaun Reed, all rights reserved ##
|
||||
## About: Wrapper module for git written in Rust ##
|
||||
## ##
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
use std::os::linux::raw::stat;
|
||||
use std::path::{PathBuf};
|
||||
use std::process::{Command};
|
||||
use crate::kot::err;
|
||||
use super::kerror::{Error, ErrorKind};
|
||||
|
||||
use super::kfs;
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// IMPLEMENTATION
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Clones a Git repository using https or ssh
|
||||
/// + By default, cloned repositories are stored in $HOME/.local/share/kot/dotfiles/
|
||||
pub fn clone(repo_url: &str, clone_dir: &PathBuf)
|
||||
-> super::Result<PathBuf> {
|
||||
// Clone the repository, check that status return value is 0
|
||||
let status = Command::new("git")
|
||||
.args(["clone", repo_url, clone_dir.to_str().unwrap(), "--recursive"])
|
||||
.status().unwrap();
|
||||
|
||||
return match status.code() {
|
||||
Some(0) => Ok(clone_dir.to_owned()),
|
||||
_ => {
|
||||
return
|
||||
err!(ErrorKind::GitError(status.to_string()),
|
||||
format!("Unable to clone repository"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts repository name from URL
|
||||
pub fn repo_name(repo_url: &str) -> String {
|
||||
return repo_url.rsplit_once('/').unwrap().1
|
||||
.strip_suffix(".git").unwrap().to_owned();
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
// Allow use of kot::io::Result
|
||||
pub use std::io::Result;
|
||||
|
||||
use std::io;
|
||||
|
||||
// =============================================================================
|
||||
@@ -20,14 +17,14 @@ use std::io;
|
||||
/// Asks user for y/n Y/N input, returns true/false respectively
|
||||
/// + Prompt output defined by msg parameter String
|
||||
pub fn prompt(msg: String) -> bool {
|
||||
println!("{}", msg);
|
||||
let mut reply = String::new();
|
||||
io::stdin().read_line(&mut reply)
|
||||
.expect("Failed to read user input");
|
||||
match reply.trim() {
|
||||
"y" | "Y" => true,
|
||||
"n" | "N" => false,
|
||||
// Handle garbage input
|
||||
_ => prompt("Please enter y/n or Y/N\n".to_owned()),
|
||||
}
|
||||
println!("{}", msg);
|
||||
let mut reply = String::new();
|
||||
io::stdin().read_line(&mut reply)
|
||||
.expect("Failed to read user input");
|
||||
match reply.trim() {
|
||||
"y" | "Y" => true,
|
||||
"n" | "N" => false,
|
||||
// Handle garbage input
|
||||
_ => prompt("Please enter Y/y or N/n\n".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
27
src/main.rs
27
src/main.rs
@@ -6,7 +6,7 @@
|
||||
## Contact: shaunrd0@gmail.com | URL: www.shaunreed.com | GitHub: shaunrd0 ##
|
||||
##############################################################################*/
|
||||
|
||||
use std::path::PathBuf;
|
||||
use crate::kot::kerror::ErrorKind;
|
||||
|
||||
mod kot;
|
||||
|
||||
@@ -18,29 +18,10 @@ mod kot;
|
||||
|
||||
fn main() -> kot::Result<()> {
|
||||
// Call augmented kot::cli::from_args() to parse CLI arguments
|
||||
let args = kot::kcli::from_args()?;
|
||||
let mut args = kot::kcli::from_args()?;
|
||||
// At this point all paths exist and have been converted to absolute paths
|
||||
println!("args: {:?}\n", args);
|
||||
|
||||
// Attempt to install the configurations, checking for collisions
|
||||
match kot::install_configs(&args) {
|
||||
Err(e) => {
|
||||
// If there was an error, show the error type and run settings
|
||||
println!(
|
||||
"Error: {:?}\n+ Configs used: {:?}\n+ Install directory: {:?}\n",
|
||||
e, args.dotfiles_dir, args.install_dir
|
||||
);
|
||||
|
||||
// If we were forcing a backup and met some error, revert backups to last good state
|
||||
// TODO: Isolate this to limit error scope to backup related functions
|
||||
if args.force {
|
||||
let mut temp_path : PathBuf = kot::kfs::Path::new("/tmp/").to_path_buf();
|
||||
temp_path.push(args.backup_dir.file_name().unwrap());
|
||||
kot::kfs::move_dir(&temp_path, &args.backup_dir, None)?;
|
||||
}
|
||||
},
|
||||
_ => ()
|
||||
}
|
||||
// Configurations installed successfully
|
||||
Ok(())
|
||||
// Apply CLI arguments and attempt to install dotfiles
|
||||
return kot::handle_args(&mut args);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user