dots-bin 0.2.1

A cozy, simple-to-use dotfiles manager
Documentation
#![cfg(test)]
//! tests

use etcetera::BaseStrategy as _;
use pretty_assertions::assert_eq;

use std::{collections::HashSet, convert, env, fs, path::Path};

use dots::{Link, World, WritePath};
use tap::Pipe as _;
use tempfile::tempdir;

/// Need to do this so `dots` discovers the config root,
/// however we don't put anything in it
///
/// Instead, we will mutate the `World.links` fields directly
/// in order to not make network requests from tests
fn create_dummy_config_file(dir: &Path) {
    fs::write(dir.join("dots.toml"), "").unwrap();
}

/// Check that the list of (`file_path`, `file_contents`) exists in the directory `cwd`
#[track_caller]
fn check(
    cwd: impl AsRef<Path>,
    transform_world: impl FnOnce(World) -> World,
    paths: impl IntoIterator<Item = (impl AsRef<Path>, impl AsRef<str>)>,
) {
    let world = World::new(cwd.as_ref()).unwrap().pipe(transform_world);
    // NOTE: we use a HashSet internally for the test because we don't care about the order in which paths get written
    let writes = world
        .process()
        .unwrap()
        .writes
        .into_iter()
        .pipe(HashSet::from_iter);

    assert_eq!(
        writes,
        paths
            .into_iter()
            .map(|(path, contents)| WritePath {
                path: path.as_ref().to_path_buf(),
                contents: contents.as_ref().to_string()
            })
            .collect::<HashSet<_>>()
    );
}

fn create_files_in(
    dir: &Path,
    files: impl IntoIterator<Item = (impl AsRef<Path>, impl AsRef<str>)>,
) {
    for (path, contents) in files {
        let path = dir.join(path.as_ref());
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, contents.as_ref()).unwrap();
    }
}

#[bon::builder]
fn link(contents: &str, path: &str, sha256: Option<&str>, marker: Option<&str>) -> Link {
    Link {
        url: "dummy".into(),
        contents: contents.to_string(),
        path: path.into(),
        sha256: sha256.as_ref().map(ToString::to_string),
        marker: marker.as_ref().map(ToString::to_string),
    }
}

#[test]
fn dirs() {
    let dir = tempdir().unwrap();
    let dir = dir.path();

    let strat = etcetera::choose_base_strategy().unwrap();

    create_files_in(
        dir,
        [
            (
                "dots.toml",
                r#"
                [[dir]]
                input = "configs"
                output = "{config_dir}"
                [[dir]]
                input = "data"
                output = "{data_dir}/foo/"
                [[dir]]
                input = "cache"
                output = "{cache_dir}/bar/"
                [[dir]]
                input = "cache"
                output = "~/baz/"
                "#,
            ),
            ("configs/foo.txt", "foo"),
            ("configs/bar.txt", "bar"),
            ("data/foo.txt", "foo"),
            ("data/bar.txt", "bar"),
            ("cache/foo.txt", "foo"),
            ("cache/bar.txt", "bar"),
        ],
    );

    check(
        dir,
        convert::identity,
        [
            (strat.config_dir().join("foo.txt"), "foo"),
            (strat.config_dir().join("bar.txt"), "bar"),
            (strat.data_dir().join("foo").join("foo.txt"), "foo"),
            (strat.data_dir().join("foo").join("bar.txt"), "bar"),
            (strat.cache_dir().join("bar").join("foo.txt"), "foo"),
            (strat.cache_dir().join("bar").join("bar.txt"), "bar"),
            (strat.home_dir().join("baz").join("foo.txt"), "foo"),
            (strat.home_dir().join("baz").join("bar.txt"), "bar"),
        ],
    );
}

#[test]
fn links() {
    let dir = tempdir().unwrap();
    let dir = dir.path();

    create_dummy_config_file(dir);

    check(
        dir,
        |mut world| {
            world.links = vec![
                // A
                link().contents("foo").path("configs/foo.txt").call(),
                // B
                link()
                    .contents("bar")
                    .path("bar.txt")
                    .marker("foo bar baz")
                    .call(),
            ];
            world
        },
        [
            // A
            (
                dir.join("configs").join("foo.txt"),
                concat!(
                    "# @generated by `@dots ` <https://github.com/nik-rev/dots>\n# ",
                    "Do not edit by hand.\n# \n# downloaded from: dummy\nfoo"
                )
                .to_string(),
            ),
            // B
            (
                dir.join("bar.txt"),
                concat!(
                    "# @dots foo bar baz\n# @generated by `@dots `",
                    " <https://github.com/nik-rev/dots>\n# ",
                    "Do not edit by hand.\n# \n# downloaded from: dummy\nbar"
                )
                .to_string(),
            ),
        ],
    );
}

#[test]
fn marker_does_not_affect_sha256() {
    const SHA256: &str = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae";

    let dir = tempdir().unwrap();
    let dir = dir.path();

    create_dummy_config_file(dir);

    check(
        dir,
        |mut world| {
            world.links = vec![
                // With marker
                link()
                    .contents("foo")
                    .path("configs/foo-1.txt")
                    .sha256(SHA256)
                    .call(),
                // No marker
                link()
                    .contents("foo")
                    .path("foo-2.txt")
                    .sha256(SHA256)
                    .marker("marker")
                    .call(),
            ];
            world
        },
        [
            // A
            (
                dir.join("configs").join("foo-1.txt"),
                concat!(
                    "# @generated by `@dots ` <https://github.com/nik-rev/dots>\n",
                    "# Do not edit by hand.\n",
                    "# \n",
                    "# downloaded from: dummy\n",
                    // actual file contents
                    "foo"
                )
                .to_string(),
            ),
            // B
            (
                dir.join("foo-2.txt"),
                concat!(
                    "# @dots marker\n",
                    "# @generated by `@dots ` <https://github.com/nik-rev/dots>\n",
                    "# Do not edit by hand.\n",
                    "# \n",
                    "# downloaded from: dummy\n",
                    // actual file contents
                    "foo"
                )
                .to_string(),
            ),
        ],
    );
}

#[test]
fn hash_mismatch_is_err() {
    /// Should be any string which is unlikely to exist as an actual file
    const UNIQUE_FILENAME: &str =
        "2c26b46b68ffc68ff99b45ec1d304134e3422d70e483bfa0f98a5e886266e7ae";
    let dir = tempdir().unwrap();
    let dir = dir.path();

    let strat = etcetera::choose_base_strategy().unwrap();

    fs::write(
        dir.join("dots.toml"),
        r#"
        [[dir]]
        input = "configs"
        output = "{config_dir}"
        "#,
    )
    .unwrap();

    fs::create_dir_all(dir.join("configs")).unwrap();
    fs::write(dir.join("configs").join(UNIQUE_FILENAME), "foo").unwrap();

    let mut world = World::new(dir).unwrap();
    world.links = vec![
        link()
            .contents("foo")
            .path("configs/foo-1.txt")
            .sha256("incorrect-hash")
            .call(),
    ];

    assert!(
        world
            .process()
            .unwrap_err()
            .first()
            .unwrap()
            .to_string()
            .contains("hash mismatch")
    );

    // this should NOT have been written even though it is specified in the config
    // Reason: `dots` is atomic. If one operation failed, all should fail
    fs::read_to_string(strat.config_dir().join("configs").join(UNIQUE_FILENAME)).unwrap_err();
}

#[test]
fn env_variable_interpolation() {
    let dir = tempdir().unwrap();
    let dir = dir.path();

    let strat = etcetera::choose_base_strategy().unwrap();

    // SAFETY: variable is only accessed from a single thread
    unsafe {
        env::set_var("VARIABLE_FOO", "<foo>");
    }
    // SAFETY: variable is only accessed from a single thread
    unsafe {
        env::set_var("VARIABLE_BAR", "<bar>");
    }

    create_files_in(
        dir,
        [
            (
                "dots.toml",
                r#"
                [[dir]]
                input = "configs"
                output = "{config_dir}/{$VARIABLE_FOO}/{$VARIABLE_BAR}"
                "#,
            ),
            ("configs/foo.txt", "foo"),
        ],
    );

    check(
        dir,
        convert::identity,
        [(
            strat
                .config_dir()
                .join("<foo>")
                .join("<bar>")
                .join("foo.txt"),
            "foo",
        )],
    );
}