use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub splitrs: SplitRsConfig,
pub naming: NamingConfig,
pub output: OutputConfig,
#[serde(default, rename = "target_modules")]
pub target_modules: Vec<TargetModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assign_unlisted: Option<String>,
}
impl Config {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let contents =
fs::read_to_string(path.as_ref()).context("Failed to read configuration file")?;
let config: Config =
toml::from_str(&contents).context("Failed to parse TOML configuration")?;
Ok(config)
}
pub fn load_from_current_dir() -> Self {
Self::find_and_load(".").unwrap_or_default()
}
pub fn find_and_load<P: AsRef<Path>>(start_dir: P) -> Option<Self> {
let mut current_dir = start_dir.as_ref().to_path_buf();
loop {
let config_path = current_dir.join(".splitrs.toml");
if config_path.exists() {
return Self::from_file(&config_path).ok();
}
if !current_dir.pop() {
break;
}
}
None
}
#[allow(dead_code)]
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let toml_string =
toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")?;
fs::write(path.as_ref(), toml_string).context("Failed to write configuration file")?;
Ok(())
}
pub fn merge_with_args(
&mut self,
max_lines: Option<usize>,
max_impl_lines: Option<usize>,
split_impl_blocks: Option<bool>,
) {
if let Some(max_lines) = max_lines {
self.splitrs.max_lines = max_lines;
}
if let Some(max_impl_lines) = max_impl_lines {
self.splitrs.max_impl_lines = max_impl_lines;
}
if let Some(split_impl_blocks) = split_impl_blocks {
self.splitrs.split_impl_blocks = split_impl_blocks;
}
}
pub fn merge_nested_args(
&mut self,
split_nested_mods: Option<bool>,
max_mod_depth: Option<usize>,
facade: Option<&str>,
) {
if let Some(split_nested_mods) = split_nested_mods {
self.splitrs.split_nested_mods = split_nested_mods;
}
if let Some(max_mod_depth) = max_mod_depth {
self.splitrs.max_mod_depth = max_mod_depth;
}
if let Some(facade) = facade {
self.output.facade = facade.to_string();
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SplitRsConfig {
pub max_lines: usize,
pub max_impl_lines: usize,
pub split_impl_blocks: bool,
pub incremental: bool,
pub generate_tests: bool,
pub extract_tests: bool,
pub split_nested_mods: bool,
pub max_mod_depth: usize,
}
impl Default for SplitRsConfig {
fn default() -> Self {
Self {
max_lines: 1000,
max_impl_lines: 500,
split_impl_blocks: false,
incremental: false,
generate_tests: false,
extract_tests: false,
split_nested_mods: false,
max_mod_depth: 8,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TargetModule {
pub name: String,
#[serde(default)]
pub items: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default)]
pub pull_dependencies: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lines: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TargetModulesFile {
#[serde(default)]
pub target_modules: Vec<TargetModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assign_unlisted: Option<String>,
}
impl TargetModulesFile {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let contents = fs::read_to_string(path.as_ref())
.context("Failed to read target-modules configuration file")?;
let spec: TargetModulesFile = toml::from_str(&contents)
.context("Failed to parse target-modules TOML configuration")?;
Ok(spec)
}
}
pub fn matches_pattern(item_name: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return item_name == pattern;
}
let parts: Vec<&str> = pattern.split('*').collect();
let Some((first, rest)) = parts.split_first() else {
return false;
};
let Some((last, mids)) = rest.split_last() else {
return false;
};
let mut remaining = match item_name.strip_prefix(first) {
Some(r) => r,
None => return false,
};
for mid in mids {
if mid.is_empty() {
continue;
}
match remaining.find(mid) {
Some(pos) => remaining = &remaining[pos + mid.len()..],
None => return false,
}
}
remaining.ends_with(last)
}
pub fn route_item<'a>(item_name: &str, target_modules: &'a [TargetModule]) -> Option<&'a str> {
route_item_detailed(item_name, target_modules).map(|(idx, _)| target_modules[idx].name.as_str())
}
pub fn route_item_detailed<'a>(
item_name: &str,
target_modules: &'a [TargetModule],
) -> Option<(usize, &'a str)> {
for (idx, tm) in target_modules.iter().enumerate() {
for pattern in &tm.items {
if matches_pattern(item_name, pattern) {
return Some((idx, pattern.as_str()));
}
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AssignUnlisted {
#[default]
Heuristic,
Seeded,
}
impl AssignUnlisted {
pub fn parse(value: Option<&str>) -> Result<Self> {
match value {
None => Ok(Self::Heuristic),
Some("heuristic") => Ok(Self::Heuristic),
Some("seeded") => Ok(Self::Seeded),
Some(other) => anyhow::bail!(
"invalid assign_unlisted value {:?}: expected \"heuristic\" or \"seeded\"",
other
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FacadeStyle {
#[default]
Glob,
Named,
None,
}
impl FacadeStyle {
pub fn parse(value: &str) -> Result<Self> {
match value {
"glob" => Ok(Self::Glob),
"named" => Ok(Self::Named),
"none" => Ok(Self::None),
other => anyhow::bail!(
"invalid facade style {:?}: expected \"glob\", \"named\" or \"none\"",
other
),
}
}
}
pub fn validate_target_modules(rules: &[TargetModule]) -> Result<()> {
use std::collections::HashSet;
let mut seen: HashSet<(String, Option<String>)> = HashSet::new();
for rule in rules {
if rule.name.is_empty() {
anyhow::bail!(
"[[target_modules]] rule with empty `name`; every rule needs a module name"
);
}
if rule.items.is_empty() {
anyhow::bail!(
"[[target_modules]] rule `{}` has an empty `items` list; \
add at least one pattern or remove the rule",
rule.name
);
}
if !seen.insert((rule.name.clone(), rule.parent.clone())) {
match &rule.parent {
Some(parent) => anyhow::bail!(
"duplicate [[target_modules]] rule `{}` under parent `{}`",
rule.name,
parent
),
None => anyhow::bail!("duplicate [[target_modules]] rule `{}`", rule.name),
}
}
}
let mut scopes: Vec<(Option<&String>, Vec<&TargetModule>)> = Vec::new();
for rule in rules {
let parent = rule.parent.as_ref();
match scopes.iter_mut().find(|(p, _)| *p == parent) {
Some((_, list)) => list.push(rule),
None => scopes.push((parent, vec![rule])),
}
}
for (parent, list) in &scopes {
if let Some(pos) = list.iter().position(|r| r.items.iter().any(|p| p == "*")) {
if pos + 1 < list.len() {
let scope = parent
.map(|p| format!(" (parent `{}`)", p))
.unwrap_or_default();
anyhow::bail!(
"[[target_modules]] rule `{}`{} uses the catch-all pattern `*` but is not \
the last rule of its scope; the {} later rule(s) would never match — move \
the catch-all to the end",
list[pos].name,
scope,
list.len() - pos - 1
);
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NamingConfig {
pub strategy: String,
pub type_module_suffix: String,
pub impl_module_suffix: String,
pub trait_module_suffix: String,
pub use_snake_case: bool,
#[serde(default)]
pub custom_type_mappings: std::collections::HashMap<String, String>,
#[serde(default)]
pub custom_pattern_mappings: std::collections::HashMap<String, String>,
}
impl Default for NamingConfig {
fn default() -> Self {
Self {
strategy: "snake_case".to_string(),
type_module_suffix: "_type".to_string(),
impl_module_suffix: "_impl".to_string(),
trait_module_suffix: "_traits".to_string(),
use_snake_case: true,
custom_type_mappings: std::collections::HashMap::new(),
custom_pattern_mappings: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub module_doc_template: String,
pub preserve_comments: bool,
pub format_output: bool,
pub facade: String,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
module_doc_template: "//! Auto-generated module\n".to_string(),
preserve_comments: true,
format_output: true,
facade: "glob".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.splitrs.max_lines, 1000);
assert_eq!(config.splitrs.max_impl_lines, 500);
assert!(!config.splitrs.split_impl_blocks);
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_string = toml::to_string(&config).unwrap();
assert!(toml_string.contains("max_lines"));
assert!(toml_string.contains("max_impl_lines"));
}
#[test]
fn test_config_deserialization() {
let toml_str = r#"
[splitrs]
max_lines = 800
max_impl_lines = 400
split_impl_blocks = true
[naming]
type_module_suffix = "_types"
impl_module_suffix = "_methods"
[output]
preserve_comments = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.splitrs.max_lines, 800);
assert_eq!(config.splitrs.max_impl_lines, 400);
assert!(config.splitrs.split_impl_blocks);
assert_eq!(config.naming.type_module_suffix, "_types");
assert!(!config.output.preserve_comments);
}
#[test]
fn test_config_merge_with_args() {
let mut config = Config::default();
config.merge_with_args(Some(1500), Some(600), Some(true));
assert_eq!(config.splitrs.max_lines, 1500);
assert_eq!(config.splitrs.max_impl_lines, 600);
assert!(config.splitrs.split_impl_blocks);
}
#[test]
fn test_config_save_and_load() {
let temp_dir = env::temp_dir();
let config_path = temp_dir.join("test_splitrs.toml");
let config = Config::default();
config.save_to_file(&config_path).unwrap();
let loaded_config = Config::from_file(&config_path).unwrap();
assert_eq!(loaded_config.splitrs.max_lines, config.splitrs.max_lines);
let _ = fs::remove_file(config_path);
}
#[test]
fn test_extract_tests_default_is_false() {
let config = Config::default();
assert!(!config.splitrs.extract_tests);
}
#[test]
fn test_extract_tests_deserialization() {
let toml_str = r#"
[splitrs]
extract_tests = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.splitrs.extract_tests);
}
#[test]
fn test_target_modules_embedded_in_main_config() {
let toml_str = r#"
[splitrs]
max_lines = 1000
[[target_modules]]
name = "v3"
items = ["FooV3", "BarV3"]
[[target_modules]]
name = "core"
items = ["*"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.target_modules.len(), 2);
assert_eq!(config.target_modules[0].name, "v3");
assert_eq!(config.target_modules[0].items, vec!["FooV3", "BarV3"]);
assert_eq!(config.target_modules[1].name, "core");
assert_eq!(config.target_modules[1].items, vec!["*"]);
}
#[test]
fn test_target_modules_standalone_file() {
let toml_str = r#"
[[target_modules]]
name = "v3"
items = ["BoundaryExtV3", "StreamingBoundaryEstimator"]
[[target_modules]]
name = "extended"
items = ["BoundaryExt*"]
"#;
let spec: TargetModulesFile = toml::from_str(toml_str).unwrap();
assert_eq!(spec.target_modules.len(), 2);
assert_eq!(spec.target_modules[0].name, "v3");
assert_eq!(spec.target_modules[1].items, vec!["BoundaryExt*"]);
}
#[test]
fn test_matches_pattern_exact() {
assert!(matches_pattern("Foo", "Foo"));
assert!(!matches_pattern("Foo", "Bar"));
assert!(!matches_pattern("FooBar", "Foo"));
}
#[test]
fn test_matches_pattern_prefix() {
assert!(matches_pattern("FooBar", "Foo*"));
assert!(matches_pattern("Foo", "Foo*"));
assert!(matches_pattern("FooBarBaz", "Foo*"));
assert!(!matches_pattern("Bar", "Foo*"));
}
#[test]
fn test_matches_pattern_suffix() {
assert!(matches_pattern("MyConfig", "*Config"));
assert!(matches_pattern("Config", "*Config"));
assert!(!matches_pattern("ConfigPath", "*Config"));
}
#[test]
fn test_matches_pattern_wildcard() {
assert!(matches_pattern("", "*"));
assert!(matches_pattern("AnyThing", "*"));
assert!(matches_pattern("foo_bar", "*"));
}
#[test]
fn test_route_item_first_match_wins() {
let rules = vec![
TargetModule {
name: "v3".to_string(),
items: vec!["FooV3".to_string()],
..Default::default()
},
TargetModule {
name: "extended".to_string(),
items: vec!["Foo*".to_string()],
..Default::default()
},
TargetModule {
name: "core".to_string(),
items: vec!["*".to_string()],
..Default::default()
},
];
assert_eq!(route_item("FooV3", &rules), Some("v3"));
assert_eq!(route_item("FooBar", &rules), Some("extended"));
assert_eq!(route_item("Quux", &rules), Some("core"));
}
#[test]
fn test_route_item_no_match_returns_none() {
let rules = vec![TargetModule {
name: "v3".to_string(),
items: vec!["FooV3".to_string()],
..Default::default()
}];
assert_eq!(route_item("Bar", &rules), None);
}
#[test]
fn test_matches_pattern_infix() {
assert!(matches_pattern("compute_hash_fast", "*hash*"));
assert!(matches_pattern("hash", "*hash*"));
assert!(matches_pattern("rehash", "*hash*"));
assert!(!matches_pattern("digest", "*hash*"));
}
#[test]
fn test_matches_pattern_multi_glob() {
assert!(matches_pattern("alpha_beta_gamma", "alpha*gamma"));
assert!(matches_pattern("alpha_beta_gamma", "a*beta*ma"));
assert!(!matches_pattern("alpha_gamma", "a*beta*ma"));
assert!(!matches_pattern("gamma_beta_alpha", "alpha*gamma"));
assert!(!matches_pattern("ab", "a*b*ab"));
}
#[test]
fn test_route_item_detailed_reports_rule_and_pattern() {
let rules = vec![
TargetModule {
name: "hash".to_string(),
items: vec!["*hash*".to_string()],
..Default::default()
},
TargetModule {
name: "fs".to_string(),
items: vec!["copy_*".to_string()],
..Default::default()
},
];
assert_eq!(
route_item_detailed("compute_hash", &rules),
Some((0, "*hash*"))
);
assert_eq!(
route_item_detailed("copy_file", &rules),
Some((1, "copy_*"))
);
assert_eq!(route_item_detailed("unrelated", &rules), None);
}
#[test]
fn test_target_module_extended_schema_parses() {
let toml_str = r#"
assign_unlisted = "seeded"
[[target_modules]]
name = "hash"
parent = "core"
items = ["*hash*", "Sha*"]
pull_dependencies = true
doc = "Hashing helpers"
max_lines = 1200
"#;
let spec: TargetModulesFile = toml::from_str(toml_str).unwrap();
assert_eq!(spec.assign_unlisted.as_deref(), Some("seeded"));
let rule = &spec.target_modules[0];
assert_eq!(rule.name, "hash");
assert_eq!(rule.parent.as_deref(), Some("core"));
assert!(rule.pull_dependencies);
assert_eq!(rule.doc.as_deref(), Some("Hashing helpers"));
assert_eq!(rule.max_lines, Some(1200));
}
#[test]
fn test_old_spec_files_parse_unchanged() {
let toml_str = r#"
[[target_modules]]
name = "v3"
items = ["FooV3"]
"#;
let spec: TargetModulesFile = toml::from_str(toml_str).unwrap();
let rule = &spec.target_modules[0];
assert_eq!(rule.parent, None);
assert!(!rule.pull_dependencies);
assert_eq!(rule.doc, None);
assert_eq!(rule.max_lines, None);
assert_eq!(spec.assign_unlisted, None);
}
#[test]
fn test_assign_unlisted_parse() {
assert_eq!(
AssignUnlisted::parse(None).unwrap(),
AssignUnlisted::Heuristic
);
assert_eq!(
AssignUnlisted::parse(Some("seeded")).unwrap(),
AssignUnlisted::Seeded
);
assert!(AssignUnlisted::parse(Some("magic")).is_err());
}
#[test]
fn test_facade_style_parse() {
assert_eq!(FacadeStyle::parse("glob").unwrap(), FacadeStyle::Glob);
assert_eq!(FacadeStyle::parse("named").unwrap(), FacadeStyle::Named);
assert_eq!(FacadeStyle::parse("none").unwrap(), FacadeStyle::None);
assert!(FacadeStyle::parse("all").is_err());
}
#[test]
fn test_validate_rejects_duplicate_names() {
let rules = vec![
TargetModule {
name: "core".to_string(),
items: vec!["Foo".to_string()],
..Default::default()
},
TargetModule {
name: "core".to_string(),
items: vec!["Bar".to_string()],
..Default::default()
},
];
assert!(validate_target_modules(&rules).is_err());
}
#[test]
fn test_validate_allows_same_name_in_different_parents() {
let rules = vec![
TargetModule {
name: "fs".to_string(),
items: vec!["Foo".to_string()],
..Default::default()
},
TargetModule {
name: "fs".to_string(),
items: vec!["Bar".to_string()],
parent: Some("core".to_string()),
..Default::default()
},
];
assert!(validate_target_modules(&rules).is_ok());
}
#[test]
fn test_validate_rejects_empty_items() {
let rules = vec![TargetModule {
name: "core".to_string(),
..Default::default()
}];
assert!(validate_target_modules(&rules).is_err());
}
#[test]
fn test_validate_rejects_early_wildcard() {
let rules = vec![
TargetModule {
name: "everything".to_string(),
items: vec!["*".to_string()],
..Default::default()
},
TargetModule {
name: "dead".to_string(),
items: vec!["Foo".to_string()],
..Default::default()
},
];
let err = validate_target_modules(&rules)
.expect_err("early wildcard must be rejected")
.to_string();
assert!(err.contains("catch-all"), "unexpected error: {err}");
}
#[test]
fn test_validate_accepts_trailing_wildcard() {
let rules = vec![
TargetModule {
name: "v3".to_string(),
items: vec!["FooV3".to_string()],
..Default::default()
},
TargetModule {
name: "everything".to_string(),
items: vec!["*".to_string()],
..Default::default()
},
];
assert!(validate_target_modules(&rules).is_ok());
}
#[test]
fn test_nested_mods_config_defaults_and_parse() {
let config = Config::default();
assert!(!config.splitrs.split_nested_mods);
assert_eq!(config.splitrs.max_mod_depth, 8);
assert_eq!(config.output.facade, "glob");
let toml_str = r#"
[splitrs]
split_nested_mods = true
max_mod_depth = 3
[output]
facade = "named"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.splitrs.split_nested_mods);
assert_eq!(config.splitrs.max_mod_depth, 3);
assert_eq!(config.output.facade, "named");
}
#[test]
fn test_merge_nested_args_cli_precedence() {
let mut config = Config::default();
config.merge_nested_args(Some(true), Some(2), Some("none"));
assert!(config.splitrs.split_nested_mods);
assert_eq!(config.splitrs.max_mod_depth, 2);
assert_eq!(config.output.facade, "none");
}
}