use super::App;
use crate::constants::MAX_CSS_FILE_SIZE;
use crate::plugin::{Plugin, PluginRegistry};
use crate::style::{parse_css, StyleSheet};
use std::fs;
use std::path::PathBuf;
#[cfg(feature = "hot-reload")]
use super::HotReload;
pub struct AppBuilder {
stylesheet: StyleSheet,
style_paths: Vec<PathBuf>,
hot_reload: bool,
devtools: bool,
mouse_capture: bool,
plugins: PluginRegistry,
}
impl AppBuilder {
pub fn new() -> Self {
Self {
stylesheet: StyleSheet::new(),
style_paths: Vec::new(),
hot_reload: false,
devtools: cfg!(feature = "devtools"),
mouse_capture: true,
plugins: PluginRegistry::new(),
}
}
pub fn plugin<P: Plugin + 'static>(mut self, plugin: P) -> Self {
self.plugins.register(plugin);
self
}
pub fn style(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
self.style_paths.push(path.clone());
match fs::metadata(&path) {
Ok(metadata) => {
if metadata.len() > MAX_CSS_FILE_SIZE {
log_warn!(
"CSS file too large ({} bytes, max {}): {:?}",
metadata.len(),
MAX_CSS_FILE_SIZE,
path
);
return self;
}
}
Err(e) => {
log_warn!("Failed to read CSS file metadata {:?}: {}", path, e);
return self;
}
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
log_warn!("Failed to read CSS file {:?}: {}", path, e);
return self;
}
};
match parse_css(&content) {
Ok(sheet) => self.stylesheet.merge(sheet),
Err(e) => log_warn!("Failed to parse CSS from {:?}: {}", path, e),
}
self
}
pub fn css(mut self, css: impl Into<String>) -> Self {
let css = css.into();
match parse_css(&css) {
Ok(sheet) => self.stylesheet.merge(sheet),
Err(e) => log_warn!("Failed to parse inline CSS: {}", e),
}
self
}
pub fn hot_reload(mut self, enabled: bool) -> Self {
self.hot_reload = enabled;
self
}
pub fn devtools(mut self, enabled: bool) -> Self {
self.devtools = enabled;
self
}
pub fn mouse_capture(mut self, enabled: bool) -> Self {
self.mouse_capture = enabled;
self
}
pub fn build(mut self) -> App {
let initial_size = {
let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
(w.max(1), h.max(1))
};
let plugin_css = self.plugins.collect_styles();
if !plugin_css.is_empty() {
if let Ok(sheet) = parse_css(&plugin_css) {
self.stylesheet.merge(sheet);
}
}
if let Err(e) = self.plugins.init() {
log_warn!("Plugin initialization failed: {}", e);
}
#[cfg(feature = "hot-reload")]
let hot_reload = if self.hot_reload && !self.style_paths.is_empty() {
match HotReload::new() {
Ok(mut hr) => {
for path in &self.style_paths {
if let Err(e) = hr.watch(path) {
log_warn!("Failed to watch {:?} for hot reload: {}", path, e);
}
}
Some(hr)
}
Err(e) => {
log_warn!("Failed to initialize hot reload: {}", e);
None
}
}
} else {
None
};
#[cfg(feature = "hot-reload")]
return App::new_with_hot_reload(
initial_size,
self.stylesheet,
self.mouse_capture,
self.plugins,
self.devtools,
hot_reload,
self.style_paths,
);
#[cfg(not(feature = "hot-reload"))]
App::new_with_plugins(
initial_size,
self.stylesheet,
self.mouse_capture,
self.plugins,
self.devtools,
)
}
}
impl Default for AppBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_new() {
let builder = AppBuilder::new();
assert!(builder.style_paths.is_empty());
assert!(!builder.hot_reload);
assert!(builder.mouse_capture);
}
#[test]
fn test_builder_default_trait() {
let builder = AppBuilder::default();
assert!(builder.style_paths.is_empty());
assert!(!builder.hot_reload);
assert!(builder.mouse_capture);
}
#[test]
fn test_builder_hot_reload_enabled() {
let builder = AppBuilder::new().hot_reload(true);
assert!(builder.hot_reload);
}
#[test]
fn test_builder_hot_reload_disabled() {
let builder = AppBuilder::new().hot_reload(false);
assert!(!builder.hot_reload);
}
#[test]
fn test_builder_devtools_enabled() {
let builder = AppBuilder::new().devtools(true);
assert!(builder.devtools);
}
#[test]
fn test_builder_devtools_disabled() {
let builder = AppBuilder::new().devtools(false);
assert!(!builder.devtools);
}
#[test]
fn test_builder_mouse_capture_enabled() {
let builder = AppBuilder::new().mouse_capture(true);
assert!(builder.mouse_capture);
}
#[test]
fn test_builder_mouse_capture_disabled() {
let builder = AppBuilder::new().mouse_capture(false);
assert!(!builder.mouse_capture);
}
#[test]
fn test_builder_css_valid() {
let builder = AppBuilder::new().css("div { color: red; }");
assert!(!builder.stylesheet.rules.is_empty());
}
#[test]
fn test_builder_css_empty() {
let builder = AppBuilder::new().css("");
assert!(builder.stylesheet.rules.is_empty());
}
#[test]
fn test_builder_css_invalid() {
let builder = AppBuilder::new().css("not { valid {{{ css");
assert!(builder.style_paths.is_empty());
}
#[test]
fn test_builder_multiple_css() {
let builder = AppBuilder::new()
.css("div { color: red; }")
.css("span { color: blue; }");
assert!(!builder.stylesheet.rules.is_empty());
}
#[test]
fn test_builder_chaining() {
let builder = AppBuilder::new()
.hot_reload(true)
.devtools(true)
.mouse_capture(false)
.css("div { display: flex; }");
assert!(builder.hot_reload);
assert!(builder.devtools);
assert!(!builder.mouse_capture);
assert!(!builder.stylesheet.rules.is_empty());
}
#[test]
fn test_builder_style_nonexistent_file() {
let builder = AppBuilder::new().style("/nonexistent/path/style.css");
assert_eq!(builder.style_paths.len(), 1);
}
#[test]
fn test_builder_build() {
let app = AppBuilder::new()
.mouse_capture(false)
.css("div { color: red; }")
.build();
assert!(!app.is_running());
assert!(!app.mouse_capture);
}
#[test]
fn test_builder_build_with_defaults() {
let app = AppBuilder::new().build();
assert!(!app.is_running());
assert!(app.mouse_capture); }
#[test]
#[ignore = "flaky: crossterm::terminal::size() returns (0,0) in parallel test environment"]
fn test_builder_build_initializes_buffers() {
let app = AppBuilder::new().build();
assert!(app.buffers[0].width() > 0 || app.buffers[0].height() > 0);
}
#[test]
fn test_builder_devtools_actually_enables() {
let app = AppBuilder::new().devtools(true).build();
assert!(
app.is_devtools_enabled(),
"devtools should be enabled after build() with devtools(true)"
);
}
#[test]
fn test_builder_devtools_disabled_by_default_when_feature_off() {
let app = AppBuilder::new().devtools(false).build();
assert!(
!app.is_devtools_enabled(),
"devtools should be disabled when devtools(false)"
);
}
#[test]
#[cfg(feature = "hot-reload")]
#[ignore = "HotReload::new() blocks for extended time on Windows CI (24+ minutes)"]
fn test_builder_hot_reload_with_style_path() {
use std::io::Write;
let temp_dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(_) => return, };
let css_path = temp_dir.path().join("test.css");
let mut file = match std::fs::File::create(&css_path) {
Ok(f) => f,
Err(_) => return, };
let _ = writeln!(file, "div {{ color: red; }}");
let app = AppBuilder::new().hot_reload(true).style(&css_path).build();
assert!(app.hot_reload.is_some(), "hot_reload should be initialized");
}
#[test]
#[cfg(feature = "hot-reload")]
fn test_builder_hot_reload_disabled_no_watcher() {
let app = AppBuilder::new().hot_reload(false).build();
assert!(
app.hot_reload.is_none(),
"hot_reload should be None when disabled"
);
}
#[test]
#[cfg(feature = "hot-reload")]
fn test_builder_hot_reload_no_style_paths() {
let app = AppBuilder::new().hot_reload(true).build();
assert!(
app.hot_reload.is_none(),
"hot_reload should be None when no style paths"
);
}
}