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;
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());
}
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
}
fn assert_single_changed_file(
watcher: &SourceTreeWatcher,
expected_name: &str,
pattern: EditorSavePattern,
) {
let files = poll_changed_files(watcher, event_timeout());
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:?}"
);
}
#[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);
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);
}
#[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);
}
}
#[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);
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));
}
std::thread::sleep(Duration::from_millis(300));
let files = poll_changed_files(&watcher, event_timeout());
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:?}"
);
}
#[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"]);
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);
simulate_save(
&tmp.path().join("build.log"),
b"updated log\n",
EditorSavePattern::VimAtomicRename,
);
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:?}"
);
}
#[test]
fn watcher_fails_on_non_git_directory() {
let tmp = TempDir::new().unwrap();
let result = SourceTreeWatcher::new(tmp.path());
assert!(
result.is_err(),
"SourceTreeWatcher should fail on non-git directory"
);
}
#[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(),
],
);
let watcher = SourceTreeWatcher::new(&work_path);
assert!(
watcher.is_ok(),
"SourceTreeWatcher should work with worktree layout: {:?}",
watcher.err()
);
}