coliru

A minimal, flexible, dotfile installer
git clone https://git.ashermorgan.net/coliru/
Log | Files | Refs | README

commit 6bf55414c191dbdd1655c817701a0b9d02512325
parent ef67865a71a4d6d529eb0f63bbdf0dface3c7f6f
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Thu,  4 Jul 2024 14:44:05 -0700

Merge branch 'ssh'

Implemented dotfiles installation on remote machines over SSH. Also refactored
e2e tests, changed Windows shell from PowerShell to cmd, and restructured local
script execution.

Diffstat:
M.github/workflows/build-and-test.yml | 5+++++
M.gitignore | 6++++++
MCargo.lock | 48++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
MREADME.md | 24+++++++++++++++---------
Rexamples/bashrc -> examples/basic/bashrc | 0
Rexamples/gitconfig -> examples/basic/gitconfig | 0
Rexamples/manifest.yml -> examples/basic/manifest.yml | 0
Aexamples/basic/script.bat | 2++
Aexamples/basic/script.sh | 3+++
Rexamples/vimrc -> examples/basic/vimrc | 0
Dexamples/manifest-windows-test.yml | 27---------------------------
Dexamples/script.bat | 3---
Dexamples/script.sh | 4----
Aexamples/test/bashrc | 1+
Aexamples/test/gitconfig | 1+
Rexamples/manifest-invalid.yml -> examples/test/invalid.yml | 0
Aexamples/test/manifest.yml | 27+++++++++++++++++++++++++++
Aexamples/test/scripts/script.bat | 3+++
Aexamples/test/scripts/script.sh | 4++++
Aexamples/test/vimrc | 1+
Msrc/cli.rs | 8++++++--
Msrc/core.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Asrc/local.rs | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 7++++++-
Msrc/manifest.rs | 27+++++++++++++++------------
Asrc/ssh.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/utils.rs | 364-------------------------------------------------------------------------------
Atests/.temp/ssh/.gitkeep | 3+++
Mtests/basic.rs | 439++++++++++---------------------------------------------------------------------
Mtests/common/mod.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Atests/local.rs | 365+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/server/Dockerfile | 17+++++++++++++++++
Atests/server/compose.yml | 13+++++++++++++
Atests/server/entry.sh | 9+++++++++
Atests/ssh.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
36 files changed, 1676 insertions(+), 869 deletions(-)

diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml @@ -17,6 +17,11 @@ jobs: - name: Build run: cargo build --verbose + - name: Start SSH server + run: | + echo 'PUID=1001' > tests/server/.env + docker compose -f tests/server/compose.yml up -d + - name: Run tests run: cargo test --verbose diff --git a/.gitignore b/.gitignore @@ -1 +1,7 @@ /target + +# Temporary directories used for testing +/tests/.temp/home/* +/tests/.temp/local/* +/tests/.temp/ssh/* +!/tests/.temp/ssh/.gitkeep diff --git a/Cargo.lock b/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "serde", "serde_yaml", "shellexpand", + "tempfile", ] [[package]] @@ -147,6 +148,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -208,6 +225,12 @@ dependencies = [ ] [[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -243,6 +266,19 @@ dependencies = [ ] [[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -308,6 +344,18 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] name = "thiserror" version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -8,3 +8,4 @@ clap = { version = "4.5.7", features = ["derive"] } shellexpand = "3.0" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" +tempfile = "3" diff --git a/README.md b/README.md @@ -2,20 +2,26 @@ A minimal, flexible, dotfile installer ## Installation -To install `coliru`, clone the repository and run `cargo install --path coliru/` +To install coliru, clone the repository and run `cargo install --path coliru/` -To uninstall `coliru`, run `cargo uninstall coliru` +To uninstall coliru, run `cargo uninstall coliru` ## Usage Dotfiles are defined as a series of steps inside a manifest file that are executed conditionally based on tag rules. To install dotfiles, pass the -manifest file and tag rules to `coliru`: +manifest file and tag rules to coliru: ``` -coliru path/to/manifest.yml --tag-rules tag1 tag2,tag3 ^tag4 +coliru path/to/manifest.yml --tag-rules tag1 tag2 ^tag3 ``` -Some helpful options include: +Coliru can also install dotfiles on remote machines over SSH: + +``` +coliru path/to/manifest.yml --tag-rules tag1 tag2 ^tag3 --host user@hostname +``` + +Some other helpful options include: - `--help`, `-h`: Print full help information - `--dry-run`, `-n`: Do a trial run without any permanent changes @@ -29,13 +35,13 @@ directory containing the manifest file. The copy command copies a file from a source (`src`) to a (`dst`). The link command links a file from a source (`src`) to a (`dst`) using symbolic links on Unix platforms and hard links on Windows. Finally, the run command executes a script (`src`) from the command line, using -`sh` on Unix platforms and `powershell` on Windows, with an optional `prefix` -(e.g. `python3`) or `postfix` (e.g. `arg1 arg2 arg3`) string. Inside `postfix`, +`sh` on Unix platforms and `cmd` on Windows, with an optional `prefix` (e.g. +`python3`) or `postfix` (e.g. `arg1 arg2 arg3`) string. Inside `postfix`, `$COLIRU_RULES` will be expanded into a space-delimited list of the current tag rules. -Example YAML manifest (see [`examples/manifest.yml`](examples/manifest.yml) for -more details): +Example YAML manifest (see +[`examples/basic/manifest.yml`](examples/basic/manifest.yml) for more details): ```yml steps: diff --git a/examples/bashrc b/examples/basic/bashrc diff --git a/examples/gitconfig b/examples/basic/gitconfig diff --git a/examples/manifest.yml b/examples/basic/manifest.yml diff --git a/examples/basic/script.bat b/examples/basic/script.bat @@ -0,0 +1,2 @@ +@ECHO OFF +ECHO script.bat called with %* diff --git a/examples/basic/script.sh b/examples/basic/script.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "script.sh called with $@" diff --git a/examples/vimrc b/examples/basic/vimrc diff --git a/examples/manifest-windows-test.yml b/examples/manifest-windows-test.yml @@ -1,27 +0,0 @@ -# Identical to manifest.yml, but uses relative paths instead of paths with -# tildes becuase $HOME is difficult to mock on windows. - -steps: - - copy: - - src: gitconfig - dst: .gitconfig.coliru - tags: [ windows, linux, macos ] - - - link: - - src: bashrc - dst: .bashrc.coliru - - src: vimrc - dst: .vimrc.coliru # Will create symbolic links on Linux & MacOS - run: - - src: script.sh - prefix: sh # unecessary on Unix if script.sh is executable - postfix: arg1 $COLIRU_RULES - tags: [ linux, macos ] - - - link: - - src: vimrc - dst: _vimrc.coliru # Will create hard link on Windows - run: - - src: script.bat - postfix: arg1 $COLIRU_RULES - tags: [ windows ] diff --git a/examples/script.bat b/examples/script.bat @@ -1,3 +0,0 @@ -@ECHO OFF -ECHO script.bat called with %* -ECHO script.bat called with %* > log.txt diff --git a/examples/script.sh b/examples/script.sh @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -echo "script.sh called with $@" -echo "script.sh called with $@" > log.txt diff --git a/examples/test/bashrc b/examples/test/bashrc @@ -0,0 +1 @@ +bash #1 diff --git a/examples/test/gitconfig b/examples/test/gitconfig @@ -0,0 +1 @@ +git #1 diff --git a/examples/manifest-invalid.yml b/examples/test/invalid.yml diff --git a/examples/test/manifest.yml b/examples/test/manifest.yml @@ -0,0 +1,27 @@ +# Note: copy_manifest() updates replaces ~/ and scripts/ with unique paths for +# ssh e2e tests and Windows tests to ensure test dotfiles are isolated + +steps: + - copy: + - src: gitconfig + dst: ~/.gitconfig + tags: [ windows, linux, macos ] + + - link: + - src: bashrc + dst: ~/.bashrc + - src: vimrc + dst: ~/.vimrc + run: + - src: scripts/script.sh + prefix: sh + postfix: arg1 $COLIRU_RULES + tags: [ linux, macos ] + + - link: + - src: vimrc + dst: ~/_vimrc + run: + - src: scripts/script.bat + postfix: arg1 $COLIRU_RULES + tags: [ windows ] diff --git a/examples/test/scripts/script.bat b/examples/test/scripts/script.bat @@ -0,0 +1,3 @@ +@ECHO OFF +ECHO script.bat called with %* +ECHO script.bat called with %* > scripts/log.txt diff --git a/examples/test/scripts/script.sh b/examples/test/scripts/script.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +echo "script.sh called with $@" +echo "script.sh called with $@" > scripts/log.txt diff --git a/examples/test/vimrc b/examples/test/vimrc @@ -0,0 +1 @@ +vim #1 diff --git a/src/cli.rs b/src/cli.rs @@ -14,6 +14,10 @@ struct Args { #[arg(short, long, num_args=0..)] pub tag_rules: Vec<String>, + /// Install dotfiles on another machine over SSH + #[arg(long, default_value="", hide_default_value=true)] + pub host: String, + /// Interpret link commands as copy commands #[arg(short, long)] pub copy: bool, @@ -27,6 +31,6 @@ struct Args { pub fn run() { let args = Args::parse(); let manifest_path = Path::new(&args.manifest); - execute_manifest_file(&manifest_path, args.tag_rules, args.dry_run, - args.copy); + execute_manifest_file(&manifest_path, args.tag_rules, &args.host, + args.dry_run, args.copy); } diff --git a/src/core.rs b/src/core.rs @@ -1,22 +1,29 @@ use std::env::set_current_dir; use std::path::Path; -use super::manifest::{CopyLinkOptions, RunOptions, Manifest, parse_manifest_file -}; +use super::manifest::{CopyLinkOptions, RunOptions, parse_manifest_file}; use super::tags::tags_match; -use super::utils::{copy_file, link_file, run_script}; +use super::local::{copy_file, link_file, run_command}; +use super::ssh::{send_command, send_staged_files, stage_file}; +use tempfile::tempdir; /// Execute the steps in a coliru manifest file according to a set of tag rules -pub fn execute_manifest_file(path: &Path, tag_rules: Vec<String>, dry_run: bool, - copy: bool) { - match parse_manifest_file(path) { - Ok(manifest) => execute_manifest(manifest, tag_rules, dry_run, copy), - Err(why) => eprintln!("Error: {}", why), - }; -} +pub fn execute_manifest_file(path: &Path, tag_rules: Vec<String>, host: &str, + dry_run: bool, copy: bool) { + + let _manifest = parse_manifest_file(path); + if let Err(why) = _manifest { + eprintln!("Error: {}", why); + return; + } + let manifest = _manifest.unwrap(); + + let _temp_dir = tempdir(); + if let Err(why) = _temp_dir { + eprintln!("Error: {}", why); + return; + } + let temp_dir = _temp_dir.unwrap(); -/// Execute the steps in a coliru manifest according to a set of tag rules -fn execute_manifest(manifest: Manifest, tag_rules: Vec<String>, dry_run: bool, - copy: bool) { if let Err(why) = set_current_dir(manifest.base_dir) { eprintln!("Error: {}", why); return; @@ -27,20 +34,30 @@ fn execute_manifest(manifest: Manifest, tag_rules: Vec<String>, dry_run: bool, let step_str = format!("[{}/{}]", i+1, manifest.steps.len()); - execute_copies(&step.copy, dry_run, &step_str); - if copy { - execute_copies(&step.link, dry_run, &step_str); - } else { + execute_copies(&step.copy, host, temp_dir.path(), dry_run, &step_str); + + if !copy && host == "" { execute_links(&step.link, dry_run, &step_str); + } else { + execute_copies(&step.link, host, temp_dir.path(), dry_run, + &step_str); } - execute_runs(&step.run, &tag_rules, dry_run, &step_str); + + execute_runs(&step.run, &tag_rules, host, temp_dir.path(), dry_run, + &step_str); } } -/// Execute the copy commands specified in a coliru manifest step -fn execute_copies(copies: &[CopyLinkOptions], dry_run: bool, step_str: &str) { +/// Execute a set of copy commands +fn execute_copies(copies: &[CopyLinkOptions], host: &str, staging_dir: &Path, + dry_run: bool, step_str: &str) { + for copy in copies { - print!("{} Copy {} to {}", step_str, copy.src, copy.dst); + if host == "" { + print!("{} Copy {} to {}", step_str, copy.src, copy.dst); + } else { + print!("{} Copy {} to {}:{}", step_str, copy.src, host, copy.dst); + } if dry_run { println!(" (DRY RUN)"); @@ -48,13 +65,25 @@ fn execute_copies(copies: &[CopyLinkOptions], dry_run: bool, step_str: &str) { } println!(""); - if let Err(why) = copy_file(&copy.src, &copy.dst) { + if host == "" { + if let Err(why) = copy_file(&copy.src, &copy.dst) { + eprintln!(" Error: {}", why); + } + } else { + if let Err(why) = stage_file(&copy.src, &copy.dst, staging_dir) { + eprintln!(" Error: {}", why); + } + } + } + + if !dry_run { + if let Err(why) = send_staged_files(staging_dir, host) { eprintln!(" Error: {}", why); } } } -/// Execute the link commands specified in a coliru manifest step +/// Execute a set of link commands fn execute_links(links: &[CopyLinkOptions], dry_run: bool, step_str: &str) { for link in links { print!("{} Link {} to {}", step_str, link.src, link.dst); @@ -71,13 +100,30 @@ fn execute_links(links: &[CopyLinkOptions], dry_run: bool, step_str: &str) { } } -/// Execute the run commands specified in a coliru manifest step -fn execute_runs(runs: &[RunOptions], tag_rules: &[String], dry_run: bool, - step_str: &str) { +/// Execute a set of run commands +fn execute_runs(runs: &[RunOptions], tag_rules: &[String], host: &str, + staging_dir: &Path, dry_run: bool, step_str: &str) { + + if !dry_run && host != "" { + for run in runs { + if let Err(why) = stage_file(&run.src, &run.src, staging_dir) { + eprintln!("Error: {}", why); + } + } + + if let Err(why) = send_staged_files(staging_dir, host) { + eprintln!("Error: {}", why); + } + } for run in runs { let postfix = run.postfix.replace("$COLIRU_RULES", &tag_rules.join(" ")); - print!("{} Run {} {} {}", step_str, run.prefix, run.src, postfix); + let cmd = format!("{} {} {}", run.prefix, run.src, postfix); + if host == "" { + print!("{} Run {}", step_str, cmd); + } else { + print!("{} Run {} on {}", step_str, cmd, host); + } if dry_run { println!(" (DRY RUN)"); @@ -85,8 +131,15 @@ fn execute_runs(runs: &[RunOptions], tag_rules: &[String], dry_run: bool, } println!(""); - if let Err(why) = run_script(&run.src, &run.prefix, &postfix) { - eprintln!(" Error: {}", why); + if host == "" { + if let Err(why) = run_command(&cmd) { + eprintln!(" Error: {}", why); + } + } else { + let ssh_cmd = format!("cd .coliru && {}", &cmd); + if let Err(why) = send_command(&ssh_cmd, host) { + eprintln!(" Error: {}", why); + } } } } diff --git a/src/local.rs b/src/local.rs @@ -0,0 +1,358 @@ +use shellexpand::tilde; +use std::io; +use std::fs; +#[cfg(target_family = "unix")] +use std::os::unix::fs::symlink; +use std::path::{PathBuf, absolute}; +use std::process::Command; + +/// Copies the contents of a local file to another local file. +/// +/// Tildes are expanded if present and the destination file is overwritten if +/// necessary. +pub fn copy_file(src: &str, dst: &str) -> io::Result<()> { + if absolute(src)? == absolute(dst)? { return Ok(()); } + let _dst = prepare_path(dst)?; + fs::copy(src, _dst)?; + Ok(()) +} + +/// Creates a symbolic link to a local file. +/// +/// Tildes are expanded if present and the destination file is overwritten if +/// necessary. On non-Unix platforms, a hard link will be created instead. +#[cfg(target_family = "unix")] +pub fn link_file(src: &str, dst: &str) -> io::Result<()> { + if absolute(src)? == absolute(dst)? { return Ok(()); } + let _dst = prepare_path(dst)?; + symlink(fs::canonicalize(src)?, _dst)?; + Ok(()) +} +#[cfg(not(target_family = "unix"))] +pub fn link_file(src: &str, dst: &str) -> io::Result<()> { + if absolute(src)? == absolute(dst)? { return Ok(()); } + let _dst = prepare_path(dst)?; + fs::hard_link(src, _dst)?; + Ok(()) +} + +/// Creates the parent directories of a path and return the path with tildes +/// expanded. +fn prepare_path(path: &str) -> io::Result<PathBuf> { + let _dst: PathBuf = (&tilde(path).to_mut()).into(); + if let Some(_path) = _dst.parent() { + fs::create_dir_all(_path)?; + } + if fs::symlink_metadata(&_dst).is_ok() { + // Check for existing files, including broken symlinks + fs::remove_file(&_dst)?; + } + Ok(_dst) +} + +/// Executes a local command using sh on Unix and cmd on Windows +pub fn run_command(command: &str) -> Result<(), String> +{ + let status; + if cfg!(target_family = "unix") { + status = Command::new("sh") + .args(["-c", command]) + .status() + .map_err(|why| why.to_string())?; + } else { + status = Command::new("cmd.exe") + .args(["/C", command]) + .status() + .map_err(|why| why.to_string())?; + } + if status.success() { + Ok(()) + } else { + Err(format!("Process exited with {status}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::{setup_integration, write_file}; + + #[test] + fn test_copy_file_create_dirs() { + let tmp = setup_integration("test_copy_file_create_dirs"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("dir1").join("dir2").join("bar"); + write_file(src, "old contents of foo"); + + let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "old contents of foo"); + } + + #[test] + fn test_copy_file_same_file() { + let tmp = setup_integration("test_copy_file_same_file"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("foo"); + write_file(src, "contents of foo"); + + let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "contents of foo"); + } + + #[test] + fn test_copy_file_existing_file() { + let tmp = setup_integration("test_copy_file_existing_file"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("bar"); + write_file(src, "old contents of foo"); + write_file(dst, "old contents of bar"); + + let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "old contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_copy_file_existing_broken_symlink() { + let tmp = setup_integration("test_copy_file_existing_broken_symlink"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("bar"); + write_file(src, "old contents of foo"); + symlink("missing", dst).unwrap(); + + let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "old contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_copy_file_tilde_expansion() { + let tmp = setup_integration("test_copy_file_tilde_expansion"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.home.join("dir").join("bar"); + let dst_tilde = "~/test_copy_file_tilde_expansion/dir/bar"; + write_file(src, "old contents of foo"); + + let result = copy_file(src.to_str().unwrap(), dst_tilde); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "old contents of foo"); + } + + #[test] + fn test_link_file_create_dirs() { + let tmp = setup_integration("test_link_file_create_dirs"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("dir1").join("dir2").join("bar"); + write_file(src, "old contents of foo"); + + let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "new contents of foo"); + } + + #[test] + fn test_link_file_same_file() { + let tmp = setup_integration("test_link_file_same_file"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("foo"); + write_file(src, "contents of foo"); + + let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "contents of foo"); + } + + #[test] + fn test_link_file_existing_file() { + let tmp = setup_integration("test_link_file_existing_file"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("bar"); + write_file(src, "old contents of foo"); + write_file(dst, "old contents of bar"); + + let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "new contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_link_file_existing_broken_symlink() { + let tmp = setup_integration("test_link_file_existing_broken_symlink"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("bar"); + write_file(src, "old contents of foo"); + symlink("missing", dst).unwrap(); + + let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "new contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_link_file_tilde_expansion() { + let tmp = setup_integration("test_link_file_tilde_expansion"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.home.join("dir").join("bar"); + let dst_tilde = "~/test_link_file_tilde_expansion/dir/bar"; + write_file(src, "old contents of foo"); + + let result = link_file(src.to_str().unwrap(), dst_tilde); + + write_file(src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "new contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_link_file_relative_source() { + let dir = PathBuf::from("tests/.temp/ssh/test_link_file_relative_source"); + fs::create_dir_all(&dir).unwrap(); + + let src = absolute(&dir.join("foo")).unwrap(); + let src_rel = "tests/.temp/ssh/test_link_file_relative_source/foo"; + let dst = &dir.join("dir1").join("dir2").join("bar"); + write_file(&src, "old contents of foo"); + + let result = link_file(src_rel, dst.to_str().unwrap()); + + write_file(&src, "new contents of foo"); + let contents = fs::read_to_string(dst).unwrap(); + let link = fs::read_link(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "new contents of foo"); + assert_eq!(link, src); // src changed to absolute path + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_run_command_successful() { + let tmp = setup_integration("test_run_command_successful"); + + let src = &tmp.local.join("foo"); + write_file(src, "exit 0"); + + let result = run_command(&format!("sh {}", src.to_str().unwrap())); + + assert_eq!(result.is_ok(), true); + } + + #[test] + #[cfg(target_family = "windows")] + fn test_run_command_successful() { + let tmp = setup_integration("test_run_command_successful"); + + let src = &tmp.local.join("foo.bat"); + write_file(src, "exit 0"); + + let result = run_command(src.to_str().unwrap()); + + assert_eq!(result.is_ok(), true); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_run_command_failure() { + let tmp = setup_integration("test_run_command_failure"); + + let src = &tmp.local.join("foo"); + write_file(src, "exit 2"); + + let result = run_command(&format!("sh {}", src.to_str().unwrap())); + + assert_eq!(result.is_ok(), false); + assert_eq!(result.unwrap_err(), "Process exited with exit status: 2"); + } + + #[test] + #[cfg(target_family = "windows")] + fn test_run_command_failure() { + let tmp = setup_integration("test_run_command_failure"); + + let src = &tmp.local.join("foo.bat"); + write_file(src, "exit 1"); + + let result = run_command(src.to_str().unwrap()); + + assert_eq!(result.is_ok(), false); + assert_eq!(result.unwrap_err(), "Process exited with exit code: 1"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_run_command_arguments() { + let tmp = setup_integration("test_run_command_arguments"); + + let src = &tmp.local.join("foo"); + let dst = &tmp.local.join("bar"); + write_file(src, &format!("echo $@ > {}", dst.to_str().unwrap())); + + let result = run_command(&format!("sh {} arg1 arg2", + src.to_str().unwrap())); + + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "arg1 arg2\n"); + } + + #[test] + #[cfg(target_family = "windows")] + fn test_run_command_arguments() { + let tmp = setup_integration("test_run_command_arguments"); + + let src = &tmp.local.join("foo.bat"); + let dst = &tmp.local.join("bar"); + write_file(src, &format!("echo %* > {}", dst.to_str().unwrap())); + + let result = run_command(&format!("{} arg1 arg2", + src.to_str().unwrap())); + + let contents = fs::read_to_string(dst).unwrap(); + assert_eq!(result.is_ok(), true); + assert_eq!(contents, "arg1 arg2 \r\n"); + } +} diff --git a/src/main.rs b/src/main.rs @@ -1,8 +1,13 @@ mod cli; mod core; +mod local; mod manifest; +mod ssh; mod tags; -mod utils; + +#[cfg(test)] +#[path = "../tests/common/mod.rs"] +mod common; // Re-use e2e test utils for integration tests fn main() { cli::run(); diff --git a/src/manifest.rs b/src/manifest.rs @@ -69,36 +69,39 @@ mod tests { #[test] #[cfg(target_family = "unix")] fn parse_manifest_file_missing() { + let manifest_path = Path::new("examples/test/missing.yml"); let expected = "No such file or directory (os error 2)"; - let actual = parse_manifest_file(Path::new("examples/missing.yml")); + let actual = parse_manifest_file(manifest_path); assert_eq!(actual, Err(String::from(expected))); } #[test] #[cfg(target_family = "windows")] fn parse_manifest_file_missing() { + let manifest_path = Path::new("examples/test/missing.yml"); let exp = "The system cannot find the file specified. (os error 2)"; - let actual = parse_manifest_file(Path::new("examples/missing.yml")); + let actual = parse_manifest_file(manifest_path); assert_eq!(actual, Err(String::from(exp))); } #[test] fn parse_manifest_file_invalid() { - let path = Path::new("examples/manifest-invalid.yml"); + let manifest_path = Path::new("examples/test/invalid.yml"); let exp = "steps[0].copy[0]: missing field `src` at line 5 column 7"; - let actual = parse_manifest_file(path); + let actual = parse_manifest_file(manifest_path); assert_eq!(actual, Err(String::from(exp))); } #[test] fn parse_manifest_file_valid() { + let manifest_path = Path::new("examples/test/manifest.yml"); let expected = Manifest { steps: vec![ Step { copy: vec![ CopyLinkOptions { src: String::from("gitconfig"), - dst: String::from("~/.gitconfig.coliru"), + dst: String::from("~/.gitconfig"), }, ], link: vec![], @@ -114,16 +117,16 @@ mod tests { link: vec![ CopyLinkOptions { src: String::from("bashrc"), - dst: String::from("~/.bashrc.coliru"), + dst: String::from("~/.bashrc"), }, CopyLinkOptions { src: String::from("vimrc"), - dst: String::from("~/.vimrc.coliru"), + dst: String::from("~/.vimrc"), }, ], run: vec![ RunOptions { - src: String::from("script.sh"), + src: String::from("scripts/script.sh"), prefix: String::from("sh"), postfix: String::from("arg1 $COLIRU_RULES"), }, @@ -135,12 +138,12 @@ mod tests { link: vec![ CopyLinkOptions { src: String::from("vimrc"), - dst: String::from("~/_vimrc.coliru"), + dst: String::from("~/_vimrc"), }, ], run: vec![ RunOptions { - src: String::from("script.bat"), + src: String::from("scripts/script.bat"), prefix: String::from(""), postfix: String::from("arg1 $COLIRU_RULES"), }, @@ -148,9 +151,9 @@ mod tests { tags: vec![String::from("windows")], }, ], - base_dir: PathBuf::from("examples"), + base_dir: PathBuf::from("examples/test"), }; - let actual = parse_manifest_file(Path::new("examples/manifest.yml")); + let actual = parse_manifest_file(manifest_path); assert_eq!(actual, Ok(expected)); } } diff --git a/src/ssh.rs b/src/ssh.rs @@ -0,0 +1,311 @@ +use shellexpand::tilde_with_context; +use std::fs::{read_dir, remove_dir_all}; +use std::path::{MAIN_SEPARATOR_STR, Path, PathBuf}; +use std::process::Command; +use super::local::copy_file; + +/// Copy a file to an SCP staging directory +/// +/// The destination directory structure will be recreated in the staging +/// directory under either the home or root subdirectories. This staging system +/// allows missing directories to be created automatically on the remote +/// machine. +pub fn stage_file(src: &str, dst: &str, staging_dir: &Path) -> Result<(), String> { + let home_dir = staging_dir.join("home"); + let root_dir = staging_dir.join("root"); + let get_home_dir = || { + Some::<String>(home_dir.to_string_lossy().into()) + }; + + // Resolve ~/... paths to home staging directory: + let mut _dst: PathBuf = (&tilde_with_context(dst, get_home_dir).to_mut()) + .into(); + + // Resolve relative paths to home staging directory: + _dst = home_dir.join(".coliru").join(_dst); + + // Resolve other absolute paths to root staging directory: + if !_dst.starts_with(home_dir) { + // Root should be / and C:\ on Unix and Windows respectively, but + // iter().next() will return / and C:, so we must manually add another + // path separator. (Duplicate slashes are ignored on Unix). + let root = PathBuf::from(match _dst.iter().next() { + Some(x) => Ok(x), + None => Err(String::from("Destination path does not have root")), + }?).join(MAIN_SEPARATOR_STR); + _dst = root_dir.join(_dst.strip_prefix(root) + .map_err(|why| why.to_string())?); + } + + copy_file(src, _dst.to_string_lossy().to_mut()) + .map_err(|why| why.to_string()) +} + +/// Transfer the files in an SCP staging directory to a remote machine +pub fn send_staged_files(staging_dir: &Path, host: &str) -> Result<(), String> { + let home_dir = staging_dir.join("home"); + if home_dir.exists() { + send_dir(home_dir.to_string_lossy().to_mut(), "~", host)?; + remove_dir_all(home_dir).map_err(|why| why.to_string())?; + } + let root_dir = staging_dir.join("root"); + if root_dir.exists() { + send_dir(root_dir.to_string_lossy().to_mut(), "/", host)?; + remove_dir_all(root_dir).map_err(|why| why.to_string())?; + } + Ok(()) +} + +/// Copy a directory to another machine via SCP and merge it with a destination +/// directory +fn send_dir(src: &str, dst: &str, host: &str) -> Result<(), String> { + // To avoid the source directory being copied as a subdirectory of the + // destination directory, we must send the contents of the directory + // item by item. + for item in read_dir(&src).map_err(|why| why.to_string())? { + let _src = item.map_err(|why| why.to_string())?.path(); + + let mut cmd = Command::new("scp"); + if host == "test@localhost" { + // SSH options and port for test server hard coded for now + cmd.args(["-o", "StrictHostKeyChecking=no", "-P", "2222"]); + } + cmd.args(["-r", &_src.to_string_lossy(), &format!("{host}:{dst}")]); + + let status = cmd.status().map_err(|why| why.to_string())?; + if !status.success() { + return Err(format!("SCP exited with {status}")); + } + } + Ok(()) +} + +/// Execute a command on another machine via SSH +#[allow(dead_code)] +pub fn send_command(command: &str, host: &str) -> Result<(), String> { + let mut cmd = Command::new("ssh"); + if host == "test@localhost" { + // SSH options and port for test server hard coded for now + cmd.args(["-o", "StrictHostKeyChecking=no", "-p", "2222"]); + } + cmd.args([host, command]); + + let status = cmd.status().map_err(|why| why.to_string())?; + if !status.success() { + return Err(format!("SSH exited with {status}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(unused_imports)] + + use super::*; + use crate::common::{SSH_HOST, read_file, setup_integration, write_file}; + + use std::fs; + + #[test] + fn test_stage_file_tilde() { + let tmp = setup_integration("test_stage_file_tilde"); + + let src = tmp.local.join("foo"); + let dst = "~/dir/bar"; + let dst_real = tmp.local.join("home").join("dir").join("bar"); + let staging = &tmp.local; + write_file(&src, "contents of foo"); + + let result = stage_file(src.to_str().unwrap(), dst, staging); + + assert_eq!(result, Ok(())); + assert_eq!(dst_real.exists(), true); + assert_eq!(read_file(&dst_real), "contents of foo"); + } + + #[test] + fn test_stage_file_relative() { + let tmp = setup_integration("test_stage_file_relative"); + + let src = tmp.local.join("foo"); + let dst = "dir/bar"; + let dst_real = tmp.local.join("home").join(".coliru").join("dir") + .join("bar"); + let staging = &tmp.local; + write_file(&src, "contents of foo"); + + let result = stage_file(src.to_str().unwrap(), dst, staging); + + assert_eq!(result, Ok(())); + assert_eq!(dst_real.exists(), true); + assert_eq!(read_file(&dst_real), "contents of foo"); + } + + #[test] + fn test_stage_file_absolute() { + let tmp = setup_integration("test_stage_file_absolute"); + + let src = tmp.local.join("foo"); + let dst = "/dir/bar"; + let dst_real = tmp.local.join("root").join("dir").join("bar"); + let staging = &tmp.local; + write_file(&src, "contents of foo"); + + let result = stage_file(src.to_str().unwrap(), dst, staging); + + assert_eq!(result, Ok(())); + assert_eq!(dst_real.exists(), true); + assert_eq!(read_file(&dst_real), "contents of foo"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_staged_files_no_files() { + let tmp = setup_integration("test_send_staged_files_no_files"); + + let result = send_staged_files(&tmp.local, SSH_HOST); + + assert_eq!(result, Ok(())); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_staged_files_home() { + let tmp = setup_integration("test_send_staged_files_home"); + + let src = tmp.local.join("home").join("test_send_staged_files_home"); + let src_foo = src.join("foo"); + let src_bar = src.join("dir").join("bar"); + fs::create_dir_all(&src_bar.parent().unwrap()).unwrap(); + write_file(&src_foo, "contents of foo"); + write_file(&src_bar, "contents of bar"); + + let result = send_staged_files(&tmp.local, SSH_HOST); + + let dst_foo = tmp.ssh.join("foo"); + let dst_bar = tmp.ssh.join("dir").join("bar"); + assert_eq!(result, Ok(())); + assert_eq!(dst_foo.exists(), true); + assert_eq!(read_file(&dst_foo), "contents of foo"); + assert_eq!(dst_bar.exists(), true); + assert_eq!(read_file(&dst_bar), "contents of bar"); + assert_eq!(tmp.local.join("home").exists(), false); + assert_eq!(tmp.local.join("root").exists(), false); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_staged_files_root() { + let tmp = setup_integration("test_send_staged_files_root"); + + let src = tmp.local.join("root").join("home").join("test") + .join("test_send_staged_files_root"); + let src_foo = src.join("foo"); + let src_bar = src.join("dir").join("bar"); + fs::create_dir_all(&src_bar.parent().unwrap()).unwrap(); + write_file(&src_foo, "contents of foo"); + write_file(&src_bar, "contents of bar"); + + let result = send_staged_files(&tmp.local, SSH_HOST); + + let dst_foo = tmp.ssh.join("foo"); + let dst_bar = tmp.ssh.join("dir").join("bar"); + assert_eq!(result, Ok(())); + assert_eq!(dst_foo.exists(), true); + assert_eq!(read_file(&dst_foo), "contents of foo"); + assert_eq!(dst_bar.exists(), true); + assert_eq!(read_file(&dst_bar), "contents of bar"); + assert_eq!(tmp.local.join("home").exists(), false); + assert_eq!(tmp.local.join("root").exists(), false); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_dir_basic() { + let tmp = setup_integration("test_send_dir_basic"); + + write_file(&tmp.local.join("foo"), "contents of foo"); + write_file(&tmp.local.join("bar"), "contents of bar"); + + let dst = "~/test_send_dir_basic"; + let dst_foo = tmp.ssh.join("foo"); + let dst_bar = tmp.ssh.join("bar"); + + let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST); + + assert_eq!(result, Ok(())); + assert_eq!(dst_foo.exists(), true); + assert_eq!(read_file(&dst_foo), "contents of foo"); + assert_eq!(dst_bar.exists(), true); + assert_eq!(read_file(&dst_bar), "contents of bar"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_dir_nested_dir() { + let tmp = setup_integration("test_send_dir_nested_dir"); + + let src_foo = tmp.local.join("foo"); + let src_bar = tmp.local.join("dir").join("bar"); + write_file(&src_foo, "contents of foo"); + fs::create_dir_all(&src_bar.parent().unwrap()).unwrap(); + write_file(&src_bar, "contents of bar"); + + let dst = "~/test_send_dir_nested_dir"; + let dst_foo = tmp.ssh.join("foo"); + let dst_bar = tmp.ssh.join("dir").join("bar"); + + let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST); + + assert_eq!(result, Ok(())); + assert_eq!(dst_foo.exists(), true); + assert_eq!(read_file(&dst_foo), "contents of foo"); + assert_eq!(dst_bar.exists(), true); + assert_eq!(read_file(&dst_bar), "contents of bar"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_dir_merge_dir() { + let tmp = setup_integration("test_send_dir_merge_dir"); + + let src_bar = tmp.local.join("dir").join("bar"); + fs::create_dir_all(src_bar.parent().unwrap()).unwrap(); + write_file(&src_bar, "new contents of bar"); + + let dst = "~/test_send_dir_merge_dir"; + let dst_foo = tmp.ssh.join("foo"); + let dst_bar = tmp.ssh.join("dir").join("bar"); + let dst_baz = tmp.ssh.join("dir").join("baz"); + write_file(&dst_foo, "old contents of foo"); + fs::create_dir_all(&dst_bar.parent().unwrap()).unwrap(); + write_file(&dst_bar, "old contents of bar"); + write_file(&dst_baz, "old contents of baz"); + + let result = send_dir(tmp.local.to_str().unwrap(), dst, SSH_HOST); + + assert_eq!(result, Ok(())); + assert_eq!(dst_foo.exists(), true); + assert_eq!(read_file(&dst_foo), "old contents of foo"); + assert_eq!(dst_bar.exists(), true); + assert_eq!(read_file(&dst_bar), "new contents of bar"); + assert_eq!(dst_baz.exists(), true); + assert_eq!(read_file(&dst_baz), "old contents of baz"); + } + + #[test] + #[cfg(target_family = "unix")] + fn test_send_command_basic() { + let tmp = setup_integration("test_send_command_basic"); + + let dst = "~/test_send_command_basic/foo"; + let dst_real = tmp.ssh.join("foo"); + let cmd = format!("echo 'contents of foo' > {}", dst); + + let result = send_command(&cmd, SSH_HOST); + + assert_eq!(result, Ok(())); + assert_eq!(dst_real.exists(), true); + assert_eq!(read_file(&dst_real), "contents of foo\n"); + } +} diff --git a/src/utils.rs b/src/utils.rs @@ -1,364 +0,0 @@ -use shellexpand::tilde; -use std::io; -use std::fs; -#[cfg(target_family = "unix")] -use std::os::unix::fs::symlink; -use std::path::{PathBuf, absolute}; -use std::process::Command; - -/// Copies the contents of a local file to another local file. -/// -/// Tildes are expanded if present and the destination file is overwritten if -/// necessary. -pub fn copy_file(src: &str, dst: &str) -> io::Result<()> { - if absolute(src)? == absolute(dst)? { return Ok(()); } - let _dst = prepare_path(dst)?; - fs::copy(src, _dst)?; - Ok(()) -} - -/// Creates a symbolic link to a local file. -/// -/// Tildes are expanded if present and the destination file is overwritten if -/// necessary. On non-Unix platforms, a hard link will be created instead. -#[cfg(target_family = "unix")] -pub fn link_file(src: &str, dst: &str) -> io::Result<()> { - if absolute(src)? == absolute(dst)? { return Ok(()); } - let _dst = prepare_path(dst)?; - symlink(fs::canonicalize(src)?, _dst)?; - Ok(()) -} -#[cfg(not(target_family = "unix"))] -pub fn link_file(src: &str, dst: &str) -> io::Result<()> { - if absolute(src)? == absolute(dst)? { return Ok(()); } - let _dst = prepare_path(dst)?; - fs::hard_link(src, _dst)?; - Ok(()) -} - -/// Creates the parent directories of a path and return the path with tildes -/// expanded. -fn prepare_path(path: &str) -> io::Result<PathBuf> { - let _dst: PathBuf = (&tilde(path).to_mut()).into(); - if let Some(_path) = _dst.parent() { - fs::create_dir_all(_path)?; - } - if fs::symlink_metadata(&_dst).is_ok() { - // Check for existing files, including broken symlinks - fs::remove_file(&_dst)?; - } - Ok(_dst) -} - -/// Executes a local shell script, optionally with a command prefix or postfix. -/// -/// Uses sh on Unix and PowerShell on Windows. -pub fn run_script(path: &str, prefix: &str, postfix: &str) -> Result<(), String> -{ - // Use absolute() to avoid incompatible "UNC" paths on Windows: - // https://github.com/rust-lang/rust/issues/42869 - let _path = absolute(path).map_err(|why| why.to_string())?; - let status; - if cfg!(target_family = "unix") { - status = Command::new("sh") - .arg("-c") - .arg(format!("{} {} {}", prefix, _path.display(), postfix)) - .status() - .map_err(|why| why.to_string())?; - } else { - status = Command::new("powershell") - .args(["-ExecutionPolicy", "Bypass", "-Command"]) - .arg(format!("{} {} {}", prefix, _path.display(), postfix)) - .status() - .map_err(|why| why.to_string())?; - } - if status.success() { - Ok(()) - } else { - Err(format!("Process exited with {status}")) - } -} - -#[cfg(test)] -#[path = "../tests/common/mod.rs"] -mod common; - -#[cfg(test)] -mod tests { - use super::*; - use common::{setup_integration, write_file}; - - #[test] - fn test_copy_file_create_dirs() { - let tmp = setup_integration("test_copy_file_create_dirs"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("dir1").join("dir2").join("bar"); - write_file(src, "old contents of foo"); - - let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "old contents of foo"); - } - - #[test] - fn test_copy_file_same_file() { - let tmp = setup_integration("test_copy_file_same_file"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("foo"); - write_file(src, "contents of foo"); - - let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "contents of foo"); - } - - #[test] - fn test_copy_file_existing_file() { - let tmp = setup_integration("test_copy_file_existing_file"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("bar"); - write_file(src, "old contents of foo"); - write_file(dst, "old contents of bar"); - - let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "old contents of foo"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_copy_file_existing_broken_symlink() { - let tmp = setup_integration("test_copy_file_existing_broken_symlink"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("bar"); - write_file(src, "old contents of foo"); - symlink("missing", dst).unwrap(); - - let result = copy_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "old contents of foo"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_copy_file_tilde_expansion() { - let tmp = setup_integration("test_copy_file_tilde_expansion"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("dir").join("bar"); - let dst_tilde = "~/test_copy_file_tilde_expansion/dir/bar"; - write_file(src, "old contents of foo"); - - let result = copy_file(src.to_str().unwrap(), dst_tilde); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "old contents of foo"); - } - - #[test] - fn test_link_file_create_dirs() { - let tmp = setup_integration("test_link_file_create_dirs"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("dir1").join("dir2").join("bar"); - write_file(src, "old contents of foo"); - - let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "new contents of foo"); - } - - #[test] - fn test_link_file_same_file() { - let tmp = setup_integration("test_link_file_same_file"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("foo"); - write_file(src, "contents of foo"); - - let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "contents of foo"); - } - - #[test] - fn test_link_file_existing_file() { - let tmp = setup_integration("test_link_file_existing_file"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("bar"); - write_file(src, "old contents of foo"); - write_file(dst, "old contents of bar"); - - let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "new contents of foo"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_link_file_existing_broken_symlink() { - let tmp = setup_integration("test_link_file_existing_broken_symlink"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("bar"); - write_file(src, "old contents of foo"); - symlink("missing", dst).unwrap(); - - let result = link_file(src.to_str().unwrap(), dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "new contents of foo"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_link_file_tilde_expansion() { - let tmp = setup_integration("test_link_file_tilde_expansion"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("dir").join("bar"); - let dst_tilde = "~/test_link_file_tilde_expansion/dir/bar"; - write_file(src, "old contents of foo"); - - let result = link_file(src.to_str().unwrap(), dst_tilde); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "new contents of foo"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_link_file_relative_source() { - let tmp = setup_integration("test_link_file_relative_source"); - - let src = &tmp.dir.join("foo"); - let src_rel = "test_link_file_relative_source/foo"; - let dst = &tmp.dir.join("dir1").join("dir2").join("bar"); - write_file(src, "old contents of foo"); - - let result = link_file(src_rel, dst.to_str().unwrap()); - - write_file(src, "new contents of foo"); - let contents = fs::read_to_string(dst).unwrap(); - let link = fs::read_link(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "new contents of foo"); - assert_eq!(&link, src); // src changed to absolute path - } - - #[test] - #[cfg(target_family = "unix")] - fn test_run_script_successful() { - let tmp = setup_integration("test_run_script_successful"); - - let src = &tmp.dir.join("foo"); - write_file(src, "exit 0"); - - let result = run_script(src.to_str().unwrap(), "sh", ""); - - assert_eq!(result.is_ok(), true); - } - - #[test] - #[cfg(target_family = "windows")] - fn test_run_script_successful() { - let tmp = setup_integration("test_run_script_successful"); - - let src = &tmp.dir.join("foo.bat"); - write_file(src, "exit 0"); - - let result = run_script(src.to_str().unwrap(), "", ""); - - assert_eq!(result.is_ok(), true); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_run_script_failure() { - let tmp = setup_integration("test_run_script_failure"); - - let src = &tmp.dir.join("foo"); - write_file(src, "exit 2"); - - let result = run_script(src.to_str().unwrap(), "sh", ""); - - assert_eq!(result.is_ok(), false); - assert_eq!(result.unwrap_err(), "Process exited with exit status: 2"); - } - - #[test] - #[cfg(target_family = "windows")] - fn test_run_script_failure() { - let tmp = setup_integration("test_run_script_failure"); - - let src = &tmp.dir.join("foo.bat"); - write_file(src, "exit 1"); - - let result = run_script(src.to_str().unwrap(), "", ""); - - assert_eq!(result.is_ok(), false); - assert_eq!(result.unwrap_err(), "Process exited with exit code: 1"); - } - - #[test] - #[cfg(target_family = "unix")] - fn test_run_script_postfix() { - let tmp = setup_integration("test_run_script_postfix"); - - let src = &tmp.dir.join("foo"); - let dst = &tmp.dir.join("bar"); - write_file(src, &format!("echo $@ > {}", dst.to_str().unwrap())); - - let result = run_script(src.to_str().unwrap(), "sh", "arg1 arg2"); - - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "arg1 arg2\n"); - } - - #[test] - #[cfg(target_family = "windows")] - fn test_run_script_postfix() { - let tmp = setup_integration("test_run_script_postfix"); - - let src = &tmp.dir.join("foo.bat"); - let dst = &tmp.dir.join("bar"); - write_file(src, &format!("echo %* > {}", dst.to_str().unwrap())); - - let result = run_script(src.to_str().unwrap(), "", "arg1 arg2"); - - let contents = fs::read_to_string(dst).unwrap(); - assert_eq!(result.is_ok(), true); - assert_eq!(contents, "arg1 arg2 \r\n"); - } -} diff --git a/tests/.temp/ssh/.gitkeep b/tests/.temp/ssh/.gitkeep @@ -0,0 +1,3 @@ +# This directory is mounted to the SSH docker container used for E2E tests. All +# children are ignored, but this file guarantees that the directory will exist +# on a fresh clone. diff --git a/tests/basic.rs b/tests/basic.rs @@ -1,33 +1,14 @@ +/// End to end tests that do not test general CLI behavior rather than specific +/// installation behavior + mod common; use common::*; -use std::env::{current_exe, consts::EXE_SUFFIX}; -use std::fs::{copy, remove_file}; -use std::path::Path; - -/// Create a basic manifest file and its associated dotfiles in a directory -fn manifest_1(dir: &Path) { - // Copy files from examples - let examples = current_exe().unwrap().parent().unwrap().to_path_buf() - .join("../../../examples"); - let copy_file = |name: &str| { - copy(examples.join(name), &dir.join(name)).unwrap(); - }; - copy_file("script.bat"); - copy_file("script.sh"); - copy_file("manifest.yml"); - copy_file("manifest-windows-test.yml"); - - // Create simplified config files - write_file(&dir.join("bashrc"), "bash #1"); - write_file(&dir.join("gitconfig"), "git #1"); - write_file(&dir.join("vimrc"), "vim #1"); - -} +use std::env::consts::EXE_SUFFIX; #[test] -fn test_help() { - let (_dir, mut cmd) = setup_e2e("test_help"); +fn test_basic_help() { + let (_dirs, mut cmd) = setup_e2e_local("test_basic_help"); cmd.arg("--help"); let expected = format!("\ A minimal, flexible, dotfile installer @@ -39,6 +20,7 @@ Arguments: Options: -t, --tag-rules [<TAG_RULES>...] The set of tag rules to enforce + --host <HOST> Install dotfiles on another machine over SSH -c, --copy Interpret link commands as copy commands -n, --dry-run Do a trial run without any permanent changes -h, --help Print help @@ -49,154 +31,61 @@ Options: } #[test] -#[cfg(target_family = "unix")] -fn test_standard() { - let (dir, mut cmd) = setup_e2e("test_standard"); - cmd.args(["manifest.yml", "-t", "linux"]); - manifest_1(&dir.dir); - - let expected = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru -[2/3] Link bashrc to ~/.bashrc.coliru -[2/3] Link vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 linux -script.sh called with arg1 linux -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_contents = read_file(&dir.dir.join(".vimrc.coliru")); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_contents, "bash #2"); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_contents, "vim #2"); - assert_eq!(vim2_exists, false); - assert_eq!(log_contents, "script.sh called with arg1 linux\n"); -} - -#[test] -#[cfg(target_family = "windows")] -fn test_standard() { - let (dir, mut cmd) = setup_e2e("test_standard"); - cmd.args(["manifest-windows-test.yml", "-t", "windows"]); - manifest_1(&dir.dir); - - let expected = "\ -[1/3] Copy gitconfig to .gitconfig.coliru -[3/3] Link vimrc to _vimrc.coliru -[3/3] Run script.bat arg1 windows -script.bat called with arg1 windows\r -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); +fn test_basic_empty_manifest() { + let (dirs, mut cmd) = setup_e2e_local("test_basic_empty_manifest"); + cmd.args(["manifest.yml"]); + write_file(&dirs.local.join("manifest.yml"), ""); - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_exists = dir.dir.join(".vimrc.coliru").exists(); - let vim2_contents = read_file(&dir.dir.join("_vimrc.coliru")); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_exists, false); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_exists, false); - assert_eq!(vim2_contents, "vim #2"); - assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); + let expected = "Error: missing field `steps`\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected); + assert_eq!(&stdout_to_string(&mut cmd), ""); } #[test] #[cfg(target_family = "unix")] -fn test_run_alternate_tag_rules_1() { - let (dir, mut cmd) = setup_e2e("test_run_alternate_tag_rules_1"); - cmd.args(["manifest.yml", "-t", "linux", "^windows"]); - manifest_1(&dir.dir); - - let expected = "\ -[2/3] Link bashrc to ~/.bashrc.coliru -[2/3] Link vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 linux ^windows -script.sh called with arg1 linux ^windows -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); +fn test_basic_missing_manifest() { + let (_dirs, mut cmd) = setup_e2e_local("test_basic_missing_manifest"); + cmd.args(["missing.yml"]); - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_exists = dir.dir.join(".gitconfig.coliru").exists(); - let vim1_contents = read_file(&dir.dir.join(".vimrc.coliru")); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_contents, "bash #2"); - assert_eq!(git_exists, false); - assert_eq!(vim1_contents, "vim #2"); - assert_eq!(vim2_exists, false); - assert_eq!(log_contents, "script.sh called with arg1 linux ^windows\n"); + let expected = "Error: No such file or directory (os error 2)\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected); + assert_eq!(&stdout_to_string(&mut cmd), ""); } #[test] -#[cfg(target_family = "unix")] -fn test_run_alternate_tag_rules_2() { - let (dir, mut cmd) = setup_e2e("test_run_alternate_tag_rules_2"); - cmd.args(["manifest.yml", "-t", "macos"]); - manifest_1(&dir.dir); - - let expected = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru -[2/3] Link bashrc to ~/.bashrc.coliru -[2/3] Link vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 macos -script.sh called with arg1 macos -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); +#[cfg(target_family = "windows")] +fn test_basic_missing_manifest() { + let (_dirs, mut cmd) = setup_e2e_local("test_basic_missing_manifest"); + cmd.args(["missing.yml"]); - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_contents = read_file(&dir.dir.join(".vimrc.coliru")); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_contents, "bash #2"); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_contents, "vim #2"); - assert_eq!(vim2_exists, false); - assert_eq!(log_contents, "script.sh called with arg1 macos\n"); + let expected = "Error: The system cannot find the file specified. \ + (os error 2)\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected); + assert_eq!(&stdout_to_string(&mut cmd), ""); } #[test] -fn test_dry_run() { - let (dir, mut cmd) = setup_e2e("test_dry_run"); - cmd.args(["manifest.yml", "--dry-run", "-t", "linux"]); - manifest_1(&dir.dir); +#[cfg(target_family = "unix")] +fn test_basic_absolute_manifest() { + let (dirs, mut cmd) = setup_e2e_local("test_basic_absolute_manifest"); + let manifest_path = dirs.local.join("manifest.yml"); + cmd.args([&manifest_path.to_str().unwrap(), "--dry-run", "-t", "linux"]); let expected = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru (DRY RUN) -[2/3] Link bashrc to ~/.bashrc.coliru (DRY RUN) -[2/3] Link vimrc to ~/.vimrc.coliru (DRY RUN) +[1/3] Copy gitconfig to ~/.gitconfig (DRY RUN) +[2/3] Link bashrc to ~/.bashrc (DRY RUN) +[2/3] Link vimrc to ~/.vimrc (DRY RUN) [2/3] Run sh script.sh arg1 linux (DRY RUN) "; - assert_eq!(&stdout_to_string(&mut cmd), expected); assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); // Assert files are correctly copied/linked/run - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_exists = dir.dir.join(".gitconfig.coliru").exists(); - let vim1_exists = dir.dir.join(".vimrc.coliru").exists(); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_exists = dir.dir.join("log.txt").exists(); + let bash_exists = dirs.home.join(".bashrc").exists(); + let git_exists = dirs.home.join(".gitconfig").exists(); + let vim1_exists = dirs.home.join(".vimrc").exists(); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_exists = dirs.home.join("log.txt").exists(); assert_eq!(bash_exists, false); assert_eq!(git_exists, false); assert_eq!(vim1_exists, false); @@ -205,247 +94,27 @@ fn test_dry_run() { } #[test] -#[cfg(target_family = "unix")] -fn test_copy() { - let (dir, mut cmd) = setup_e2e("test_copy"); - cmd.args(["manifest.yml", "--copy", "-t", "linux"]); - manifest_1(&dir.dir); - - let expected = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru -[2/3] Copy bashrc to ~/.bashrc.coliru -[2/3] Copy vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 linux -script.sh called with arg1 linux -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_contents = read_file(&dir.dir.join(".vimrc.coliru")); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_contents, "bash #1"); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_contents, "vim #1"); - assert_eq!(vim2_exists, false); - assert_eq!(log_contents, "script.sh called with arg1 linux\n"); -} - -#[test] #[cfg(target_family = "windows")] -fn test_copy() { - let (dir, mut cmd) = setup_e2e("test_copy"); - cmd.args(["manifest-windows-test.yml", "--copy", "-t", "windows"]); - manifest_1(&dir.dir); - - let expected = "\ -[1/3] Copy gitconfig to .gitconfig.coliru -[3/3] Copy vimrc to _vimrc.coliru -[3/3] Run script.bat arg1 windows -script.bat called with arg1 windows\r -"; - assert_eq!(&stdout_to_string(&mut cmd), expected); - assert_eq!(&stderr_to_string(&mut cmd), ""); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_exists = dir.dir.join(".vimrc.coliru").exists(); - let vim2_contents = read_file(&dir.dir.join("_vimrc.coliru")); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_exists, false); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_exists, false); - assert_eq!(vim2_contents, "vim #1"); - assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); -} - -#[test] -#[cfg(target_family = "unix")] -fn test_run_failure() { - let (dir, mut cmd) = setup_e2e("test_run_failure"); - cmd.args(["manifest.yml", "-t", "linux"]); - manifest_1(&dir.dir); - write_file(&dir.dir.join("script.sh"), "exit 1"); - - let expected_stdout = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru -[2/3] Link bashrc to ~/.bashrc.coliru -[2/3] Link vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 linux -"; - let expected_stderr = " Error: Process exited with exit status: 1\n"; - assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); - assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_contents = read_file(&dir.dir.join(".vimrc.coliru")); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - assert_eq!(bash_contents, "bash #2"); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_contents, "vim #2"); - assert_eq!(vim2_exists, false); -} - -#[test] -#[cfg(target_family = "windows")] -fn test_run_failure() { - let (dir, mut cmd) = setup_e2e("test_run_failure"); - cmd.args(["manifest-windows-test.yml", "-t", "windows"]); - manifest_1(&dir.dir); - write_file(&dir.dir.join("script.bat"), "@echo off\r\nexit 1"); - - let expected_stdout = "\ -[1/3] Copy gitconfig to .gitconfig.coliru -[3/3] Link vimrc to _vimrc.coliru -[3/3] Run script.bat arg1 windows -"; - let expected_stderr = " Error: Process exited with exit code: 1\n"; - assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); - assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("gitconfig"), "git #2"); - write_file(&dir.dir.join("vimrc"), "vim #2"); - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let vim1_exists = dir.dir.join(".vimrc.coliru").exists(); - let vim2_contents = read_file(&dir.dir.join("_vimrc.coliru")); - assert_eq!(bash_exists, false); - assert_eq!(git_contents, "git #1"); - assert_eq!(vim1_exists, false); - assert_eq!(vim2_contents, "vim #2"); -} - -#[test] -#[cfg(target_family = "unix")] -fn test_missing_file() { - let (dir, mut cmd) = setup_e2e("test_missing_file"); - cmd.args(["manifest.yml", "-t", "linux"]); - manifest_1(&dir.dir); - remove_file(&dir.dir.join("vimrc")).unwrap(); - - let expected_stdout = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru -[2/3] Link bashrc to ~/.bashrc.coliru -[2/3] Link vimrc to ~/.vimrc.coliru -[2/3] Run sh script.sh arg1 linux -script.sh called with arg1 linux -"; - let expected_stderr = " Error: No such file or directory \ - (os error 2)\n"; - assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); - assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("bashrc"), "bash #2"); - write_file(&dir.dir.join("gitconfig"), "git #2"); - let bash_contents = read_file(&dir.dir.join(".bashrc.coliru")); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_contents, "bash #2"); - assert_eq!(git_contents, "git #1"); - assert_eq!(log_contents, "script.sh called with arg1 linux\n"); -} - -#[test] -#[cfg(target_family = "windows")] -fn test_missing_file() { - let (dir, mut cmd) = setup_e2e("test_missing_file"); - cmd.args(["manifest-windows-test.yml", "-t", "windows"]); - manifest_1(&dir.dir); - remove_file(&dir.dir.join("vimrc")).unwrap(); - - let expected_stdout = "\ -[1/3] Copy gitconfig to .gitconfig.coliru -[3/3] Link vimrc to _vimrc.coliru -[3/3] Run script.bat arg1 windows -script.bat called with arg1 windows\r -"; - let expected_stderr = " Error: The system cannot find the file specified. \ - (os error 2)\n"; - assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); - assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); - - // Assert files are correctly copied/linked/run - write_file(&dir.dir.join("gitconfig"), "git #2"); - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_contents = read_file(&dir.dir.join(".gitconfig.coliru")); - let log_contents = read_file(&dir.dir.join("log.txt")); - assert_eq!(bash_exists, false); - assert_eq!(git_contents, "git #1"); - assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); -} - -#[test] -fn test_empty_manifest() { - let (dir, mut cmd) = setup_e2e("test_empty_manifest"); - cmd.args(["manifest.yml"]); - write_file(&dir.dir.join("manifest.yml"), ""); - - let expected = "Error: missing field `steps`\n"; - assert_eq!(&stdout_to_string(&mut cmd), ""); - assert_eq!(&stderr_to_string(&mut cmd), expected); -} - -#[test] -#[cfg(target_family = "unix")] -fn test_missing_manifest() { - let (_dir, mut cmd) = setup_e2e("test_missing_manifest"); - cmd.args(["missing.yml"]); - - let expected = "Error: No such file or directory (os error 2)\n"; - assert_eq!(&stdout_to_string(&mut cmd), ""); - assert_eq!(&stderr_to_string(&mut cmd), expected); -} - -#[test] -#[cfg(target_family = "windows")] -fn test_missing_manifest() { - let (_dir, mut cmd) = setup_e2e("test_missing_manifest"); - cmd.args(["missing.yml"]); - - let expected = "Error: The system cannot find the file specified. \ - (os error 2)\n"; - assert_eq!(&stdout_to_string(&mut cmd), ""); - assert_eq!(&stderr_to_string(&mut cmd), expected); -} - -#[test] -fn test_absolute_manifest() { - let (dir, mut cmd) = setup_e2e("test_absolute_manifest"); - let manifest_path = dir.dir.join("manifest.yml"); +fn test_basic_absolute_manifest() { + let (dirs, mut cmd) = setup_e2e_local("test_basic_absolute_manifest"); + let manifest_path = dirs.local.join("manifest.yml"); cmd.args([&manifest_path.to_str().unwrap(), "--dry-run", "-t", "linux"]); - manifest_1(&dir.dir); let expected = "\ -[1/3] Copy gitconfig to ~/.gitconfig.coliru (DRY RUN) -[2/3] Link bashrc to ~/.bashrc.coliru (DRY RUN) -[2/3] Link vimrc to ~/.vimrc.coliru (DRY RUN) +[1/3] Copy gitconfig to .gitconfig (DRY RUN) +[2/3] Link bashrc to .bashrc (DRY RUN) +[2/3] Link vimrc to .vimrc (DRY RUN) [2/3] Run sh script.sh arg1 linux (DRY RUN) "; - assert_eq!(&stdout_to_string(&mut cmd), expected); assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); // Assert files are correctly copied/linked/run - let bash_exists = dir.dir.join(".bashrc.coliru").exists(); - let git_exists = dir.dir.join(".gitconfig.coliru").exists(); - let vim1_exists = dir.dir.join(".vimrc.coliru").exists(); - let vim2_exists = dir.dir.join("_vimrc.coliru").exists(); - let log_exists = dir.dir.join("log.txt").exists(); + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_exists = dirs.local.join(".gitconfig").exists(); + let vim1_exists = dirs.local.join(".vimrc").exists(); + let vim2_exists = dirs.local.join("_vimrc").exists(); + let log_exists = dirs.local.join("log.txt").exists(); assert_eq!(bash_exists, false); assert_eq!(git_exists, false); assert_eq!(vim1_exists, false); diff --git a/tests/common/mod.rs b/tests/common/mod.rs @@ -6,59 +6,142 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; -/// Stores the path to a temporary directory that is automatically deleted -/// when the value is dropped. -/// -/// Adapted from ripgrep's tests (crates/ignore/src/lib.rs) -pub struct TempDir { - pub dir: PathBuf +/// The SSH test server +pub const SSH_HOST: &str = "test@localhost"; // TODO: add explicit port + +/// A set of temporary directories that are automatically deleted when the value +/// is dropped +pub struct TempDirs { + /// A temporary directory that is located at or in $HOME on Unix + pub home: PathBuf, + + /// A temporary directory that is located at or under the CWD + pub local: PathBuf, + + /// A temporary directory that is mounted to the SSH server under $HOME + pub ssh: PathBuf, + + /// A temporary directory that is mounted to the SSH server under ~/.coliru + pub ssh_cwd: PathBuf, } -impl Drop for TempDir { +impl Drop for TempDirs { fn drop(&mut self) { - fs::remove_dir_all(&self.dir).unwrap(); + fs::remove_dir_all(&self.home).unwrap(); + fs::remove_dir_all(&self.local).unwrap(); + fs::remove_dir_all(&self.ssh).unwrap(); + fs::remove_dir_all(&self.ssh_cwd).unwrap(); } } -impl TempDir { - fn new(name: &str) -> TempDir { - let dir = env::temp_dir().join("coliru-tests").join(name); - assert_eq!(dir.exists(), false); - fs::create_dir_all(&dir).unwrap(); - TempDir { dir } +impl TempDirs { + fn new(name: &str) -> TempDirs { + // The CWD of the current process is always the repository root + let dir = env::current_dir().unwrap().join("tests").join(".temp"); + + let home = dir.join("home").join(name); + let local = dir.join("local").join(name); + let ssh = dir.join("ssh").join(name); + let ssh_cwd = dir.join("ssh").join(".coliru").join(name); + + assert_eq!(home.exists(), false); + assert_eq!(local.exists(), false); + assert_eq!(ssh.exists(), false); + assert_eq!(ssh_cwd.exists(), false); + + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&local).unwrap(); + fs::create_dir_all(&ssh).unwrap(); + fs::create_dir_all(&ssh_cwd).unwrap(); + + TempDirs { home, local, ssh, ssh_cwd } } } -/// Creates a temporary directory with a certain name and sets $HOME and the -/// CWD to the parent directory. +/// Initializes temporary directories for integration tests /// -/// All tests in this module use the same values for $HOME and the CWD, -/// which prevents issues when tests are run in multiple threads. -pub fn setup_integration(name: &str) -> TempDir { - let dir = TempDir::new(name); - let root = dir.dir.parent().unwrap(); - env::set_current_dir(root).unwrap(); +/// On Unix, $HOME is set to the parent directory of the home temporary +/// directory, which is the same for all integration tests. This prevents issues +/// when tests are run in multiple threads. +pub fn setup_integration(name: &str) -> TempDirs { + let dirs = TempDirs::new(name); if cfg!(target_family = "unix") { - env::set_var("HOME", root); + env::set_var("HOME", dirs.home.parent().unwrap()); } - dir + dirs } -/// Creates a temporary directory with a certain name and create a new coliru -/// Command with $HOME and the CWD set the the temporary directory. +/// Initializes temporary directories and a coliru Command for e2e tests /// -/// Adapted from ripgrep's tests (tests/utils.rs) -pub fn setup_e2e(name: &str) -> (TempDir, Command) { - let dir = TempDir::new(name); +/// The Command's CWD is set to the local temporary directory, and on Unix, the +/// Command's $HOME variable is set to the home temporary directory. +fn setup_e2e(name: &str) -> (TempDirs, Command) { + let dirs = TempDirs::new(name); let exe = env::current_exe().unwrap().parent().unwrap().to_path_buf() .join(format!("../coliru{}", env::consts::EXE_SUFFIX)); let mut cmd = Command::new(exe); - cmd.current_dir(&dir.dir); - + cmd.current_dir(&dirs.local); if cfg!(target_family = "unix") { - cmd.env("HOME", &dir.dir); + cmd.env("HOME", &dirs.home); } - (dir, cmd) + (dirs, cmd) +} + +/// Initializes temporary directories and a coliru Command for local e2e tests +/// +/// A test dotfile repo is copied to the local folder, to be installed to $HOME +/// on Unix and CWD on Windows. +pub fn setup_e2e_local(name: &str) -> (TempDirs, Command) { + let (dirs, cmd) = setup_e2e(name); + + // It's difficult to mock $HOME on Windows, so install dotfiles in CWD + let home_dir = if cfg!(target_family = "unix") { "~/" } else { "" }; + copy_manifest(&dirs.local, home_dir, ""); + + (dirs, cmd) +} + +/// Initializes temporary directories and a coliru Command for ssh e2e tests +/// +/// A test dotfile repo is copied to the local folder, to be installed to +/// ~/test_name/ with scripts copied to ~/.coliru/test_name/, and the --host +/// argument is set to the test SSH server. +pub fn setup_e2e_ssh(name: &str) -> (TempDirs, Command) { + let (dirs, mut cmd) = setup_e2e(name); + cmd.args(["--host", SSH_HOST]); + + // Replace ~/ and scripts/ with custom directory to isolate SSH tests + copy_manifest(&dirs.local, &format!("~/{name}/"), &format!("{name}/")); + + (dirs, cmd) +} + +/// Create a basic manifest file and its associated dotfiles in a directory +/// +/// All occurrences of the string "~/" and "scripts/" (e.g. in manifest.yml and +/// scripts/*) will be replaced with the value of home_dir and script_dir +/// respectively to ensures that dotfiles are isolated across tests when +/// necessary. +fn copy_manifest(dir: &Path, home_dir: &str, script_dir: &str) { + let examples = env::current_exe().unwrap().parent().unwrap().to_path_buf() + .join("../../../examples/test"); + + let copy_file = |path: &str| { + let mut contents = read_file(&examples.join(path)); + contents = contents.replace("~/", home_dir); + contents = contents.replace("scripts/", script_dir); + let dst = path.replace("scripts/", script_dir); + write_file(&dir.join(dst), &contents); + }; + + copy_file("manifest.yml"); + fs::create_dir_all(&dir.join(script_dir)).unwrap(); + copy_file("scripts/script.bat"); + copy_file("scripts/script.sh"); + copy_file("bashrc"); + copy_file("gitconfig"); + copy_file("vimrc"); + } /// Writes a string to a file, overwriting it if it already exists. diff --git a/tests/local.rs b/tests/local.rs @@ -0,0 +1,365 @@ +/// End to end tests that test specific installation behavior on the local file +/// system + +mod common; + +use common::*; +use std::fs::remove_file; + +#[test] +#[cfg(target_family = "unix")] +fn test_local_standard() { + let (dirs, mut cmd) = setup_e2e_local("test_local_standard"); + cmd.args(["manifest.yml", "-t", "linux"]); + + let expected = "\ +[1/3] Copy gitconfig to ~/.gitconfig +[2/3] Link bashrc to ~/.bashrc +[2/3] Link vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 linux +script.sh called with arg1 linux +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("gitconfig"), "git #2\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_contents = read_file(&dirs.home.join(".gitconfig")); + let vim1_contents = read_file(&dirs.home.join(".vimrc")); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_contents, "bash #2\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #2\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +} + +#[test] +#[cfg(target_family = "windows")] +fn test_local_standard() { + let (dirs, mut cmd) = setup_e2e_local("test_local_standard"); + cmd.args(["manifest.yml", "-t", "windows"]); + + let expected = "\ +[1/3] Copy gitconfig to .gitconfig +[3/3] Link vimrc to _vimrc +[3/3] Run script.bat arg1 windows +script.bat called with arg1 windows\r +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("gitconfig"), "git #2\r\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\r\n"); + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_contents = read_file(&dirs.local.join(".gitconfig")); + let vim1_exists = dirs.local.join(".vimrc").exists(); + let vim2_contents = read_file(&dirs.local.join("_vimrc")); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_exists, false); + assert_eq!(git_contents, "git #1\r\n"); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_contents, "vim #2\r\n"); + assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_run_alternate_tag_rules_1() { + let (dirs, mut cmd) = setup_e2e_local("test_local_run_alternate_tag_rules_1"); + cmd.args(["manifest.yml", "-t", "linux", "^windows"]); + + let expected = "\ +[2/3] Link bashrc to ~/.bashrc +[2/3] Link vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 linux ^windows +script.sh called with arg1 linux ^windows +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_exists = dirs.home.join(".gitconfig").exists(); + let vim1_contents = read_file(&dirs.home.join(".vimrc")); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_contents, "bash #2\n"); + assert_eq!(git_exists, false); + assert_eq!(vim1_contents, "vim #2\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux ^windows\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_run_alternate_tag_rules_2() { + let (dirs, mut cmd) = setup_e2e_local("test_local_run_alternate_tag_rules_2"); + cmd.args(["manifest.yml", "-t", "macos"]); + + let expected = "\ +[1/3] Copy gitconfig to ~/.gitconfig +[2/3] Link bashrc to ~/.bashrc +[2/3] Link vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 macos +script.sh called with arg1 macos +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("gitconfig"), "git #2\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_contents = read_file(&dirs.home.join(".gitconfig")); + let vim1_contents = read_file(&dirs.home.join(".vimrc")); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_contents, "bash #2\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #2\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 macos\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_dry_run() { + let (dirs, mut cmd) = setup_e2e_local("test_local_dry_run"); + cmd.args(["manifest.yml", "--dry-run", "-t", "linux"]); + + let expected = "\ +[1/3] Copy gitconfig to ~/.gitconfig (DRY RUN) +[2/3] Link bashrc to ~/.bashrc (DRY RUN) +[2/3] Link vimrc to ~/.vimrc (DRY RUN) +[2/3] Run sh script.sh arg1 linux (DRY RUN) +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + let bash_exists = dirs.home.join(".bashrc").exists(); + let git_exists = dirs.home.join(".gitconfig").exists(); + let vim1_exists = dirs.home.join(".vimrc").exists(); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_exists = dirs.local.join("log.txt").exists(); + assert_eq!(bash_exists, false); + assert_eq!(git_exists, false); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_exists, false); + assert_eq!(log_exists, false); +} + +#[test] +#[cfg(target_family = "windows")] +fn test_local_dry_run() { + let (dirs, mut cmd) = setup_e2e_local("test_local_dry_run"); + cmd.args(["manifest.yml", "--dry-run", "-t", "windows"]); + + let expected = "\ +[1/3] Copy gitconfig to .gitconfig (DRY RUN) +[3/3] Link vimrc to _vimrc (DRY RUN) +[3/3] Run script.bat arg1 windows (DRY RUN) +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_exists = dirs.local.join(".gitconfig").exists(); + let vim1_exists = dirs.local.join(".vimrc").exists(); + let vim2_exists = dirs.local.join("_vimrc").exists(); + let log_exists = dirs.local.join("log.txt").exists(); + assert_eq!(bash_exists, false); + assert_eq!(git_exists, false); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_exists, false); + assert_eq!(log_exists, false); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_copy() { + let (dirs, mut cmd) = setup_e2e_local("test_local_copy"); + cmd.args(["manifest.yml", "--copy", "-t", "linux"]); + + let expected = "\ +[1/3] Copy gitconfig to ~/.gitconfig +[2/3] Copy bashrc to ~/.bashrc +[2/3] Copy vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 linux +script.sh called with arg1 linux +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("gitconfig"), "git #2\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_contents = read_file(&dirs.home.join(".gitconfig")); + let vim1_contents = read_file(&dirs.home.join(".vimrc")); + let vim2_exists = dirs.home.join("_vimrc").exists(); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +} + +#[test] +#[cfg(target_family = "windows")] +fn test_local_copy() { + let (dirs, mut cmd) = setup_e2e_local("test_local_copy"); + cmd.args(["manifest.yml", "--copy", "-t", "windows"]); + + let expected = "\ +[1/3] Copy gitconfig to .gitconfig +[3/3] Copy vimrc to _vimrc +[3/3] Run script.bat arg1 windows +script.bat called with arg1 windows\r +"; + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(&stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("gitconfig"), "git #2\r\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\r\n"); + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_contents = read_file(&dirs.local.join(".gitconfig")); + let vim1_exists = dirs.local.join(".vimrc").exists(); + let vim2_contents = read_file(&dirs.local.join("_vimrc")); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_exists, false); + assert_eq!(git_contents, "git #1\r\n"); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_contents, "vim #1\r\n"); + assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_run_failure() { + let (dirs, mut cmd) = setup_e2e_local("test_local_run_failure"); + cmd.args(["manifest.yml", "-t", "linux"]); + write_file(&dirs.local.join("script.sh"), "exit 1"); + + let expected_stdout = "\ +[1/3] Copy gitconfig to ~/.gitconfig +[2/3] Link bashrc to ~/.bashrc +[2/3] Link vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 linux +"; + let expected_stderr = " Error: Process exited with exit status: 1\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("gitconfig"), "git #2\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_contents = read_file(&dirs.home.join(".gitconfig")); + let vim1_contents = read_file(&dirs.home.join(".vimrc")); + let vim2_exists = dirs.home.join("_vimrc").exists(); + assert_eq!(bash_contents, "bash #2\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #2\n"); + assert_eq!(vim2_exists, false); +} + +#[test] +#[cfg(target_family = "windows")] +fn test_local_run_failure() { + let (dirs, mut cmd) = setup_e2e_local("test_local_run_failure"); + cmd.args(["manifest.yml", "-t", "windows"]); + write_file(&dirs.local.join("script.bat"), "@echo off\r\nexit 1"); + + let expected_stdout = "\ +[1/3] Copy gitconfig to .gitconfig +[3/3] Link vimrc to _vimrc +[3/3] Run script.bat arg1 windows +"; + let expected_stderr = " Error: Process exited with exit code: 1\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("gitconfig"), "git #2\r\n"); + write_file(&dirs.local.join("vimrc"), "vim #2\r\n"); + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_contents = read_file(&dirs.local.join(".gitconfig")); + let vim1_exists = dirs.local.join(".vimrc").exists(); + let vim2_contents = read_file(&dirs.local.join("_vimrc")); + assert_eq!(bash_exists, false); + assert_eq!(git_contents, "git #1\r\n"); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_contents, "vim #2\r\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_local_missing_file() { + let (dirs, mut cmd) = setup_e2e_local("test_local_missing_file"); + cmd.args(["manifest.yml", "-t", "linux"]); + remove_file(&dirs.local.join("vimrc")).unwrap(); + + let expected_stdout = "\ +[1/3] Copy gitconfig to ~/.gitconfig +[2/3] Link bashrc to ~/.bashrc +[2/3] Link vimrc to ~/.vimrc +[2/3] Run sh script.sh arg1 linux +script.sh called with arg1 linux +"; + let expected_stderr = " Error: No such file or directory (os error 2)\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("bashrc"), "bash #2\n"); + write_file(&dirs.local.join("gitconfig"), "git #2\n"); + let bash_contents = read_file(&dirs.home.join(".bashrc")); + let git_contents = read_file(&dirs.home.join(".gitconfig")); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_contents, "bash #2\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +} + +#[test] +#[cfg(target_family = "windows")] +fn test_local_missing_file() { + let (dirs, mut cmd) = setup_e2e_local("test_local_missing_file"); + cmd.args(["manifest.yml", "-t", "windows"]); + remove_file(&dirs.local.join("vimrc")).unwrap(); + + let expected_stdout = "\ +[1/3] Copy gitconfig to .gitconfig +[3/3] Link vimrc to _vimrc +[3/3] Run script.bat arg1 windows +script.bat called with arg1 windows\r +"; + let expected_stderr = " Error: The system cannot find the file specified. \ + (os error 2)\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(&stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/linked/run + write_file(&dirs.local.join("gitconfig"), "git #2\r\n"); + let bash_exists = dirs.local.join(".bashrc").exists(); + let git_contents = read_file(&dirs.local.join(".gitconfig")); + let log_contents = read_file(&dirs.local.join("log.txt")); + assert_eq!(bash_exists, false); + assert_eq!(git_contents, "git #1\r\n"); + assert_eq!(log_contents, "script.bat called with arg1 windows \r\n"); +} diff --git a/tests/server/Dockerfile b/tests/server/Dockerfile @@ -0,0 +1,17 @@ +# An SSH server with authentication disabled for the user "test" + +FROM alpine + +RUN apk update && apk add openssh + +RUN ssh-keygen -A + +RUN echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config +RUN echo 'PermitEmptyPasswords yes' >> /etc/ssh/sshd_config +RUN echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config + +COPY entry.sh /entry.sh + +EXPOSE 22 + +CMD ["/entry.sh"] diff --git a/tests/server/compose.yml b/tests/server/compose.yml @@ -0,0 +1,13 @@ +services: + coliru-ssh: + environment: + - PUID # loaded from .env, =1000 by default + build: + context: . + ports: + - 2222:22 + volumes: + - type: bind + source: ../.temp/ssh + target: /home/test + restart: unless-stopped diff --git a/tests/server/entry.sh b/tests/server/entry.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh + +PUID=${PUID:-1000} +echo "Creating user test with PUID=$PUID ..." +adduser -h /home/test -s /bin/sh -D -u "$PUID" test +passwd -d test + +echo "Starting sshd ..." +/usr/sbin/sshd -D diff --git a/tests/ssh.rs b/tests/ssh.rs @@ -0,0 +1,204 @@ +/// End to end tests that test specific installation behavior on a remote +/// machine via SSH + +mod common; + +use common::*; +use std::fs::remove_file; + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_standard() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_standard"); + cmd.args(["manifest.yml", "-t", "linux"]); + + let expected = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_standard/.gitconfig +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_standard/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_standard/.vimrc +[2/3] Run sh test_ssh_standard/script.sh arg1 linux on {SSH_HOST} +script.sh called with arg1 linux +"); + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_contents = read_file(&dirs.ssh.join(".gitconfig")); + let vim1_contents = read_file(&dirs.ssh.join(".vimrc")); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + let log_contents = read_file(&dirs.ssh_cwd.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_run_alternate_tag_rules_1() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_run_alternate_tag_rules_1"); + cmd.args(["manifest.yml", "-t", "linux", "^windows"]); + + let expected = format!("\ +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_run_alternate_tag_rules_1/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_run_alternate_tag_rules_1/.vimrc +[2/3] Run sh test_ssh_run_alternate_tag_rules_1/script.sh arg1 linux ^windows on {SSH_HOST} +script.sh called with arg1 linux ^windows +"); + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_exists = dirs.ssh.join(".gitconfig").exists(); + let vim1_contents = read_file(&dirs.ssh.join(".vimrc")); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + let log_contents = read_file(&dirs.ssh_cwd.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_exists, false); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux ^windows\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_run_alternate_tag_rules_2() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_run_alternate_tag_rules_2"); + cmd.args(["manifest.yml", "-t", "macos"]); + + let expected = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_run_alternate_tag_rules_2/.gitconfig +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_run_alternate_tag_rules_2/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_run_alternate_tag_rules_2/.vimrc +[2/3] Run sh test_ssh_run_alternate_tag_rules_2/script.sh arg1 macos on {SSH_HOST} +script.sh called with arg1 macos +"); + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_contents = read_file(&dirs.ssh.join(".gitconfig")); + let vim1_contents = read_file(&dirs.ssh.join(".vimrc")); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + let log_contents = read_file(&dirs.ssh_cwd.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 macos\n"); +} + +#[test] +fn test_ssh_dry_run() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_dry_run"); + cmd.args(["manifest.yml", "--dry-run", "-t", "linux"]); + + let expected = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_dry_run/.gitconfig (DRY RUN) +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_dry_run/.bashrc (DRY RUN) +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_dry_run/.vimrc (DRY RUN) +[2/3] Run sh test_ssh_dry_run/script.sh arg1 linux on {SSH_HOST} (DRY RUN) +"); + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/run + let bash_exists = dirs.ssh.join(".bashrc").exists(); + let git_exists = dirs.ssh.join(".gitconfig").exists(); + let vim1_exists = dirs.ssh.join(".vimrc").exists(); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + let log_exists = dirs.ssh_cwd.join("log.txt").exists(); + assert_eq!(bash_exists, false); + assert_eq!(git_exists, false); + assert_eq!(vim1_exists, false); + assert_eq!(vim2_exists, false); + assert_eq!(log_exists, false); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_copy() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_copy"); + cmd.args(["manifest.yml", "--copy", "-t", "linux"]); + + let expected = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_copy/.gitconfig +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_copy/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_copy/.vimrc +[2/3] Run sh test_ssh_copy/script.sh arg1 linux on {SSH_HOST} +script.sh called with arg1 linux +"); + assert_eq!(&stderr_to_string(&mut cmd), ""); + assert_eq!(stdout_to_string(&mut cmd), expected); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_contents = read_file(&dirs.ssh.join(".gitconfig")); + let vim1_contents = read_file(&dirs.ssh.join(".vimrc")); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + let log_contents = read_file(&dirs.ssh_cwd.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_run_failure() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_run_failure"); + cmd.args(["manifest.yml", "-t", "linux"]); + write_file(&dirs.local.join("test_ssh_run_failure/script.sh"), "exit 1"); + + let expected_stdout = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_run_failure/.gitconfig +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_run_failure/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_run_failure/.vimrc +[2/3] Run sh test_ssh_run_failure/script.sh arg1 linux on {SSH_HOST} +"); + let expected_stderr = " Error: SSH exited with exit status: 1\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_contents = read_file(&dirs.ssh.join(".gitconfig")); + let vim1_contents = read_file(&dirs.ssh.join(".vimrc")); + let vim2_exists = dirs.ssh.join("_vimrc").exists(); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(vim1_contents, "vim #1\n"); + assert_eq!(vim2_exists, false); +} + +#[test] +#[cfg(target_family = "unix")] +fn test_ssh_missing_file() { + let (dirs, mut cmd) = setup_e2e_ssh("test_ssh_missing_file"); + cmd.args(["manifest.yml", "-t", "linux"]); + remove_file(&dirs.local.join("vimrc")).unwrap(); + + let expected_stdout = format!("\ +[1/3] Copy gitconfig to {SSH_HOST}:~/test_ssh_missing_file/.gitconfig +[2/3] Copy bashrc to {SSH_HOST}:~/test_ssh_missing_file/.bashrc +[2/3] Copy vimrc to {SSH_HOST}:~/test_ssh_missing_file/.vimrc +[2/3] Run sh test_ssh_missing_file/script.sh arg1 linux on {SSH_HOST} +script.sh called with arg1 linux +"); + let expected_stderr = " Error: No such file or directory (os error 2)\n"; + assert_eq!(&stderr_to_string(&mut cmd), expected_stderr); + assert_eq!(stdout_to_string(&mut cmd), expected_stdout); + + // Assert files are correctly copied/run + let bash_contents = read_file(&dirs.ssh.join(".bashrc")); + let git_contents = read_file(&dirs.ssh.join(".gitconfig")); + let log_contents = read_file(&dirs.ssh_cwd.join("log.txt")); + assert_eq!(bash_contents, "bash #1\n"); + assert_eq!(git_contents, "git #1\n"); + assert_eq!(log_contents, "script.sh called with arg1 linux\n"); +}