use std::fs;
use std::path::{Path, PathBuf};
const FORBIDDEN: &[(&str, &str)] = &[
("std::fs::", "blocking filesystem call — use tokio::fs"),
(
"std::io::Write::write",
"blocking write — use AsyncWriteExt or BackgroundSink",
),
(
"std::thread::sleep",
"blocking sleep — use tokio::time::sleep",
),
(
"reqwest::blocking",
"blocking HTTP client — use reqwest::Client",
),
];
const ALLOW_MARKER: &str = "allow-sync-in-async";
fn collect_rs_files(root: &Path, acc: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, acc);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
acc.push(path);
}
}
}
fn scan_file(path: &Path) -> Vec<String> {
let Ok(source) = fs::read_to_string(path) else {
return Vec::new();
};
let mut violations = Vec::new();
let mut depth: i32 = 0;
let mut async_depths: Vec<i32> = Vec::new();
let mut test_mod_depths: Vec<i32> = Vec::new();
let mut pending_async = false;
let mut pending_test_mod = false;
let mut last_was_cfg_test = false;
for (lineno, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
let code = match trimmed.find("//") {
Some(i) => &trimmed[..i],
None => trimmed,
};
let cfg_test_here = code.contains("#[cfg(test)]");
if cfg_test_here {
last_was_cfg_test = true;
}
if last_was_cfg_test && code.starts_with("mod ") {
pending_test_mod = true;
last_was_cfg_test = false;
}
if code.contains("async fn") && !code.trim_end().ends_with(';') {
pending_async = true;
}
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
if pending_test_mod {
test_mod_depths.push(depth);
pending_test_mod = false;
}
if pending_async {
async_depths.push(depth);
pending_async = false;
}
}
'}' => {
if async_depths.last() == Some(&depth) {
async_depths.pop();
}
if test_mod_depths.last() == Some(&depth) {
test_mod_depths.pop();
}
depth -= 1;
}
_ => {}
}
}
if !cfg_test_here && !code.is_empty() && !code.starts_with("#[") {
last_was_cfg_test = false;
}
if async_depths.is_empty() || !test_mod_depths.is_empty() {
continue;
}
if line.contains(ALLOW_MARKER) {
continue;
}
for (needle, reason) in FORBIDDEN {
if code.contains(needle) {
violations.push(format!(
"{}:{}: forbidden `{}` ({})",
path.display(),
lineno + 1,
needle,
reason,
));
}
}
}
violations
}
#[test]
fn no_sync_in_async() {
let mut files = Vec::new();
collect_rs_files(Path::new("src"), &mut files);
let violations: Vec<String> = files.iter().flat_map(|p| scan_file(p)).collect();
if !violations.is_empty() {
eprintln!("\n== sync-in-async violations ==");
for v in &violations {
eprintln!(" {v}");
}
eprintln!("== total: {} ==\n", violations.len());
}
assert!(
violations.is_empty(),
"found {} sync-in-async violation(s) — see eprintln output above. \
Migrate to BackgroundSink / PeriodicWorker / tokio::fs, or add \
an `// allow-sync-in-async: <reason>` marker if genuinely safe.",
violations.len(),
);
}
#[test]
fn lint_finds_its_own_violations() {
let tmp = std::env::temp_dir().join("sync_in_async_lint_test.rs");
let source = "\
fn sync_ok() {
std::fs::write(\"/tmp/x\", b\"\").unwrap();
}
async fn bad() {
std::fs::write(\"/tmp/x\", b\"\").unwrap();
}
async fn allowed() {
std::fs::write(\"/tmp/x\", b\"\").unwrap(); // allow-sync-in-async: test
}
";
fs::write(&tmp, source).expect("write temp");
let violations = scan_file(&tmp);
let _ = fs::remove_file(&tmp);
assert_eq!(
violations.len(),
1,
"expected exactly one violation (the bad() body), got: {violations:?}",
);
assert!(
violations[0].contains("std::fs::"),
"violation should be std::fs::, got {}",
violations[0],
);
}