use crate::{CssParser, ParserError, Result};
use std::collections::HashMap;
#[cfg(feature = "fs")]
use std::fs;
#[cfg(feature = "fs")]
use std::path::Path;
use std::path::PathBuf;
#[cfg(feature = "fs")]
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct Component {
pub tag_name: String,
pub html_content: String,
pub css_content: Option<String>,
pub css_tokens: Vec<String>,
pub source_path: PathBuf,
pub class_name: Option<String>,
}
#[derive(Debug)]
pub struct ComponentRegistry {
components: HashMap<String, Component>,
css_parser: CssParser,
}
impl Default for ComponentRegistry {
fn default() -> Self {
Self::new()
}
}
impl ComponentRegistry {
pub fn new() -> Self {
Self {
components: HashMap::new(),
css_parser: CssParser::new(),
}
}
#[cfg(feature = "fs")]
pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
for dir in directories {
for entry in WalkDir::new(dir.as_ref())
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().is_some_and(|ext| ext == "html") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename.contains('-') {
let css_path = path.with_extension("css");
self.register_component_from_paths(
path,
if css_path.exists() {
Some(&css_path)
} else {
None
},
)?;
}
}
}
}
}
Ok(self)
}
#[cfg(feature = "fs")]
pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
&mut self,
html_path: P,
css_path: Option<Q>,
) -> Result<()> {
let html_path = html_path.as_ref();
let tag_name = html_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
if !tag_name.contains('-') {
return Err(ParserError::Component(format!(
"Component name '{}' must contain a hyphen",
tag_name
)));
}
if self.components.contains_key(tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
tag_name
)));
}
let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
context: format!("Failed to read HTML file: {}", html_path.display()),
source,
})?;
let (css_content, css_tokens) = if let Some(css_path) = css_path {
let css_path = css_path.as_ref();
if css_path.exists() {
let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
context: format!("Failed to read CSS file: {}", css_path.display()),
source,
})?;
let tokens = self.extract_sorted_tokens(&content)?;
(Some(content), tokens)
} else {
(None, Vec::new())
}
} else {
(None, Vec::new())
};
let component = Component {
tag_name: tag_name.to_string(),
html_content,
css_content,
css_tokens,
source_path: html_path.to_path_buf(),
class_name: None,
};
self.components.insert(tag_name.to_string(), component);
Ok(())
}
pub fn register_component(
&mut self,
tag_name: &str,
html_content: &str,
css_content: Option<&str>,
) -> Result<()> {
if !tag_name.contains('-') {
return Err(ParserError::Component(format!(
"Component name '{}' must contain a hyphen",
tag_name
)));
}
if self.components.contains_key(tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
tag_name
)));
}
let css_tokens = match css_content {
Some(css) => self.extract_sorted_tokens(css)?,
None => Vec::new(),
};
let component: Component = Component {
tag_name: tag_name.to_string(),
html_content: html_content.to_string(),
css_content: css_content.map(ToString::to_string),
css_tokens,
source_path: PathBuf::new(), class_name: None,
};
self.components.insert(tag_name.to_string(), component);
Ok(())
}
fn extract_sorted_tokens(&mut self, css_content: &str) -> Result<Vec<String>> {
let tokens = self.css_parser.extract_tokens(css_content)?;
let mut sorted: Vec<String> = tokens.into_iter().collect();
sorted.sort();
Ok(sorted)
}
pub fn contains(&self, tag_name: &str) -> bool {
self.components.contains_key(tag_name)
}
pub fn get(&self, tag_name: &str) -> Option<&Component> {
self.components.get(tag_name)
}
pub fn get_all(&self) -> impl Iterator<Item = &Component> {
self.components.values()
}
pub fn len(&self) -> usize {
self.components.len()
}
pub fn is_empty(&self) -> bool {
self.components.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use webui_test_utils::TestFileSystem;
#[test]
fn test_register_component() {
let html_content = "<p>Hello World</p>";
let css_content = "p { color: red; }";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/test-component.html", html_content);
let css_path = fs.add_file("components/test-component.css", css_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, Some(&css_path));
assert!(result.is_ok());
assert!(registry.contains("test-component"));
let component = registry
.get("test-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content.as_deref(), Some(css_content));
}
#[test]
fn test_component_name_validation() {
let html_content = "<p>Invalid</p>";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("invalid_name.html", html_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, None::<&str>);
assert!(result.is_err());
assert_eq!(registry.len(), 0);
}
#[test]
fn test_missing_css_file() {
let html_content = "<p>CSS Optional</p>";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("test-component.html", html_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, None::<&str>);
assert!(result.is_ok());
let component = registry
.get("test-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content, None);
}
#[test]
fn test_register_component_from_strings() {
let html_content = "<p>Hello from string!</p>";
let css_content = "p { color: green; }";
let mut registry = ComponentRegistry::new();
let result =
registry.register_component("string-component", html_content, Some(css_content));
assert!(result.is_ok());
assert!(registry.contains("string-component"));
let component = registry
.get("string-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content.as_deref(), Some(css_content));
}
#[test]
fn test_invalid_component_name_from_strings() {
let html_content = "<p>Invalid component</p>";
let mut registry = ComponentRegistry::new();
let result = registry.register_component("invalid", html_content, None);
assert!(result.is_err(), "Expected error for invalid component name");
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
}
#[test]
fn test_duplicate_component_from_strings() {
let html_content = "<p>First component</p>";
let html_content2 = "<p>Second component with same name</p>";
let mut registry = ComponentRegistry::new();
let result1 = registry.register_component("dupe-component", html_content, None);
assert!(result1.is_ok());
let result2 = registry.register_component("dupe-component", html_content2, None);
assert!(result2.is_err());
assert!(
matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
"Expected 'already registered' error, got: {:?}",
result2
);
let component = registry
.get("dupe-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
}
#[test]
fn test_duplicate_component_from_paths() {
let html_content1 = "<p>Component from dir A</p>";
let html_content2 = "<p>Component from dir B</p>";
let mut fs = TestFileSystem::new();
let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
let mut registry = ComponentRegistry::new();
let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
assert!(result1.is_ok());
let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
assert!(result2.is_err());
assert!(
matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
"Expected 'already registered' error, got: {:?}",
result2
);
let component = registry
.get("my-comp")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content1);
}
#[test]
fn test_exclude_dot_in_component_name() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component("fluent.button", "<p>Dot name</p>", None);
assert!(
result.is_err(),
"Component name with dot but no hyphen should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_exclude_no_hyphen_html() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component("foobar", "<p>No hyphen</p>", None);
assert!(
result.is_err(),
"Component name without hyphen should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_valid_component_with_hyphen() {
let mut registry = ComponentRegistry::new();
let result =
registry.register_component("fluent-button", "<button>Click me</button>", None);
assert!(
result.is_ok(),
"Component name with hyphen should be accepted"
);
assert!(registry.contains("fluent-button"));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_valid_component_css_only() {
let mut registry = ComponentRegistry::new();
let result =
registry.register_component("styled-widget", "", Some(".widget { color: blue; }"));
assert!(
result.is_ok(),
"Component with empty HTML and CSS should be accepted"
);
assert!(registry.contains("styled-widget"));
let component = registry
.get("styled-widget")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, "");
assert_eq!(
component.css_content.as_deref(),
Some(".widget { color: blue; }")
);
}
#[test]
fn test_component_name_requires_hyphen() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component("single", "<p>Single word</p>", None);
assert!(
result.is_err(),
"Single-word component name should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_multiple_hyphens_valid() {
let mut registry = ComponentRegistry::new();
let result =
registry.register_component("my-custom-element", "<div>Custom element</div>", None);
assert!(
result.is_ok(),
"Component name with multiple hyphens should be accepted"
);
assert!(registry.contains("my-custom-element"));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_empty_component_name_rejected() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component("", "<p>Empty name</p>", None);
assert!(result.is_err(), "Empty component name should be rejected");
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_register_component_extracts_css_tokens() {
let mut registry = ComponentRegistry::new();
registry
.register_component(
"my-btn",
"<button>Click</button>",
Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
)
.expect("register failed");
let component = registry.get("my-btn").expect("component not found");
assert_eq!(component.css_tokens, vec!["spacing-m", "text-color"]);
}
#[test]
fn test_register_component_no_css_no_tokens() {
let mut registry = ComponentRegistry::new();
registry
.register_component("my-card", "<div>Card</div>", None)
.expect("register failed");
let component = registry.get("my-card").expect("component not found");
assert!(component.css_tokens.is_empty());
}
#[test]
fn test_register_component_excludes_local_defs_from_tokens() {
let mut registry = ComponentRegistry::new();
registry
.register_component(
"my-widget",
"<div>W</div>",
Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
)
.expect("register failed");
let component = registry.get("my-widget").expect("component not found");
assert_eq!(component.css_tokens, vec!["external"]);
}
}