cockup 0.2.0

Yet another backup tool for various configurations
use super::*;

#[test]
fn included_rules_are_wrapped_under_the_root_destination() {
    let dir = TempDir::new().unwrap();
    let p = dir.path();
    write(p, "source/file", "contents");
    write(
        p,
        "nested/child.yaml",
        "symlinks: referece\ndestination: ignored\nrules:\n  - from: ../source\n    targets: [file]\n    to: config\n",
    );
    write(
        p,
        "config.yaml",
        "symlinks: referece\ndestination: backup\ninclude:\n  - file: nested/child.yaml\n    wrap: imported\nrules: []\n",
    );
    let out = cli(p, &["backup", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    assert_eq!(
        fs::read_to_string(p.join("backup/imported/config/file")).unwrap(),
        "contents"
    );
    fs::remove_file(p.join("source/file")).unwrap();
    let out = cli(p, &["restore", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    assert_eq!(
        fs::read_to_string(p.join("source/file")).unwrap(),
        "contents"
    );
}

#[test]
fn nested_include_and_rule_settings_override_inherited_defaults() {
    use std::os::unix::fs::symlink;
    let dir = TempDir::new().unwrap();
    let p = dir.path();
    write(p, "source/original", "contents");
    for name in ["inherited", "nested", "local"] {
        symlink("original", p.join("source").join(name)).unwrap();
    }
    write(
        p,
        "nested/grand.yaml",
        "symlinks: prompt\ndestination: ignored\nrules:\n  - from: ../source\n    targets: [nested]\n    to: files\n    symlinks: referece\n  - from: ../source\n    targets: [local]\n    to: files\n    symlinks: dereference\n",
    );
    write(
        p,
        "nested/child.yaml",
        "symlinks: prompt\ndestination: ignored\ninclude:\n  - file: grand.yaml\n    wrap: grand\n    symlinks: referece\nrules:\n  - from: ../source\n    targets: [inherited]\n    to: files\n",
    );
    write(
        p,
        "config.yaml",
        "symlinks: referece\ndestination: backup\ninclude:\n  - file: nested/child.yaml\n    wrap: outer\n    symlinks: dereference\nrules: []\n",
    );
    let out = cli(p, &["backup", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    for (name, path, expected_link) in [
        ("inherited", "backup/outer/files/inherited", false),
        ("nested", "backup/outer/grand/files/nested", true),
        ("local", "backup/outer/grand/files/local", false),
    ] {
        assert_eq!(
            fs::symlink_metadata(p.join(path)).unwrap().is_symlink(),
            expected_link,
            "{name}"
        );
    }
    for name in ["inherited", "nested", "local"] {
        fs::remove_file(p.join("source").join(name)).unwrap();
    }
    let out = cli(p, &["restore", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    for name in ["inherited", "nested", "local"] {
        assert_eq!(
            fs::read_link(p.join("source").join(name)).unwrap(),
            Path::new("original")
        );
    }
}

#[test]
fn include_and_rule_metadata_overrides_apply_on_backup_and_restore() {
    use filetime::{FileTime, set_file_times};
    let dir = TempDir::new().unwrap();
    let p = dir.path();
    for name in ["root", "included", "rule"] {
        write(p, &format!("source/{name}"), name);
        let old = FileTime::from_unix_time(946684800, 0);
        set_file_times(p.join("source").join(name), old, old).unwrap();
    }
    write(
        p,
        "child.yaml",
        "symlinks: referece\ndestination: ignored\nrules:\n  - from: source\n    targets: [included]\n    to: files\n  - from: source\n    targets: [rule]\n    to: files\n    metadata: true\n",
    );
    write(
        p,
        "config.yaml",
        "symlinks: referece\nmetadata: true\ndestination: backup\ninclude:\n  - file: child.yaml\n    metadata: false\nrules:\n  - from: source\n    targets: [root]\n    to: files\n",
    );
    let out = cli(p, &["backup", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    for (name, preserved) in [("root", true), ("included", false), ("rule", true)] {
        let path = p.join("backup/files").join(name);
        let timestamp = fs::metadata(path).unwrap().modified().unwrap();
        let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(946684800);
        assert_eq!(timestamp == old, preserved, "{name}");
        fs::remove_file(p.join("source").join(name)).unwrap();
    }
    let out = cli(p, &["restore", "config.yaml", "-q"], "");
    assert!(out.status.success(), "{}", text(&out));
    for (name, preserved) in [("root", true), ("included", false), ("rule", true)] {
        let timestamp = fs::metadata(p.join("source").join(name))
            .unwrap()
            .modified()
            .unwrap();
        let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(946684800);
        assert_eq!(timestamp == old, preserved, "{name}");
    }
}

#[test]
fn invalid_include_shapes_and_wraps_fail_before_cleaning() {
    let dir = TempDir::new().unwrap();
    let p = dir.path();
    write(p, "backup/keep", "unchanged");
    for include in [
        "[child.yaml]",
        "[{file: child.yaml, wrap: ../outside}]",
        "[{file: child.yaml, wrap: /outside}]",
        "[{file: child.yaml, wrap: ''}]",
        "[{file: child.yaml, warp: typo}]",
    ] {
        write(
            p,
            "config.yaml",
            &format!(
                "symlinks: referece\ndestination: backup\nclean: true\ninclude: {include}\nrules: []\n"
            ),
        );
        let out = cli(p, &["backup", "config.yaml", "-q"], "");
        assert_eq!(out.status.code(), Some(1), "{}", text(&out));
        assert_eq!(
            fs::read_to_string(p.join("backup/keep")).unwrap(),
            "unchanged"
        );
    }
}

#[test]
fn invalid_configuration_fails_before_side_effects() {
    let dir = TempDir::new().unwrap();
    for yaml in [
        "rules: []",
        "symlinks: referece\ndestination: backup\nrules: [bad]",
        "symlinks: referece\ndestination: backup\nrules: []\ninclude: [{file: missing.yaml}]",
        "symlinks: referece\ndestination: backup\nrules: []\ninclude: [{file: config.yaml}]",
    ] {
        write(dir.path(), "config.yaml", yaml);
        let out = cli(dir.path(), &["backup", "config.yaml", "-q"], "");
        assert_eq!(out.status.code(), Some(1), "{}", text(&out));
        assert!(!dir.path().join("backup").exists());
        assert!(!out.stderr.is_empty());
    }
}

#[test]
fn symlink_policy_is_required_and_validated_before_side_effects() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    for policy in ["", "symlinks: unknown\n", "symlinks: null\n"] {
        write(p, "backup/keep", "old backup");
        write(
            p,
            "config.yaml",
            &format!("{policy}destination: backup\nclean: true\nrules: []\n"),
        );
        let out = cli(p, &["backup", "config.yaml", "-q"], "");
        assert_eq!(out.status.code(), Some(1), "{}", text(&out));
        assert!(text(&out).contains("symlinks") || text(&out).contains("unknown"));
        assert_eq!(
            fs::read_to_string(p.join("backup/keep")).unwrap(),
            "old backup"
        );
    }
}

#[test]
fn included_hooks_prompt_once_and_quiet_suppresses_confirmation() {
    let dir = TempDir::new().unwrap();
    write(
        dir.path(),
        "nested/child.yaml",
        "symlinks: referece\ndestination: ignored\nrules: []\nhooks:\n  pre-backup:\n    - name: child\n      command: [echo, hello]\n",
    );
    write(
        dir.path(),
        "config.yaml",
        "symlinks: referece\ndestination: backup\nrules: []\ninclude: [{file: nested/child.yaml}]\n",
    );
    let denied = cli(dir.path(), &["backup", "config.yaml"], "n\n");
    assert!(denied.status.success());
    assert_eq!(text(&denied).matches("Continue?").count(), 1);
    assert!(!dir.path().join("backup").exists());
    let retried = cli(dir.path(), &["backup", "config.yaml"], "invalid\ny\n");
    assert!(retried.status.success(), "{}", text(&retried));
    assert_eq!(text(&retried).matches("Continue?").count(), 2);
    assert!(dir.path().join("backup").exists());
    let quiet = cli(dir.path(), &["backup", "config.yaml", "-q"], "");
    assert!(!text(&quiet).contains("Continue?"));
}

#[test]
fn invalid_rule_paths_fail_before_cleaning_backup() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    write(p, "backup/keep", "untouched");
    for fields in [
        "targets: [/absolute]\n    to: files",
        "targets: [../outside]\n    to: files",
        "targets: [file]\n    to: ../outside",
    ] {
        write(
            p,
            "config.yaml",
            &format!(
                "symlinks: referece\ndestination: backup\nclean: true\nrules:\n  - from: source\n    {fields}\n"
            ),
        );
        let out = cli(p, &["backup", "config.yaml", "-q"], "");
        assert_eq!(out.status.code(), Some(1), "{}", text(&out));
        assert_eq!(
            fs::read_to_string(p.join("backup/keep")).unwrap(),
            "untouched"
        );
    }
}

#[test]
fn malformed_globs_fail_before_confirmation_hooks_or_cleanup() {
    for (from, target, pattern) in [("source", "[", "["), ("source/[", "file", "source/[")] {
        for included in [false, true] {
            for quiet in [false, true] {
                let d = TempDir::new().unwrap();
                let p = d.path();
                write(p, "backup/keep", "previous backup");
                let rules = format!(
                    "symlinks: referece\ndestination: backup\nrules:\n  - from: '{from}'\n    targets: ['{target}']\n    to: files\n"
                );
                let (config, invalid_file) = if included {
                    write(p, "nested/child.yaml", &rules);
                    (
                        "symlinks: referece\ndestination: backup\nrules: []\ninclude: [{file: nested/child.yaml}]\n"
                            .to_string(),
                        "child.yaml",
                    )
                } else {
                    (rules, "config.yaml")
                };
                write(
                    p,
                    "config.yaml",
                    &(config
                        + "clean: true\nhooks:\n  pre-backup:\n    - name: marker\n      command: [sh, -c, 'touch hook-ran']\n"),
                );
                let mut args = vec!["backup", "config.yaml"];
                if quiet {
                    args.push("-q");
                }
                let out = cli(p, &args, "y\n");
                assert_eq!(out.status.code(), Some(1), "{}", text(&out));
                assert_eq!(
                    fs::read_to_string(p.join("backup/keep")).unwrap(),
                    "previous backup"
                );
                assert!(!p.join("hook-ran").exists());
                assert!(!text(&out).contains("Continue?"));
                let error = String::from_utf8_lossy(&out.stderr);
                assert!(error.contains(invalid_file), "{error}");
                assert!(error.contains("rule 1"), "{error}");
                assert!(error.contains(pattern), "{error}");
            }
        }
    }
}

#[test]
fn home_expansion_and_dotfile_globs_work_without_shell_expansion() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    write(p, "home/.config/app/.hidden", "hidden");
    write(p, "home/.config/app/file", "visible");
    write(
        p,
        "config.yaml",
        "symlinks: referece\ndestination: '~/backup'\nrules:\n  - from: '~/.config'\n    targets: ['app/*', 'app/.*']\n    to: .\n",
    );
    let out = Command::new(env!("CARGO_BIN_EXE_cockup"))
        .current_dir(p)
        .args(["backup", "config.yaml", "-q"])
        .env("HOME", p.join("home"))
        .output()
        .unwrap();
    assert!(out.status.success(), "{}", text(&out));
    assert_eq!(
        fs::read_to_string(p.join("home/backup/app/.hidden")).unwrap(),
        "hidden"
    );
    assert_eq!(
        fs::read_to_string(p.join("home/backup/app/file")).unwrap(),
        "visible"
    );
}