fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
mod common;

use common::*;
use std::process::Command;

/// Prepares the Fedora container with a completed setup pointing to a local bare Git repository
async fn bootstrap_test_environment() -> testcontainers::ContainerAsync<testcontainers::GenericImage> {
    let container = setup_fedora_container().await;
    let container_id = container.id();

    // 1. Initialize empty Git server
    let status = Command::new("docker")
        .args(&["exec", container_id, "git", "init", "--bare", "/tmp/mock-gitlab-repo.git"])
        .status()
        .expect("Failed to initialize mock bare git remote");
    assert!(status.success());

    // 2. Generate valid SSH keys
    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "mkdir",
            "-p",
            "/root/.config/fp-dotfiles-manager",
        ])
        .status()
        .expect("Failed to create config dir");
    assert!(status.success());

    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "ssh-keygen",
            "-t",
            "ed25519",
            "-N",
            "",
            "-f",
            "/root/.config/fp-dotfiles-manager/ssh_gitlab",
        ])
        .status()
        .expect("Failed to generate ed25519 SSH keys");
    assert!(status.success());

    // Configure global git user name and email so git commit works inside the container
    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "git",
            "config",
            "--global",
            "user.name",
            "Test User",
        ])
        .status()
        .expect("Failed to configure git user.name");
    assert!(status.success());

    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "git",
            "config",
            "--global",
            "user.email",
            "test@example.com",
        ])
        .status()
        .expect("Failed to configure git user.email");
    assert!(status.success());

    // 3. Initialize setup
    run_manager_success(
        &container,
        &[
            "setup",
            "--repo-url",
            "/tmp/mock-gitlab-repo.git",
            "--ssh-key",
            "/root/.config/fp-dotfiles-manager/ssh_gitlab",
            "--force",
        ],
    ).await;

    container
}

#[tokio::test]
async fn test_commands_add_and_add_secret() {
    let container = bootstrap_test_environment().await;

    // 1. Write dummy files
    write_file_in_container(&container, "/root/.bashrc", "some content");
    write_file_in_container(&container, "/root/.zshrc", "some zsh content");
    write_file_in_container(&container, "/root/.ssh/config", "Host *");

    // 2. Add them using the add subcommands
    run_manager_success(&container, &["add", "/root/.bashrc", "/root/.zshrc"]).await;
    run_manager_success(&container, &["add-secret", "/root/.ssh/config"]).await;

    // 3. Read the lists inside the container and verify
    let tracked = read_file_in_container(&container, "/root/.config/fp-dotfiles-manager/tracked");
    assert!(tracked.contains(".bashrc"));
    assert!(tracked.contains(".zshrc"));

    let tracked_enc = read_file_in_container(&container, "/root/.config/fp-dotfiles-manager/tracked_encrypted");
    assert!(tracked_enc.contains(".ssh/config"));
}

#[tokio::test]
async fn test_command_bootstrap() {
    let container = bootstrap_test_environment().await;

    // 1. Create a mock executable bootstrap hook
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/bootstrap.d/01-test-hook.sh",
        "#!/bin/sh\necho 'BOOTSTRAPPED' > /tmp/bootstrap_test_ran\n"
    );

    // Make the script executable
    let status = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "chmod",
            "+x",
            "/root/.config/fp-dotfiles-manager/bootstrap.d/01-test-hook.sh",
        ])
        .status()
        .expect("Failed to chmod bootstrap script");
    assert!(status.success());

    // 2. Execute bootstrap
    let stdout = run_manager_success(&container, &["bootstrap"]).await;
    assert!(stdout.contains("Running bootstrap scripts"));

    // 3. Verify side effect
    let bootstrap_marker = read_file_in_container(&container, "/tmp/bootstrap_test_ran");
    assert_eq!(bootstrap_marker.trim(), "BOOTSTRAPPED");
}

#[tokio::test]
async fn test_command_backup() {
    let container = bootstrap_test_environment().await;

    // 1. Set up some tracked paths
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".bashrc\n"
    );
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -lh'\n");

    // 2. Perform backup
    let stdout = run_manager_success(&container, &["backup", "/tmp/dotfiles_backup.tar.gz"]).await;
    assert!(stdout.contains("Unencrypted backup successfully archived"));

    // 3. Verify backup file exists
    let ls_output = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "ls",
            "-lh",
            "/tmp/dotfiles_backup.tar.gz",
        ])
        .output()
        .expect("Failed to locate backup file");
    assert!(ls_output.status.success());
    let ls_str = String::from_utf8_lossy(&ls_output.stdout);
    assert!(ls_str.contains("dotfiles_backup.tar.gz"));
}

#[tokio::test]
async fn test_command_logs() {
    let container = bootstrap_test_environment().await;

    // 1. Force a sync commit
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".bashrc\n"
    );
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -l'\n");
    run_manager_success(&container, &["sync"]).await;

    // 2. Verify logs prints our commit correctly
    let stdout = run_manager_success(&container, &["logs"]).await;
    assert!(stdout.contains("chore: auto-sync dotfiles"));
}

#[tokio::test]
async fn test_command_clean_history() {
    let container = bootstrap_test_environment().await;

    // 1. Force a sync commit so we have multiple commits in local and remote
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".bashrc\n"
    );
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -l'\n");
    run_manager_success(&container, &["sync"]).await;

    // 2. Modify and sync again to have even more commits
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -lh'\n");
    run_manager_success(&container, &["sync"]).await;

    // Verify initial remote commit count is at least 2
    let count_output = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "git",
            "-C",
            "/tmp/mock-gitlab-repo.git",
            "rev-list",
            "--count",
            "HEAD",
        ])
        .output()
        .expect("Failed to count commits");
    assert!(count_output.status.success());
    let count_str = String::from_utf8_lossy(&count_output.stdout).trim().to_string();
    let count: u32 = count_str.parse().expect("Failed to parse commit count");
    assert!(count >= 2, "Expected at least 2 commits, got {count}");

    // 3. Run clean-history via run_manager_with_stdin (providing 'y' for both confirmations)
    let output = run_manager_with_stdin(
        &container,
        &["clean-history", "--repo-path", "/tmp/mock-gitlab-repo.git"],
        "y\ny\n",
    )
    .await;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "clean-history failed.\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
    );
    assert!(stdout.contains("History cleaned successfully."));

    // 4. Verify remote commit count is now exactly 1
    let count_output_after = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "git",
            "-C",
            "/tmp/mock-gitlab-repo.git",
            "rev-list",
            "--count",
            "HEAD",
        ])
        .output()
        .expect("Failed to count commits after cleaning");
    assert!(count_output_after.status.success());
    let count_after_str = String::from_utf8_lossy(&count_output_after.stdout).trim().to_string();
    assert_eq!(count_after_str, "1");

    // 5. Verify the single commit's message
    let log_output = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "git",
            "-C",
            "/tmp/mock-gitlab-repo.git",
            "log",
            "-1",
            "--oneline",
        ])
        .output()
        .expect("Failed to get git log");
    assert!(log_output.status.success());
    let log_str = String::from_utf8_lossy(&log_output.stdout);
    assert!(log_str.contains("chore: reset repository history"));
}

#[tokio::test]
async fn test_commands_status_diff_forget() {
    let container = bootstrap_test_environment().await;

    // 1. Track a file
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".bashrc\n"
    );
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -lh'\n");
    run_manager_success(&container, &["sync"]).await;

    // 2. Modify the file so it has differences
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -la'\n");

    // 3. Test 'status' subcommand
    let status_stdout = run_manager_success(&container, &["status"]).await;
    assert!(status_stdout.contains("bashrc") || status_stdout.contains("M"));

    // 4. Test 'diff' subcommand
    let diff_stdout = run_manager_success(&container, &["diff"]).await;
    assert!(diff_stdout.contains("alias ll='ls -la'"));

    // 5. Test 'forget' subcommand on the file
    run_manager_success(&container, &["forget", "/root/.bashrc"]).await;

    // 6. Test 'status' again, it shouldn't show it anymore
    let status_stdout_after = run_manager_success(&container, &["status"]).await;
    assert!(!status_stdout_after.contains("bashrc") && !status_stdout_after.contains("M"));
}