#[path = "oracle_helpers.rs"]
mod oracle_helpers;
use oracle_helpers::{normalize_ndjson, rg_available};
use proptest::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use syntext::index::Index;
use syntext::{Config, IndexError};
use tempfile::TempDir;
#[derive(Debug, Clone)]
enum MutationOp {
ModifyFile { path: String, content: Vec<u8> },
CreateFile { path: String, content: Vec<u8> },
DeleteFile { path: String },
RenameFile { from: String, to: String },
BinaryifyFile { path: String },
GrowPastLimit { path: String },
ChangeThenDeleteSameBatch { path: String, content: Vec<u8> },
}
fn text_content() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
Just(b"fn parse_query() {}\n".to_vec()),
Just(b"fn reparse() { let x = 1; }\n".to_vec()),
Just(b"def snake_case(camelCase):\n parse\n".to_vec()),
Just(b"// TODO: query\nfn helper() {}\n".to_vec()),
Just(b"let result = process_batch();\n".to_vec()),
Just(b"fn new_function() { parse_query(42); }\n".to_vec()),
]
}
fn file_path_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("src/main.rs".to_string()),
Just("src/lib.rs".to_string()),
Just("src/util.rs".to_string()),
Just("src/helper.rs".to_string()),
Just("docs/notes.md".to_string()),
]
}
fn mutation_op_strategy() -> impl Strategy<Value = MutationOp> {
prop_oneof![
3 => (file_path_strategy(), text_content())
.prop_map(|(path, content)| MutationOp::ModifyFile { path, content }),
2 => (file_path_strategy(), text_content())
.prop_map(|(path, content)| MutationOp::CreateFile { path, content }),
2 => file_path_strategy()
.prop_map(|path| MutationOp::DeleteFile { path }),
1 => (file_path_strategy(), file_path_strategy())
.prop_filter("rename must change path", |(a, b)| a != b)
.prop_map(|(from, to)| MutationOp::RenameFile { from, to }),
1 => file_path_strategy()
.prop_map(|path| MutationOp::BinaryifyFile { path }),
1 => file_path_strategy()
.prop_map(|path| MutationOp::GrowPastLimit { path }),
1 => (file_path_strategy(), text_content())
.prop_map(|(path, content)| MutationOp::ChangeThenDeleteSameBatch { path, content }),
]
}
fn generate_mutation_sequence() -> impl Strategy<Value = Vec<MutationOp>> {
prop::collection::vec(mutation_op_strategy(), 3..=8)
}
fn apply_mutation(
repo: &Path,
index: &Index,
op: &MutationOp,
max_file_size: u64,
git_cmd: &dyn Fn(&[&str]),
) -> Result<bool, String> {
match op {
MutationOp::ModifyFile { path, content } => {
let abs = repo.join(path);
if !abs.exists() {
return Ok(false);
}
fs::write(&abs, content).map_err(|e| format!("ModifyFile write failed: {e}"))?;
index
.notify_change(&abs)
.map_err(|e| format!("notify_change failed: {e}"))?;
Ok(true)
}
MutationOp::CreateFile { path, content } => {
let abs = repo.join(path);
if let Some(p) = abs.parent() {
fs::create_dir_all(p).map_err(|e| format!("create_dir_all failed: {e}"))?;
}
fs::write(&abs, content).map_err(|e| format!("CreateFile write failed: {e}"))?;
index
.notify_change(&abs)
.map_err(|e| format!("notify_change failed: {e}"))?;
git_cmd(&["add", path]);
Ok(true)
}
MutationOp::DeleteFile { path } => {
let abs = repo.join(path);
if !abs.exists() {
return Ok(false);
}
fs::remove_file(&abs).map_err(|e| format!("DeleteFile remove_file failed: {e}"))?;
index
.notify_delete(&abs)
.map_err(|e| format!("notify_delete failed: {e}"))?;
git_cmd(&["rm", "--cached", "--ignore-unmatch", path]);
Ok(true)
}
MutationOp::RenameFile { from, to } => {
let abs_from = repo.join(from);
let abs_to = repo.join(to);
if !abs_from.exists() {
return Ok(false);
}
if let Some(p) = abs_to.parent() {
fs::create_dir_all(p)
.map_err(|e| format!("RenameFile create_dir_all failed: {e}"))?;
}
fs::rename(&abs_from, &abs_to).map_err(|e| format!("RenameFile rename failed: {e}"))?;
index
.notify_delete(&abs_from)
.map_err(|e| format!("notify_delete(from) failed: {e}"))?;
index
.notify_change(&abs_to)
.map_err(|e| format!("notify_change(to) failed: {e}"))?;
git_cmd(&["rm", "--cached", "--ignore-unmatch", from]);
git_cmd(&["add", to]);
Ok(true)
}
MutationOp::BinaryifyFile { path } => {
let abs = repo.join(path);
let mut content = b"fn binary_content() { ".to_vec();
content.push(0);
content.extend_from_slice(b" }\n");
if let Some(p) = abs.parent() {
fs::create_dir_all(p)
.map_err(|e| format!("BinaryifyFile create_dir_all failed: {e}"))?;
}
fs::write(&abs, &content).map_err(|e| format!("BinaryifyFile write failed: {e}"))?;
index
.notify_change(&abs)
.map_err(|e| format!("notify_change failed: {e}"))?;
Ok(true)
}
MutationOp::GrowPastLimit { path } => {
let abs = repo.join(path);
if let Some(p) = abs.parent() {
fs::create_dir_all(p)
.map_err(|e| format!("GrowPastLimit create_dir_all failed: {e}"))?;
}
let oversized = vec![b'x'; (max_file_size + 1) as usize];
fs::write(&abs, &oversized).map_err(|e| format!("GrowPastLimit write failed: {e}"))?;
index
.notify_change(&abs)
.map_err(|e| format!("notify_change failed: {e}"))?;
Ok(true)
}
MutationOp::ChangeThenDeleteSameBatch { path, content } => {
let abs = repo.join(path);
if let Some(p) = abs.parent() {
fs::create_dir_all(p)
.map_err(|e| format!("ChangeThenDelete create_dir_all failed: {e}"))?;
}
fs::write(&abs, content).map_err(|e| format!("ChangeThenDelete write failed: {e}"))?;
index
.notify_change(&abs)
.map_err(|e| format!("notify_change failed: {e}"))?;
fs::remove_file(&abs).map_err(|e| format!("ChangeThenDelete remove failed: {e}"))?;
index
.notify_delete(&abs)
.map_err(|e| format!("notify_delete failed: {e}"))?;
git_cmd(&["rm", "--cached", "--ignore-unmatch", path]);
Ok(true)
}
}
}
fn assert_st_matches_rg(
repo: &Path,
index_dir: &Path,
query: &str,
step: usize,
) -> Result<(), String> {
if !rg_available() {
return Ok(());
}
let st_bin = env!("CARGO_BIN_EXE_st");
let st_args = [
"--repo-root",
repo.to_str().unwrap(),
"--index-dir",
index_dir.to_str().unwrap(),
"--json",
query,
];
let st_output = Command::new(st_bin)
.args(&st_args)
.current_dir(repo)
.env("SYNTEXT_DETERMINISTIC", "1")
.output()
.map_err(|e| format!("step {step}: failed to run st: {e}"))?;
let rg_output = Command::new("rg")
.args([
"--json",
"--hidden",
"--crlf",
"--glob",
"!.gitignore",
"--glob",
"!.syntext",
query,
".",
])
.current_dir(repo)
.output()
.map_err(|e| format!("step {step}: failed to run rg: {e}"))?;
let st_matches = normalize_ndjson(&st_output.stdout)
.map_err(|e| format!("step {step}: st NDJSON parse error: {e}"))?;
let rg_matches = normalize_ndjson(&rg_output.stdout)
.map_err(|e| format!("step {step}: rg NDJSON parse error: {e}"))?;
let st_line_keys: std::collections::HashSet<(&str, usize)> = st_matches
.iter()
.map(|m| (m.path.as_str(), m.line_number))
.collect();
for m in &rg_matches {
if !st_line_keys.contains(&(m.path.as_str(), m.line_number)) {
return Err(format!(
"step {step}: Tier A Violation: rg found {:?} but st did not.\n\
Query: {:?}\n\
st stdout:\n{}\n\
rg stdout:\n{}",
m,
query,
String::from_utf8_lossy(&st_output.stdout),
String::from_utf8_lossy(&rg_output.stdout),
));
}
}
if st_matches.len() != rg_matches.len() {
return Err(format!(
"step {step}: Tier B Violation: st={} matches, rg={} matches.\n\
Query: {:?}\n\
st stdout:\n{}\n\
rg stdout:\n{}",
st_matches.len(),
rg_matches.len(),
query,
String::from_utf8_lossy(&st_output.stdout),
String::from_utf8_lossy(&rg_output.stdout),
));
}
Ok(())
}
fn commit_batch_with_retry(index: &Index) -> Result<(), IndexError> {
use std::thread;
use std::time::Duration;
const MAX: usize = 5;
for attempt in 1..=MAX {
match index.commit_batch() {
Ok(()) => return Ok(()),
Err(IndexError::LockConflict(_)) if attempt < MAX => {
thread::sleep(Duration::from_millis(10));
}
Err(e) => return Err(e),
}
}
unreachable!()
}
fn generate_incremental_run() -> impl Strategy<Value = (Vec<MutationOp>, String)> {
let query_strat = prop_oneof![
Just("parse".to_string()),
Just("parse_query".to_string()),
Just("fn".to_string()),
Just("let".to_string()),
Just("helper".to_string()),
Just("result".to_string()),
];
(generate_mutation_sequence(), query_strat)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(15))]
#[test]
fn test_incremental_differential((mutations, query) in generate_incremental_run()) {
let repo = TempDir::new().unwrap();
let index_dir_tmp = TempDir::new().unwrap();
let index_dir: PathBuf = index_dir_tmp.path().to_path_buf();
let initial_files = [
("src/main.rs", b"fn parse_query() {}\n".as_ref()),
("src/lib.rs", b"fn reparse() { let x = 1; }\n".as_ref()),
("src/util.rs", b"fn helper() {}\n".as_ref()),
];
for (path, content) in &initial_files {
let abs = repo.path().join(path);
fs::create_dir_all(abs.parent().unwrap()).unwrap();
fs::write(&abs, content).unwrap();
}
let git = |args: &[&str]| {
Command::new("git")
.arg("-C").arg(repo.path())
.args(args)
.output()
.ok();
};
git(&["init"]);
git(&["config", "user.name", "oracle"]);
git(&["config", "user.email", "oracle@example.com"]);
fs::write(repo.path().join(".gitignore"), b".syntext/\n.git/\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "init", "--no-gpg-sign"]);
let max_file_size: u64 = 512 * 1024; let config = Config {
index_dir: index_dir.clone(),
repo_root: repo.path().to_path_buf(),
max_file_size,
auto_update: false, ..Config::default()
};
let mut index = Index::build(config.clone()).expect("build index");
for (step, op) in mutations.iter().enumerate() {
let needs_commit = apply_mutation(repo.path(), &index, op, max_file_size, &git)
.unwrap_or(false);
if needs_commit {
match commit_batch_with_retry(&index) {
Ok(()) => {}
Err(IndexError::OverlayFull { .. }) => {
break;
}
Err(e) => panic!("step {step}: commit_batch failed: {e}"),
}
drop(index);
assert_st_matches_rg(
repo.path(),
&index_dir,
&query,
step,
).expect("incremental differential mismatch");
index = Index::open(config.clone()).expect("reopen index");
}
}
drop(index);
}
}
#[test]
fn overlay_full_correctness() {
if !rg_available() {
return;
}
let repo = TempDir::new().unwrap();
let index_dir_tmp = TempDir::new().unwrap();
let index_dir = index_dir_tmp.path().to_path_buf();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/main.rs"), b"fn parse_query() {}\n").unwrap();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(repo.path())
.args(args)
.output()
.ok();
};
git(&["init"]);
git(&["config", "user.name", "oracle"]);
git(&["config", "user.email", "oracle@example.com"]);
fs::write(repo.path().join(".gitignore"), b".syntext/\n.git/\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "init", "--no-gpg-sign"]);
let config = Config {
index_dir: index_dir.clone(),
repo_root: repo.path().to_path_buf(),
auto_update: false,
..Config::default()
};
let index = Index::build(config).expect("build");
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 0)
.expect("baseline differential mismatch");
let extra_files = ["src/lib.rs", "src/util.rs"];
for path in &extra_files {
let abs = repo.path().join(path);
fs::write(&abs, b"fn helper() { let x = parse_all(); }\n").unwrap();
index.notify_change(&abs).ok();
}
let result = index.commit_batch();
match result {
Err(IndexError::OverlayFull { .. }) => {
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 99).expect(
"post-OverlayFull differential mismatch: verifier must not fabricate matches",
);
}
Ok(()) => {
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 99)
.expect("post-commit differential mismatch");
}
Err(e) => panic!("unexpected commit_batch error: {e}"),
}
drop(index);
}
#[test]
fn golden_incremental_rename() {
if !rg_available() {
return;
}
let repo = TempDir::new().unwrap();
let index_dir_tmp = TempDir::new().unwrap();
let index_dir = index_dir_tmp.path().to_path_buf();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/old.rs"), b"fn parse_query() {}\n").unwrap();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(repo.path())
.args(args)
.output()
.ok();
};
git(&["init"]);
git(&["config", "user.name", "oracle"]);
git(&["config", "user.email", "oracle@example.com"]);
fs::write(repo.path().join(".gitignore"), b".syntext/\n.git/\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "init", "--no-gpg-sign"]);
let config = Config {
index_dir: index_dir.clone(),
repo_root: repo.path().to_path_buf(),
auto_update: false,
..Config::default()
};
let index = Index::build(config).expect("build");
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 0).unwrap();
let abs_old = repo.path().join("src/old.rs");
let abs_new = repo.path().join("src/new.rs");
fs::rename(&abs_old, &abs_new).unwrap();
index.notify_delete(&abs_old).unwrap();
index.notify_change(&abs_new).unwrap();
index.commit_batch().unwrap();
git(&["rm", "--cached", "--ignore-unmatch", "src/old.rs"]);
git(&["add", "src/new.rs"]);
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 1).unwrap();
drop(index);
}
#[test]
fn golden_incremental_grow_past_limit() {
if !rg_available() {
return;
}
let repo = TempDir::new().unwrap();
let index_dir_tmp = TempDir::new().unwrap();
let index_dir = index_dir_tmp.path().to_path_buf();
fs::create_dir_all(repo.path().join("src")).unwrap();
fs::write(repo.path().join("src/main.rs"), b"fn parse_query() {}\n").unwrap();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(repo.path())
.args(args)
.output()
.ok();
};
git(&["init"]);
git(&["config", "user.name", "oracle"]);
git(&["config", "user.email", "oracle@example.com"]);
fs::write(repo.path().join(".gitignore"), b".syntext/\n.git/\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "init", "--no-gpg-sign"]);
let max_file_size: u64 = 1024; let config = Config {
index_dir: index_dir.clone(),
repo_root: repo.path().to_path_buf(),
max_file_size,
auto_update: false,
..Config::default()
};
let index = Index::build(config).expect("build");
let abs_main = repo.path().join("src/main.rs");
let oversized = vec![b'x'; (max_file_size + 1) as usize];
fs::write(&abs_main, &oversized).unwrap();
index.notify_change(&abs_main).unwrap();
index.commit_batch().unwrap();
assert_st_matches_rg(repo.path(), &index_dir, "parse_query", 1).unwrap();
drop(index);
}