use anyhow::{Context, Result};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use syn::{File, Item};
pub struct TestModBlock {
pub mod_name: String,
pub cfg_attrs: Vec<String>,
pub item: syn::ItemMod,
}
pub struct SplitTestAnalysis {
pub test_modules: Vec<TestModBlock>,
pub production_items: Vec<Item>,
pub use_items: Vec<Item>,
}
pub fn analyse_test_modules(file: &File) -> SplitTestAnalysis {
let mut test_modules: Vec<TestModBlock> = Vec::new();
let mut production_items: Vec<Item> = Vec::new();
let mut use_items: Vec<Item> = Vec::new();
for item in &file.items {
if let Item::Mod(mod_item) = item {
if is_test_mod_item(mod_item) {
let cfg_attrs = extract_cfg_attr_strings(mod_item);
test_modules.push(TestModBlock {
mod_name: mod_item.ident.to_string(),
cfg_attrs,
item: mod_item.clone(),
});
continue;
}
}
if matches!(item, Item::Use(_)) {
use_items.push(item.clone());
}
production_items.push(item.clone());
}
SplitTestAnalysis {
test_modules,
production_items,
use_items,
}
}
fn is_test_mod_item(mod_item: &syn::ItemMod) -> bool {
for attr in &mod_item.attrs {
if attr.path().is_ident("cfg") {
let tokens = match &attr.meta {
syn::Meta::List(ml) => ml.tokens.to_string(),
_ => continue,
};
if tokens
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|tok| tok == "test")
{
return true;
}
}
}
false
}
fn extract_cfg_attr_strings(mod_item: &syn::ItemMod) -> Vec<String> {
let mut out = Vec::new();
for attr in &mod_item.attrs {
if attr.path().is_ident("cfg") {
let dummy = syn::File {
shebang: None,
attrs: Vec::new(),
items: vec![Item::Mod(syn::ItemMod {
attrs: vec![attr.clone()],
vis: syn::parse_quote!(pub),
unsafety: None,
mod_token: Default::default(),
ident: mod_item.ident.clone(),
content: None,
semi: Some(Default::default()),
})],
};
let rendered = prettyplease::unparse(&dummy);
for line in rendered.lines() {
if line.trim_start().starts_with("#[cfg") {
out.push(line.to_string());
break;
}
}
}
}
out
}
pub fn generate_per_test_file(block: &TestModBlock, file_uses: &[Item]) -> String {
let mut content = String::new();
content.push_str("// Copyright 2026 COOLJAPAN OU (Team KitaSan)\n");
content.push_str("// SPDX-License-Identifier: Apache-2.0\n\n");
let use_items: Vec<Item> = file_uses
.iter()
.filter(|it| matches!(it, Item::Use(_)))
.cloned()
.collect();
if !use_items.is_empty() {
let formatted = prettyplease::unparse(&syn::File {
shebang: None,
attrs: Vec::new(),
items: use_items,
});
content.push_str(&formatted);
content.push('\n');
}
let formatted_mod = prettyplease::unparse(&syn::File {
shebang: None,
attrs: Vec::new(),
items: vec![Item::Mod(block.item.clone())],
});
content.push_str(&formatted_mod);
if !content.ends_with('\n') {
content.push('\n');
}
content
}
pub fn generate_split_mod_rs(
analysis: &SplitTestAnalysis,
unique_names: &[String],
) -> Result<String> {
let mut content = String::new();
content.push_str("// Copyright 2026 COOLJAPAN OU (Team KitaSan)\n");
content.push_str("// SPDX-License-Identifier: Apache-2.0\n\n");
if !analysis.production_items.is_empty() {
let prod_file = syn::File {
shebang: None,
attrs: Vec::new(),
items: analysis.production_items.clone(),
};
let formatted = prettyplease::unparse(&prod_file);
content.push_str(&formatted);
if !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
}
for (block, unique_name) in analysis.test_modules.iter().zip(unique_names.iter()) {
for cfg_line in &block.cfg_attrs {
content.push_str(cfg_line);
content.push('\n');
}
content.push_str(&format!("mod {};\n", unique_name));
}
Ok(content)
}
pub fn generate_fallback_tests_rs(test_mod: &TestModBlock, file_uses: &[Item]) -> String {
crate::module_generator::generate_tests_rs_with_uses(
&[Item::Mod(test_mod.item.clone())],
file_uses,
)
}
pub fn run_split_test_modules(input_file: &Path, dry_run: bool) -> Result<bool> {
use std::fs;
let source = fs::read_to_string(input_file)
.with_context(|| format!("Failed to read input file: {}", input_file.display()))?;
let syntax_tree: File = syn::parse_file(&source)
.with_context(|| format!("Failed to parse Rust file: {}", input_file.display()))?;
let analysis = analyse_test_modules(&syntax_tree);
let n = analysis.test_modules.len();
if n == 0 {
println!("No test modules found in {}", input_file.display());
return Ok(false);
}
let parent = input_file.parent().unwrap_or_else(|| Path::new("."));
let stem = input_file
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
anyhow::anyhow!("Cannot determine file stem for {}", input_file.display())
})?;
let output_dir: PathBuf = parent.join(stem);
if n == 1 {
println!("Only 1 test module found; using single tests.rs fallback.");
let tests_content =
generate_fallback_tests_rs(&analysis.test_modules[0], &analysis.use_items);
let unique_names: Vec<String> = vec!["tests".to_string()];
let fallback_cfg = vec!["#[cfg(test)]".to_string()];
let fallback_block = TestModBlock {
mod_name: "tests".to_string(),
cfg_attrs: fallback_cfg,
item: analysis.test_modules[0].item.clone(),
};
let fallback_analysis = SplitTestAnalysis {
test_modules: vec![fallback_block],
production_items: analysis.production_items.clone(),
use_items: analysis.use_items.clone(),
};
let mod_content = generate_split_mod_rs(&fallback_analysis, &unique_names)?;
if dry_run {
println!(
"\nDRY RUN — files that would be created in {}:",
output_dir.display()
);
println!(" mod.rs ({} lines)", mod_content.lines().count());
println!(" tests.rs ({} lines)", tests_content.lines().count());
return Ok(false);
}
fs::create_dir_all(&output_dir)
.with_context(|| format!("Cannot create output dir: {}", output_dir.display()))?;
fs::write(output_dir.join("tests.rs"), &tests_content)
.with_context(|| "Failed to write tests.rs")?;
fs::write(output_dir.join("mod.rs"), &mod_content)
.with_context(|| "Failed to write mod.rs")?;
println!("Created: {}/tests.rs", output_dir.display());
println!("Created: {}/mod.rs", output_dir.display());
if input_file.exists() {
fs::remove_file(input_file).with_context(|| {
format!("Cannot remove original file: {}", input_file.display())
})?;
println!("Removed: {}", input_file.display());
}
return Ok(false);
}
let unique_names = make_unique_names(&analysis.test_modules);
println!(
"Found {} test modules in {} — splitting into per-module files.",
n,
input_file.display()
);
if dry_run {
println!(
"\nDRY RUN — files that would be created in {}:",
output_dir.display()
);
println!(" mod.rs");
for (block, uname) in analysis.test_modules.iter().zip(unique_names.iter()) {
println!(" {}.rs (was mod {})", uname, block.mod_name);
}
return Ok(true);
}
fs::create_dir_all(&output_dir)
.with_context(|| format!("Cannot create output dir: {}", output_dir.display()))?;
for (block, unique_name) in analysis.test_modules.iter().zip(unique_names.iter()) {
let file_content = generate_per_test_file(block, &analysis.use_items);
let file_path = output_dir.join(format!("{}.rs", unique_name));
fs::write(&file_path, &file_content)
.with_context(|| format!("Failed to write {}", file_path.display()))?;
println!("Created: {}", file_path.display());
}
let mod_content = generate_split_mod_rs(&analysis, &unique_names)?;
let mod_path = output_dir.join("mod.rs");
fs::write(&mod_path, &mod_content).with_context(|| "Failed to write mod.rs")?;
println!("Created: {}", mod_path.display());
if input_file.exists() {
fs::remove_file(input_file)
.with_context(|| format!("Cannot remove original file: {}", input_file.display()))?;
println!("Removed: {}", input_file.display());
}
Ok(true)
}
fn make_unique_names(blocks: &[TestModBlock]) -> Vec<String> {
let mut used: HashSet<String> = HashSet::new();
let mut result = Vec::with_capacity(blocks.len());
for block in blocks {
let name = pick_unique_name(&block.mod_name, &used);
used.insert(name.clone());
result.push(name);
}
result
}
fn pick_unique_name(original: &str, used: &HashSet<String>) -> String {
if !used.contains(original) {
return original.to_string();
}
let mut suffix = 2usize;
loop {
let candidate = format!("{}_{}", original, suffix);
if !used.contains(&candidate) {
return candidate;
}
suffix += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn analyse_str(src: &str) -> SplitTestAnalysis {
let file = syn::parse_file(src).expect("parse failed");
analyse_test_modules(&file)
}
#[test]
fn test_split_multiple_test_modules_basic() {
let src = r#"
use std::fmt;
pub fn production_fn() -> u32 { 42 }
#[cfg(all(test, feature = "future-tests"))]
mod tests_alpha {
use super::*;
#[test]
fn test_one() { assert_eq!(production_fn(), 42); }
}
#[cfg(all(test, feature = "future-tests"))]
mod tests_beta {
use super::*;
#[test]
fn test_two() { assert_eq!(production_fn(), 42); }
}
"#;
let analysis = analyse_str(src);
assert_eq!(
analysis.test_modules.len(),
2,
"expected 2 test modules, got {}",
analysis.test_modules.len()
);
assert_eq!(analysis.test_modules[0].mod_name, "tests_alpha");
assert_eq!(analysis.test_modules[1].mod_name, "tests_beta");
let prod_names: Vec<String> = analysis
.production_items
.iter()
.filter_map(|it| {
if let Item::Fn(f) = it {
Some(f.sig.ident.to_string())
} else {
None
}
})
.collect();
assert!(prod_names.contains(&"production_fn".to_string()));
let unique = make_unique_names(&analysis.test_modules);
assert_eq!(unique, vec!["tests_alpha", "tests_beta"]);
let tmp = std::env::temp_dir().join("splitrs_test_basic");
if tmp.exists() {
fs::remove_dir_all(&tmp).ok();
}
fs::create_dir_all(&tmp).expect("create tmp dir");
for (block, uname) in analysis.test_modules.iter().zip(unique.iter()) {
let content = generate_per_test_file(block, &analysis.use_items);
let path = tmp.join(format!("{}.rs", uname));
fs::write(&path, &content).expect("write test file");
}
let mod_content = generate_split_mod_rs(&analysis, &unique).expect("generate mod.rs");
fs::write(tmp.join("mod.rs"), &mod_content).expect("write mod.rs");
assert!(
tmp.join("tests_alpha.rs").exists(),
"tests_alpha.rs missing"
);
assert!(tmp.join("tests_beta.rs").exists(), "tests_beta.rs missing");
assert!(tmp.join("mod.rs").exists(), "mod.rs missing");
let mod_text = fs::read_to_string(tmp.join("mod.rs")).unwrap();
assert!(
mod_text.contains("production_fn"),
"mod.rs should contain production_fn"
);
assert!(
mod_text.contains("mod tests_alpha"),
"mod.rs should declare mod tests_alpha"
);
assert!(
mod_text.contains("mod tests_beta"),
"mod.rs should declare mod tests_beta"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_split_single_test_module_fallback() {
let src = r#"
pub fn solo() -> &'static str { "hello" }
#[cfg(test)]
mod tests_only {
use super::*;
#[test]
fn test_solo() { assert_eq!(solo(), "hello"); }
}
"#;
let analysis = analyse_str(src);
assert_eq!(
analysis.test_modules.len(),
1,
"expected 1 test module, got {}",
analysis.test_modules.len()
);
assert_eq!(analysis.test_modules[0].mod_name, "tests_only");
let tmp = std::env::temp_dir().join("splitrs_test_fallback");
if tmp.exists() {
fs::remove_dir_all(&tmp).ok();
}
fs::create_dir_all(&tmp).expect("create tmp dir");
let tests_content =
generate_fallback_tests_rs(&analysis.test_modules[0], &analysis.use_items);
fs::write(tmp.join("tests.rs"), &tests_content).expect("write tests.rs");
let unique_names = vec!["tests".to_string()];
let fallback_block = TestModBlock {
mod_name: "tests".to_string(),
cfg_attrs: vec!["#[cfg(test)]".to_string()],
item: analysis.test_modules[0].item.clone(),
};
let fallback_analysis = SplitTestAnalysis {
test_modules: vec![fallback_block],
production_items: analysis.production_items.clone(),
use_items: analysis.use_items.clone(),
};
let mod_content =
generate_split_mod_rs(&fallback_analysis, &unique_names).expect("generate mod.rs");
fs::write(tmp.join("mod.rs"), &mod_content).expect("write mod.rs");
assert!(tmp.join("tests.rs").exists(), "tests.rs should exist");
assert!(
!tmp.join("tests_only.rs").exists(),
"tests_only.rs should NOT exist (fallback to tests.rs)"
);
assert!(tmp.join("mod.rs").exists(), "mod.rs missing");
let tests_text = fs::read_to_string(tmp.join("tests.rs")).unwrap();
assert!(
tests_text.contains("test_solo"),
"tests.rs should contain test_solo"
);
fs::remove_dir_all(&tmp).ok();
}
}