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());

    // Generate age key pair inside the container
    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "mkdir",
            "-p",
            "/root/.config/chezmoi",
        ])
        .status()
        .expect("Failed to create chezmoi config dir");
    assert!(status.success());

    let status = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "age-keygen",
            "-o",
            "/root/.config/chezmoi/.age-private-key.txt",
        ])
        .status()
        .expect("Failed to generate age keys");
    assert!(status.success());

    // Extract public key from generated age key
    let output = Command::new("docker")
        .args(&[
            "exec",
            container_id,
            "cat",
            "/root/.config/chezmoi/.age-private-key.txt",
        ])
        .output()
        .expect("Failed to read age key");
    let content = String::from_utf8_lossy(&output.stdout);
    let mut public_key = String::new();
    for line in content.lines() {
        if line.starts_with("# public key: ") {
            public_key = line["# public key: ".len()..].trim().to_string();
            break;
        }
    }

    // Write ~/.config/chezmoi/chezmoi.toml
    let chezmoi_toml_content = format!(
        r#"encryption = "age"
[age]
    identity = "/root/.config/chezmoi/.age-private-key.txt"
    recipient = "{}"
"#,
        public_key
    );
    write_file_in_container(
        &container,
        "/root/.config/chezmoi/chezmoi.toml",
        &chezmoi_toml_content
    );

    // 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_sync_and_discovery_pipeline() {
    let container = bootstrap_test_environment().await;

    // 1. Configure tracked paths
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".bashrc\n.config/my_app\n"
    );

    // 2. Write mock dotfiles
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -l'\n");
    write_file_in_container(&container, "/root/.config/my_app/config.json", "{\"theme\": \"dark\"}");

    // 3. Run sync to discover and track the files
    let stdout = run_manager_success(&container, &["sync"]).await;
    assert!(stdout.contains("Adding 2 new file(s)"));
    assert!(stdout.contains("Sync engine finished"));

    // 4. Verify chezmoi now tracks the files in plain text
    let bashrc_source = read_file_in_container(&container, "/root/.local/share/chezmoi/dot_bashrc");
    assert_eq!(bashrc_source.trim(), "alias ll='ls -l'");

    let my_app_config_source = read_file_in_container(&container, "/root/.local/share/chezmoi/dot_config/my_app/config.json");
    assert_eq!(my_app_config_source.trim(), "{\"theme\": \"dark\"}");

    // 5. Modify .bashrc and sync again (should re-add)
    write_file_in_container(&container, "/root/.bashrc", "alias ll='ls -lh'\n");
    let stdout = run_manager_success(&container, &["sync"]).await;
    assert!(stdout.contains("Re-adding 1 modified managed file(s)"));

    let bashrc_source_updated = read_file_in_container(&container, "/root/.local/share/chezmoi/dot_bashrc");
    assert_eq!(bashrc_source_updated.trim(), "alias ll='ls -lh'");

    // 6. Verify Git remote pushed changes
    let git_log = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "git",
            "-C",
            "/tmp/mock-gitlab-repo.git",
            "log",
            "-1",
            "--oneline",
        ])
        .output()
        .expect("Failed to check Git server logs");
    assert!(git_log.status.success());
    let log_str = String::from_utf8_lossy(&git_log.stdout);
    assert!(log_str.contains("chore: auto-sync dotfiles"));
}

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

    // 1. Setup nested tracking rules: track folder, but encrypt specifically secrets.json
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".config/my_app\n"
    );
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked_encrypted",
        ".config/my_app/secrets.json\n"
    );

    // 2. Write standard mock dotfile & sensitive dotfile
    write_file_in_container(&container, "/root/.config/my_app/public.json", "{\"status\": \"public\"}");
    write_file_in_container(&container, "/root/.config/my_app/secrets.json", "{\"api_key\": \"SUPER_SECRET_123\"}");

    // 3. Run sync
    let stdout = run_manager_success(&container, &["sync"]).await;
    assert!(stdout.contains("Encrypting & adding 1 new file(s)"));
    assert!(stdout.contains("Adding 1 new file(s)"));

    // 4. Verify public.json is plain text
    let public_source = read_file_in_container(&container, "/root/.local/share/chezmoi/dot_config/my_app/public.json");
    assert_eq!(public_source.trim(), "{\"status\": \"public\"}");

    // 5. Verify secrets.json is encrypted (stored as encrypted_secrets.json or encrypted_secrets.json.age)
    let ls_output = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "ls",
            "/root/.local/share/chezmoi/dot_config/my_app/",
        ])
        .output()
        .expect("Failed to list chezmoi directory");
    assert!(ls_output.status.success());
    let files_list = String::from_utf8_lossy(&ls_output.stdout);
    let files: Vec<&str> = files_list.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
    assert!(files.iter().any(|f| f.starts_with("encrypted_") && f.contains("secrets")));
    assert!(!files.contains(&"secrets.json")); // Plaintext file should NOT be here
}

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

    // 1. Setup folder tracking with exclusion pattern for cache_ files
    write_file_in_container(
        &container,
        "/root/.config/fp-dotfiles-manager/tracked",
        ".config/my_app\n!.config/my_app/cache_*\n"
    );

    // 2. Create wanted and unwanted files
    write_file_in_container(&container, "/root/.config/my_app/main.conf", "enabled=true");
    write_file_in_container(&container, "/root/.config/my_app/cache_data.db", "BINARY_TRASH");

    // 3. Run sync
    run_manager_success(&container, &["sync"]).await;

    // 4. Verify main.conf is tracked
    let main_conf = read_file_in_container(&container, "/root/.local/share/chezmoi/dot_config/my_app/main.conf");
    assert_eq!(main_conf.trim(), "enabled=true");

    // 5. Verify cache_data.db is NOT tracked in chezmoi source
    let ls_output = Command::new("docker")
        .args(&[
            "exec",
            container.id(),
            "ls",
            "/root/.local/share/chezmoi/dot_config/my_app/",
        ])
        .output()
        .expect("Failed to list chezmoi directory");
    assert!(ls_output.status.success());
    let files_list = String::from_utf8_lossy(&ls_output.stdout);
    assert!(!files_list.contains("cache_data.db"));
}