diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e16d8088a..1e8442c86 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ - "." + ".", + "herb-config" ] [package] diff --git a/rust/herb-config/Cargo.toml b/rust/herb-config/Cargo.toml new file mode 100644 index 000000000..9a9b7bd5b --- /dev/null +++ b/rust/herb-config/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "herb-config" +version = "0.8.10" +edition = "2021" +authors = ["Marco Roth "] +description = "Shared configuration utilities for Herb tools" +license = "MIT" +repository = "https://github.com/marcoroth/herb" + +[lib] +name = "herb_config" +path = "src/lib.rs" + +[dependencies] +globset = "0.4" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" + +[dev-dependencies] +serde_yaml = "0.9" +tempfile = "3" diff --git a/rust/herb-config/src/formatter_config.rs b/rust/herb-config/src/formatter_config.rs new file mode 100644 index 000000000..39299fa95 --- /dev/null +++ b/rust/herb-config/src/formatter_config.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +pub(crate) fn default_indent_width() -> usize { + 2 +} + +pub(crate) fn default_max_line_length() -> usize { + 80 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormatterConfig { + #[serde(default = "default_indent_width")] + pub indent_width: usize, + #[serde(default = "default_max_line_length")] + pub max_line_length: usize, +} + +impl Default for FormatterConfig { + fn default() -> Self { + Self { + indent_width: default_indent_width(), + max_line_length: default_max_line_length(), + } + } +} + +impl FormatterConfig { + pub fn new() -> Self { + Self::default() + } +} diff --git a/rust/herb-config/src/glob.rs b/rust/herb-config/src/glob.rs new file mode 100644 index 000000000..a8cad853e --- /dev/null +++ b/rust/herb-config/src/glob.rs @@ -0,0 +1,12 @@ +use globset::GlobBuilder; + +pub fn is_path_matching(file_path: &str, patterns: &[impl AsRef]) -> bool { + patterns.iter().any(|pattern| { + GlobBuilder::new(pattern.as_ref()) + .literal_separator(true) + .build() + .ok() + .map(|g| g.compile_matcher().is_match(file_path)) + .unwrap_or(false) + }) +} diff --git a/rust/herb-config/src/herb_config.rs b/rust/herb-config/src/herb_config.rs new file mode 100644 index 000000000..9cb914130 --- /dev/null +++ b/rust/herb-config/src/herb_config.rs @@ -0,0 +1,272 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::formatter_config::{default_indent_width, default_max_line_length}; +use crate::rule_config::default_enabled; +use crate::{FormatterConfig, LinterConfig, RuleConfig, Severity}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FilesConfig { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HerbLinterConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub fail_level: Option, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, + #[serde(default)] + pub rules: HashMap, +} + +impl Default for HerbLinterConfig { + fn default() -> Self { + Self { + enabled: true, + fail_level: None, + include: Vec::new(), + exclude: Vec::new(), + rules: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HerbFormatterConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_indent_width")] + pub indent_width: usize, + #[serde(default = "default_max_line_length")] + pub max_line_length: usize, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl Default for HerbFormatterConfig { + fn default() -> Self { + Self { + enabled: false, + indent_width: default_indent_width(), + max_line_length: default_max_line_length(), + include: Vec::new(), + exclude: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HerbConfig { + #[serde(default)] + pub version: Option, + #[serde(default)] + pub files: FilesConfig, + #[serde(default)] + pub linter: HerbLinterConfig, + #[serde(default)] + pub formatter: HerbFormatterConfig, +} + +pub const DEFAULT_INCLUDE_PATTERNS: &[&str] = &[ + "**/*.herb", + "**/*.html.erb", + "**/*.html.herb", + "**/*.html", + "**/*.html+*.erb", + "**/*.rhtml", + "**/*.turbo_stream.erb", +]; + +pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &["coverage/**/*", "log/**/*", "node_modules/**/*", "storage/**/*", "tmp/**/*", "vendor/**/*"]; + +const CONFIG_FILE_NAME: &str = ".herb.yml"; +const PROJECT_INDICATORS: &[&str] = &[".git", ".herb", "Gemfile", "package.json", "Rakefile", "README.md"]; + +impl HerbConfig { + pub fn load(start_path: &Path, explicit_config_file: Option<&str>) -> (Self, Option) { + if let Some(explicit) = explicit_config_file { + let config_path = Path::new(explicit); + + if !config_path.exists() { + eprintln!("Error: Configuration file '{}' does not exist", explicit); + std::process::exit(1); + } + + match Self::load_from_path(config_path) { + Ok(config) => return (config, Some(config_path.to_path_buf())), + Err(error) => { + eprintln!("Error: Failed to parse configuration file '{}': {}", explicit, error); + std::process::exit(1); + } + } + } + + if let Some(config_path) = Self::find_config_file(start_path) { + match Self::load_from_path(&config_path) { + Ok(config) => return (config, Some(config_path)), + Err(error) => { + eprintln!("Error: Failed to parse configuration file '{}': {}", config_path.display(), error); + std::process::exit(1); + } + } + } + + (Self::default(), None) + } + + pub fn load_from_path(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|error| format!("Failed to read file: {}", error))?; + + serde_yaml::from_str(&content).map_err(|error| format!("Failed to parse YAML: {}", error)) + } + + pub fn find_config_file(start_path: &Path) -> Option { + let start = if start_path.is_file() { start_path.parent()? } else { start_path }; + + let mut current = start.to_path_buf(); + + loop { + let config_path = current.join(CONFIG_FILE_NAME); + if config_path.exists() { + return Some(config_path); + } + + if !current.pop() { + break; + } + } + + None + } + + pub fn find_project_root(start_path: &Path) -> PathBuf { + let start = if start_path.is_file() { + start_path.parent().unwrap_or(start_path) + } else { + start_path + }; + + let mut current = start.to_path_buf(); + + loop { + if current.join(CONFIG_FILE_NAME).exists() { + return current; + } + + for indicator in PROJECT_INDICATORS { + if current.join(indicator).exists() { + return current; + } + } + + if !current.pop() { + break; + } + } + + start.to_path_buf() + } + + pub fn linter_include_patterns(&self) -> Vec { + let mut patterns: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|string| string.to_string()).collect(); + + for pattern in &self.files.include { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + for pattern in &self.linter.include { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + patterns + } + + pub fn linter_exclude_patterns(&self) -> Vec { + let mut patterns: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|string| string.to_string()).collect(); + + for pattern in &self.files.exclude { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + for pattern in &self.linter.exclude { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + patterns + } + + pub fn to_linter_config(&self) -> LinterConfig { + LinterConfig { + rules: self.linter.rules.clone(), + } + } + + pub fn to_formatter_config(&self) -> FormatterConfig { + FormatterConfig { + indent_width: self.formatter.indent_width, + max_line_length: self.formatter.max_line_length, + } + } + + pub fn formatter_include_patterns(&self) -> Vec { + let mut patterns: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|string| string.to_string()).collect(); + + for pattern in &self.files.include { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + for pattern in &self.formatter.include { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + patterns + } + + pub fn formatter_exclude_patterns(&self) -> Vec { + let mut patterns: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|string| string.to_string()).collect(); + + for pattern in &self.files.exclude { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + for pattern in &self.formatter.exclude { + if !patterns.contains(pattern) { + patterns.push(pattern.clone()); + } + } + + patterns + } + + pub fn default_template() -> &'static str { + include_str!("../../../javascript/packages/config/src/config-template.yml") + } +} diff --git a/rust/herb-config/src/lib.rs b/rust/herb-config/src/lib.rs new file mode 100644 index 000000000..b209da832 --- /dev/null +++ b/rust/herb-config/src/lib.rs @@ -0,0 +1,14 @@ +pub mod glob; + +mod formatter_config; +mod herb_config; +mod linter_config; +mod rule_config; +mod severity; + +pub use formatter_config::FormatterConfig; +pub use glob::is_path_matching; +pub use herb_config::{FilesConfig, HerbConfig, HerbFormatterConfig, HerbLinterConfig, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_INCLUDE_PATTERNS}; +pub use linter_config::LinterConfig; +pub use rule_config::RuleConfig; +pub use severity::Severity; diff --git a/rust/herb-config/src/linter_config.rs b/rust/herb-config/src/linter_config.rs new file mode 100644 index 000000000..bcd26ddbc --- /dev/null +++ b/rust/herb-config/src/linter_config.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::glob::is_path_matching; +use crate::{RuleConfig, Severity}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LinterConfig { + #[serde(default)] + pub rules: HashMap, +} + +impl LinterConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn get_rule_config(&self, rule_name: &str) -> Option<&RuleConfig> { + self.rules.get(rule_name) + } + + pub fn is_rule_enabled(&self, rule_name: &str, default_enabled: bool) -> bool { + match self.rules.get(rule_name) { + Some(config) => config.enabled, + None => default_enabled, + } + } + + pub fn get_severity(&self, rule_name: &str, default_severity: Severity) -> Severity { + match self.rules.get(rule_name) { + Some(config) => config.severity.unwrap_or(default_severity), + None => default_severity, + } + } + + pub fn is_rule_enabled_for_path(&self, rule_name: &str, file_path: &str, default_exclude: &[&str]) -> bool { + if let Some(config) = self.rules.get(rule_name) { + if !config.only.is_empty() { + if !is_path_matching(file_path, &config.only) { + return false; + } + + return !is_path_matching(file_path, &config.exclude); + } + + if !config.include.is_empty() { + if !is_path_matching(file_path, &config.include) { + return false; + } + + return !is_path_matching(file_path, &config.exclude); + } + + if is_path_matching(file_path, &config.exclude) { + return false; + } + } + + let has_user_exclude = self.rules.get(rule_name).map(|c| !c.exclude.is_empty()).unwrap_or(false); + + if !has_user_exclude && !default_exclude.is_empty() && is_path_matching(file_path, default_exclude) { + return false; + } + + true + } +} diff --git a/rust/herb-config/src/rule_config.rs b/rust/herb-config/src/rule_config.rs new file mode 100644 index 000000000..eda4a7a61 --- /dev/null +++ b/rust/herb-config/src/rule_config.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +use crate::Severity; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuleConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub only: Vec, + #[serde(default)] + pub exclude: Vec, +} + +pub(crate) fn default_enabled() -> bool { + true +} + +impl Default for RuleConfig { + fn default() -> Self { + Self { + enabled: true, + severity: None, + include: Vec::new(), + only: Vec::new(), + exclude: Vec::new(), + } + } +} diff --git a/rust/herb-config/src/severity.rs b/rust/herb-config/src/severity.rs new file mode 100644 index 000000000..0124b17ee --- /dev/null +++ b/rust/herb-config/src/severity.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +impl Severity { + pub fn as_str(&self) -> &'static str { + match self { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Hint => "hint", + } + } +} + +impl std::fmt::Display for Severity { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.as_str()) + } +} diff --git a/rust/herb-config/tests/glob_test.rs b/rust/herb-config/tests/glob_test.rs new file mode 100644 index 000000000..97cd1165c --- /dev/null +++ b/rust/herb-config/tests/glob_test.rs @@ -0,0 +1,62 @@ +use herb_config::is_path_matching; + +fn glob_match(pattern: &str, path: &str) -> bool { + is_path_matching(path, &[pattern]) +} + +#[test] +fn test_double_star_extension() { + assert!(glob_match("**/*.xml", "file.xml")); + assert!(glob_match("**/*.xml", "dir/file.xml")); + assert!(glob_match("**/*.xml", "a/b/c/file.xml")); + assert!(!glob_match("**/*.xml", "file.rs")); + assert!(!glob_match("**/*.xml", "file.xml.erb")); +} + +#[test] +fn test_double_star_compound_extension() { + assert!(glob_match("**/*.xml.erb", "file.xml.erb")); + assert!(glob_match("**/*.xml.erb", "dir/file.xml.erb")); + assert!(!glob_match("**/*.xml.erb", "file.xml")); + assert!(!glob_match("**/*.xml.erb", "file.erb")); +} + +#[test] +fn test_path_prefix_with_double_star() { + assert!(glob_match("app/views/**/*", "app/views/home/index.html")); + assert!(glob_match("app/views/**/*", "app/views/file.erb")); + assert!(!glob_match("app/views/**/*", "lib/views/file.erb")); +} + +#[test] +fn test_single_star() { + assert!(glob_match("*.xml", "file.xml")); + assert!(!glob_match("*.xml", "dir/file.xml")); +} + +#[test] +fn test_question_mark() { + assert!(glob_match("file?.xml", "file1.xml")); + assert!(!glob_match("file?.xml", "file.xml")); + assert!(!glob_match("file?.xml", "file12.xml")); +} + +#[test] +fn test_exact_match() { + assert!(glob_match("file.xml", "file.xml")); + assert!(!glob_match("file.xml", "other.xml")); +} + +#[test] +fn test_is_path_matching() { + let patterns = vec!["**/*.xml".to_string(), "**/*.xml.erb".to_string()]; + assert!(is_path_matching("file.xml", &patterns)); + assert!(is_path_matching("dir/file.xml.erb", &patterns)); + assert!(!is_path_matching("file.html", &patterns)); +} + +#[test] +fn test_empty_patterns() { + let patterns: Vec = vec![]; + assert!(!is_path_matching("file.xml", &patterns)); +} diff --git a/rust/herb-config/tests/herb_config_test.rs b/rust/herb-config/tests/herb_config_test.rs new file mode 100644 index 000000000..fdf056975 --- /dev/null +++ b/rust/herb-config/tests/herb_config_test.rs @@ -0,0 +1,507 @@ +use std::fs; + +use herb_config::{HerbConfig, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_INCLUDE_PATTERNS}; + +#[test] +fn default_include_patterns_contains_common_erb_patterns() { + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.html.erb")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.html")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.rhtml")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.turbo_stream.erb")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.herb")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.html.herb")); + assert!(DEFAULT_INCLUDE_PATTERNS.contains(&"**/*.html+*.erb")); +} + +#[test] +fn default_exclude_patterns_contains_common_directories() { + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"coverage/**/*")); + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"log/**/*")); + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"node_modules/**/*")); + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"storage/**/*")); + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"tmp/**/*")); + assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"vendor/**/*")); +} + +#[test] +fn default_config_has_linter_enabled() { + let config = HerbConfig::default(); + + assert!(config.linter.enabled); +} + +#[test] +fn default_config_has_formatter_disabled() { + let config = HerbConfig::default(); + + assert!(!config.formatter.enabled); +} + +#[test] +fn default_config_has_default_formatter_settings() { + let config = HerbConfig::default(); + + assert_eq!(config.formatter.indent_width, 2); + assert_eq!(config.formatter.max_line_length, 80); +} + +#[test] +fn linter_include_patterns_returns_defaults_when_no_config() { + let config = HerbConfig::default(); + let patterns = config.linter_include_patterns(); + + let expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_include_patterns_combines_files_include_with_defaults() { + let yaml = r#" +files: + include: + - '**/*.xml' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_include_patterns(); + + let mut expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("**/*.xml".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_include_patterns_combines_linter_include_with_defaults() { + let yaml = r#" +linter: + include: + - '**/*.xml.erb' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_include_patterns(); + + let mut expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("**/*.xml.erb".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_include_patterns_combines_all_levels() { + let yaml = r#" +files: + include: + - '**/*.xml' +linter: + include: + - '**/*.custom.erb' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_include_patterns(); + + let mut expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("**/*.xml".into()); + expected.push("**/*.custom.erb".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_include_patterns_deduplicates() { + let yaml = r#" +files: + include: + - '**/*.html.erb' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_include_patterns(); + + let count = patterns.iter().filter(|p| p.as_str() == "**/*.html.erb").count(); + assert_eq!(count, 1); +} + +#[test] +fn linter_exclude_patterns_returns_defaults_when_no_config() { + let config = HerbConfig::default(); + let patterns = config.linter_exclude_patterns(); + + let expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_exclude_patterns_combines_files_exclude_with_defaults() { + let yaml = r#" +files: + exclude: + - 'public/**/*' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_exclude_patterns(); + + let mut expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("public/**/*".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_exclude_patterns_combines_linter_exclude_with_defaults() { + let yaml = r#" +linter: + exclude: + - 'legacy/**/*' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_exclude_patterns(); + + let mut expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("legacy/**/*".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_exclude_patterns_combines_files_and_linter_exclude() { + let yaml = r#" +files: + exclude: + - 'public/**/*' +linter: + exclude: + - 'legacy/**/*' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_exclude_patterns(); + + let mut expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("public/**/*".into()); + expected.push("legacy/**/*".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_exclude_patterns_deduplicates() { + let yaml = r#" +files: + exclude: + - 'vendor/**/*' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.linter_exclude_patterns(); + + let count = patterns.iter().filter(|p| p.as_str() == "vendor/**/*").count(); + assert_eq!(count, 1); +} + +#[test] +fn formatter_include_patterns_returns_defaults_when_no_config() { + let config = HerbConfig::default(); + let patterns = config.formatter_include_patterns(); + + let expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + assert_eq!(patterns, expected); +} + +#[test] +fn formatter_include_patterns_combines_files_and_formatter_include() { + let yaml = r#" +files: + include: + - '**/*.xml' +formatter: + include: + - '**/*.custom.erb' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.formatter_include_patterns(); + + let mut expected: Vec = DEFAULT_INCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("**/*.xml".into()); + expected.push("**/*.custom.erb".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn formatter_exclude_patterns_returns_defaults_when_no_config() { + let config = HerbConfig::default(); + let patterns = config.formatter_exclude_patterns(); + + let expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + assert_eq!(patterns, expected); +} + +#[test] +fn formatter_exclude_patterns_combines_files_and_formatter_exclude() { + let yaml = r#" +files: + exclude: + - 'public/**/*' +formatter: + exclude: + - 'test/**/*' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let patterns = config.formatter_exclude_patterns(); + + let mut expected: Vec = DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()).collect(); + expected.push("public/**/*".into()); + expected.push("test/**/*".into()); + assert_eq!(patterns, expected); +} + +#[test] +fn linter_and_formatter_patterns_are_independent() { + let yaml = r#" +files: + include: + - '**/*.xml' +linter: + include: + - '**/*.custom.erb' +formatter: + include: + - '**/*.special.erb' +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + + let linter = config.linter_include_patterns(); + let formatter = config.formatter_include_patterns(); + + assert!(linter.contains(&"**/*.xml".to_string())); + assert!(formatter.contains(&"**/*.xml".to_string())); + + assert!(linter.contains(&"**/*.custom.erb".to_string())); + assert!(!linter.contains(&"**/*.special.erb".to_string())); + + assert!(formatter.contains(&"**/*.special.erb".to_string())); + assert!(!formatter.contains(&"**/*.custom.erb".to_string())); +} + +#[test] +fn to_linter_config_transfers_rules() { + let yaml = r#" +linter: + rules: + html-tag-name-lowercase: + enabled: false + severity: error +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let linter_config = config.to_linter_config(); + + assert!(!linter_config.is_rule_enabled("html-tag-name-lowercase", true)); +} + +#[test] +fn to_formatter_config_transfers_settings() { + let yaml = r#" +formatter: + indentWidth: 4 + maxLineLength: 120 +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let formatter_config = config.to_formatter_config(); + + assert_eq!(formatter_config.indent_width, 4); + assert_eq!(formatter_config.max_line_length, 120); +} + +#[test] +fn to_formatter_config_uses_defaults() { + let config = HerbConfig::default(); + let formatter_config = config.to_formatter_config(); + + assert_eq!(formatter_config.indent_width, 2); + assert_eq!(formatter_config.max_line_length, 80); +} + +#[test] +fn load_from_path_parses_yaml() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + fs::write( + &config_path, + r#" +version: 0.8.10 +linter: + enabled: true + rules: + html-tag-name-lowercase: + enabled: false +formatter: + enabled: false + indentWidth: 4 + maxLineLength: 120 +"#, + ) + .unwrap(); + + let config = HerbConfig::load_from_path(&config_path).unwrap(); + + assert_eq!(config.version, Some("0.8.10".into())); + assert!(config.linter.enabled); + assert!(!config.formatter.enabled); + assert_eq!(config.formatter.indent_width, 4); + assert_eq!(config.formatter.max_line_length, 120); + + let linter_config = config.to_linter_config(); + assert!(!linter_config.is_rule_enabled("html-tag-name-lowercase", true)); +} + +#[test] +fn load_from_path_returns_error_for_nonexistent_file() { + let result = HerbConfig::load_from_path(std::path::Path::new("/nonexistent/.herb.yml")); + + assert!(result.is_err()); +} + +#[test] +fn load_from_path_returns_error_for_invalid_yaml() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + fs::write(&config_path, "{{invalid yaml}}").unwrap(); + + let result = HerbConfig::load_from_path(&config_path); + + assert!(result.is_err()); +} + +#[test] +fn find_config_file_finds_config_in_current_dir() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + fs::write(&config_path, "version: 0.8.10\n").unwrap(); + + let found = HerbConfig::find_config_file(dir.path()); + + assert_eq!(found, Some(config_path)); +} + +#[test] +fn find_config_file_walks_up_directory_tree() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + fs::write(&config_path, "version: 0.8.10\n").unwrap(); + + let sub_dir = dir.path().join("app").join("views"); + fs::create_dir_all(&sub_dir).unwrap(); + + let found = HerbConfig::find_config_file(&sub_dir); + + assert_eq!(found, Some(config_path)); +} + +#[test] +fn find_config_file_returns_none_when_not_found() { + let dir = tempfile::tempdir().unwrap(); + + let found = HerbConfig::find_config_file(dir.path()); + + assert!(found.is_none() || found.is_some()); +} + +#[test] +fn load_full_config_with_all_sections() { + let yaml = r#" +version: 0.8.10 + +files: + include: + - '**/*.xml.erb' + exclude: + - 'public/**/*' + +linter: + enabled: true + failLevel: warning + include: + - '**/*.custom.erb' + exclude: + - 'legacy/**/*' + rules: + html-tag-name-lowercase: + enabled: false + severity: error + erb-require-trailing-newline: + only: + - 'app/views/**/*' + exclude: + - 'app/views/admin/**/*' + +formatter: + enabled: true + indentWidth: 4 + maxLineLength: 100 + include: + - '**/*.special.erb' + exclude: + - 'generated/**/*' +"#; + + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(config.version, Some("0.8.10".into())); + + assert_eq!(config.files.include, vec!["**/*.xml.erb"]); + assert_eq!(config.files.exclude, vec!["public/**/*"]); + + assert!(config.linter.enabled); + assert_eq!(config.linter.include, vec!["**/*.custom.erb"]); + assert_eq!(config.linter.exclude, vec!["legacy/**/*"]); + + let linter_config = config.to_linter_config(); + assert!(!linter_config.is_rule_enabled("html-tag-name-lowercase", true)); + assert!(linter_config.is_rule_enabled("erb-require-trailing-newline", true)); + assert!(linter_config.is_rule_enabled_for_path("erb-require-trailing-newline", "app/views/home/index.html.erb", &[])); + assert!(!linter_config.is_rule_enabled_for_path("erb-require-trailing-newline", "app/views/admin/dashboard.html.erb", &[])); + assert!(!linter_config.is_rule_enabled_for_path("erb-require-trailing-newline", "lib/templates/email.html.erb", &[])); + + assert!(config.formatter.enabled); + assert_eq!(config.formatter.indent_width, 4); + assert_eq!(config.formatter.max_line_length, 100); + + let linter_includes = config.linter_include_patterns(); + assert!(linter_includes.contains(&"**/*.xml.erb".to_string())); + assert!(linter_includes.contains(&"**/*.custom.erb".to_string())); + + let linter_excludes = config.linter_exclude_patterns(); + assert!(linter_excludes.contains(&"public/**/*".to_string())); + assert!(linter_excludes.contains(&"legacy/**/*".to_string())); +} + +#[test] +fn severity_parses_from_yaml() { + let yaml = r#" +linter: + rules: + rule-a: + severity: error + rule-b: + severity: warning + rule-c: + severity: info + rule-d: + severity: hint +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + let linter = config.to_linter_config(); + + assert_eq!(linter.get_severity("rule-a", herb_config::Severity::Warning), herb_config::Severity::Error); + assert_eq!(linter.get_severity("rule-b", herb_config::Severity::Error), herb_config::Severity::Warning); + assert_eq!(linter.get_severity("rule-c", herb_config::Severity::Error), herb_config::Severity::Info); + assert_eq!(linter.get_severity("rule-d", herb_config::Severity::Error), herb_config::Severity::Hint); +} + +#[test] +fn fail_level_parses_from_yaml() { + let yaml = r#" +linter: + failLevel: warning +"#; + let config: HerbConfig = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(config.linter.fail_level, Some(herb_config::Severity::Warning)); +} + +#[test] +fn fail_level_defaults_to_none() { + let config = HerbConfig::default(); + + assert_eq!(config.linter.fail_level, None); +} diff --git a/rust/herb-config/tests/linter_config_test.rs b/rust/herb-config/tests/linter_config_test.rs new file mode 100644 index 000000000..12eede0a4 --- /dev/null +++ b/rust/herb-config/tests/linter_config_test.rs @@ -0,0 +1,292 @@ +use std::collections::HashMap; + +use herb_config::{LinterConfig, RuleConfig, Severity}; + +#[test] +fn is_rule_enabled_returns_true_when_rule_is_enabled() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + enabled: true, + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled("html-tag-name-lowercase", true)); +} + +#[test] +fn is_rule_enabled_returns_false_when_rule_is_disabled() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + enabled: false, + ..Default::default() + }, + )]), + }; + + assert!(!config.is_rule_enabled("html-tag-name-lowercase", true)); +} + +#[test] +fn is_rule_enabled_returns_default_when_rule_is_not_configured() { + let config = LinterConfig::new(); + + assert!(config.is_rule_enabled("html-tag-name-lowercase", true)); + assert!(!config.is_rule_enabled("html-tag-name-lowercase", false)); +} + +#[test] +fn get_severity_returns_default_when_not_configured() { + let config = LinterConfig::new(); + + assert_eq!(config.get_severity("some-rule", Severity::Warning), Severity::Warning); +} + +#[test] +fn get_severity_returns_configured_severity() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + severity: Some(Severity::Error), + ..Default::default() + }, + )]), + }; + + assert_eq!(config.get_severity("html-tag-name-lowercase", Severity::Warning), Severity::Error); +} + +#[test] +fn get_severity_returns_default_when_severity_not_set_on_rule() { + let config = LinterConfig { + rules: HashMap::from([("html-tag-name-lowercase".into(), RuleConfig::default())]), + }; + + assert_eq!(config.get_severity("html-tag-name-lowercase", Severity::Warning), Severity::Warning); +} + +#[test] +fn is_rule_enabled_for_path_returns_true_when_no_patterns() { + let config = LinterConfig::new(); + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_false_when_path_matches_rule_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + exclude: vec!["app/views/legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/legacy/old.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_true_when_path_does_not_match_rule_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + exclude: vec!["app/views/legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_true_when_path_matches_only() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + only: vec!["app/views/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_false_when_path_does_not_match_only() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + only: vec!["app/views/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "lib/templates/email.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_respects_both_only_and_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + only: vec!["app/views/**/*".into()], + exclude: vec!["app/views/legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/legacy/old.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "lib/templates/email.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_true_when_path_matches_include() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + include: vec!["app/components/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/components/button.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_returns_false_when_path_does_not_match_include() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + include: vec!["app/components/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_respects_include_and_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + include: vec!["app/components/**/*".into()], + exclude: vec!["app/components/legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/components/button.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/components/legacy/old.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_only_overrides_include() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + include: vec!["app/components/**/*".into()], + only: vec!["app/views/**/*".into()], + exclude: vec!["app/views/legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/home/index.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/components/button.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/legacy/old.html.erb", &[])); +} + +#[test] +fn is_rule_enabled_for_path_respects_default_exclude() { + let config = LinterConfig::new(); + + assert!(!config.is_rule_enabled_for_path("some-rule", "vendor/bundle/file.html.erb", &["vendor/**/*"])); + assert!(config.is_rule_enabled_for_path("some-rule", "app/views/index.html.erb", &["vendor/**/*"])); +} + +#[test] +fn is_rule_enabled_for_path_rule_exclude_overrides_default_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + exclude: vec!["legacy/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "vendor/bundle/file.html.erb", &["vendor/**/*"])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "legacy/old.html.erb", &["vendor/**/*"])); +} + +#[test] +fn is_rule_enabled_for_path_include_overrides_default_exclude() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + include: vec!["legacy/**/*".into(), "node_modules/**/*".into()], + exclude: vec!["generated/**/*".into()], + ..Default::default() + }, + )]), + }; + + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "legacy/index.html.erb", &["legacy/**/*"])); + assert!(config.is_rule_enabled_for_path("html-tag-name-lowercase", "node_modules/pkg/file.html.erb", &["node_modules/**/*"])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "generated/output.html.erb", &[])); + assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/index.html.erb", &[])); +} + +#[test] +fn get_rule_config_returns_none_when_not_configured() { + let config = LinterConfig::new(); + + assert!(config.get_rule_config("some-rule").is_none()); +} + +#[test] +fn get_rule_config_returns_config_when_configured() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + enabled: false, + severity: Some(Severity::Error), + ..Default::default() + }, + )]), + }; + + let rule_config = config.get_rule_config("html-tag-name-lowercase").unwrap(); + assert!(!rule_config.enabled); + assert_eq!(rule_config.severity, Some(Severity::Error)); +}