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:
2022-05-29 19:19:42 -04:00
parent eabc227c09
commit a01ab6b532
15 changed files with 869 additions and 376 deletions

View File

@@ -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(());
}

View File

@@ -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
View 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());
}
}
// -----------------------------------------------------------------------------

View File

@@ -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, &copy_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
View 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();
}

View File

@@ -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()),
}
}

View File

@@ -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);
}