cockup 0.2.3

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

#[test]
fn usage_help_explains_defaults_and_exclusive_choices() {
    let dir = TempDir::new().unwrap();
    let refresh = cli(dir.path(), &["cache", "refresh", "--help"], "");
    assert!(refresh.status.success());
    let refresh_usage = "Usage: cockup cache refresh [NAME|--ingredients|--catalog]";
    assert!(text(&refresh).contains(refresh_usage), "{}", text(&refresh));
    assert!(
        text(&refresh)
            .contains("With no choice, refresh all downloaded ingredients and the catalog.")
    );
    let conflict = cli(
        dir.path(),
        &["cache", "refresh", "--ingredients", "--catalog"],
        "",
    );
    assert_eq!(conflict.status.code(), Some(2));
    assert!(
        text(&conflict).contains(refresh_usage),
        "{}",
        text(&conflict)
    );
    let restore = cli(dir.path(), &["restore", "--help"], "");
    assert!(restore.status.success());
    let restore_usage =
        "Usage: cockup restore [OPTIONS] [--override|--skip-existing] <CONFIG_FILE>";
    assert!(text(&restore).contains(restore_usage), "{}", text(&restore));
    let conflict = cli(
        dir.path(),
        &["restore", "config.yaml", "--override", "--skip-existing"],
        "",
    );
    assert_eq!(conflict.status.code(), Some(2));
    assert!(
        text(&conflict).contains(restore_usage),
        "{}",
        text(&conflict)
    );
    assert!(
        text(&restore)
            .contains("By default, ask before replacing conflicting local files and symlinks.")
    );
    for (args, description) in [
        (&["backup", "--help"][..], "Path to the YAML configuration"),
        (&["verify", "--help"][..], "Path to the YAML configuration"),
        (&["diff", "--help"][..], "Path to the YAML configuration"),
        (
            &["hook", "--help"][..],
            "Select hooks interactively when omitted",
        ),
        (
            &["detect", "--help"][..],
            "Detect all installed casks when omitted",
        ),
        (
            &["ingredient", "search", "--help"][..],
            "Case-insensitive ingredient name search",
        ),
        (
            &["cache", "show", "--help"][..],
            "Name of the cached ingredient",
        ),
        (
            &["cache", "delete", "--help"][..],
            "One or more cached ingredient names",
        ),
    ] {
        let out = cli(dir.path(), args, "");
        assert!(out.status.success());
        assert!(text(&out).contains(description), "{}", text(&out));
    }
}

#[test]
fn cli_exposes_commands_and_version() {
    let dir = TempDir::new().unwrap();
    let out = cli(dir.path(), &["--help"], "");
    assert!(out.status.success());
    for word in [
        "backup",
        "restore",
        "diff",
        "hook",
        "detect",
        "template",
        "completions",
    ] {
        assert!(text(&out).contains(word), "{}", text(&out));
    }
    let version = cli(dir.path(), &["--version"], "");
    assert!(version.status.success(), "{}", text(&version));
    assert_eq!(
        String::from_utf8_lossy(&version.stdout).trim(),
        concat!("cockup ", env!("CARGO_PKG_VERSION"))
    );
    assert_eq!(cli(dir.path(), &["list"], "").status.code(), Some(2));
    assert_eq!(cli(dir.path(), &["unknown"], "").status.code(), Some(2));
}

#[test]
fn completions_print_scripts_for_supported_shells() {
    let dir = TempDir::new().unwrap();
    for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
        let out = cli(dir.path(), &["completions", shell], "");
        assert!(out.status.success(), "{shell}: {}", text(&out));
        assert!(out.stderr.is_empty(), "{shell}: {}", text(&out));
        let script = String::from_utf8(out.stdout).unwrap();
        assert!(script.contains("cockup"), "{shell}: {script}");
        assert!(script.contains("backup"), "{shell}: {script}");
    }
    let unsupported = cli(dir.path(), &["completions", "unknown"], "");
    assert_eq!(unsupported.status.code(), Some(2));
}

#[test]
fn template_prints_commented_complete_valid_yaml() {
    let dir = TempDir::new().unwrap();
    let out = cli(dir.path(), &["template"], "");
    assert!(out.status.success(), "{}", text(&out));
    assert!(out.stderr.is_empty());
    let yaml = String::from_utf8(out.stdout).unwrap();
    for field in [
        "symlinks:",
        "destination:",
        "rules:",
        "clean:",
        "metadata:",
        "include:",
        "file:",
        "wrap:",
        "from:",
        "targets:",
        "to:",
        "on-start:",
        "on-end:",
        "hooks:",
        "pre-backup:",
        "post-backup:",
        "pre-restore:",
        "post-restore:",
        "name:",
        "command:",
        "check:",
        "output:",
        "timeout:",
        "env:",
    ] {
        assert!(yaml.contains(field), "missing {field}");
    }
    assert!(yaml.contains("Required:"));
    assert!(yaml.contains("Optional:"));
    write(dir.path(), "config.yaml", &yaml);
    let loaded = cli(dir.path(), &["backup", "config.yaml", "-a"], "");
    assert!(loaded.status.success(), "{}", text(&loaded));
}

#[test]
fn approve_hooks_accepts_only_approve_hooks_and_a_flags() {
    let dir = TempDir::new().unwrap();
    write(
        dir.path(),
        "config.yaml",
        "symlinks: reference\ndestination: backup\nrules: []\n",
    );
    for flag in ["--approve-hooks", "-a"] {
        let out = cli(dir.path(), &["backup", "config.yaml", flag], "");
        assert!(out.status.success(), "{}", text(&out));
    }
    let help = cli(dir.path(), &["backup", "--help"], "");
    assert!(text(&help).contains("--approve-hooks"));
    assert!(text(&help).contains("Run configured hooks without asking"));
    for flag in ["--quiet", "-q", "--yes", "-y"] {
        let out = cli(dir.path(), &["backup", "config.yaml", flag], "");
        assert_eq!(out.status.code(), Some(2), "{}", text(&out));
    }
}

#[test]
fn no_arguments_shows_help_successfully() {
    let d = TempDir::new().unwrap();
    let out = cli(d.path(), &[], "");
    assert!(out.status.success(), "{}", text(&out));
    assert!(text(&out).contains("backup"));
}

#[test]
fn documented_sample_checks_have_expected_exit_statuses() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    write(p, "config.yaml", include_str!("../../sample/hooks.yaml"));
    let pass = cli(
        p,
        &["hook", "config.yaml", "-a", "--name", "Check created file"],
        "",
    );
    assert!(pass.status.success(), "{}", text(&pass));
    assert!(text(&pass).contains("Check passed"));
    assert!(!p.join(".hook-check-result.txt").exists());
    let fail = cli(
        p,
        &["hook", "config.yaml", "-a", "--name", "Check failure"],
        "",
    );
    assert_eq!(fail.status.code(), Some(1));
    assert!(text(&fail).contains("Completed 0/1 hook. Error: 1 hook failed."));
}

#[test]
fn backup_and_restore_progress_use_green_with_color_enabled() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    write(p, "source/file", "content");
    write(
        p,
        "config.yaml",
        "symlinks: reference\ndestination: backup\nrules:\n  - from: source\n    targets: [file]\n    to: files\n",
    );
    for command in ["backup", "restore"] {
        let out = Command::new(env!("CARGO_BIN_EXE_cockup"))
            .current_dir(p)
            .args([command, "config.yaml", "-a"])
            .env_remove("NO_COLOR")
            .env("FORCE_COLOR", "1")
            .output()
            .unwrap();
        assert!(out.status.success(), "{}", text(&out));
        let stdout = String::from_utf8_lossy(&out.stdout);
        assert!(
            stdout.contains("\x1b[36;1m=> \x1b[0m\x1b[32;1m"),
            "{stdout}"
        );
        assert!(
            stdout.contains("\x1b[1mFile copied:\x1b[0m ")
                || stdout.contains("\x1b[1mFile existed, updating:\x1b[0m "),
            "{stdout}"
        );
        let summary = if command == "backup" {
            "Backup completed."
        } else {
            "Restore completed."
        };
        assert!(stdout.contains(summary), "{stdout}");
        assert!(stdout.contains("\x1b[0m"), "{stdout}");
    }
}

#[test]
fn backup_and_restore_report_original_operation_progress() {
    let d = TempDir::new().unwrap();
    let p = d.path();
    write(p, "source/one.txt", "one");
    write(p, "source/two.txt", "two");
    write(p, "source/folder/item", "nested");
    write(p, "backup/stale", "old");
    write(
        p,
        "config.yaml",
        "symlinks: reference\nclean: true\nmetadata: false\ndestination: backup\nrules:\n  - from: source\n    targets: ['*.txt', 'folder*']\n    to: files\nhooks:\n  post-backup:\n    - name: done\n      command: 'true'\n",
    );
    let out = cli(p, &["backup", "config.yaml", "-a"], "");
    assert!(out.status.success(), "{}", text(&out));
    let stdout = String::from_utf8_lossy(&out.stdout);
    for expected in [
        "Starting backup...",
        "Clean mode enabled, will remove backup folder first if exists.",
        "Found existing backup folder, removing...",
        "Metadata preservation disabled.",
        "Target pattern matched (2 found):",
        "File copied:",
        "Folder copied:",
        "Running post-backup hooks...",
        "Running hook (1/1): done",
        "Completed 1/1 hook.",
        "Backup completed.",
    ] {
        assert!(stdout.contains(expected), "missing {expected}: {stdout}");
    }
    assert!(stdout.find("Starting backup...").unwrap() < stdout.find("File copied:").unwrap());
    assert!(
        stdout.find("File copied:").unwrap() < stdout.find("Running post-backup hooks...").unwrap()
    );
    assert!(!p.join("backup/stale").exists());
    let out = cli(p, &["restore", "config.yaml", "-a"], "");
    assert!(out.status.success(), "{}", text(&out));
    assert!(text(&out).contains("Starting restore..."));
}