diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..66a3e84 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: Test +on: + pull_request: + branches: + - '*' + push: + branches: + - '*' + +jobs: + test: + name: test + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + matrix: + include: + - build: pinned + os: ubuntu-22.04 + rust: 1.66.1 + - build: stable + os: ubuntu-22.04 + rust: stable + - build: beta + os: ubuntu-22.04 + rust: beta + - build: macos + os: macos-12 + rust: stable + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.rust }} + components: 'clippy, rustfmt' + + - name: Formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Test + run: cargo test + + - name: Docs + run: cargo doc + + - name: Install cargo-tarpaulin + uses: taiki-e/install-action@v2 + with: + tool: cargo-tarpaulin@0.25.0 + + - name: cargo-tarpaulin + run: cargo tarpaulin --out html --output-dir target/tarpaulin + + - name: Upload coverage + if: ${{ always() }} + uses: actions/upload-artifact@v3 + with: + name: tarpaulin-report-${{ matrix.build }} + path: target/tarpaulin/tarpaulin-report.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..df99c69 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4ada3b1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,481 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anyhow" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" +dependencies = [ + "backtrace", +] + +[[package]] +name = "backtrace" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cc" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f13b9c79b5d1dd500d20ef541215a6423c75829ef43117e1b4d17fd8af0b5d76" +dependencies = [ + "bitflags", + "clap_derive", + "clap_lex", + "is-terminal", + "once_cell", + "strsim", + "termcolor", +] + +[[package]] +name = "clap_derive" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + +[[package]] +name = "gimli" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221996f774192f0f718773def8201c4ae31f02616a54ccfc2d358bb0e5cefdec" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d6c6f8c91b4b9ed43484ad1a938e393caf35960fce7f82a040497207bd8e9e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "is-terminal" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dfb6c8100ccc63462345b67d1bbc3679177c75ee4bf59bf29c8b1d110b8189" +dependencies = [ + "hermit-abi", + "io-lifetimes", + "rustix", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" + +[[package]] +name = "linux-raw-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + +[[package]] +name = "object" +version = "0.30.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea86265d3d3dcb6a27fc51bd29a4bf387fae9d2986b823079d4986af253eb439" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" + +[[package]] +name = "os_str_bytes" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" +dependencies = [ + "memchr", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "rsdir" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "os_str_bytes", + "tempfile", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" + +[[package]] +name = "rustix" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fdebc4b395b7fbb9ab11e462e20ed9051e7b16e42d24042c776eca0ac81b03" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "unicode-ident" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8c56a68 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rsdir" +version = "0.1.0" +edition = "2021" +authors = ["Johan Holmerin "] +description = """ +Edit directories as a text file. Reimplementation of vidir from moreutils +""" +documentation = "https://github.com/johanholmerin/rsdir" +homepage = "https://github.com/johanholmerin/rsdir" +repository = "https://github.com/johanholmerin/rsdir" +keywords = ["cli", "vidir"] +categories = ["command-line-utilities"] +license = "MIT" + +[dependencies] +anyhow = { version = "1.0.68", features = ["backtrace"] } +clap = { version = "4.1.4", features = ["derive"] } +os_str_bytes = "6.4.1" +tempfile = "3.3.0" + +[dev-dependencies] +walkdir = "2.3.2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a93176 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 Johan Holmerin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2e0055 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# rsdir + +Edit directories as a text file. Rust reimplementation of vidir from +[moreutils](https://joeyh.name/code/moreutils/), with some features from the +[fork by trapd00r](https://github.com/trapd00r/vidir) + +## Installation + +```sh +cargo install rsdir +``` + +## Usage + +```sh +# Say you have a directory that looks like this +├─ old/ +│ ├─ file1 +├─ file1 +└─ file_2 + +# Running rsdir will open your editor with the following content +1 ./file1 +2 ./file_2 +3 ./old/ + +# The numbers are used to keep track of each file - editing a path will +# rename the file/directory while removing a line will delete it + +# We can the remove the _ from the second file to make the naming consistent +# and remove the third line, leaving us with the following +1 ./file1 +2 ./file2 + +# Save the file and exit the editor and rsdir will perform the modifications +├─ file1 +└─ file2 + +# If you ran with the --verbose flag you would get the following log +Moved file "./file_2" to "./file2" +Removed directory "./old" +``` + +## Examples + +```sh +# Defaults to currenty directory +rsdir + +# Supports multiple directories +rsdir ./foo ../bar + +# Verbose mode will log what files are moved/deleted +rsdir --verbose + +# Use another editor. Will default to vi if EDITOR isn't set +EDITOR=nano rsdir +``` diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..793e756 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,265 @@ +use anyhow::{anyhow, bail, Context, Result}; +use clap::Parser; +use os_str_bytes::RawOsString; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs, io, result}; +use tempfile::NamedTempFile; + +const DEFAULT_DIR: &str = "."; +const DEFAULT_EDITOR: &str = "vi"; +const EDITOR_ENV: &str = "EDITOR"; + +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct Args { + /// Verbosely display the actions taken by the program + #[arg(short, long)] + verbose: bool, + + /// Directories to edit. Defaults to current directory + path: Vec, +} + +#[derive(Debug)] +struct PathInfo { + name: PathBuf, + is_dir: bool, +} + +#[derive(Debug)] +struct InputRow { + index: usize, + name: PathBuf, + is_dir: bool, +} + +#[derive(Debug)] +struct OutputRow { + index: usize, + name: PathBuf, +} + +fn get_path_args(paths: Vec) -> Vec { + if paths.is_empty() { + vec![PathBuf::from(DEFAULT_DIR)] + } else { + paths.iter().map(PathBuf::from).collect() + } +} + +fn read_dir(path: &Path) -> result::Result, io::Error> { + fs::read_dir(path)? + .map(|res| { + let entry = res?; + Ok(PathInfo { + name: entry.path(), + is_dir: entry.file_type()?.is_dir(), + }) + }) + .collect() +} + +fn list_files(paths: Vec) -> Result> { + let mut entries = Vec::::new(); + + for path in paths { + entries.extend( + read_dir(&path) + .with_context(|| format!("Couldn't list files in {path:?}"))?, + ) + } + + entries.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(entries + .into_iter() + .enumerate() + .map(|(index, file)| InputRow { + index: index + 1, + name: file.name, + is_dir: file.is_dir, + }) + .collect()) +} + +/// Generates the text content for the temporary file +/// Since the text will contain file paths(which may not be valid UTF-8) +/// [`RawOsString`] is used instead of a normal UTF-8 [`String`] +fn get_input(files: &[InputRow]) -> RawOsString { + let list = files + .iter() + .map(|res| { + let mut row = OsString::from(format!("{: >5} ", res.index)); + row.push(res.name.clone().into_os_string()); + if res.is_dir { + row.push("/") + } + row + }) + .collect::>(); + + RawOsString::new(list.join(&OsString::from("\n"))) +} + +/// Writes the content to a new temporary file and returns a handle +/// Uses [`NamedTempFile`] since we need the to pass the path to the editor +/// This should be fine as the file should have a short lifespan +/// The file will be automatically removed when dropped +fn write_file(file_input: &RawOsString) -> Result { + let mut file = + NamedTempFile::new().context("Failed to create temporary file")?; + file.write_all(file_input.as_raw_bytes()) + .context("Failed to write to temporary file")?; + Ok(file) +} + +fn read_file(path: &Path) -> Result { + Ok(RawOsString::assert_from_raw_vec( + fs::read(path).context("Failed to read temporary file")?, + )) +} + +fn get_editor() -> String { + env::var(EDITOR_ENV).unwrap_or_else(|_| DEFAULT_EDITOR.into()) +} + +fn open_editor(editor: &String, file_path: &Path) -> Result<()> { + Command::new(editor) + .arg(file_path) + .status() + .with_context(|| format!("Failed to open editor {editor:?}")) + .and_then(|status| { + if status.success() { + return Ok(()); + } + + if let Some(code) = status.code() { + bail!("Editor {editor:?} returned error code {code}") + } else { + bail!("Editor {editor:?} returned an error") + } + }) +} + +fn parse_files(input: RawOsString) -> Result> { + input + .trim_matches(' ') + .split('\n') + .filter(|row| row.ne(&"")) + .enumerate() + .map(|(i, row)| { + let (index_str, name_str) = + row.trim_matches(' ') + .split_once(' ') + .ok_or_else(|| anyhow!("Couldn't find index at row {i}"))?; + let index_str = index_str.to_str_lossy(); + let index = index_str.parse::().map_err(|_| { + anyhow!("Invalid index {index_str:?} at row {i}",) + })?; + let name = PathBuf::from( + name_str.trim_matches(" ").to_owned().into_os_string(), + ); + Ok(OutputRow { index, name }) + }) + .collect() +} + +fn rm_file(file: &InputRow, verbose: bool) -> Result<()> { + if file.is_dir { + fs::remove_dir_all(&file.name) + } else { + fs::remove_file(&file.name) + } + .with_context(|| { + format!( + "Error deleting {} {:?}", + if file.is_dir { "directory" } else { "file" }, + file.name + ) + }) + .map(|_| { + if verbose { + println!( + "Removed {} {:?}", + if file.is_dir { "directory" } else { "file" }, + file.name + ) + } + }) +} +fn mv_file(from: &InputRow, to: &OutputRow, verbose: bool) -> Result<()> { + fs::rename(&from.name, &to.name) + .with_context(|| { + format!( + "Error moving {} {:?} to {:?}", + if from.is_dir { "directory" } else { "file" }, + from.name, + to.name + ) + }) + .map(|_| { + if verbose { + println!( + "Moved {} {:?} to {:?}", + if from.is_dir { "directory" } else { "file" }, + from.name, + to.name + ) + } + }) +} + +fn update_files( + input: &[InputRow], + output: &[OutputRow], + verbose: bool, +) -> Result<()> { + let input_idxs: HashSet<_> = input.iter().map(|row| row.index).collect(); + output.iter().enumerate().try_for_each(|(i, output_row)| { + if !input_idxs.contains(&output_row.index) { + bail!("Unknown index {} at row {i}", output_row.index) + } else { + Ok(()) + } + })?; + + let output_hash = output + .iter() + .map(|row| (row.index, row)) + .collect::>(); + + input.iter().try_for_each(|input_row| -> Result<()> { + match output_hash.get(&input_row.index) { + None => rm_file(input_row, verbose), + Some(output_row) if output_row.name != input_row.name => { + mv_file(input_row, output_row, verbose) + } + _ => Ok(()), // No change + } + }) +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let path_args = get_path_args(args.path); + let editor = get_editor(); + + let input_files = list_files(path_args)?; + let file_input = get_input(&input_files); + + let file = write_file(&file_input)?; + let file_path = file.path(); + open_editor(&editor, file_path)?; + + let file_output = read_file(file_path)?; + + let output_files = parse_files(file_output)?; + update_files(&input_files, &output_files, args.verbose)?; + + Ok(()) +} diff --git a/tests/ed.sh b/tests/ed.sh new file mode 100755 index 0000000..b807b6f --- /dev/null +++ b/tests/ed.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +# Runs `ed` using the script from the `ED_SCRIPT` environment variable +# stdout & stderr are both supresssed so as to not pollute the output from rsdir + +echo "$ED_SCRIPT" | ed "$@" >/dev/null 2>/dev/null diff --git a/tests/kill.sh b/tests/kill.sh new file mode 100755 index 0000000..9823450 --- /dev/null +++ b/tests/kill.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +# For testing the error handling when the editor is killed by a signal, in this +# case by the script killing itself. See the `editor_killed` test + +kill $$ diff --git a/tests/tests.rs b/tests/tests.rs new file mode 100644 index 0000000..d4a0262 --- /dev/null +++ b/tests/tests.rs @@ -0,0 +1,651 @@ +// The tests in this file all test the behavior of the built binary on real +// (albeit temporary) files. It should therefore be possible to reuse the tests +// for other implementations. The tests do however rely on some POSIX +// functionality: ed is used to programmatically modify the generated files and +// sh, cat, echo & kill are used to test various behaviors. Some tests also +// rely on not being as root as they test failures when removing or renaming +// files. + +#[cfg(target_os = "linux")] +use std::ffi::OsString; +#[cfg(target_os = "linux")] +use std::os::unix::prelude::OsStringExt; +use std::path::PathBuf; +use std::process; +use std::process::Command; + +mod utils; + +#[test] +fn does_nothing() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["foo/", "foo/bar", "baz"]) + .unwrap(); + let output = utils::run_rsdir(&test_dir, "q", true).unwrap(); + utils::assert_test_files( + &test_dir, + vec![ + ("foo/", None), + ("foo/bar", Some("foo/bar")), + ("baz", Some("baz")), + ], + ); + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn help_flag() { + let bin_path = utils::get_bin_path(); + + let output = Command::new(bin_path).arg("--help").output().unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_ne!(stdout, ""); + assert_eq!(stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn version_flag() { + let bin_path = utils::get_bin_path(); + + let output = Command::new(bin_path).arg("--version").output().unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_ne!(stdout, ""); + assert_eq!(stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn unknown_flag() { + let bin_path = utils::get_bin_path(); + + let output = Command::new(bin_path).arg("--asd").output().unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_eq!(stdout, ""); + assert_ne!(stderr, ""); + assert!(!output.status.success()); +} + +#[test] +/// Tests the default editor in case the EDITOR environment variables isn't set +/// by setting the PATH to the `tests` directory, which contains a `vi` shell +/// script +fn default_editor() { + let bin_path = utils::get_bin_path(); + let tests_path = utils::get_tests_path(); + + let output = Command::new(bin_path) + .env("PATH", &tests_path) + .env_remove("EDITOR") + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_eq!(stdout, "fake vi"); + assert_eq!(stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn moves_dir() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["foo/"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/foo/bar\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("bar/", None)]); + assert_eq!(output.stdout, "Moved directory \"./foo\" to \"./bar/\""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn move_file_error() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/.\\/baz/\\/non-existent\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz", Some("baz"))]); + assert_eq!(output.stdout, ""); + assert!(output.stderr.starts_with( + "\ + Error: Error moving file \"./baz\" to \"/non-existent\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +fn move_dir_error() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz/"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/.\\/baz/\\/non-existent\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz/", None)]); + assert_eq!(output.stdout, ""); + assert!(output.stderr.starts_with( + "\ + Error: Error moving directory \"./baz\" to \"/non-existent/\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +fn moves_file() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/baz/boop\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("boop", Some("baz"))]); + assert_eq!(output.stdout, "Moved file \"./baz\" to \"./boop\""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn non_relative_path() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/\\.\\/baz/boop\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("boop", Some("baz"))]); + assert_eq!(output.stdout, "Moved file \"./baz\" to \"boop\""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +/// Checks the content of the generated file using `cat` to print to stdout +fn tmp_file_content() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz", "xox", "lol", "dir/"]) + .unwrap(); + + let bin_path = utils::get_bin_path(); + + let output = Command::new(bin_path) + .current_dir(&test_dir) + .env("EDITOR", "cat") + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + utils::assert_test_files( + &test_dir, + vec![ + ("baz", Some("baz")), + ("dir/", None), + ("xox", Some("xox")), + ("lol", Some("lol")), + ], + ); + assert_eq!( + stdout, + " 1 ./baz + 2 ./dir/ + 3 ./lol + 4 ./xox" + ); + assert_eq!(stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn deletes_dir() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["foo/"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "1d\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, Vec::<(&str, Option<&str>)>::new()); + assert_eq!(output.stdout, "Removed directory \"./foo\""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn deletes_file() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "1d\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, Vec::<(&str, Option<&str>)>::new()); + assert_eq!(output.stdout, "Removed file \"./baz\""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn delete_dir_error() { + let output = utils::run_rsdir( + "/", + "/dev\n\ + d\n\ + w\n\ + q", + true, + ) + .unwrap(); + assert_eq!(output.stdout, ""); + assert!(output.stderr.starts_with( + "\ + Error: Error deleting directory \"./dev\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +fn delete_file_error() { + let output = utils::run_rsdir( + "/dev", + "/null\n\ + d\n\ + w\n\ + q", + true, + ) + .unwrap(); + assert_eq!(output.stdout, ""); + assert!(output.stderr.starts_with( + "\ + Error: Error deleting file \"./null\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +fn multiple_args() { + let test_dir1 = utils::create_test_dir().unwrap(); + let test_dir2 = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir1, vec!["baz", "bop"]).unwrap(); + utils::create_test_files(&test_dir2, vec!["foo", "pop"]).unwrap(); + + let bin_path = utils::get_bin_path(); + let ed_path = utils::get_script_path(); + + let output = Command::new(bin_path) + .args([test_dir1.path(), test_dir2.path()]) + .env("EDITOR", "/non-existent") + .env( + "ED_SCRIPT", + "/baz\n\ + d\n\ + /foo\n\ + d\n\ + w\n\ + q", + ) + .env("EDITOR", ed_path) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + utils::assert_test_files(&test_dir1, vec![("bop", Some("bop"))]); + utils::assert_test_files(&test_dir2, vec![("pop", Some("pop"))]); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn unknown_index() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/1/2\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz", Some("baz"))]); + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, "Error: Unknown index 2 at row 0"); + assert!(!output.status.success()); +} + +#[test] +fn invalid_index() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/1/x\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz", Some("baz"))]); + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, "Error: Invalid index \"x\" at row 0"); + assert!(!output.status.success()); +} + +#[test] +fn editor_failure() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let ed_path = utils::get_script_path(); + let output = utils::run_rsdir( + &test_dir, + // Searching for a non-existent string will cause ed to return an error + "s/missing/never\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz", Some("baz"))]); + assert_eq!(output.stdout, ""); + assert!(output.stderr.starts_with(&format!( + "Error: Editor {ed_path:?} returned error code " + ))); + assert!(!output.status.success()); +} + +#[test] +/// Tests the error handling for when the editor is killed by a signal, in which +/// case no exit code will be returned +/// Uses the `kill.sh` script which kills itself +fn editor_killed() { + let test_dir = utils::create_test_dir().unwrap(); + let bin_path = utils::get_bin_path(); + let editor_path = utils::get_tests_path().join("./kill.sh"); + + let output = Command::new(bin_path) + .current_dir(&test_dir) + .env("EDITOR", &editor_path) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_eq!(stdout, ""); + assert_eq!( + stderr, + format!("Error: Editor {editor_path:?} returned an error") + ); + assert!(!output.status.success()); +} + +#[test] +fn missing_editor() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + + let bin_path = utils::get_bin_path(); + + let output = Command::new(bin_path) + .current_dir(&test_dir) + .env("EDITOR", "/non-existent") + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + println!("status: {}", output.status); + println!("stdout: {stdout}"); + println!("stderr: {stderr}"); + + utils::assert_test_files(&test_dir, vec![("baz", Some("baz"))]); + assert_eq!(stdout, ""); + assert!(stderr.starts_with( + "\ +Error: Failed to open editor \"/non-existent\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +fn missing_path() { + let bin_path = utils::get_bin_path(); + let ed_path = utils::get_script_path(); + + let output = Command::new(bin_path) + .arg("/non-existent") + .env("ED_SCRIPT", "q") + .env("EDITOR", ed_path) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_eq!(stdout, ""); + assert!(stderr.starts_with( + "\ +Error: Couldn't list files in \"/non-existent\" + +Caused by:" + )); + assert!(!output.status.success()); +} + +#[test] +/// Tests the error handling when failing to create a temporary file by setting +/// `TMPDIR` to a non-existent path +fn create_tmpfile_error() { + let test_dir = utils::create_test_dir().unwrap(); + let bin_path = utils::get_bin_path(); + let ed_path = utils::get_script_path(); + + let output = Command::new(bin_path) + .current_dir(&test_dir) + .env("TMPDIR", "/non-existent") + .env("ED_SCRIPT", "q") + .env("EDITOR", ed_path) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout) + .unwrap() + .trim_end() + .to_owned(); + let stderr = String::from_utf8(output.stderr) + .unwrap() + .trim_end() + .to_owned(); + + assert_eq!(stdout, ""); + assert!(stderr.starts_with( + "\ +Error: Failed to create temporary file + +Caused by: + No such file or directory (os error 2) at path \"/non-existent/" + )); + assert!(!output.status.success()); +} + +#[test] +fn non_verbose() { + let test_dir = utils::create_test_dir().unwrap(); + utils::create_test_files(&test_dir, vec!["baz"]).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "s/baz/boop\n\ + w\n\ + q", + false, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("boop", Some("baz"))]); + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} + +#[test] +fn removes_temp_file() { + let bin_path = utils::get_bin_path(); + + let file_path = String::from_utf8( + process::Command::new(bin_path) + .env("EDITOR", "echo") + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!(!PathBuf::from(file_path).exists()); +} + +#[test] +#[cfg(target_os = "linux")] +/// Tests that paths that are not valid Unicode are handled correctly +/// Does not apply to macOS(HFS+ or APFS) where paths are always valid Unicode +fn byte_path_linux() { + let file_path_bytes = vec![ + 97, 0o017, 0o254, 0o001, 0o103, 0o326, 0o144, 0o203, 0o261, 0o154, + 0o065, 0o053, 0o167, + ]; + // Ensure that the test path is invalid UTF-8 + String::from_utf8(file_path_bytes.clone()).unwrap_err(); + let file_path_os: OsString = OsStringExt::from_vec(file_path_bytes); + + let test_dir = utils::create_test_dir().unwrap(); + let path_buf = test_dir.path().join(PathBuf::from(file_path_os)); + std::fs::create_dir_all(path_buf).unwrap(); + let output = utils::run_rsdir( + &test_dir, + "d\n\ + i\n\ + 1 ./baz\n\ + .\n\ + w\n\ + q", + true, + ) + .unwrap(); + utils::assert_test_files(&test_dir, vec![("baz", None)]); + assert_eq!( + output.stdout, + "Moved directory \ + \"./a\\u{f}\\xAC\\u{1}C\\xD6d\\x83\\xB1l5+w\" to \"./baz\"" + ); + assert_eq!(output.stderr, ""); + assert!(output.status.success()); +} diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs new file mode 100644 index 0000000..29f88db --- /dev/null +++ b/tests/utils/mod.rs @@ -0,0 +1,137 @@ +use std::error::Error; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs, io, process}; +use walkdir::WalkDir; + +use os_str_bytes::RawOsString; +use tempfile::{tempdir, TempDir}; + +#[derive(Debug)] +pub struct Output { + pub status: process::ExitStatus, + pub stdout: String, + pub stderr: String, +} + +pub fn create_test_dir() -> io::Result { + let test_dir = tempdir()?; + // For debugging + println!("Created test dir {:?}", test_dir.path()); + Ok(test_dir) +} + +/// Crates test files and directories +/// Files will have their path as value +/// Directory names must end with a slash +pub fn create_test_files( + dir: impl AsRef, + paths: Vec>, +) -> Result<(), io::Error> { + paths.iter().try_for_each(|path| { + let path_buf = dir.as_ref().join(PathBuf::from(path)); + if path_buf.to_str().unwrap().ends_with('/') { + fs::create_dir_all(path_buf) + } else { + let contents = RawOsString::new(OsString::from(&path)); + fs::write(path_buf, contents.as_raw_bytes()) + } + }) +} + +/// Asserts that the files the a directory matches the specified structure +/// Will panic on any extra files/directories, mismatched type, or incorrect +/// content +/// The expected paths are specified as pairs of paths and optional content for +/// files +pub fn assert_test_files( + dir: impl AsRef, + paths: Vec<(impl AsRef, Option<&str>)>, +) { + let mut result = WalkDir::new(dir.as_ref()) + .into_iter() + .skip(1) // Skip the direcotry itself + .map(|res| { + res.map(|entry| { + let contents = if entry.file_type().is_dir() { + None + } else { + Some(fs::read_to_string(entry.path()).unwrap()) + }; + ( + entry.path().strip_prefix(dir.as_ref()).unwrap().to_owned(), + contents, + ) + }) + }) + .collect::, _>>() + .unwrap(); + let mut expected: Vec<(PathBuf, Option)> = paths + .into_iter() + .map(|(path, contents)| { + (Into::::into(&path), contents.map(|s| s.to_owned())) + }) + .collect(); + + result.sort(); + expected.sort(); + + println!("Result: {result:#?}"); + println!("Expected: {expected:#?}"); + + assert!(result.iter().eq(expected.iter())); +} + +pub fn get_bin_path() -> PathBuf { + env::current_exe() + .unwrap() + .parent() + .unwrap() + .to_path_buf() + .join(format!("../rsdir{}", env::consts::EXE_SUFFIX)) +} + +pub fn get_tests_path() -> PathBuf { + env::current_exe() + .unwrap() + .parent() + .unwrap() + .to_path_buf() + .join("../../../tests") +} + +pub fn get_script_path() -> PathBuf { + get_tests_path().join("./ed.sh") +} + +pub fn run_rsdir( + dir: impl AsRef, + ed_script: &str, + verbose: bool, +) -> Result> { + let bin_path = get_bin_path(); + let ed_path = get_script_path(); + + let mut cmd = Command::new(bin_path); + cmd.current_dir(dir); + cmd.env("ED_SCRIPT", ed_script); + cmd.env("EDITOR", ed_path); + if verbose { + cmd.args(["--verbose"]); + } + + let output = cmd.output()?; + let stdout = String::from_utf8(output.stdout)?.trim_end().to_owned(); + let stderr = String::from_utf8(output.stderr)?.trim_end().to_owned(); + + println!("status: {}", output.status); + println!("stdout: {stdout}"); + println!("stderr: {stderr}"); + + Ok(Output { + status: output.status, + stdout, + stderr, + }) +} diff --git a/tests/vi b/tests/vi new file mode 100755 index 0000000..2664dbc --- /dev/null +++ b/tests/vi @@ -0,0 +1,6 @@ +#!/bin/sh + +# Used for testing the default editor in case the EDITOR environment variables +# isn't set. See the `default_editor` test + +echo "fake vi"