duet 0.8.6

bi-directional synchronization
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

use tempfile::TempDir;

struct SyncCase {
    _temp: TempDir,
    local: PathBuf,
    remote: PathBuf,
    profile: PathBuf,
}

impl SyncCase {
    fn new() -> Self {
        let temp = tempfile::tempdir().unwrap();
        let local = temp.path().join("local");
        let remote = temp.path().join("remote");
        let profile = temp.path().join("profile.prf");

        fs::create_dir(&local).unwrap();
        fs::create_dir(&remote).unwrap();
        fs::write(
            &profile,
            format!(
                "{}\n{} {}\n+a.txt\n",
                local.display(),
                duet_bin().display(),
                remote.display()
            ),
        )
        .unwrap();

        Self {
            _temp: temp,
            local,
            remote,
            profile,
        }
    }

    fn sync(&self) -> Output {
        self.sync_with_args(&[])
    }

    fn sync_with_args(&self, args: &[&str]) -> Output {
        Command::new(duet_bin())
            .arg("--profile-file")
            .arg(&self.profile)
            .args(args)
            .arg("-b")
            .env("NO_COLOR", "1")
            .output()
            .unwrap()
    }
}

fn duet_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_duet"))
}

fn assert_success(output: Output) {
    assert!(
        output.status.success(),
        "expected success\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

fn write(path: &Path, contents: &str) {
    fs::write(path, contents).unwrap();
}

fn write_bytes(path: &Path, contents: &[u8]) {
    fs::write(path, contents).unwrap();
}

fn read(path: &Path) -> String {
    fs::read_to_string(path).unwrap()
}

fn patterned_bytes(len: usize) -> Vec<u8> {
    (0..len).map(|i| (i % 251) as u8).collect()
}

#[test]
fn local_added_file_copies_to_remote() {
    let case = SyncCase::new();
    write(&case.local.join("a.txt"), "from local");

    assert_success(case.sync());

    assert_eq!(read(&case.remote.join("a.txt")), "from local");
}

#[test]
fn remote_added_file_copies_to_local() {
    let case = SyncCase::new();
    write(&case.remote.join("a.txt"), "from remote");

    assert_success(case.sync());

    assert_eq!(read(&case.local.join("a.txt")), "from remote");
}

#[test]
fn local_modified_file_copies_to_remote() {
    let case = SyncCase::new();
    write(&case.local.join("a.txt"), "initial");
    assert_success(case.sync());

    write(&case.local.join("a.txt"), "updated from local");
    assert_success(case.sync());

    assert_eq!(read(&case.remote.join("a.txt")), "updated from local");
}

#[test]
fn remote_modified_file_copies_to_local() {
    let case = SyncCase::new();
    write(&case.local.join("a.txt"), "initial");
    assert_success(case.sync());

    write(&case.remote.join("a.txt"), "updated from remote");
    assert_success(case.sync());

    assert_eq!(read(&case.local.join("a.txt")), "updated from remote");
}

#[test]
fn local_removed_file_removes_remote() {
    let case = SyncCase::new();
    let local_file = case.local.join("a.txt");
    let remote_file = case.remote.join("a.txt");
    write(&local_file, "initial");
    assert_success(case.sync());

    fs::remove_file(local_file).unwrap();
    assert_success(case.sync());

    assert!(!remote_file.exists());
}

#[test]
fn remote_removed_file_removes_local() {
    let case = SyncCase::new();
    let local_file = case.local.join("a.txt");
    let remote_file = case.remote.join("a.txt");
    write(&local_file, "initial");
    assert_success(case.sync());

    fs::remove_file(remote_file).unwrap();
    assert_success(case.sync());

    assert!(!local_file.exists());
}

#[test]
fn batch_conflict_aborts_without_changing_files() {
    let case = SyncCase::new();
    let local_file = case.local.join("a.txt");
    let remote_file = case.remote.join("a.txt");
    write(&local_file, "initial");
    assert_success(case.sync());

    write(&local_file, "local changed");
    write(&remote_file, "remote changed");

    let output = case.sync();

    assert_eq!(output.status.code(), Some(1));
    assert_eq!(read(&local_file), "local changed");
    assert_eq!(read(&remote_file), "remote changed");
}

#[test]
fn debug_info_reports_negotiated_capabilities() {
    let case = SyncCase::new();
    write(&case.local.join("a.txt"), "from local");

    let output = case.sync_with_args(&["--debug-info"]);

    assert_success(output);

    let output = case.sync_with_args(&["--debug-info"]);
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    assert_success(output);
    assert!(stdout.contains("Debug information:"), "{}", stdout);
    assert!(stdout.contains("client protocol:"), "{}", stdout);
    assert!(stdout.contains("server protocol:"), "{}", stdout);
    assert!(
        stdout.contains(
            "agreed capabilities: profile-file-state-dir, streamed-details-v1, streamed-detail-batches-v1"
        ),
        "{}",
        stdout
    );
    assert!(stdout.contains("sync-tuning-v1"), "{}", stdout);
    assert!(stdout.contains("stream-performance-v1"), "{}", stdout);
    assert!(stdout.contains("file-byte-chunks-v1"), "{}", stdout);
    assert!(stdout.contains("sync tuning:"), "{}", stdout);
    assert!(stdout.contains("detail-batch-frames=256"), "{}", stdout);
}

#[test]
fn named_profile_debug_info_reports_negotiated_capabilities() {
    let temp = tempfile::tempdir().unwrap();
    let home = temp.path().join("home");
    let config = home.join(".config").join("duet");
    let local = temp.path().join("local");
    let remote = temp.path().join("remote");

    fs::create_dir_all(&config).unwrap();
    fs::create_dir(&local).unwrap();
    fs::create_dir(&remote).unwrap();
    fs::write(
        config.join("work.prf"),
        format!(
            "{}\n{} {}\n+a.txt\n",
            local.display(),
            duet_bin().display(),
            remote.display()
        ),
    )
    .unwrap();
    write(&local.join("a.txt"), "from local");

    let output = Command::new(duet_bin())
        .arg("--debug-info")
        .arg("work")
        .arg("-b")
        .env("HOME", &home)
        .env("NO_COLOR", "1")
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();

    assert_success(output);
    assert!(stdout.contains("Debug information:"), "{}", stdout);
    assert!(stdout.contains("server protocol:"), "{}", stdout);
    assert!(
        stdout.contains(
            "agreed capabilities: profile-file-state-dir, streamed-details-v1, streamed-detail-batches-v1"
        ),
        "{}",
        stdout
    );
    assert!(stdout.contains("sync-tuning-v1"), "{}", stdout);
    assert!(stdout.contains("file-byte-chunks-v1"), "{}", stdout);
    assert!(stdout.contains("sync tuning:"), "{}", stdout);
    assert!(stdout.contains("detail-batch-frames=256"), "{}", stdout);
    assert_eq!(read(&remote.join("a.txt")), "from local");
}

#[test]
fn performance_profile_reports_human_and_json_output() {
    let case = SyncCase::new();
    write(&case.local.join("a.txt"), "from local");
    let profile_json = case.local.parent().unwrap().join("performance.json");
    let profile_json_arg = profile_json.to_str().unwrap();

    let output = case.sync_with_args(&[
        "--profile-performance",
        "--profile-performance-json",
        profile_json_arg,
    ]);
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();

    assert_success(output);
    assert!(stdout.contains("Performance profile:"), "{}", stdout);
    assert!(stdout.contains("phases:"), "{}", stdout);
    assert!(stdout.contains("local_scan"), "{}", stdout);
    assert!(stdout.contains("remote_scan_rpc"), "{}", stdout);
    assert!(stdout.contains("signatures:"), "{}", stdout);
    assert!(stdout.contains("stream remote->local"), "{}", stdout);
    assert!(stdout.contains("stream local->remote"), "{}", stdout);
    assert!(stdout.contains("remote server stream:"), "{}", stdout);

    let json = fs::read_to_string(profile_json).unwrap();
    assert!(json.contains("\"total_ms\""), "{}", json);
    assert!(json.contains("\"phases\""), "{}", json);
    assert!(json.contains("\"sync_tuning\""), "{}", json);
    assert!(json.contains("\"streamed_details\": true"), "{}", json);
    assert!(json.contains("\"local_to_remote\""), "{}", json);
    assert!(json.contains("\"remote_server\""), "{}", json);
    assert!(json.contains("\"apply_frames_ms\""), "{}", json);
    assert_eq!(read(&case.remote.join("a.txt")), "from local");
}

#[test]
fn large_local_added_file_streams_to_remote() {
    let case = SyncCase::new();
    let contents = patterned_bytes(3 * 1024 * 1024 + 17);
    write_bytes(&case.local.join("a.txt"), &contents);

    assert_success(case.sync());

    assert_eq!(fs::read(case.remote.join("a.txt")).unwrap(), contents);
}

#[test]
fn large_remote_modified_file_streams_to_local() {
    let case = SyncCase::new();
    let initial = patterned_bytes(3 * 1024 * 1024 + 17);
    write_bytes(&case.local.join("a.txt"), &initial);
    assert_success(case.sync());

    let mut updated = initial;
    for byte in &mut updated[1024 * 1024..1024 * 1024 + 64 * 1024] {
        *byte = byte.wrapping_add(17);
    }
    write_bytes(&case.remote.join("a.txt"), &updated);

    assert_success(case.sync());

    assert_eq!(fs::read(case.local.join("a.txt")).unwrap(), updated);
}

#[test]
fn large_local_modified_file_streams_to_remote() {
    let case = SyncCase::new();
    let initial = patterned_bytes(3 * 1024 * 1024 + 17);
    write_bytes(&case.local.join("a.txt"), &initial);
    assert_success(case.sync());

    let mut updated = initial;
    for byte in &mut updated[1024 * 1024..1024 * 1024 + 64 * 1024] {
        *byte = byte.wrapping_add(17);
    }
    write_bytes(&case.local.join("a.txt"), &updated);

    assert_success(case.sync());

    assert_eq!(fs::read(case.remote.join("a.txt")).unwrap(), updated);
}