dampen_dev/
reload.rs

1//! Hot-reload state preservation and coordination
2//!
3//! This module handles the hot-reload process, including model snapshotting,
4//! state restoration, and error recovery.
5
6use dampen_core::binding::UiBindable;
7use dampen_core::parser::error::ParseError;
8use dampen_core::state::AppState;
9use serde::{Serialize, de::DeserializeOwned};
10use std::collections::HashMap;
11use std::marker::PhantomData;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::time::{Duration, Instant};
15
16/// Cache entry for parsed XML documents
17#[derive(Clone)]
18struct ParsedDocumentCache {
19    /// Cached parsed document
20    document: dampen_core::ir::DampenDocument,
21
22    /// Timestamp when cached
23    cached_at: Instant,
24}
25
26/// Tracks hot-reload state and history for debugging
27pub struct HotReloadContext<M> {
28    /// Last successful model snapshot (JSON)
29    last_model_snapshot: Option<String>,
30
31    /// Timestamp of last reload
32    last_reload_timestamp: Instant,
33
34    /// Reload count (for metrics)
35    reload_count: usize,
36
37    /// Current error state (if any)
38    error: Option<String>,
39
40    /// Cache of parsed XML documents (keyed by content hash)
41    /// This avoids re-parsing the same XML content repeatedly
42    parse_cache: HashMap<u64, ParsedDocumentCache>,
43
44    /// Maximum number of cached documents
45    max_cache_size: usize,
46
47    /// Count of cache hits for hit rate calculation
48    cache_hits: AtomicUsize,
49
50    /// Count of cache misses for hit rate calculation
51    cache_misses: AtomicUsize,
52
53    _marker: PhantomData<M>,
54}
55
56impl<M: UiBindable> HotReloadContext<M> {
57    /// Create a new hot-reload context
58    pub fn new() -> Self {
59        Self {
60            last_model_snapshot: None,
61            last_reload_timestamp: Instant::now(),
62            reload_count: 0,
63            error: None,
64            parse_cache: HashMap::new(),
65            max_cache_size: 10,
66            cache_hits: AtomicUsize::new(0),
67            cache_misses: AtomicUsize::new(0),
68            _marker: PhantomData,
69        }
70    }
71
72    /// Create a new hot-reload context with custom cache size
73    pub fn with_cache_size(cache_size: usize) -> Self {
74        Self {
75            last_model_snapshot: None,
76            last_reload_timestamp: Instant::now(),
77            reload_count: 0,
78            error: None,
79            parse_cache: HashMap::new(),
80            max_cache_size: cache_size,
81            cache_hits: AtomicUsize::new(0),
82            cache_misses: AtomicUsize::new(0),
83            _marker: PhantomData,
84        }
85    }
86
87    /// Compute content hash for caching
88    fn compute_content_hash(xml_source: &str) -> u64 {
89        use std::collections::hash_map::DefaultHasher;
90        use std::hash::{Hash, Hasher};
91
92        let mut hasher = DefaultHasher::new();
93        xml_source.hash(&mut hasher);
94        hasher.finish()
95    }
96
97    /// Try to get a parsed document from cache
98    fn get_cached_document(&self, xml_source: &str) -> Option<dampen_core::ir::DampenDocument> {
99        let content_hash = Self::compute_content_hash(xml_source);
100
101        self.parse_cache
102            .get(&content_hash)
103            .map(|entry| entry.document.clone())
104            .inspect(|_| {
105                self.cache_hits.fetch_add(1, Ordering::Relaxed);
106            })
107            .or_else(|| {
108                self.cache_misses.fetch_add(1, Ordering::Relaxed);
109                None
110            })
111    }
112
113    /// Cache a parsed document
114    fn cache_document(&mut self, xml_source: &str, document: dampen_core::ir::DampenDocument) {
115        // Evict oldest entry if cache is full
116        if self.parse_cache.len() >= self.max_cache_size
117            && let Some(oldest_key) = self
118                .parse_cache
119                .iter()
120                .min_by_key(|(_, entry)| entry.cached_at)
121                .map(|(key, _)| *key)
122        {
123            self.parse_cache.remove(&oldest_key);
124        }
125
126        let content_hash = Self::compute_content_hash(xml_source);
127
128        self.parse_cache.insert(
129            content_hash,
130            ParsedDocumentCache {
131                document,
132                cached_at: Instant::now(),
133            },
134        );
135    }
136
137    /// Clear the parse cache
138    pub fn clear_cache(&mut self) {
139        self.parse_cache.clear();
140    }
141
142    /// Get cache statistics
143    pub fn cache_stats(&self) -> (usize, usize) {
144        (self.parse_cache.len(), self.max_cache_size)
145    }
146
147    /// Get detailed performance metrics from the last reload
148    pub fn performance_metrics(&self) -> ReloadPerformanceMetrics {
149        ReloadPerformanceMetrics {
150            reload_count: self.reload_count,
151            last_reload_latency: self.last_reload_latency(),
152            cache_hit_rate: self.calculate_cache_hit_rate(),
153            cache_size: self.parse_cache.len(),
154        }
155    }
156
157    /// Calculate cache hit rate (0.0 to 1.0)
158    fn calculate_cache_hit_rate(&self) -> f64 {
159        let hits = self.cache_hits.load(Ordering::Relaxed);
160        let misses = self.cache_misses.load(Ordering::Relaxed);
161        let total = hits.saturating_add(misses);
162
163        if total == 0 {
164            0.0
165        } else {
166            hits as f64 / total as f64
167        }
168    }
169
170    /// Snapshot the current model state to JSON
171    pub fn snapshot_model(&mut self, model: &M) -> Result<(), String>
172    where
173        M: Serialize,
174    {
175        match serde_json::to_string(model) {
176            Ok(json) => {
177                self.last_model_snapshot = Some(json);
178                Ok(())
179            }
180            Err(e) => Err(format!("Failed to serialize model: {}", e)),
181        }
182    }
183
184    /// Restore the model from the last snapshot
185    pub fn restore_model(&self) -> Result<M, String>
186    where
187        M: DeserializeOwned,
188    {
189        match &self.last_model_snapshot {
190            Some(json) => serde_json::from_str(json)
191                .map_err(|e| format!("Failed to deserialize model: {}", e)),
192            None => Err("No model snapshot available".to_string()),
193        }
194    }
195
196    /// Record a reload attempt
197    pub fn record_reload(&mut self, success: bool) {
198        self.reload_count += 1;
199        self.last_reload_timestamp = Instant::now();
200        if !success {
201            self.error = Some("Reload failed".to_string());
202        } else {
203            self.error = None;
204        }
205    }
206
207    /// Record a reload with timing information
208    pub fn record_reload_with_timing(&mut self, success: bool, elapsed: Duration) {
209        self.reload_count += 1;
210        self.last_reload_timestamp = Instant::now();
211        if !success {
212            self.error = Some("Reload failed".to_string());
213        } else {
214            self.error = None;
215        }
216
217        // Log performance if it exceeds target
218        if success && elapsed.as_millis() > 300 {
219            eprintln!(
220                "Warning: Hot-reload took {}ms (target: <300ms)",
221                elapsed.as_millis()
222            );
223        }
224    }
225
226    /// Get the latency of the last reload
227    pub fn last_reload_latency(&self) -> Duration {
228        self.last_reload_timestamp.elapsed()
229    }
230}
231
232impl<M: UiBindable> Default for HotReloadContext<M> {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238/// Performance metrics for hot-reload operations
239#[derive(Debug, Clone, Copy)]
240pub struct ReloadPerformanceMetrics {
241    /// Total number of reloads performed
242    pub reload_count: usize,
243
244    /// Latency of the last reload operation
245    pub last_reload_latency: Duration,
246
247    /// Cache hit rate (0.0 to 1.0)
248    pub cache_hit_rate: f64,
249
250    /// Current cache size
251    pub cache_size: usize,
252}
253
254impl ReloadPerformanceMetrics {
255    /// Check if the last reload met the performance target (<300ms)
256    pub fn meets_target(&self) -> bool {
257        self.last_reload_latency.as_millis() < 300
258    }
259
260    /// Get latency in milliseconds
261    pub fn latency_ms(&self) -> u128 {
262        self.last_reload_latency.as_millis()
263    }
264}
265
266/// Result type for hot-reload attempts with detailed error information
267#[derive(Debug)]
268pub enum ReloadResult<M: UiBindable> {
269    /// Reload succeeded
270    Success(AppState<M>),
271
272    /// XML parse error (reject reload)
273    ParseError(ParseError),
274
275    /// Schema validation error (reject reload)
276    ValidationError(Vec<String>),
277
278    /// Model deserialization failed, using default (accept reload with warning)
279    StateRestoreWarning(AppState<M>, String),
280}
281
282/// Attempts to hot-reload the UI from a new XML source while preserving application state.
283///
284/// This function orchestrates the entire hot-reload process:
285/// 1. Snapshot the current model state
286/// 2. Parse the new XML
287/// 3. Rebuild the handler registry
288/// 4. Validate the document (all referenced handlers exist)
289/// 5. Restore the model (or use default on failure)
290/// 6. Create a new AppState with the updated UI
291///
292/// # Arguments
293///
294/// * `xml_source` - New XML UI definition as a string
295/// * `current_state` - Current application state (for model snapshotting)
296/// * `context` - Hot-reload context for state preservation
297/// * `create_handlers` - Function to rebuild the handler registry
298///
299/// # Returns
300///
301/// A `ReloadResult` indicating success or the specific type of failure:
302/// - `Success`: Reload succeeded with model restored
303/// - `ParseError`: XML parse failed (reject reload, keep old state)
304/// - `ValidationError`: Handler validation failed (reject reload, keep old state)
305/// - `StateRestoreWarning`: Reload succeeded but model used default (accept with warning)
306///
307/// # Error Handling Matrix
308///
309/// | Error Type | Action | State Preservation |
310/// |------------|--------|-------------------|
311/// | Parse error | Reject reload | Keep old state completely |
312/// | Validation error | Reject reload | Keep old state completely |
313/// | Model restore failure | Accept reload | Use M::default() with warning |
314///
315/// # Example
316///
317/// ```no_run
318/// use dampen_dev::reload::{attempt_hot_reload, HotReloadContext};
319/// use dampen_core::{AppState, handler::HandlerRegistry};
320/// # use dampen_core::binding::UiBindable;
321/// # #[derive(Default, serde::Serialize, serde::Deserialize)]
322/// # struct Model;
323/// # impl UiBindable for Model {
324/// #     fn get_field(&self, _path: &[&str]) -> Option<dampen_core::binding::BindingValue> { None }
325/// #     fn available_fields() -> Vec<String> { vec![] }
326/// # }
327///
328/// fn handle_file_change(
329///     new_xml: &str,
330///     app_state: &AppState<Model>,
331///     context: &mut HotReloadContext<Model>,
332/// ) {
333///     let result = attempt_hot_reload(
334///         new_xml,
335///         app_state,
336///         context,
337///         || create_handler_registry(),
338///     );
339///
340///     match result {
341///         dampen_dev::reload::ReloadResult::Success(new_state) => {
342///             // Apply the new state
343///         }
344///         dampen_dev::reload::ReloadResult::ParseError(err) => {
345///             // Show error overlay, keep old UI
346///             eprintln!("Parse error: {}", err.message);
347///         }
348///         _ => {
349///             // Handle other cases
350///         }
351///     }
352/// }
353///
354/// fn create_handler_registry() -> dampen_core::handler::HandlerRegistry {
355///     dampen_core::handler::HandlerRegistry::new()
356/// }
357/// ```
358pub fn attempt_hot_reload<M, F>(
359    xml_source: &str,
360    current_state: &AppState<M>,
361    context: &mut HotReloadContext<M>,
362    create_handlers: F,
363) -> ReloadResult<M>
364where
365    M: UiBindable + Serialize + DeserializeOwned + Default,
366    F: FnOnce() -> dampen_core::handler::HandlerRegistry,
367{
368    let reload_start = Instant::now();
369
370    // Step 1: Snapshot current model state
371    if let Err(e) = context.snapshot_model(&current_state.model) {
372        // If we can't snapshot, continue with reload but warn
373        eprintln!("Warning: Failed to snapshot model: {}", e);
374    }
375
376    // Step 2: Parse new XML (with caching)
377    let new_document = if let Some(cached_doc) = context.get_cached_document(xml_source) {
378        // Cache hit - reuse parsed document
379        cached_doc
380    } else {
381        // Cache miss - parse and cache
382        match dampen_core::parser::parse(xml_source) {
383            Ok(doc) => {
384                context.cache_document(xml_source, doc.clone());
385                doc
386            }
387            Err(err) => {
388                context.record_reload(false);
389                return ReloadResult::ParseError(err);
390            }
391        }
392    };
393
394    // Step 3: Rebuild handler registry (before validation)
395    let new_handlers = create_handlers();
396
397    // Step 4: Validate the parsed document against the handler registry
398    if let Err(missing_handlers) = validate_handlers(&new_document, &new_handlers) {
399        context.record_reload(false);
400        let error_messages: Vec<String> = missing_handlers
401            .iter()
402            .map(|h| format!("Handler '{}' is referenced but not registered", h))
403            .collect();
404        return ReloadResult::ValidationError(error_messages);
405    }
406
407    // Step 5: Restore model from snapshot
408    let restored_model = match context.restore_model() {
409        Ok(model) => {
410            // Successfully restored
411            model
412        }
413        Err(e) => {
414            // Failed to restore, use default
415            eprintln!("Warning: Failed to restore model ({}), using default", e);
416
417            // Create new state with default model
418            let new_state = AppState::with_all(new_document, M::default(), new_handlers);
419
420            context.record_reload(true);
421            return ReloadResult::StateRestoreWarning(new_state, e);
422        }
423    };
424
425    // Step 6: Create new AppState with restored model and new UI
426    let new_state = AppState::with_all(new_document, restored_model, new_handlers);
427
428    let elapsed = reload_start.elapsed();
429    context.record_reload_with_timing(true, elapsed);
430    ReloadResult::Success(new_state)
431}
432
433/// Async version of `attempt_hot_reload` that performs XML parsing asynchronously.
434///
435/// This function is optimized for non-blocking hot-reload by offloading the CPU-intensive
436/// XML parsing to a background thread using `tokio::task::spawn_blocking`.
437///
438/// # Performance Benefits
439///
440/// - XML parsing happens on a thread pool, avoiding UI blocking
441/// - Reduces hot-reload latency for large XML files
442/// - Maintains UI responsiveness during reload
443///
444/// # Arguments
445///
446/// * `xml_source` - New XML UI definition as a string
447/// * `current_state` - Current application state (for model snapshotting)
448/// * `context` - Hot-reload context for state preservation
449/// * `create_handlers` - Function to rebuild the handler registry
450///
451/// # Returns
452///
453/// A `ReloadResult` wrapped in a future, indicating success or failure
454///
455/// # Example
456///
457/// ```no_run
458/// use dampen_dev::reload::{attempt_hot_reload_async, HotReloadContext};
459/// use dampen_core::{AppState, handler::HandlerRegistry};
460/// use std::sync::Arc;
461/// # use dampen_core::binding::UiBindable;
462/// # #[derive(Default, serde::Serialize, serde::Deserialize)]
463/// # struct Model;
464/// # impl UiBindable for Model {
465/// #     fn get_field(&self, _path: &[&str]) -> Option<dampen_core::binding::BindingValue> { None }
466/// #     fn available_fields() -> Vec<String> { vec![] }
467/// # }
468///
469/// async fn handle_file_change_async(
470///     new_xml: String,
471///     app_state: AppState<Model>,
472///     mut context: HotReloadContext<Model>,
473/// ) {
474///     let result = attempt_hot_reload_async(
475///         Arc::new(new_xml),
476///         &app_state,
477///         &mut context,
478///         || create_handler_registry(),
479///     ).await;
480///
481///     match result {
482///         dampen_dev::reload::ReloadResult::Success(new_state) => {
483///             // Apply the new state
484///         }
485///         _ => {
486///             // Handle errors
487///         }
488///     }
489/// }
490///
491/// fn create_handler_registry() -> dampen_core::handler::HandlerRegistry {
492///     dampen_core::handler::HandlerRegistry::new()
493/// }
494/// ```
495pub async fn attempt_hot_reload_async<M, F>(
496    xml_source: Arc<String>,
497    current_state: &AppState<M>,
498    context: &mut HotReloadContext<M>,
499    create_handlers: F,
500) -> ReloadResult<M>
501where
502    M: UiBindable + Serialize + DeserializeOwned + Default + Send + 'static,
503    F: FnOnce() -> dampen_core::handler::HandlerRegistry + Send + 'static,
504{
505    let reload_start = Instant::now();
506
507    // Step 1: Snapshot current model state (fast, can do synchronously)
508    if let Err(e) = context.snapshot_model(&current_state.model) {
509        eprintln!("Warning: Failed to snapshot model: {}", e);
510    }
511
512    // Clone snapshot for async context
513    let model_snapshot = context.last_model_snapshot.clone();
514
515    // Step 2: Parse new XML asynchronously (CPU-intensive work offloaded, with caching)
516    let new_document = if let Some(cached_doc) = context.get_cached_document(&xml_source) {
517        // Cache hit - reuse parsed document
518        cached_doc
519    } else {
520        // Cache miss - parse asynchronously and cache
521        let xml_for_parse = Arc::clone(&xml_source);
522        let parse_result =
523            tokio::task::spawn_blocking(move || dampen_core::parser::parse(&xml_for_parse)).await;
524
525        match parse_result {
526            Ok(Ok(doc)) => {
527                context.cache_document(&xml_source, doc.clone());
528                doc
529            }
530            Ok(Err(err)) => {
531                context.record_reload(false);
532                return ReloadResult::ParseError(err);
533            }
534            Err(join_err) => {
535                context.record_reload(false);
536                let error = ParseError {
537                    kind: dampen_core::parser::error::ParseErrorKind::XmlSyntax,
538                    span: dampen_core::ir::span::Span::default(),
539                    message: format!("Async parsing failed: {}", join_err),
540                    suggestion: Some(
541                        "Check if the XML file is accessible and not corrupted".to_string(),
542                    ),
543                };
544                return ReloadResult::ParseError(error);
545            }
546        }
547    };
548
549    // Step 3: Rebuild handler registry (before validation)
550    let new_handlers = create_handlers();
551
552    // Step 4: Validate the parsed document against the handler registry
553    if let Err(missing_handlers) = validate_handlers(&new_document, &new_handlers) {
554        context.record_reload(false);
555        let error_messages: Vec<String> = missing_handlers
556            .iter()
557            .map(|h| format!("Handler '{}' is referenced but not registered", h))
558            .collect();
559        return ReloadResult::ValidationError(error_messages);
560    }
561
562    // Step 5: Restore model from snapshot
563    let restored_model = match model_snapshot {
564        Some(json) => match serde_json::from_str::<M>(&json) {
565            Ok(model) => model,
566            Err(e) => {
567                eprintln!("Warning: Failed to restore model ({}), using default", e);
568                let new_state = AppState::with_all(new_document, M::default(), new_handlers);
569                context.record_reload(true);
570                return ReloadResult::StateRestoreWarning(
571                    new_state,
572                    format!("Failed to deserialize model: {}", e),
573                );
574            }
575        },
576        None => {
577            eprintln!("Warning: No model snapshot available, using default");
578            let new_state = AppState::with_all(new_document, M::default(), new_handlers);
579            context.record_reload(true);
580            return ReloadResult::StateRestoreWarning(
581                new_state,
582                "No model snapshot available".to_string(),
583            );
584        }
585    };
586
587    // Step 6: Create new AppState with restored model and new UI
588    let new_state = AppState::with_all(new_document, restored_model, new_handlers);
589
590    let elapsed = reload_start.elapsed();
591    context.record_reload_with_timing(true, elapsed);
592    ReloadResult::Success(new_state)
593}
594
595/// Collects all handler names referenced in a document.
596///
597/// This function recursively traverses the widget tree and collects all unique
598/// handler names from event bindings.
599///
600/// # Arguments
601///
602/// * `document` - The parsed UI document to scan
603///
604/// # Returns
605///
606/// A vector of unique handler names referenced in the document
607fn collect_handler_names(document: &dampen_core::ir::DampenDocument) -> Vec<String> {
608    use std::collections::HashSet;
609
610    let mut handlers = HashSet::new();
611    collect_handlers_from_node(&document.root, &mut handlers);
612    handlers.into_iter().collect()
613}
614
615/// Recursively collects handler names from a widget node and its children.
616fn collect_handlers_from_node(
617    node: &dampen_core::ir::node::WidgetNode,
618    handlers: &mut std::collections::HashSet<String>,
619) {
620    // Collect handlers from events
621    for event in &node.events {
622        handlers.insert(event.handler.clone());
623    }
624
625    // Recursively collect from children
626    for child in &node.children {
627        collect_handlers_from_node(child, handlers);
628    }
629}
630
631/// Validates that all handlers referenced in the document exist in the registry.
632///
633/// # Arguments
634///
635/// * `document` - The parsed UI document to validate
636/// * `registry` - The handler registry to check against
637///
638/// # Returns
639///
640/// `Ok(())` if all handlers exist, or `Err(Vec<String>)` with a list of missing handlers
641fn validate_handlers(
642    document: &dampen_core::ir::DampenDocument,
643    registry: &dampen_core::handler::HandlerRegistry,
644) -> Result<(), Vec<String>> {
645    let referenced_handlers = collect_handler_names(document);
646    let mut missing_handlers = Vec::new();
647
648    for handler_name in referenced_handlers {
649        if registry.get(&handler_name).is_none() {
650            missing_handlers.push(handler_name);
651        }
652    }
653
654    if missing_handlers.is_empty() {
655        Ok(())
656    } else {
657        Err(missing_handlers)
658    }
659}
660
661/// Result type for theme hot-reload attempts
662#[derive(Debug)]
663pub enum ThemeReloadResult {
664    /// Reload succeeded
665    Success,
666
667    /// Theme file parse error
668    ParseError(String),
669
670    /// Theme document validation error
671    ValidationError(String),
672
673    /// No theme context to reload
674    NoThemeContext,
675
676    /// Theme file not found
677    FileNotFound,
678}
679
680/// Attempts to hot-reload the theme from a changed theme.dampen file.
681///
682/// This function handles theme file changes specifically:
683/// 1. Read the new theme file content
684/// 2. Parse the new ThemeDocument
685/// 3. Update the ThemeContext if valid
686///
687/// # Arguments
688///
689/// * `theme_path` - Path to the theme.dampen file
690/// * `theme_context` - The current ThemeContext to reload
691///
692/// # Returns
693///
694/// A `ThemeReloadResult` indicating success or the specific type of failure
695///
696/// # Example
697///
698/// ```no_run
699/// use dampen_dev::reload::{attempt_theme_hot_reload, ThemeReloadResult};
700/// use dampen_core::state::ThemeContext;
701///
702/// fn handle_theme_file_change(theme_path: &std::path::Path, ctx: &mut Option<ThemeContext>) {
703///     let result = attempt_theme_hot_reload(theme_path, ctx);
704///
705///     match result {
706///         ThemeReloadResult::Success => {
707///             println!("Theme reloaded successfully");
708///         }
709///         ThemeReloadResult::ParseError(err) => {
710///             eprintln!("Failed to parse theme file: {}", err);
711///         }
712///         ThemeReloadResult::ValidationError(err) => {
713///             eprintln!("Theme validation failed: {}", err);
714///         }
715///         ThemeReloadResult::NoThemeContext => {
716///             eprintln!("No theme context to reload");
717///         }
718///         ThemeReloadResult::FileNotFound => {
719///             eprintln!("Theme file not found");
720///         }
721///     }
722/// }
723/// ```
724pub fn attempt_theme_hot_reload(
725    theme_path: &std::path::Path,
726    theme_context: &mut Option<dampen_core::state::ThemeContext>,
727) -> ThemeReloadResult {
728    let theme_context = match theme_context {
729        Some(ctx) => ctx,
730        None => return ThemeReloadResult::NoThemeContext,
731    };
732
733    let content = match std::fs::read_to_string(theme_path) {
734        Ok(c) => c,
735        Err(e) => return ThemeReloadResult::ParseError(format!("Failed to read file: {}", e)),
736    };
737
738    let new_doc = match dampen_core::parser::theme_parser::parse_theme_document(&content) {
739        Ok(doc) => doc,
740        Err(e) => {
741            return ThemeReloadResult::ParseError(format!(
742                "Failed to parse theme document: {}",
743                e.message
744            ));
745        }
746    };
747
748    if let Err(e) = new_doc.validate() {
749        return ThemeReloadResult::ValidationError(e.to_string());
750    }
751
752    theme_context.reload(new_doc);
753    ThemeReloadResult::Success
754}
755
756/// Check if a path is a theme file path
757pub fn is_theme_file_path(path: &std::path::Path) -> bool {
758    path.file_name()
759        .and_then(|n| n.to_str())
760        .map(|n| n == "theme.dampen")
761        .unwrap_or(false)
762}
763
764/// Find the theme directory path from a changed file path
765///
766/// If the changed file is in or under the theme directory, returns the theme directory.
767/// Otherwise returns None.
768pub fn get_theme_dir_from_path(path: &std::path::Path) -> Option<std::path::PathBuf> {
769    let path = std::fs::canonicalize(path).ok()?;
770    let theme_file_name = path.file_name()?;
771
772    if theme_file_name == "theme.dampen" {
773        return Some(path.parent()?.to_path_buf());
774    }
775
776    None
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use serde::{Deserialize, Serialize};
783
784    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
785    struct TestModel {
786        count: i32,
787        name: String,
788    }
789
790    impl UiBindable for TestModel {
791        fn get_field(&self, _path: &[&str]) -> Option<dampen_core::binding::BindingValue> {
792            None
793        }
794
795        fn available_fields() -> Vec<String> {
796            vec![]
797        }
798    }
799
800    impl Default for TestModel {
801        fn default() -> Self {
802            Self {
803                count: 0,
804                name: "default".to_string(),
805            }
806        }
807    }
808
809    #[test]
810    fn test_snapshot_model_success() {
811        let mut context = HotReloadContext::<TestModel>::new();
812        let model = TestModel {
813            count: 42,
814            name: "Alice".to_string(),
815        };
816
817        let result = context.snapshot_model(&model);
818        assert!(result.is_ok());
819        assert!(context.last_model_snapshot.is_some());
820    }
821
822    #[test]
823    fn test_restore_model_success() {
824        let mut context = HotReloadContext::<TestModel>::new();
825        let original = TestModel {
826            count: 42,
827            name: "Alice".to_string(),
828        };
829
830        // First snapshot
831        context.snapshot_model(&original).unwrap();
832
833        // Then restore
834        let restored = context.restore_model().unwrap();
835        assert_eq!(restored, original);
836    }
837
838    #[test]
839    fn test_restore_model_no_snapshot() {
840        let context = HotReloadContext::<TestModel>::new();
841
842        // Try to restore without snapshot
843        let result = context.restore_model();
844        assert!(result.is_err());
845        assert!(result.unwrap_err().contains("No model snapshot"));
846    }
847
848    #[test]
849    fn test_snapshot_restore_round_trip() {
850        let mut context = HotReloadContext::<TestModel>::new();
851        let original = TestModel {
852            count: 999,
853            name: "Bob".to_string(),
854        };
855
856        // Snapshot, modify, and restore
857        context.snapshot_model(&original).unwrap();
858
859        let mut modified = original.clone();
860        modified.count = 0;
861        modified.name = "Changed".to_string();
862
863        // Restore should get original back
864        let restored = context.restore_model().unwrap();
865        assert_eq!(restored, original);
866        assert_ne!(restored, modified);
867    }
868
869    #[test]
870    fn test_multiple_snapshots() {
871        let mut context = HotReloadContext::<TestModel>::new();
872
873        // First snapshot
874        let model1 = TestModel {
875            count: 1,
876            name: "First".to_string(),
877        };
878        context.snapshot_model(&model1).unwrap();
879
880        // Second snapshot (should overwrite first)
881        let model2 = TestModel {
882            count: 2,
883            name: "Second".to_string(),
884        };
885        context.snapshot_model(&model2).unwrap();
886
887        // Restore should get the second model
888        let restored = context.restore_model().unwrap();
889        assert_eq!(restored, model2);
890        assert_ne!(restored, model1);
891    }
892
893    #[test]
894    fn test_record_reload() {
895        let mut context = HotReloadContext::<TestModel>::new();
896
897        assert_eq!(context.reload_count, 0);
898        assert!(context.error.is_none());
899
900        // Record successful reload
901        context.record_reload(true);
902        assert_eq!(context.reload_count, 1);
903        assert!(context.error.is_none());
904
905        // Record failed reload
906        context.record_reload(false);
907        assert_eq!(context.reload_count, 2);
908        assert!(context.error.is_some());
909
910        // Record successful reload again
911        context.record_reload(true);
912        assert_eq!(context.reload_count, 3);
913        assert!(context.error.is_none());
914    }
915
916    #[test]
917    fn test_cache_hit_rate_calculated_correctly() {
918        use std::sync::atomic::Ordering;
919
920        let mut context = HotReloadContext::<TestModel>::new();
921
922        // Initially: 0 hits, 0 misses
923        assert_eq!(context.calculate_cache_hit_rate(), 0.0);
924
925        // Simulate 3 hits, 2 misses
926        context.cache_hits.store(3, Ordering::Relaxed);
927        context.cache_misses.store(2, Ordering::Relaxed);
928
929        // Hit rate = 3 / (3 + 2) = 0.6
930        assert_eq!(context.calculate_cache_hit_rate(), 0.6);
931    }
932
933    #[test]
934    fn test_cache_hit_rate_zero_division() {
935        let context = HotReloadContext::<TestModel>::new();
936        // Should not panic on division by zero
937        assert_eq!(context.calculate_cache_hit_rate(), 0.0);
938    }
939
940    #[test]
941    fn test_cache_hit_rate_full_misses() {
942        use std::sync::atomic::Ordering;
943
944        let mut context = HotReloadContext::<TestModel>::new();
945        context.cache_hits.store(0, Ordering::Relaxed);
946        context.cache_misses.store(5, Ordering::Relaxed);
947        assert_eq!(context.calculate_cache_hit_rate(), 0.0);
948    }
949
950    #[test]
951    fn test_cache_hit_rate_full_hits() {
952        use std::sync::atomic::Ordering;
953
954        let mut context = HotReloadContext::<TestModel>::new();
955        context.cache_hits.store(5, Ordering::Relaxed);
956        context.cache_misses.store(0, Ordering::Relaxed);
957        assert_eq!(context.calculate_cache_hit_rate(), 1.0);
958    }
959
960    #[test]
961    fn test_attempt_hot_reload_success() {
962        use dampen_core::handler::HandlerRegistry;
963        use dampen_core::parser;
964
965        // Create initial state with a model
966        let xml_v1 =
967            r#"<dampen version="1.0"><column><text value="Version 1" /></column></dampen>"#;
968        let doc_v1 = parser::parse(xml_v1).unwrap();
969        let model_v1 = TestModel {
970            count: 42,
971            name: "Alice".to_string(),
972        };
973        let registry_v1 = HandlerRegistry::new();
974        let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
975
976        // Create hot-reload context
977        let mut context = HotReloadContext::<TestModel>::new();
978
979        // New XML with changes
980        let xml_v2 =
981            r#"<dampen version="1.0"><column><text value="Version 2" /></column></dampen>"#;
982
983        // Attempt hot-reload
984        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
985
986        // Should succeed and preserve model
987        match result {
988            ReloadResult::Success(new_state) => {
989                assert_eq!(new_state.model.count, 42);
990                assert_eq!(new_state.model.name, "Alice");
991                assert_eq!(context.reload_count, 1);
992            }
993            _ => panic!("Expected Success, got {:?}", result),
994        }
995    }
996
997    #[test]
998    fn test_attempt_hot_reload_parse_error() {
999        use dampen_core::handler::HandlerRegistry;
1000        use dampen_core::parser;
1001
1002        // Create initial state
1003        let xml_v1 =
1004            r#"<dampen version="1.0"><column><text value="Version 1" /></column></dampen>"#;
1005        let doc_v1 = parser::parse(xml_v1).unwrap();
1006        let model_v1 = TestModel {
1007            count: 10,
1008            name: "Bob".to_string(),
1009        };
1010        let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
1011
1012        let mut context = HotReloadContext::<TestModel>::new();
1013
1014        // Invalid XML (unclosed tag)
1015        let xml_invalid = r#"<dampen version="1.0"><column><text value="Broken"#;
1016
1017        // Attempt hot-reload
1018        let result = attempt_hot_reload(xml_invalid, &state_v1, &mut context, || {
1019            HandlerRegistry::new()
1020        });
1021
1022        // Should return ParseError
1023        match result {
1024            ReloadResult::ParseError(_err) => {
1025                // Expected
1026                assert_eq!(context.reload_count, 1); // Failed reload is recorded
1027            }
1028            _ => panic!("Expected ParseError, got {:?}", result),
1029        }
1030    }
1031
1032    #[test]
1033    fn test_attempt_hot_reload_model_restore_failure() {
1034        use dampen_core::handler::HandlerRegistry;
1035        use dampen_core::parser;
1036
1037        // Create initial state
1038        let xml_v1 =
1039            r#"<dampen version="1.0"><column><text value="Version 1" /></column></dampen>"#;
1040        let doc_v1 = parser::parse(xml_v1).unwrap();
1041        let model_v1 = TestModel {
1042            count: 99,
1043            name: "Charlie".to_string(),
1044        };
1045        let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
1046
1047        // Create context and manually corrupt the snapshot to trigger restore failure
1048        let mut context = HotReloadContext::<TestModel>::new();
1049        context.last_model_snapshot = Some("{ invalid json }".to_string()); // Invalid JSON
1050
1051        // New valid XML
1052        let xml_v2 =
1053            r#"<dampen version="1.0"><column><text value="Version 2" /></column></dampen>"#;
1054
1055        // Attempt hot-reload (will snapshot current model, then try to restore from corrupted snapshot)
1056        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
1057
1058        // The function snapshots the current model first, so it will actually succeed
1059        // because the new snapshot overwrites the corrupted one.
1060        // To truly test restore failure, we need to test the restore_model method directly,
1061        // which we already do in test_restore_model_no_snapshot.
1062
1063        // This test actually validates that the snapshot-before-parse strategy works correctly.
1064        match result {
1065            ReloadResult::Success(new_state) => {
1066                // Model preserved via the snapshot taken at the start of attempt_hot_reload
1067                assert_eq!(new_state.model.count, 99);
1068                assert_eq!(new_state.model.name, "Charlie");
1069                assert_eq!(context.reload_count, 1);
1070            }
1071            _ => panic!("Expected Success, got {:?}", result),
1072        }
1073    }
1074
1075    #[test]
1076    fn test_attempt_hot_reload_preserves_model_across_multiple_reloads() {
1077        use dampen_core::handler::HandlerRegistry;
1078        use dampen_core::parser;
1079
1080        // Create initial state
1081        let xml_v1 = r#"<dampen version="1.0"><column><text value="V1" /></column></dampen>"#;
1082        let doc_v1 = parser::parse(xml_v1).unwrap();
1083        let model_v1 = TestModel {
1084            count: 100,
1085            name: "Dave".to_string(),
1086        };
1087        let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
1088
1089        let mut context = HotReloadContext::<TestModel>::new();
1090
1091        // First reload
1092        let xml_v2 = r#"<dampen version="1.0"><column><text value="V2" /></column></dampen>"#;
1093        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || HandlerRegistry::new());
1094
1095        let state_v2 = match result {
1096            ReloadResult::Success(s) => s,
1097            _ => panic!("First reload failed"),
1098        };
1099
1100        assert_eq!(state_v2.model.count, 100);
1101        assert_eq!(state_v2.model.name, "Dave");
1102
1103        // Second reload
1104        let xml_v3 = r#"<dampen version="1.0"><column><text value="V3" /></column></dampen>"#;
1105        let result = attempt_hot_reload(xml_v3, &state_v2, &mut context, || HandlerRegistry::new());
1106
1107        let state_v3 = match result {
1108            ReloadResult::Success(s) => s,
1109            _ => panic!("Second reload failed"),
1110        };
1111
1112        // Model still preserved
1113        assert_eq!(state_v3.model.count, 100);
1114        assert_eq!(state_v3.model.name, "Dave");
1115        assert_eq!(context.reload_count, 2);
1116    }
1117
1118    #[test]
1119    fn test_attempt_hot_reload_with_handler_registry() {
1120        use dampen_core::handler::HandlerRegistry;
1121        use dampen_core::parser;
1122
1123        // Create initial state
1124        let xml_v1 = r#"<dampen version="1.0"><column><button label="Click" on_click="test" /></column></dampen>"#;
1125        let doc_v1 = parser::parse(xml_v1).unwrap();
1126        let model_v1 = TestModel {
1127            count: 5,
1128            name: "Eve".to_string(),
1129        };
1130
1131        let registry_v1 = HandlerRegistry::new();
1132        registry_v1.register_simple("test", |_model| {
1133            // Handler v1
1134        });
1135
1136        let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
1137
1138        let mut context = HotReloadContext::<TestModel>::new();
1139
1140        // New XML with different handler
1141        let xml_v2 = r#"<dampen version="1.0"><column><button label="Click Me" on_click="test2" /></column></dampen>"#;
1142
1143        // Create NEW handler registry (simulating code change)
1144        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
1145            let registry = HandlerRegistry::new();
1146            registry.register_simple("test2", |_model| {
1147                // Handler v2
1148            });
1149            registry
1150        });
1151
1152        // Should succeed
1153        match result {
1154            ReloadResult::Success(new_state) => {
1155                // Model preserved
1156                assert_eq!(new_state.model.count, 5);
1157                assert_eq!(new_state.model.name, "Eve");
1158
1159                // Handler registry updated
1160                assert!(new_state.handler_registry.get("test2").is_some());
1161            }
1162            _ => panic!("Expected Success, got {:?}", result),
1163        }
1164    }
1165
1166    #[test]
1167    fn test_collect_handler_names() {
1168        use dampen_core::parser;
1169
1170        // XML with multiple handlers
1171        let xml = r#"
1172            <dampen version="1.0">
1173                <column>
1174                    <button label="Click" on_click="handle_click" />
1175                    <text_input placeholder="Type" on_input="handle_input" />
1176                    <button label="Submit" on_click="handle_submit" />
1177                </column>
1178            </dampen>
1179        "#;
1180
1181        let doc = parser::parse(xml).unwrap();
1182        let handlers = collect_handler_names(&doc);
1183
1184        // Should collect all three unique handlers
1185        assert_eq!(handlers.len(), 3);
1186        assert!(handlers.contains(&"handle_click".to_string()));
1187        assert!(handlers.contains(&"handle_input".to_string()));
1188        assert!(handlers.contains(&"handle_submit".to_string()));
1189    }
1190
1191    #[test]
1192    fn test_collect_handler_names_nested() {
1193        use dampen_core::parser;
1194
1195        // XML with nested handlers
1196        let xml = r#"
1197            <dampen version="1.0">
1198                <column>
1199                    <row>
1200                        <button label="A" on_click="handler_a" />
1201                    </row>
1202                    <row>
1203                        <button label="B" on_click="handler_b" />
1204                        <column>
1205                            <button label="C" on_click="handler_c" />
1206                        </column>
1207                    </row>
1208                </column>
1209            </dampen>
1210        "#;
1211
1212        let doc = parser::parse(xml).unwrap();
1213        let handlers = collect_handler_names(&doc);
1214
1215        // Should collect handlers from all nesting levels
1216        assert_eq!(handlers.len(), 3);
1217        assert!(handlers.contains(&"handler_a".to_string()));
1218        assert!(handlers.contains(&"handler_b".to_string()));
1219        assert!(handlers.contains(&"handler_c".to_string()));
1220    }
1221
1222    #[test]
1223    fn test_collect_handler_names_duplicates() {
1224        use dampen_core::parser;
1225
1226        // XML with duplicate handler names
1227        let xml = r#"
1228            <dampen version="1.0">
1229                <column>
1230                    <button label="1" on_click="same_handler" />
1231                    <button label="2" on_click="same_handler" />
1232                    <button label="3" on_click="same_handler" />
1233                </column>
1234            </dampen>
1235        "#;
1236
1237        let doc = parser::parse(xml).unwrap();
1238        let handlers = collect_handler_names(&doc);
1239
1240        // Should deduplicate
1241        assert_eq!(handlers.len(), 1);
1242        assert!(handlers.contains(&"same_handler".to_string()));
1243    }
1244
1245    #[test]
1246    fn test_validate_handlers_all_present() {
1247        use dampen_core::handler::HandlerRegistry;
1248        use dampen_core::parser;
1249
1250        let xml = r#"
1251            <dampen version="1.0">
1252                <column>
1253                    <button label="Click" on_click="test_handler" />
1254                </column>
1255            </dampen>
1256        "#;
1257
1258        let doc = parser::parse(xml).unwrap();
1259        let registry = HandlerRegistry::new();
1260        registry.register_simple("test_handler", |_model| {});
1261
1262        let result = validate_handlers(&doc, &registry);
1263        assert!(result.is_ok());
1264    }
1265
1266    #[test]
1267    fn test_validate_handlers_missing() {
1268        use dampen_core::handler::HandlerRegistry;
1269        use dampen_core::parser;
1270
1271        let xml = r#"
1272            <dampen version="1.0">
1273                <column>
1274                    <button label="Click" on_click="missing_handler" />
1275                </column>
1276            </dampen>
1277        "#;
1278
1279        let doc = parser::parse(xml).unwrap();
1280        let registry = HandlerRegistry::new();
1281        // Registry is empty, handler not registered
1282
1283        let result = validate_handlers(&doc, &registry);
1284        assert!(result.is_err());
1285
1286        let missing = result.unwrap_err();
1287        assert_eq!(missing.len(), 1);
1288        assert_eq!(missing[0], "missing_handler");
1289    }
1290
1291    #[test]
1292    fn test_validate_handlers_multiple_missing() {
1293        use dampen_core::handler::HandlerRegistry;
1294        use dampen_core::parser;
1295
1296        let xml = r#"
1297            <dampen version="1.0">
1298                <column>
1299                    <button label="A" on_click="handler_a" />
1300                    <button label="B" on_click="handler_b" />
1301                    <button label="C" on_click="handler_c" />
1302                </column>
1303            </dampen>
1304        "#;
1305
1306        let doc = parser::parse(xml).unwrap();
1307        let registry = HandlerRegistry::new();
1308        // Only register handler_b
1309        registry.register_simple("handler_b", |_model| {});
1310
1311        let result = validate_handlers(&doc, &registry);
1312        assert!(result.is_err());
1313
1314        let missing = result.unwrap_err();
1315        assert_eq!(missing.len(), 2);
1316        assert!(missing.contains(&"handler_a".to_string()));
1317        assert!(missing.contains(&"handler_c".to_string()));
1318    }
1319
1320    #[test]
1321    fn test_attempt_hot_reload_validation_error() {
1322        use dampen_core::handler::HandlerRegistry;
1323        use dampen_core::parser;
1324
1325        // Create initial state
1326        let xml_v1 = r#"<dampen version="1.0"><column><text value="V1" /></column></dampen>"#;
1327        let doc_v1 = parser::parse(xml_v1).unwrap();
1328        let model_v1 = TestModel {
1329            count: 10,
1330            name: "Test".to_string(),
1331        };
1332        let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
1333
1334        let mut context = HotReloadContext::<TestModel>::new();
1335
1336        // New XML with a handler that won't be registered
1337        let xml_v2 = r#"
1338            <dampen version="1.0">
1339                <column>
1340                    <button label="Click" on_click="unregistered_handler" />
1341                </column>
1342            </dampen>
1343        "#;
1344
1345        // Create handler registry WITHOUT the required handler
1346        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
1347            HandlerRegistry::new() // Empty registry
1348        });
1349
1350        // Should return ValidationError
1351        match result {
1352            ReloadResult::ValidationError(errors) => {
1353                assert!(!errors.is_empty());
1354                assert!(errors[0].contains("unregistered_handler"));
1355                assert_eq!(context.reload_count, 1); // Failed reload is recorded
1356            }
1357            _ => panic!("Expected ValidationError, got {:?}", result),
1358        }
1359    }
1360
1361    #[test]
1362    fn test_attempt_hot_reload_validation_success() {
1363        use dampen_core::handler::HandlerRegistry;
1364        use dampen_core::parser;
1365
1366        // Create initial state
1367        let xml_v1 = r#"<dampen version="1.0"><column><text value="V1" /></column></dampen>"#;
1368        let doc_v1 = parser::parse(xml_v1).unwrap();
1369        let model_v1 = TestModel {
1370            count: 20,
1371            name: "Valid".to_string(),
1372        };
1373        let state_v1 = AppState::with_all(doc_v1, model_v1, HandlerRegistry::new());
1374
1375        let mut context = HotReloadContext::<TestModel>::new();
1376
1377        // New XML with a handler
1378        let xml_v2 = r#"
1379            <dampen version="1.0">
1380                <column>
1381                    <button label="Click" on_click="registered_handler" />
1382                </column>
1383            </dampen>
1384        "#;
1385
1386        // Create handler registry WITH the required handler
1387        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
1388            let registry = HandlerRegistry::new();
1389            registry.register_simple("registered_handler", |_model| {});
1390            registry
1391        });
1392
1393        // Should succeed
1394        match result {
1395            ReloadResult::Success(new_state) => {
1396                assert_eq!(new_state.model.count, 20);
1397                assert_eq!(new_state.model.name, "Valid");
1398                assert_eq!(context.reload_count, 1);
1399            }
1400            _ => panic!("Expected Success, got {:?}", result),
1401        }
1402    }
1403
1404    #[test]
1405    fn test_handler_registry_complete_replacement() {
1406        use dampen_core::handler::HandlerRegistry;
1407        use dampen_core::parser;
1408
1409        // Create initial state with handler "old_handler"
1410        let xml_v1 = r#"
1411            <dampen version="1.0">
1412                <column>
1413                    <button label="Old" on_click="old_handler" />
1414                </column>
1415            </dampen>
1416        "#;
1417        let doc_v1 = parser::parse(xml_v1).unwrap();
1418        let model_v1 = TestModel {
1419            count: 1,
1420            name: "Initial".to_string(),
1421        };
1422
1423        let registry_v1 = HandlerRegistry::new();
1424        registry_v1.register_simple("old_handler", |_model| {});
1425
1426        let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
1427
1428        // Verify old handler exists
1429        assert!(state_v1.handler_registry.get("old_handler").is_some());
1430
1431        let mut context = HotReloadContext::<TestModel>::new();
1432
1433        // New XML with completely different handler
1434        let xml_v2 = r#"
1435            <dampen version="1.0">
1436                <column>
1437                    <button label="New" on_click="new_handler" />
1438                    <button label="Another" on_click="another_handler" />
1439                </column>
1440            </dampen>
1441        "#;
1442
1443        // Rebuild registry with NEW handlers only (old_handler not included)
1444        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
1445            let registry = HandlerRegistry::new();
1446            registry.register_simple("new_handler", |_model| {});
1447            registry.register_simple("another_handler", |_model| {});
1448            registry
1449        });
1450
1451        // Should succeed
1452        match result {
1453            ReloadResult::Success(new_state) => {
1454                // Model preserved
1455                assert_eq!(new_state.model.count, 1);
1456                assert_eq!(new_state.model.name, "Initial");
1457
1458                // Old handler should NOT exist in new registry
1459                assert!(new_state.handler_registry.get("old_handler").is_none());
1460
1461                // New handlers should exist
1462                assert!(new_state.handler_registry.get("new_handler").is_some());
1463                assert!(new_state.handler_registry.get("another_handler").is_some());
1464            }
1465            _ => panic!("Expected Success, got {:?}", result),
1466        }
1467    }
1468
1469    #[test]
1470    fn test_handler_registry_rebuild_before_validation() {
1471        use dampen_core::handler::HandlerRegistry;
1472        use dampen_core::parser;
1473
1474        // This test validates that registry is rebuilt BEFORE validation happens
1475        // Scenario: Old state has handler A, new XML needs handler B
1476        // If registry is rebuilt before validation, it should succeed
1477
1478        let xml_v1 = r#"<dampen version="1.0"><column><button on_click="handler_a" label="A" /></column></dampen>"#;
1479        let doc_v1 = parser::parse(xml_v1).unwrap();
1480        let model_v1 = TestModel {
1481            count: 100,
1482            name: "Test".to_string(),
1483        };
1484
1485        let registry_v1 = HandlerRegistry::new();
1486        registry_v1.register_simple("handler_a", |_model| {});
1487
1488        let state_v1 = AppState::with_all(doc_v1, model_v1, registry_v1);
1489
1490        let mut context = HotReloadContext::<TestModel>::new();
1491
1492        // New XML references handler_b (different from handler_a)
1493        let xml_v2 = r#"<dampen version="1.0"><column><button on_click="handler_b" label="B" /></column></dampen>"#;
1494
1495        // Registry rebuild provides handler_b
1496        let result = attempt_hot_reload(xml_v2, &state_v1, &mut context, || {
1497            let registry = HandlerRegistry::new();
1498            registry.register_simple("handler_b", |_model| {}); // Different handler!
1499            registry
1500        });
1501
1502        // Should succeed because registry was rebuilt with handler_b BEFORE validation
1503        match result {
1504            ReloadResult::Success(new_state) => {
1505                assert_eq!(new_state.model.count, 100);
1506                // Verify new handler exists
1507                assert!(new_state.handler_registry.get("handler_b").is_some());
1508                // Verify old handler is gone
1509                assert!(new_state.handler_registry.get("handler_a").is_none());
1510            }
1511            _ => panic!(
1512                "Expected Success (registry rebuilt before validation), got {:?}",
1513                result
1514            ),
1515        }
1516    }
1517}
1518
1519#[cfg(test)]
1520mod theme_reload_tests {
1521    use super::*;
1522    use dampen_core::ir::style::Color;
1523    use dampen_core::ir::theme::{SpacingScale, Theme, ThemeDocument, ThemePalette, Typography};
1524    use tempfile::TempDir;
1525
1526    fn create_test_palette(primary: &str) -> ThemePalette {
1527        ThemePalette {
1528            primary: Some(Color::from_hex(primary).unwrap()),
1529            secondary: Some(Color::from_hex("#2ecc71").unwrap()),
1530            success: Some(Color::from_hex("#27ae60").unwrap()),
1531            warning: Some(Color::from_hex("#f39c12").unwrap()),
1532            danger: Some(Color::from_hex("#e74c3c").unwrap()),
1533            background: Some(Color::from_hex("#ecf0f1").unwrap()),
1534            surface: Some(Color::from_hex("#ffffff").unwrap()),
1535            text: Some(Color::from_hex("#2c3e50").unwrap()),
1536            text_secondary: Some(Color::from_hex("#7f8c8d").unwrap()),
1537        }
1538    }
1539
1540    fn create_test_theme(name: &str, primary: &str) -> Theme {
1541        Theme {
1542            name: name.to_string(),
1543            palette: create_test_palette(primary),
1544            typography: Typography {
1545                font_family: Some("sans-serif".to_string()),
1546                font_size_base: Some(16.0),
1547                font_size_small: Some(12.0),
1548                font_size_large: Some(24.0),
1549                font_weight: dampen_core::ir::theme::FontWeight::Normal,
1550                line_height: Some(1.5),
1551            },
1552            spacing: SpacingScale { unit: Some(8.0) },
1553            base_styles: std::collections::HashMap::new(),
1554            extends: None,
1555        }
1556    }
1557
1558    fn create_test_document() -> ThemeDocument {
1559        ThemeDocument {
1560            themes: std::collections::HashMap::from([
1561                ("light".to_string(), create_test_theme("light", "#3498db")),
1562                ("dark".to_string(), create_test_theme("dark", "#5dade2")),
1563            ]),
1564            default_theme: Some("light".to_string()),
1565            follow_system: true,
1566        }
1567    }
1568
1569    fn create_test_theme_context() -> dampen_core::state::ThemeContext {
1570        let doc = create_test_document();
1571        dampen_core::state::ThemeContext::from_document(doc, None).unwrap()
1572    }
1573
1574    #[test]
1575    fn test_attempt_theme_hot_reload_success() {
1576        let temp_dir = TempDir::new().unwrap();
1577        let theme_dir = temp_dir.path().join("src/ui/theme");
1578        std::fs::create_dir_all(&theme_dir).unwrap();
1579
1580        let theme_content = r##"
1581            <dampen version="1.0">
1582                <themes>
1583                    <theme name="light">
1584                        <palette
1585                            primary="#111111"
1586                            secondary="#2ecc71"
1587                            success="#27ae60"
1588                            warning="#f39c12"
1589                            danger="#e74c3c"
1590                            background="#ecf0f1"
1591                            surface="#ffffff"
1592                            text="#2c3e50"
1593                            text_secondary="#7f8c8d" />
1594                    </theme>
1595                </themes>
1596                <default_theme name="light" />
1597            </dampen>
1598        "##;
1599
1600        let theme_path = theme_dir.join("theme.dampen");
1601        std::fs::write(&theme_path, theme_content).unwrap();
1602
1603        let mut theme_ctx = Some(create_test_theme_context());
1604        let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
1605
1606        match result {
1607            ThemeReloadResult::Success => {
1608                let ctx = theme_ctx.unwrap();
1609                assert_eq!(ctx.active_name(), "light");
1610                assert_eq!(
1611                    ctx.active().palette.primary,
1612                    Some(Color::from_hex("#111111").unwrap())
1613                );
1614            }
1615            _ => panic!("Expected Success, got {:?}", result),
1616        }
1617    }
1618
1619    #[test]
1620    fn test_attempt_theme_hot_reload_parse_error() {
1621        let temp_dir = TempDir::new().unwrap();
1622        let theme_dir = temp_dir.path().join("src/ui/theme");
1623        std::fs::create_dir_all(&theme_dir).unwrap();
1624
1625        let theme_path = theme_dir.join("theme.dampen");
1626        std::fs::write(&theme_path, "invalid xml").unwrap();
1627
1628        let mut theme_ctx = Some(create_test_theme_context());
1629        let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
1630
1631        match result {
1632            ThemeReloadResult::ParseError(_) => {}
1633            _ => panic!("Expected ParseError, got {:?}", result),
1634        }
1635    }
1636
1637    #[test]
1638    fn test_attempt_theme_hot_reload_no_theme_context() {
1639        let temp_dir = TempDir::new().unwrap();
1640        let theme_dir = temp_dir.path().join("src/ui/theme");
1641        std::fs::create_dir_all(&theme_dir).unwrap();
1642
1643        let theme_path = theme_dir.join("theme.dampen");
1644        std::fs::write(
1645            &theme_path,
1646            r#"<dampen version="1.0"><themes></themes></dampen>"#,
1647        )
1648        .unwrap();
1649
1650        let mut theme_ctx: Option<dampen_core::state::ThemeContext> = None;
1651        let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
1652
1653        match result {
1654            ThemeReloadResult::NoThemeContext => {}
1655            _ => panic!("Expected NoThemeContext, got {:?}", result),
1656        }
1657    }
1658
1659    #[test]
1660    fn test_attempt_theme_hot_reload_preserves_active_theme() {
1661        let temp_dir = TempDir::new().unwrap();
1662        let theme_dir = temp_dir.path().join("src/ui/theme");
1663        std::fs::create_dir_all(&theme_dir).unwrap();
1664
1665        let theme_content = r##"
1666            <dampen version="1.0">
1667                <themes>
1668                    <theme name="new_theme">
1669                        <palette
1670                            primary="#ff0000"
1671                            secondary="#2ecc71"
1672                            success="#27ae60"
1673                            warning="#f39c12"
1674                            danger="#e74c3c"
1675                            background="#ecf0f1"
1676                            surface="#ffffff"
1677                            text="#2c3e50"
1678                            text_secondary="#7f8c8d" />
1679                    </theme>
1680                    <theme name="dark">
1681                        <palette
1682                            primary="#5dade2"
1683                            secondary="#52be80"
1684                            success="#27ae60"
1685                            warning="#f39c12"
1686                            danger="#ec7063"
1687                            background="#2c3e50"
1688                            surface="#34495e"
1689                            text="#ecf0f1"
1690                            text_secondary="#95a5a6" />
1691                    </theme>
1692                </themes>
1693                <default_theme name="new_theme" />
1694            </dampen>
1695        "##;
1696
1697        let theme_path = theme_dir.join("theme.dampen");
1698        std::fs::write(&theme_path, theme_content).unwrap();
1699
1700        let mut theme_ctx = Some(create_test_theme_context());
1701        theme_ctx.as_mut().unwrap().set_theme("dark").unwrap();
1702        assert_eq!(theme_ctx.as_ref().unwrap().active_name(), "dark");
1703
1704        let result = attempt_theme_hot_reload(&theme_path, &mut theme_ctx);
1705
1706        match result {
1707            ThemeReloadResult::Success => {
1708                let ctx = theme_ctx.unwrap();
1709                assert_eq!(ctx.active_name(), "dark");
1710            }
1711            _ => panic!("Expected Success, got {:?}", result),
1712        }
1713    }
1714
1715    #[test]
1716    fn test_is_theme_file_path() {
1717        assert!(is_theme_file_path(&std::path::PathBuf::from(
1718            "src/ui/theme/theme.dampen"
1719        )));
1720        assert!(!is_theme_file_path(&std::path::PathBuf::from(
1721            "src/ui/window.dampen"
1722        )));
1723        assert!(is_theme_file_path(&std::path::PathBuf::from(
1724            "/some/path/theme.dampen"
1725        )));
1726    }
1727
1728    #[test]
1729    fn test_get_theme_dir_from_path() {
1730        let temp_dir = TempDir::new().unwrap();
1731        let theme_dir = temp_dir.path().join("src/ui/theme");
1732        std::fs::create_dir_all(&theme_dir).unwrap();
1733        let theme_path = theme_dir.join("theme.dampen");
1734        std::fs::write(
1735            &theme_path,
1736            "<dampen version=\"1.0\"><themes></themes></dampen>",
1737        )
1738        .unwrap();
1739
1740        let result = get_theme_dir_from_path(&theme_path);
1741        assert!(result.is_some());
1742        assert_eq!(result.unwrap(), theme_dir);
1743    }
1744}