From a01ab6b532e058abbb8cc9b1821bbf259aa89894 Mon Sep 17 00:00:00 2001 From: Shaun Reed Date: Sun, 29 May 2022 19:19:42 -0400 Subject: [PATCH] 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 --- .gitignore | 2 + .gitmodules | 3 - Cargo.lock | 153 ++++++++++++---- Cargo.toml | 4 +- README.md | 203 ++++++++++++--------- dotfiles/dot | 1 - dry-runs/kapper/.config/README.md | 2 - dry-runs/kapper/README.md | 1 - src/kot.rs | 288 ++++++++++++++++++++---------- src/kot/kcli.rs | 228 +++++++++++++++-------- src/kot/kerror.rs | 78 ++++++++ src/kot/kfs.rs | 185 +++++++++++++------ src/kot/kgit.rs | 47 +++++ src/kot/kio.rs | 23 ++- src/main.rs | 27 +-- 15 files changed, 869 insertions(+), 376 deletions(-) delete mode 160000 dotfiles/dot delete mode 100644 dry-runs/kapper/.config/README.md delete mode 100644 dry-runs/kapper/README.md create mode 100644 src/kot/kerror.rs create mode 100644 src/kot/kgit.rs diff --git a/.gitignore b/.gitignore index afd3106..d98ba08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ **/.idea/** /target +dotfiles/** +dry-runs/** diff --git a/.gitmodules b/.gitmodules index 0e791af..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "dotfiles/dot"] - path = dotfiles/dot - url = https://gitlab.com/shaunrd0/dot diff --git a/Cargo.lock b/Cargo.lock index 68a9cca..6680c59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,10 +3,19 @@ version = 3 [[package]] -name = "ansi_term" -version = "0.11.0" +name = "aho-corasick" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" dependencies = [ "winapi", ] @@ -22,6 +31,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + [[package]] name = "bitflags" version = "1.3.2" @@ -29,10 +44,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "clap" -version = "2.33.3" +name = "chrono" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" dependencies = [ "ansi_term", "atty", @@ -69,9 +97,11 @@ dependencies = [ [[package]] name = "kot" -version = "0.1.0" +version = "0.1.5" dependencies = [ + "chrono", "fs_extra", + "regex", "structopt", ] @@ -83,9 +113,34 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.102" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2a5ac8f984bfcf3a823267e5fde638acc3325f6496633a5da6bb6eb2171e103" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] [[package]] name = "proc-macro-error" @@ -113,22 +168,39 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.29" +version = "1.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" +checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "quote" -version = "1.0.9" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" + [[package]] name = "strsim" version = "0.8.0" @@ -137,9 +209,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] name = "structopt" -version = "0.3.23" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9d950ef167e25e0bdb073cf1d68e9ad2795ac826f2f3f59647817cf23c0bfa" +checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" dependencies = [ "clap", "lazy_static", @@ -148,9 +220,9 @@ dependencies = [ [[package]] name = "structopt-derive" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134d838a2c9943ac3125cf6df165eda53493451b719f3255b2a26b85f772d0ba" +checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" dependencies = [ "heck", "proc-macro-error", @@ -161,13 +233,13 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.77" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5239bc68e0fef57495900cfea4e8dc75596d9a319d7e16b1e0a440d24e6fe0a0" +checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] @@ -180,10 +252,27 @@ dependencies = [ ] [[package]] -name = "unicode-segmentation" -version = "1.8.0" +name = "time" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" + +[[package]] +name = "unicode-segmentation" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" [[package]] name = "unicode-width" @@ -191,12 +280,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" -[[package]] -name = "unicode-xid" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" - [[package]] name = "vec_map" version = "0.8.2" @@ -205,9 +288,15 @@ checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" [[package]] name = "version_check" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "winapi" diff --git a/Cargo.toml b/Cargo.toml index 1b8f68f..287e9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "kot" -version = "0.1.0" +version = "0.1.5" edition = "2018" # See more keys and their definitions at # https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +regex = "1" structopt = "0.3.23" fs_extra = "1.2.0" +chrono = "0.4" diff --git a/README.md b/README.md index b821306..6cfaee1 100644 --- a/README.md +++ b/README.md @@ -6,50 +6,61 @@ This helps to protect against installing broken dotfiles by providing a way to r and return the system back to the previous state. The installation process creates symbolic links, much like what you would expect when using [stow](https://linux.die.net/man/8/stow). -`kot` can install dotfiles from any directory, using any target directory. To test how `kot` might behave, -you could point `--install-dir` to any directory that you've created for testing. -This directory could be empty, or it could contain another set of dotfiles. `kot` will attempt - to install the configurations. If conflicts are detected, output shows the conflicts and +`kot` can install dotfiles from any source directory, to any target directory. +To test how `kot` might behave, you could point `--install` to any directory that you've created for testing. +This directory could be empty, or it could contain another set of dotfiles. +Alternatively, you could set the `--dry-run` flag that will automatically install to a predefined path (`$HOME/.local/share/kot/dry-runs/$USER`) +Note that this directory will never be cleared automatically, each subsequent `--dry-run` +will stack configurations into this default directory until it is manually cleared. + +If conflicts are detected, `kot` shows the conflicts found and prompts to abort or continue. An example of this is seen below. +This prompt will be skipped if the `--force` flag is set. ```bash -kot dotfiles/dot/ - -args: Cli { dotfiles_dir: "/home/kapper/Code/kot/dotfiles/dot", install_dir: "/home/kapper/Code/kot/dry-runs/kapper", backup_dir: "/home/kapper/Code/kot/backups/kapper", force: false } +kot --dry-run dotfiles/dot/ +args: Cli { dotfiles: "/home/kapper/Code/kot/dotfiles/dot", install_dir: "/home/kapper/.local/share/kot/dry-runs/kapper", backup_dir: Some("/home/kapper/.local/share/kot/backups/dot:2022-05-29T19:03:27"), clone_dir: None, force: false, dry_run: true, is_repo: false, conflicts: [] } The following configurations already exist: - "/home/kapper/Code/kot/dry-runs/kapper/.bashrc" - "/home/kapper/Code/kot/dry-runs/kapper/.config" - "/home/kapper/Code/kot/dry-runs/kapper/README.md" - "/home/kapper/Code/kot/dry-runs/kapper/VimScreenshot.png" - "/home/kapper/Code/kot/dry-runs/kapper/fix-vbox.sh" - "/home/kapper/Code/kot/dry-runs/kapper/.git" - "/home/kapper/Code/kot/dry-runs/kapper/.bash_aliases" - "/home/kapper/Code/kot/dry-runs/kapper/.gitignore" - "/home/kapper/Code/kot/dry-runs/kapper/.gitmodules" - "/home/kapper/Code/kot/dry-runs/kapper/.vimrc" - "/home/kapper/Code/kot/dry-runs/kapper/.vim" -If you continue, backups will be made in "/home/kapper/Code/kot/backups/kapper". Any configurations there will be overwritten. -Abort? Enter y/n or Y/N: + "/home/kapper/.local/share/kot/dry-runs/kapper/.git" + "/home/kapper/.local/share/kot/dry-runs/kapper/.vimrc" + "/home/kapper/.local/share/kot/dry-runs/kapper/.bash_aliases" + "/home/kapper/.local/share/kot/dry-runs/kapper/.vim" + "/home/kapper/.local/share/kot/dry-runs/kapper/VimScreenshot.png" + "/home/kapper/.local/share/kot/dry-runs/kapper/.gitignore" + "/home/kapper/.local/share/kot/dry-runs/kapper/.config" + "/home/kapper/.local/share/kot/dry-runs/kapper/fix-vbox.sh" + "/home/kapper/.local/share/kot/dry-runs/kapper/.gitmodules" + "/home/kapper/.local/share/kot/dry-runs/kapper/.bashrc" + "/home/kapper/.local/share/kot/dry-runs/kapper/README.md" +If you continue, backups will be made in "/home/kapper/.local/share/kot/backups/dot:2022-05-29T19:03:27". +Any configurations there will be overwritten. +Continue? Enter Y/y or N/n: + + ``` -If there are already files within the backup directory, `kot` will exit and show an error message. -This is to protect existing backups from being merged with configs from subsequent runs. -If you want to erase these backups and create a new backup, rerun the command with the `--force` flag set. -Otherwise, specify a different backup directory with the `--backup-dir` option. -If the backup directory does not exist, it will be created. +#### User Data +`kot` stores user data within `$HOME/.local/share/kot/` -```bash -kot dotfiles/dot/ +When we provide a repository URL as our `dotfiles` to install, the repo will be *recursively* cloned into +`$HOME/.local/share/kot/dotfiles/`. +This is to ensure each user of `kot` maintains their own dotfiles in a location that is accessible but not easy to accidentally modify or erase. +If needed, the user can provide a preferred clone directory to the CLI by setting the `--clone-dir` option -thread 'main' panicked at ' - Error: Backups already exist at "/home/kapper/Code/kot/backups/kapper" - Set the --force flag to overwrite configurations stored here', src/kot/kcli.rs:94:17 -note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -``` +When we encounter conflicts during installation of these dotfiles, backups will be created in +`$HOME/.local/share/kot/backups/:` +If there are no conflicts found during installation, no backup is created. +Configurations are said to be conflicting if the `--install` path contains configuration files that are +also within the dotfiles we are currently installing. -#### Installation +Backups are intended to reverse changes applied during installation of dotfiles. +These backups are not exhaustive of all configurations tied to the system or user. +The backups only include files that were direct conflicts with configurations being installed. +When we reach an error during installation, `kot` will restore the configurations within the last backup, and then removes unused configurations. + +#### Installing kot Follow [Rustup instructions](https://rustup.rs/) to setup the Rust toolchain @@ -60,65 +71,97 @@ git clone https://gitlab.com/shaunrd0/kot && cd kot cargo install --path . kot --help -kot 0.1.0 +kot 0.1.5 CLI for managing Linux user configurations USAGE: - kot [FLAGS] [OPTIONS] + kot [FLAGS] [OPTIONS] --install FLAGS: + -d, --dry-run + 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. + -f, --force + Overwrites existing backups + + This flag will replace existing backups if during installation we encounter conflicts and the backup-dir + provided already contains previous backups. + -h, --help + Prints help information + + -V, --version + Prints version information + + +OPTIONS: + -b, --backup-dir + 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/ + -c, --clone-dir + 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/ + -i, --install + 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 + [env: HOME=/home/kapper] + +ARGS: + + 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/ +``` + +If you don't want to install `kot`, you can also use the following `cargo` command + where all arguments after the `--` are passed as arguments to `kot` and not `cargo`. +Below is an example of the short-help output text provided with the `-h` flag +```bash +cd path/to/kot +cargo build +cargo run -- --help + + +kot 0.1.5 +CLI for managing Linux user configurations + +USAGE: + kot [FLAGS] [OPTIONS] --install + +FLAGS: + -d, --dry-run Installs configurations to $HOME/.local/shared/kot/dry-runs -f, --force Overwrites existing backups -h, --help Prints help information -V, --version Prints version information OPTIONS: - -b, --backup-dir The location to store backups for this user [default: backups/kapper] - -i, --install-dir The location to attempt installation of user configurations [default: dry- - runs/kapper] + -b, --backup-dir The location to store backups for this user + -c, --clone-dir An alternate path to clone a dotfiles repository to + -i, --install The location to attempt installation of user configurations [env: + HOME=/home/kapper] ARGS: - Local or full path to user configurations to install + Local or full path to user configurations to install. Can also be a git repository ``` -#### Dotfiles Management +#### TODO -To store dotfiles, this repository uses submodules. To update surface-level submodules, we can run the following commands -```bash -git submodule update --init - -Submodule path 'dot': checked out '7877117d5bd413ecf35c86efb4514742d8136843' -``` - -But in the case of my dotfiles repository, [shaunrd0/dot](https://gitlab.com/shaunrd0/dot), I use submodules to clone repositories for vim plugins. To update all submodules *and their nested submodules*, we can run the following commands -```bash -git submodule update --init --recursive - -Submodule 'dotfiles/dot' (https://gitlab.com/shaunrd0/dot) registered for path 'dotfiles/dot' -Cloning into '/home/kapper/Code/kotd/dotfiles/dot'... -warning: redirecting to https://gitlab.com/shaunrd0/dot.git/ -Submodule path 'dotfiles/dot': checked out '7877117d5bd413ecf35c86efb4514742d8136843' -Submodule '.vim/bundle/Colorizer' (https://github.com/chrisbra/Colorizer) registered for path 'dotfiles/dot/.vim/bundle/Colorizer' -Submodule '.vim/bundle/ale' (https://github.com/dense-analysis/ale) registered for path 'dotfiles/dot/.vim/bundle/ale' -Submodule '.vim/bundle/clang_complete' (https://github.com/xavierd/clang_complete) registered for path 'dotfiles/dot/.vim/bundle/clang_complete' -Submodule '.vim/bundle/supertab' (https://github.com/ervandew/supertab) registered for path 'dotfiles/dot/.vim/bundle/supertab' -Submodule '.vim/bundle/unicode.vim' (https://github.com/chrisbra/unicode.vim) registered for path 'dotfiles/dot/.vim/bundle/unicode.vim' -Submodule '.vim/bundle/vim-airline' (https://github.com/vim-airline/vim-airline) registered for path 'dotfiles/dot/.vim/bundle/vim-airline' -Submodule '.vim/bundle/vim-airline-themes' (https://github.com/vim-airline/vim-airline-themes) registered for path 'dotfiles/dot/.vim/bundle/vim-airline-themes' -Submodule '.vim/bundle/vim-signify' (https://github.com/mhinz/vim-signify) registered for path 'dotfiles/dot/.vim/bundle/vim-signify' -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/Colorizer'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/ale'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/clang_complete'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/supertab'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/unicode.vim'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/vim-airline'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/vim-airline-themes'... -Cloning into '/home/kapper/Code/kotd/dotfiles/dot/.vim/bundle/vim-signify'... -Submodule path 'dotfiles/dot/.vim/bundle/Colorizer': checked out '826d5691ac7d36589591314621047b1b9d89ed34' -Submodule path 'dotfiles/dot/.vim/bundle/ale': checked out '3ea887d2f4d43dd55d81213517344226f6399ed6' -Submodule path 'dotfiles/dot/.vim/bundle/clang_complete': checked out '293a1062274a06be61797612034bd8d87851406e' -Submodule path 'dotfiles/dot/.vim/bundle/supertab': checked out 'd80e8e2c1fa08607fa34c0ca5f1b66d8a906c5ef' -Submodule path 'dotfiles/dot/.vim/bundle/unicode.vim': checked out 'afb8db4f81580771c39967e89bc5772e72b9018e' -Submodule path 'dotfiles/dot/.vim/bundle/vim-airline': checked out 'cb1bc19064d3762e4e08103afb37a246b797d902' -Submodule path 'dotfiles/dot/.vim/bundle/vim-airline-themes': checked out 'd148d42d9caf331ff08b6cae683d5b210003cde7' -Submodule path 'dotfiles/dot/.vim/bundle/vim-signify': checked out 'b2a0450e23c63b75bbeabf4f0c28f9b4b2480689' -``` +* Ensure empty backups are not created +* Provide interface for managing agreed-upon /etc/skel/ configurations +* Provide more CLI options for git functionality; Branches, update submodules, etc +* Clean up warnings during build / installation +* Automate testing +* diff --git a/dotfiles/dot b/dotfiles/dot deleted file mode 160000 index 7877117..0000000 --- a/dotfiles/dot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7877117d5bd413ecf35c86efb4514742d8136843 diff --git a/dry-runs/kapper/.config/README.md b/dry-runs/kapper/.config/README.md deleted file mode 100644 index 817c7f4..0000000 --- a/dry-runs/kapper/.config/README.md +++ /dev/null @@ -1,2 +0,0 @@ -This is a test directory to test config collisions - diff --git a/dry-runs/kapper/README.md b/dry-runs/kapper/README.md deleted file mode 100644 index 34e78ff..0000000 --- a/dry-runs/kapper/README.md +++ /dev/null @@ -1 +0,0 @@ -This directory is for testing the installation of user configurations against an existing configuration set. diff --git a/src/kot.rs b/src/kot.rs index 974f496..9f37b0e 100644 --- a/src/kot.rs +++ b/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 = std::result::Result>; +pub type Result = std::result::Result; + +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 = std::result::Result>; // ----------------------------------------------------------------------------- -/// 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 + // + 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) -> 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) -> 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) + -> Option> { + 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(()); } diff --git a/src/kot/kcli.rs b/src/kot/kcli.rs index 8422245..45340a2 100644 --- a/src/kot/kcli.rs +++ b/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, - #[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, + + /// 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, } // ============================================================================= @@ -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 { - 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 { - // 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 { + // 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/ - // + 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); + } } diff --git a/src/kot/kerror.rs b/src/kot/kerror.rs new file mode 100644 index 0000000..35bd192 --- /dev/null +++ b/src/kot/kerror.rs @@ -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 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 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 for Error { + fn from(error: fs_extra::error::Error) -> Self { + return Error::new(ErrorKind::FileError(error.to_string()), + "(fs_extra error)".to_owned()); + } +} + +// ----------------------------------------------------------------------------- + + diff --git a/src/kot/kfs.rs b/src/kot/kfs.rs index 07bbf80..51963ea 100644 --- a/src/kot/kfs.rs +++ b/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 { + 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 /// + 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> { - let mut config_map = HashMap::new(); +pub fn get_target_paths(install_dir: &PathBuf, dotfiles: &PathBuf) + -> super::Result> { + 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) -> Option> { - 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 { + 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 { + 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/ + // + 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; } diff --git a/src/kot/kgit.rs b/src/kot/kgit.rs new file mode 100644 index 0000000..d76515c --- /dev/null +++ b/src/kot/kgit.rs @@ -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 { + // 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(); +} diff --git a/src/kot/kio.rs b/src/kot/kio.rs index e660508..52952e9 100644 --- a/src/kot/kio.rs +++ b/src/kot/kio.rs @@ -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()), + } } diff --git a/src/main.rs b/src/main.rs index 8386bab..3760d81 100644 --- a/src/main.rs +++ b/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); }