commit 79ea5ca604b1d361d8ec3c47881b10fe08863d3b
parent 6bf55414c191dbdd1655c817701a0b9d02512325
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date: Thu, 4 Jul 2024 20:55:20 -0700
Improve documentation
Diffstat:
12 files changed, 345 insertions(+), 205 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -1,8 +1,10 @@
+//! The coliru command line interface
+
use clap::{Parser, ColorChoice};
use std::path::Path;
use super::core::execute_manifest_file;
-/// Stores arguments to the coliru CLI
+/// Arguments to the coliru CLI
#[derive(Parser, Debug)]
#[command(version, color=ColorChoice::Never,
about="A minimal, flexible, dotfile installer")]
diff --git a/src/core.rs b/src/core.rs
@@ -1,3 +1,5 @@
+//! Manifest execution functions
+
use std::env::set_current_dir;
use std::path::Path;
use super::manifest::{CopyLinkOptions, RunOptions, parse_manifest_file};
diff --git a/src/local.rs b/src/local.rs
@@ -1,3 +1,11 @@
+//! Local dotfile installation utilities
+//!
+//! ```
+//! copy_file("foo", "~/foo");
+//! link_file("bar", "~/bar");
+//! run_command("echo 'Hello world'");
+//! ```
+
use shellexpand::tilde;
use std::io;
use std::fs;
@@ -6,10 +14,14 @@ 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.
+/// Copies the contents of a file to another file
///
/// Tildes are expanded if present and the destination file is overwritten if
/// necessary.
+///
+/// ```
+/// copy_file("foo", "~/foo");
+/// ```
pub fn copy_file(src: &str, dst: &str) -> io::Result<()> {
if absolute(src)? == absolute(dst)? { return Ok(()); }
let _dst = prepare_path(dst)?;
@@ -17,10 +29,14 @@ pub fn copy_file(src: &str, dst: &str) -> io::Result<()> {
Ok(())
}
-/// Creates a symbolic link to a local file.
+/// Creates a symbolic link to a 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.
+///
+/// ```
+/// link_file("bar", "~/bar");
+/// ```
#[cfg(target_family = "unix")]
pub fn link_file(src: &str, dst: &str) -> io::Result<()> {
if absolute(src)? == absolute(dst)? { return Ok(()); }
@@ -36,8 +52,12 @@ pub fn link_file(src: &str, dst: &str) -> io::Result<()> {
Ok(())
}
-/// Creates the parent directories of a path and return the path with tildes
-/// expanded.
+/// Creates the parent directories of a path, deletes the file if it exists, and
+/// returns the path with tildes expanded
+///
+/// ```
+/// prepare_path("~/foo");
+/// ```
fn prepare_path(path: &str) -> io::Result<PathBuf> {
let _dst: PathBuf = (&tilde(path).to_mut()).into();
if let Some(_path) = _dst.parent() {
@@ -50,7 +70,11 @@ fn prepare_path(path: &str) -> io::Result<PathBuf> {
Ok(_dst)
}
-/// Executes a local command using sh on Unix and cmd on Windows
+/// Executes a command using `sh` on Unix and `cmd` on Windows
+///
+/// ```
+/// run_command("echo 'Hello world'");
+/// ```
pub fn run_command(command: &str) -> Result<(), String>
{
let status;
@@ -75,7 +99,7 @@ pub fn run_command(command: &str) -> Result<(), String>
#[cfg(test)]
mod tests {
use super::*;
- use crate::common::{setup_integration, write_file};
+ use crate::test_utils::{setup_integration, write_file};
#[test]
fn test_copy_file_create_dirs() {
diff --git a/src/main.rs b/src/main.rs
@@ -1,3 +1,5 @@
+//! A minimal, flexible, dotfile installer
+
mod cli;
mod core;
mod local;
@@ -6,8 +8,8 @@ mod ssh;
mod tags;
#[cfg(test)]
-#[path = "../tests/common/mod.rs"]
-mod common; // Re-use e2e test utils for integration tests
+#[path = "../tests/test_utils/mod.rs"]
+mod test_utils; // Re-use E2E test utils for integration tests
fn main() {
cli::run();
diff --git a/src/manifest.rs b/src/manifest.rs
@@ -1,52 +1,78 @@
+//! Coliru manifest parsing
+
use serde::Deserialize;
use serde_yaml;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
+/// The options for a copy or link command
#[derive(Debug, PartialEq, Deserialize)]
pub struct CopyLinkOptions {
+ /// The source file (relative to the parent manifest file)
pub src: String,
+
+ /// The destination path (relative to the parent manifest file)
pub dst: String,
}
+/// The options for a run command
#[derive(Debug, PartialEq, Deserialize)]
pub struct RunOptions {
+ /// The location of the script (relative to the parent manifest file)
pub src: String,
+ /// The optional shell command prefix
#[serde(default)]
pub prefix: String,
+ /// The optional shell command postfix
#[serde(default)]
pub postfix: String,
}
+/// A manifest step
#[derive(Debug, PartialEq, Deserialize)]
pub struct Step {
+ /// The step's copy commands
#[serde(default)]
pub copy: Vec<CopyLinkOptions>,
+ /// The step's link commands
#[serde(default)]
pub link: Vec<CopyLinkOptions>,
+ /// The step's run commands
#[serde(default)]
pub run: Vec<RunOptions>,
+ /// The step's tags
#[serde(default)]
pub tags: Vec<String>,
}
+/// A coliru manifest as it appears in a file, without the base_dir property
#[derive(Debug, PartialEq, Deserialize)]
struct RawManifest {
+
+ /// The manifest steps
steps: Vec<Step>,
}
+/// A parsed coliru manifest
#[derive(Debug, PartialEq)]
pub struct Manifest {
+ /// The manifest steps
pub steps: Vec<Step>,
+
+ /// The parent directory of the manifest file
pub base_dir: PathBuf,
}
/// Parse a coliru YAML manifest file
+///
+/// ```
+/// let manifest = parse_manifest_file(Path::new("manifest.yml"))?;
+/// ```
pub fn parse_manifest_file(path: &Path) -> Result<Manifest, String> {
let raw_str = read_to_string(path).map_err(|why| why.to_string())?;
let raw_manifest = serde_yaml::from_str::<RawManifest>(&raw_str)
diff --git a/src/ssh.rs b/src/ssh.rs
@@ -1,16 +1,48 @@
+//! Remote dotfile installation utilities
+//!
+//! To send files to a remote machine via SCP, first stage them using
+//! [`stage_file`], then transfer them using [`send_staged_files`].
+//!
+//! ```
+//! let staging_dir = Path::new("/tmp/staging");
+//! let host = "user@hostname";
+//! stage_file("foo.sh", "~/foo.sh", staging_dir);
+//! send_staged_files(staging_dir, host);
+//! send_command("bash ~/foo.sh", host);
+//! ```
+
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
+/// Copies a file to an SCP staging directory
+///
+/// Tildes will be expanded to the remote user's home directory. Relative paths
+/// are interpreted relative to `~/.coliru`.
///
-/// 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.
+/// ```
+/// // Prepare to transfer foo to ~/foo, bar to /bar, and baz to ~/.coliru/baz
+/// let staging_dir = Path::new("/tmp/staging");
+/// stage_file("foo", "~/foo", staging_dir);
+/// stage_file("bar", "/bar", staging_dir);
+/// stage_file("baz", "baz", staging_dir);
+/// ```
+
pub fn stage_file(src: &str, dst: &str, staging_dir: &Path) -> Result<(), String> {
+ // Staging directories are used to copy multiple files at once while
+ // automatically creating missing directories on the remote machine. The
+ // example code above produces the following staging directory layout:
+ //
+ // /tmp/staging/
+ // ├── home/
+ // │ ├── .coliru
+ // │ │ └── baz
+ // │ └── foo
+ // └── root/
+ // └── bar
+
let home_dir = staging_dir.join("home");
let root_dir = staging_dir.join("root");
let get_home_dir = || {
@@ -41,7 +73,15 @@ pub fn stage_file(src: &str, dst: &str, staging_dir: &Path) -> Result<(), String
.map_err(|why| why.to_string())
}
-/// Transfer the files in an SCP staging directory to a remote machine
+/// Transfers the files in an SCP staging directory to a remote machine
+///
+/// `host` may be an SSH alias or a string in the form `user@hostname`. Use
+/// [`stage_file`] to produce a staging directory. The contents of the staging
+/// directory are deleted after they are successfully transferred.
+///
+/// ```
+/// send_staged_files(Path::new("/tmp/staging"), "user@hostname");
+/// ```
pub fn send_staged_files(staging_dir: &Path, host: &str) -> Result<(), String> {
let home_dir = staging_dir.join("home");
if home_dir.exists() {
@@ -56,8 +96,14 @@ pub fn send_staged_files(staging_dir: &Path, host: &str) -> Result<(), String> {
Ok(())
}
-/// Copy a directory to another machine via SCP and merge it with a destination
-/// directory
+/// Copies a directory to another machine via SCP and merges it with a
+/// destination directory
+///
+/// `host` may be an SSH alias or a string in the form `user@hostname`.
+///
+/// ```
+/// send_dir("new_home", "~/", "user@hostname");
+/// ```
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
@@ -80,8 +126,13 @@ fn send_dir(src: &str, dst: &str, host: &str) -> Result<(), String> {
Ok(())
}
-/// Execute a command on another machine via SSH
-#[allow(dead_code)]
+/// Executes a command on another machine via SSH
+///
+/// `host` may be an SSH alias or a string in the form `user@hostname`.
+///
+/// ```
+/// send_command("echo 'Hello World'");
+/// ```
pub fn send_command(command: &str, host: &str) -> Result<(), String> {
let mut cmd = Command::new("ssh");
if host == "test@localhost" {
@@ -102,7 +153,7 @@ mod tests {
#![allow(unused_imports)]
use super::*;
- use crate::common::{SSH_HOST, read_file, setup_integration, write_file};
+ use crate::test_utils::{SSH_HOST, read_file, setup_integration, write_file};
use std::fs;
diff --git a/src/tags.rs b/src/tags.rs
@@ -1,12 +1,7 @@
+//! Tag rule matching functionality
+
/// Checks if a list of tags matches a list of tag rules
///
-/// Rules and tags are both specified as strings. Each rule contains one or more
-/// desired tags separated by `,`. A list of tags matches a list of rules if
-/// each rule contains at least one tag that appears in the list of tags. The
-/// results of rules prefixed with `^` will be negated. Any list of tags will
-/// match an empty list of tag rules. An empty list of tags will only match an
-/// empty list of tag rules.
-///
/// ```
/// let rules = ["linux,macos", "system", "^work"];
/// let tags_1 = ["macos", "system", "user"];
diff --git a/tests/basic.rs b/tests/basic.rs
@@ -1,9 +1,8 @@
-/// End to end tests that do not test general CLI behavior rather than specific
-/// installation behavior
+//! End to end tests that test general, non-installation, CLI behavior
-mod common;
+mod test_utils;
-use common::*;
+use test_utils::*;
use std::env::consts::EXE_SUFFIX;
#[test]
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
@@ -1,166 +0,0 @@
-#![allow(dead_code)]
-
-use std::env;
-use std::fs;
-use std::io::Write;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-/// 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 TempDirs {
- fn drop(&mut self) {
- 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 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 }
- }
-}
-
-/// Initializes temporary directories for integration tests
-///
-/// 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", dirs.home.parent().unwrap());
- }
- dirs
-}
-
-/// Initializes temporary directories and a coliru Command for e2e tests
-///
-/// 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(&dirs.local);
- if cfg!(target_family = "unix") {
- cmd.env("HOME", &dirs.home);
- }
-
- (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.
-pub fn write_file(path: &Path, contents: &str) {
- let mut file = fs::File::create(path).unwrap();
- file.write_all(contents.as_bytes()).unwrap();
-}
-
-/// Reads the contents of a file into a string.
-pub fn read_file(path: &Path) -> String {
- fs::read_to_string(path).unwrap()
-}
-
-/// Returns the stdout of a command as a String.
-pub fn stdout_to_string(cmd: &mut Command) -> String {
- String::from_utf8_lossy(&cmd.output().unwrap().stdout).into_owned()
-}
-
-/// Returns the stderr of a command as a String.
-pub fn stderr_to_string(cmd: &mut Command) -> String {
- String::from_utf8_lossy(&cmd.output().unwrap().stderr).into_owned()
-}
diff --git a/tests/local.rs b/tests/local.rs
@@ -1,9 +1,8 @@
-/// End to end tests that test specific installation behavior on the local file
-/// system
+//! End to end tests that test installation behavior on the local file system
-mod common;
+mod test_utils;
-use common::*;
+use test_utils::*;
use std::fs::remove_file;
#[test]
diff --git a/tests/ssh.rs b/tests/ssh.rs
@@ -1,9 +1,8 @@
-/// End to end tests that test specific installation behavior on a remote
-/// machine via SSH
+//! End to end tests that test installation behavior on a remote machine via SSH
-mod common;
+mod test_utils;
-use common::*;
+use test_utils::*;
use std::fs::remove_file;
#[test]
diff --git a/tests/test_utils/mod.rs b/tests/test_utils/mod.rs
@@ -0,0 +1,207 @@
+//! Coliru testing utilities
+//!
+//! These utilities create and manage resources used in integration and
+//! end-to-end tests, including temporary directories, processes running coliru
+//! commands, and test dotfile repositories. There are also functions for basic
+//! I/O and capturing command output. All temporary directories are located
+//! under the `.temp` directory and are unique to each test according to name.
+
+#![allow(dead_code)]
+
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// 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 `~/` on Unix
+ pub home: PathBuf,
+
+ /// A temporary directory that is located at or under the current working
+ /// directory
+ pub local: PathBuf,
+
+ /// A temporary directory that is mounted to the SSH server under `~/`
+ pub ssh: PathBuf,
+
+ /// A temporary directory that is mounted to the SSH server under
+ /// `~/.coliru`
+ pub ssh_cwd: PathBuf,
+}
+impl Drop for TempDirs {
+ fn drop(&mut self) {
+ 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 TempDirs {
+ /// Creates a new set of temporary directories with a certain name
+ ///
+ /// ```
+ /// let dirs = TempDirs::new("test_foo");
+ /// ```
+ fn new(name: &str) -> TempDirs {
+ // The working directory of the main 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 }
+ }
+}
+
+/// Initializes temporary directories for integration tests
+///
+/// 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.
+///
+/// ```
+/// let dirs = setup_integration("test_foo");
+/// ```
+pub fn setup_integration(name: &str) -> TempDirs {
+ let dirs = TempDirs::new(name);
+ if cfg!(target_family = "unix") {
+ env::set_var("HOME", dirs.home.parent().unwrap());
+ }
+ dirs
+}
+
+/// Initializes temporary directories and a coliru Command for E2E tests
+///
+/// The Command's working directory is set to the local temporary directory, and
+/// on Unix, the Command's `$HOME` variable is set to the home temporary
+/// directory.
+///
+/// ```
+/// let (dirs, cmd) = setup_e2e("test_foo");
+/// ```
+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(&dirs.local);
+ if cfg!(target_family = "unix") {
+ cmd.env("HOME", &dirs.home);
+ }
+
+ (dirs, cmd)
+}
+
+/// Initializes temporary directories and a coliru Command for local E2E tests
+///
+/// A test dotfile repository is copied to the working directory (mapped to the
+/// `local` temporary directory), to be installed to the home directory on Unix
+/// (mapped to the `home` temporary directory) and the current working directory
+/// on Windows (mapped to the `local` temporary directory).
+///
+/// ```
+/// let (dirs, cmd) = setup_e2e_local("test_foo");
+/// ```
+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 repository is copied to the working directory (mapped to the
+/// `local` temporary directory), to be installed over SSH to `~/test_name/`
+/// (mapped to the `ssh` temporary directory), with scripts copied to
+/// `~/.coliru/test_name/` (mapped to the `ssh_cwd` temporary directory). The
+/// `--host` flag is set to the test SSH server.
+///
+/// ```
+/// let (dirs, cmd) = setup_e2e_ssh("test_foo");
+/// ```
+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)
+}
+
+/// Initializes a basic dotfiles repository in a directory
+///
+/// The dotfiles from `examples/test/` are used as a starting template. All
+/// occurrences of `~/` and `scripts/` are replaced with `home_dir` and
+/// `script_dir`, respectively, to allow dotfiles to be isolated across tests
+/// when necessary.
+///
+/// ```
+/// copy_manifest(&Path::new("/tmp/dotfiles/"), "~/", "scripts/");
+/// ```
+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
+pub fn write_file(path: &Path, contents: &str) {
+ let mut file = fs::File::create(path).unwrap();
+ file.write_all(contents.as_bytes()).unwrap();
+}
+
+/// Reads the contents of a file into a string
+pub fn read_file(path: &Path) -> String {
+ fs::read_to_string(path).unwrap()
+}
+
+/// Returns the stdout of a command as a String
+pub fn stdout_to_string(cmd: &mut Command) -> String {
+ String::from_utf8_lossy(&cmd.output().unwrap().stdout).into_owned()
+}
+
+/// Returns the stderr of a command as a String
+pub fn stderr_to_string(cmd: &mut Command) -> String {
+ String::from_utf8_lossy(&cmd.output().unwrap().stderr).into_owned()
+}