sqry-core 11.0.3

Core library for sqry - semantic code search engine
Documentation
//! Integration tests for [`SourceTreeWatcher`] with editor-pattern coverage.
//!
//! # Cross-reference: editor-pattern pairing (Step 9b mandate)
//!
//! Every watcher test that exercises `DirectWrite` (std::fs::write) is paired
//! with at least one editor-pattern test that covers the same scenario. The
//! table below lists the pairings:
//!
//! | Watcher scenario | DirectWrite test | Editor-pattern test(s) |
//! |---|---|---|
//! | Single file edit | `editor_direct_write_single_file` | `editor_vim_atomic_rename`, `editor_jetbrains_atomic_save`, `editor_vscode_safe_save`, `editor_emacs_backup` |
//! | All 5 patterns normalize | (covered by matrix) | `all_editor_patterns_normalize_to_one_changed_file` |
//! | Windows rename coalescing | (unit tests in source_tree.rs) | `coalesce_rename_pairs_*` in source_tree.rs |
//! | Bulk checkout | `bulk_checkout_100_files_single_changeset` (in source_tree.rs) | N/A (git operation, not editor save) |
//! | Stash/pop | `stash_pop_produces_changesets` (in source_tree.rs) | N/A |
//! | GC | `gc_zero_source_events` (in source_tree.rs) | N/A |
//! | Commit of edited file | `commit_no_additional_changeset` (in source_tree.rs) | N/A |

mod support;

use sqry_core::watch::SourceTreeWatcher;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use support::editor_patterns::{EditorSavePattern, simulate_save};
use tempfile::TempDir;

/// Timeout for waiting for watcher events; generous for CI.
fn event_timeout() -> Duration {
    let base = if cfg!(target_os = "macos") {
        Duration::from_secs(3)
    } else {
        Duration::from_secs(2)
    };
    if std::env::var("CI").is_ok() {
        base * 2
    } else {
        base
    }
}

fn init_repo(dir: &Path) {
    run_git(dir, &["init", "-q", "-b", "main"]);
    run_git(dir, &["config", "user.email", "test@sqry.dev"]);
    run_git(dir, &["config", "user.name", "Sqry Test"]);
    run_git(dir, &["config", "commit.gpgsign", "false"]);
    fs::write(dir.join("a.rs"), b"fn main() {}\n").unwrap();
    run_git(dir, &["add", "a.rs"]);
    run_git(dir, &["commit", "-q", "-m", "initial"]);
}

fn run_git(dir: &Path, args: &[&str]) {
    let status = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .status()
        .expect("git command failed to launch");
    assert!(status.success(), "git {args:?} failed in {}", dir.display());
}

/// Polls the watcher until a changeset with source files is detected, or
/// timeout expires. Returns the collected changed file names (stems only).
fn poll_changed_files(watcher: &SourceTreeWatcher, timeout: Duration) -> Vec<String> {
    let deadline = Instant::now() + timeout;
    let mut all_files: Vec<String> = Vec::new();

    loop {
        if let Some(cs) = watcher.poll_changes(None).unwrap() {
            for path in &cs.changed_files {
                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                    all_files.push(name.to_owned());
                }
            }
        }
        if !all_files.is_empty() || Instant::now() >= deadline {
            break;
        }
        std::thread::sleep(Duration::from_millis(50));
    }

    all_files
}

/// Asserts that exactly one logical changed file matching `expected_name`
/// appears in the watcher output after an editor save.
fn assert_single_changed_file(
    watcher: &SourceTreeWatcher,
    expected_name: &str,
    pattern: EditorSavePattern,
) {
    let files = poll_changed_files(watcher, event_timeout());

    // Filter to just the target file name (exclude editor temps that might
    // leak through on some platforms — the key assertion is that the target
    // file appears, and no temp files appear).
    let target_count = files.iter().filter(|f| f.as_str() == expected_name).count();
    let temp_files: Vec<&String> = files
        .iter()
        .filter(|f| {
            f.ends_with(".swp")
                || f.ends_with(".swo")
                || f.ends_with('~')
                || f.ends_with(".bak")
                || f.contains("___jb_tmp___")
                || f.contains("___jb_old___")
        })
        .collect();

    assert!(
        target_count >= 1,
        "{pattern:?}: expected at least 1 event for '{expected_name}', got files: {files:?}"
    );
    assert!(
        temp_files.is_empty(),
        "{pattern:?}: editor temporaries should be filtered out, found: {temp_files:?}"
    );
}

// ---------------------------------------------------------------------------
// Editor pattern tests: each save pattern normalizes to one logical change
// ---------------------------------------------------------------------------

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_direct_write_single_file() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None); // drain init

    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* edited */ }\n",
        EditorSavePattern::DirectWrite,
    );

    assert_single_changed_file(&watcher, "a.rs", EditorSavePattern::DirectWrite);
}

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_vim_atomic_rename() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* vim */ }\n",
        EditorSavePattern::VimAtomicRename,
    );

    assert_single_changed_file(&watcher, "a.rs", EditorSavePattern::VimAtomicRename);
}

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_jetbrains_atomic_save() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* jetbrains */ }\n",
        EditorSavePattern::JetBrainsAtomicSave,
    );

    assert_single_changed_file(&watcher, "a.rs", EditorSavePattern::JetBrainsAtomicSave);
}

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_vscode_safe_save() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* vscode */ }\n",
        EditorSavePattern::VscodeSafeSave,
    );

    assert_single_changed_file(&watcher, "a.rs", EditorSavePattern::VscodeSafeSave);
}

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_emacs_backup() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* emacs */ }\n",
        EditorSavePattern::EmacsBackup,
    );

    assert_single_changed_file(&watcher, "a.rs", EditorSavePattern::EmacsBackup);
}

// ---------------------------------------------------------------------------
// Matrix test: all 5 patterns × current OS must normalize
// ---------------------------------------------------------------------------

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn all_editor_patterns_normalize_to_one_changed_file() {
    for pattern in EditorSavePattern::all() {
        let tmp = TempDir::new().unwrap();
        init_repo(tmp.path());

        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
        std::thread::sleep(Duration::from_millis(100));
        let _ = watcher.poll_changes(None);

        simulate_save(
            &tmp.path().join("a.rs"),
            b"fn main() { /* pattern test */ }\n",
            *pattern,
        );

        assert_single_changed_file(&watcher, "a.rs", *pattern);
    }
}

// ---------------------------------------------------------------------------
// Sliding-window debounce: rapid sequential writes collapse
// ---------------------------------------------------------------------------

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn debounce_collapses_rapid_writes() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    // Rapidly write the same file 5 times.
    for i in 0..5 {
        fs::write(
            tmp.path().join("a.rs"),
            format!("fn main() {{ /* write {i} */ }}\n"),
        )
        .unwrap();
        std::thread::sleep(Duration::from_millis(10));
    }

    // Wait for debounce to settle.
    std::thread::sleep(Duration::from_millis(300));

    let files = poll_changed_files(&watcher, event_timeout());

    // Despite 5 writes, deduplication should show a.rs at most once per poll.
    let a_count = files.iter().filter(|f| f.as_str() == "a.rs").count();
    assert!(
        a_count >= 1,
        "Should detect at least one a.rs change, got: {files:?}"
    );
}

// ---------------------------------------------------------------------------
// Editor pattern + gitignore interaction
// ---------------------------------------------------------------------------

#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn editor_pattern_respects_gitignore() {
    let tmp = TempDir::new().unwrap();
    init_repo(tmp.path());
    fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
    run_git(tmp.path(), &["add", ".gitignore"]);
    run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);

    // Create a .log file so the editor pattern has something to "save".
    fs::write(tmp.path().join("build.log"), b"initial\n").unwrap();

    let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
    std::thread::sleep(Duration::from_millis(100));
    let _ = watcher.poll_changes(None);

    // Use Vim pattern to save a .log file (gitignored).
    simulate_save(
        &tmp.path().join("build.log"),
        b"updated log\n",
        EditorSavePattern::VimAtomicRename,
    );

    // Also save a non-ignored file so we can confirm watcher is alive.
    std::thread::sleep(Duration::from_millis(50));
    simulate_save(
        &tmp.path().join("a.rs"),
        b"fn main() { /* edited */ }\n",
        EditorSavePattern::DirectWrite,
    );

    let files = poll_changed_files(&watcher, event_timeout());

    let has_log = files.iter().any(|f| f.ends_with(".log"));
    let has_source = files.iter().any(|f| f == "a.rs");

    assert!(has_source, "Should detect a.rs change, got: {files:?}");
    assert!(
        !has_log,
        "Gitignored .log file should be filtered, got: {files:?}"
    );
}

// ---------------------------------------------------------------------------
// Watcher creation on non-git directory fails gracefully
// ---------------------------------------------------------------------------

#[test]
fn watcher_fails_on_non_git_directory() {
    let tmp = TempDir::new().unwrap();
    // No git init — should fail because GitStateWatcher needs .git.
    let result = SourceTreeWatcher::new(tmp.path());
    assert!(
        result.is_err(),
        "SourceTreeWatcher should fail on non-git directory"
    );
}

// ---------------------------------------------------------------------------
// Watcher works with git worktree layout (.git as file)
// ---------------------------------------------------------------------------

#[test]
fn watcher_works_with_worktree_layout() {
    let primary = TempDir::new().unwrap();
    init_repo(primary.path());

    let work = TempDir::new().unwrap();
    let work_path = work.path().join("wt");
    run_git(
        primary.path(),
        &[
            "worktree",
            "add",
            "-b",
            "feature",
            work_path.to_str().unwrap(),
        ],
    );

    // SourceTreeWatcher should work with the worktree.
    let watcher = SourceTreeWatcher::new(&work_path);
    assert!(
        watcher.is_ok(),
        "SourceTreeWatcher should work with worktree layout: {:?}",
        watcher.err()
    );
}