diff --git a/canvas/.gitignore b/canvas/.gitignore new file mode 100644 index 0000000..cf817ce --- /dev/null +++ b/canvas/.gitignore @@ -0,0 +1 @@ +docs_prompts/ diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index c046767..9669dc8 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -23,6 +23,7 @@ thiserror = { workspace = true } tracing = "0.1.41" tracing-subscriber = "0.3.19" async-trait.workspace = true +regex = { workspace = true, optional = true } [dev-dependencies] tokio-test = "0.4.4" @@ -32,6 +33,8 @@ default = [] gui = ["ratatui"] autocomplete = ["tokio"] cursor-style = ["crossterm"] +regex = ["dep:regex"] +validation = ["regex"] [[example]] name = "autocomplete" diff --git a/canvas/docs/new_function_to_config.txt b/canvas/docs/new_function_to_config.txt deleted file mode 100644 index 92aa4ad..0000000 --- a/canvas/docs/new_function_to_config.txt +++ /dev/null @@ -1,77 +0,0 @@ -❯ git status -On branch main -Your branch is ahead of 'origin/main' by 1 commit. - (use "git push" to publish your local commits) - -Changes not staged for commit: - (use "git add ..." to update what will be committed) - (use "git restore ..." to discard changes in working directory) - modified: src/canvas/actions/handlers/edit.rs - modified: src/canvas/actions/types.rs - -no changes added to commit (use "git add" and/or "git commit -a") -❯ git --no-pager diff -diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs -index a26fe6f..fa1becb 100644 ---- a/canvas/src/canvas/actions/handlers/edit.rs -+++ b/canvas/src/canvas/actions/handlers/edit.rs -@@ -29,6 +29,21 @@ pub async fn handle_edit_action( - Ok(ActionResult::success()) - } - -+ CanvasAction::SelectAll => { -+ // Select all text in current field -+ let current_input = state.get_current_input(); -+ let text_length = current_input.len(); -+ -+ // Set cursor to start and select all -+ state.set_current_cursor_pos(0); -+ // TODO: You'd need to add selection state to CanvasState trait -+ // For now, just move cursor to end to "select" all -+ state.set_current_cursor_pos(text_length); -+ *ideal_cursor_column = text_length; -+ -+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length))) -+ } -+ - CanvasAction::DeleteBackward => { - let cursor_pos = state.current_cursor_pos(); - if cursor_pos > 0 { -@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler { - is_required: false, - }); - -+ actions.push(ActionSpec { -+ name: "select_all".to_string(), -+ description: "Select all text in current field".to_string(), -+ examples: vec!["Ctrl+a".to_string()], -+ is_required: false, // Optional action -+ }); -+ - HandlerCapabilities { - mode_name: "edit".to_string(), - actions, -diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs -index 433a4d5..3794596 100644 ---- a/canvas/src/canvas/actions/types.rs -+++ b/canvas/src/canvas/actions/types.rs -@@ -31,6 +31,8 @@ pub enum CanvasAction { - NextField, - PrevField, - -+ SelectAll, -+ - // Autocomplete actions - TriggerAutocomplete, - SuggestionUp, -@@ -62,6 +64,7 @@ impl CanvasAction { - "move_word_end_prev" => Self::MoveWordEndPrev, - "next_field" => Self::NextField, - "prev_field" => Self::PrevField, -+ "select_all" => Self::SelectAll, - "trigger_autocomplete" => Self::TriggerAutocomplete, - "suggestion_up" => Self::SuggestionUp, - "suggestion_down" => Self::SuggestionDown, -╭─    ~/Doc/p/komp_ac/canvas  on   main ⇡1 !2  -╰─ - diff --git a/canvas/src/canvas/state.rs b/canvas/src/canvas/state.rs index 54fcad8..0041f5f 100644 --- a/canvas/src/canvas/state.rs +++ b/canvas/src/canvas/state.rs @@ -10,15 +10,19 @@ pub struct EditorState { pub(crate) current_field: usize, pub(crate) cursor_pos: usize, pub(crate) ideal_cursor_column: usize, - - // Mode state + + // Mode state pub(crate) current_mode: AppMode, - + // Autocomplete state pub(crate) autocomplete: AutocompleteUIState, - + // Selection state (for vim visual mode) pub(crate) selection: SelectionState, + + // Validation state (only available with validation feature) + #[cfg(feature = "validation")] + pub(crate) validation: crate::validation::ValidationState, } #[derive(Debug, Clone)] @@ -50,52 +54,61 @@ impl EditorState { active_field: None, }, selection: SelectionState::None, + #[cfg(feature = "validation")] + validation: crate::validation::ValidationState::new(), } } - + // =================================================================== // READ-ONLY ACCESS: User can fetch UI state for compatibility // =================================================================== - + /// Get current field index (for user's business logic) pub fn current_field(&self) -> usize { self.current_field } - - /// Get current cursor position (for user's business logic) + + /// Get current cursor position (for user's business logic) pub fn cursor_position(&self) -> usize { self.cursor_pos } /// Get ideal cursor column (for vim-like behavior) - pub fn ideal_cursor_column(&self) -> usize { // ADD THIS + pub fn ideal_cursor_column(&self) -> usize { self.ideal_cursor_column } - + /// Get current mode (for user's business logic) pub fn mode(&self) -> AppMode { self.current_mode } - + /// Check if autocomplete is active (for user's business logic) pub fn is_autocomplete_active(&self) -> bool { self.autocomplete.is_active } - + /// Check if autocomplete is loading (for user's business logic) pub fn is_autocomplete_loading(&self) -> bool { self.autocomplete.is_loading } - + /// Get selection state (for user's business logic) pub fn selection_state(&self) -> &SelectionState { &self.selection } + /// Get validation state (for user's business logic) + /// Only available when the 'validation' feature is enabled + #[cfg(feature = "validation")] + pub fn validation_state(&self) -> &crate::validation::ValidationState { + &self.validation + } + // =================================================================== // INTERNAL MUTATIONS: Only library modifies these // =================================================================== - + pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) { if field_index < field_count { self.current_field = field_index; @@ -103,7 +116,7 @@ impl EditorState { self.cursor_pos = 0; } } - + pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) { if for_edit_mode { // Edit mode: can go past end for insertion @@ -114,14 +127,14 @@ impl EditorState { } self.ideal_cursor_column = self.cursor_pos; } - + pub(crate) fn activate_autocomplete(&mut self, field_index: usize) { self.autocomplete.is_active = true; self.autocomplete.is_loading = true; self.autocomplete.active_field = Some(field_index); self.autocomplete.selected_index = None; } - + pub(crate) fn deactivate_autocomplete(&mut self) { self.autocomplete.is_active = false; self.autocomplete.is_loading = false; diff --git a/canvas/src/data_provider.rs b/canvas/src/data_provider.rs index aabce75..a83244c 100644 --- a/canvas/src/data_provider.rs +++ b/canvas/src/data_provider.rs @@ -27,6 +27,13 @@ pub trait DataProvider { fn display_value(&self, _index: usize) -> Option<&str> { None // Default: use actual value } + + /// Get validation configuration for a field (optional) + /// Only available when the 'validation' feature is enabled + #[cfg(feature = "validation")] + fn validation_config(&self, _field_index: usize) -> Option { + None + } } /// Optional: User implements this for autocomplete data diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index 5dc050d..f287fa5 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -26,10 +26,29 @@ pub struct FormEditor { impl FormEditor { pub fn new(data_provider: D) -> Self { - Self { + let mut editor = Self { ui_state: EditorState::new(), data_provider, suggestions: Vec::new(), + }; + + // Initialize validation configurations if validation feature is enabled + #[cfg(feature = "validation")] + { + editor.initialize_validation(); + } + + editor + } + + /// Initialize validation configurations from data provider + #[cfg(feature = "validation")] + fn initialize_validation(&mut self) { + let field_count = self.data_provider.field_count(); + for field_index in 0..field_count { + if let Some(config) = self.data_provider.validation_config(field_index) { + self.ui_state.validation.set_field_config(field_index, config); + } } } @@ -81,6 +100,25 @@ impl FormEditor { pub fn suggestions(&self) -> &[SuggestionItem] { &self.suggestions } + + /// Get validation state (for user's business logic) + /// Only available when the 'validation' feature is enabled + #[cfg(feature = "validation")] + pub fn validation_state(&self) -> &crate::validation::ValidationState { + self.ui_state.validation_state() + } + + /// Get validation result for current field + #[cfg(feature = "validation")] + pub fn current_field_validation(&self) -> Option<&crate::validation::ValidationResult> { + self.ui_state.validation.get_field_result(self.ui_state.current_field) + } + + /// Get validation result for specific field + #[cfg(feature = "validation")] + pub fn field_validation(&self, field_index: usize) -> Option<&crate::validation::ValidationResult> { + self.ui_state.validation.get_field_result(field_index) + } // =================================================================== // SYNC OPERATIONS: No async needed for basic editing @@ -96,13 +134,36 @@ impl FormEditor { let cursor_pos = self.ui_state.cursor_pos; // Get current text from user - let mut current_text = self.data_provider.field_value(field_index).to_string(); + let current_text = self.data_provider.field_value(field_index); + + // Validate character insertion if validation is enabled + #[cfg(feature = "validation")] + { + let validation_result = self.ui_state.validation.validate_char_insertion( + field_index, + current_text, + cursor_pos, + ch, + ); + + // Reject input if validation failed with error + if !validation_result.is_acceptable() { + // Log validation failure for debugging + tracing::debug!( + "Character insertion rejected for field {}: {:?}", + field_index, + validation_result + ); + return Ok(()); // Silently reject invalid input + } + } // Insert character - current_text.insert(cursor_pos, ch); + let mut new_text = current_text.to_string(); + new_text.insert(cursor_pos, ch); // Update user's data - self.data_provider.set_field_value(field_index, current_text); + self.data_provider.set_field_value(field_index, new_text); // Update library's UI state self.ui_state.cursor_pos += 1; @@ -137,6 +198,19 @@ impl FormEditor { pub fn move_to_next_field(&mut self) { let field_count = self.data_provider.field_count(); let next_field = (self.ui_state.current_field + 1) % field_count; + + // Validate current field content before moving if validation is enabled + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + // Note: We don't prevent field switching on validation failure, + // just record the validation state + } + self.ui_state.move_to_field(next_field, field_count); // Clamp cursor to new field @@ -166,7 +240,7 @@ impl FormEditor { if new_mode != AppMode::Highlight { self.ui_state.selection = SelectionState::None; } - + #[cfg(feature = "cursor-style")] { let _ = CursorManager::update_for_mode(new_mode); @@ -178,22 +252,81 @@ impl FormEditor { /// Enter edit mode with cursor positioned for append (vim 'a' command) pub fn enter_append_mode(&mut self) { let current_text = self.current_text(); - + // Calculate append position: always move right, even at line end let append_pos = if current_text.is_empty() { 0 } else { (self.ui_state.cursor_pos + 1).min(current_text.len()) }; - + // Set cursor position for append self.ui_state.cursor_pos = append_pos; self.ui_state.ideal_cursor_column = append_pos; - + // Enter edit mode (which will update cursor style) self.set_mode(AppMode::Edit); } + // =================================================================== + // VALIDATION METHODS (only available with validation feature) + // =================================================================== + + /// Enable or disable validation + #[cfg(feature = "validation")] + pub fn set_validation_enabled(&mut self, enabled: bool) { + self.ui_state.validation.set_enabled(enabled); + } + + /// Check if validation is enabled + #[cfg(feature = "validation")] + pub fn is_validation_enabled(&self) -> bool { + self.ui_state.validation.is_enabled() + } + + /// Set validation configuration for a specific field + #[cfg(feature = "validation")] + pub fn set_field_validation(&mut self, field_index: usize, config: crate::validation::ValidationConfig) { + self.ui_state.validation.set_field_config(field_index, config); + } + + /// Remove validation configuration for a specific field + #[cfg(feature = "validation")] + pub fn remove_field_validation(&mut self, field_index: usize) { + self.ui_state.validation.remove_field_config(field_index); + } + + /// Manually validate current field content + #[cfg(feature = "validation")] + pub fn validate_current_field(&mut self) -> crate::validation::ValidationResult { + let field_index = self.ui_state.current_field; + let current_text = self.current_text().to_string(); + self.ui_state.validation.validate_field_content(field_index, ¤t_text) + } + + /// Manually validate specific field content + #[cfg(feature = "validation")] + pub fn validate_field(&mut self, field_index: usize) -> Option { + if field_index < self.data_provider.field_count() { + let text = self.data_provider.field_value(field_index).to_string(); + Some(self.ui_state.validation.validate_field_content(field_index, &text)) + } else { + None + } + } + + /// Clear validation results for all fields + #[cfg(feature = "validation")] + pub fn clear_validation_results(&mut self) { + self.ui_state.validation.clear_all_results(); + } + + /// Get validation summary for all fields + #[cfg(feature = "validation")] + pub fn validation_summary(&self) -> crate::validation::ValidationSummary { + self.ui_state.validation.summary() + } + // =================================================================== // ASYNC OPERATIONS: Only autocomplete needs async // =================================================================== @@ -255,6 +388,15 @@ impl FormEditor { // Close autocomplete self.ui_state.deactivate_autocomplete(); self.suggestions.clear(); + + // Validate the new content if validation is enabled + #[cfg(feature = "validation")] + { + let _validation_result = self.ui_state.validation.validate_field_content( + field_index, + &suggestion.value_to_store, + ); + } return Some(suggestion.display_text); } @@ -263,7 +405,7 @@ impl FormEditor { } // =================================================================== - // ADD THESE MISSING MOVEMENT METHODS + // MOVEMENT METHODS (keeping existing implementations) // =================================================================== /// Move to previous field (vim k / up arrow) @@ -272,24 +414,44 @@ impl FormEditor { if field_count == 0 { return; } - + + // Validate current field before moving + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + } + let current_field = self.ui_state.current_field; let new_field = current_field.saturating_sub(1); - + self.ui_state.move_to_field(new_field, field_count); self.clamp_cursor_to_current_field(); } - /// Move to next field (vim j / down arrow) + /// Move to next field (vim j / down arrow) pub fn move_down(&mut self) { let field_count = self.data_provider.field_count(); if field_count == 0 { return; } - + + // Validate current field before moving + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + } + let current_field = self.ui_state.current_field; let new_field = (current_field + 1).min(field_count - 1); - + self.ui_state.move_to_field(new_field, field_count); self.clamp_cursor_to_current_field(); } @@ -300,7 +462,7 @@ impl FormEditor { if field_count == 0 { return; } - + self.ui_state.move_to_field(0, field_count); self.clamp_cursor_to_current_field(); } @@ -311,7 +473,7 @@ impl FormEditor { if field_count == 0 { return; } - + let last_field = field_count - 1; self.ui_state.move_to_field(last_field, field_count); self.clamp_cursor_to_current_field(); @@ -322,7 +484,7 @@ impl FormEditor { self.move_up(); } - /// Move to next field (alternative to move_down) + /// Move to next field (alternative to move_down) pub fn next_field(&mut self) { self.move_down(); } @@ -340,7 +502,7 @@ impl FormEditor { use crate::canvas::actions::movement::line::line_end_position; let current_text = self.current_text(); let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - + let new_pos = line_end_position(current_text, is_edit_mode); self.ui_state.cursor_pos = new_pos; self.ui_state.ideal_cursor_column = new_pos; @@ -350,21 +512,21 @@ impl FormEditor { pub fn move_word_next(&mut self) { use crate::canvas::actions::movement::word::find_next_word_start; let current_text = self.current_text(); - + if current_text.is_empty() { return; } - + let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos); let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - + // Clamp to valid bounds for current mode let final_pos = if is_edit_mode { new_pos.min(current_text.len()) } else { new_pos.min(current_text.len().saturating_sub(1)) }; - + self.ui_state.cursor_pos = final_pos; self.ui_state.ideal_cursor_column = final_pos; } @@ -373,11 +535,11 @@ impl FormEditor { pub fn move_word_prev(&mut self) { use crate::canvas::actions::movement::word::find_prev_word_start; let current_text = self.current_text(); - + if current_text.is_empty() { return; } - + let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos); self.ui_state.cursor_pos = new_pos; self.ui_state.ideal_cursor_column = new_pos; @@ -387,21 +549,21 @@ impl FormEditor { pub fn move_word_end(&mut self) { use crate::canvas::actions::movement::word::find_word_end; let current_text = self.current_text(); - + if current_text.is_empty() { return; } - + let current_pos = self.ui_state.cursor_pos; let new_pos = find_word_end(current_text, current_pos); - + // If we didn't move, try next word let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() { find_word_end(current_text, current_pos + 1) } else { new_pos }; - + // Clamp for read-only mode let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; let clamped_pos = if is_edit_mode { @@ -409,7 +571,7 @@ impl FormEditor { } else { final_pos.min(current_text.len().saturating_sub(1)) }; - + self.ui_state.cursor_pos = clamped_pos; self.ui_state.ideal_cursor_column = clamped_pos; } @@ -418,11 +580,11 @@ impl FormEditor { pub fn move_word_end_prev(&mut self) { use crate::canvas::actions::movement::word::find_prev_word_end; let current_text = self.current_text(); - + if current_text.is_empty() { return; } - + let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos); self.ui_state.cursor_pos = new_pos; self.ui_state.ideal_cursor_column = new_pos; @@ -433,21 +595,30 @@ impl FormEditor { if self.ui_state.current_mode != AppMode::Edit { return Ok(()); // Silently ignore in non-edit modes } - + if self.ui_state.cursor_pos == 0 { return Ok(()); // Nothing to delete } - + let field_index = self.ui_state.current_field; let mut current_text = self.data_provider.field_value(field_index).to_string(); - + if self.ui_state.cursor_pos <= current_text.len() { current_text.remove(self.ui_state.cursor_pos - 1); - self.data_provider.set_field_value(field_index, current_text); + self.data_provider.set_field_value(field_index, current_text.clone()); self.ui_state.cursor_pos -= 1; self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos; + + // Validate the new content if validation is enabled + #[cfg(feature = "validation")] + { + let _validation_result = self.ui_state.validation.validate_field_content( + field_index, + ¤t_text, + ); + } } - + Ok(()) } @@ -456,21 +627,39 @@ impl FormEditor { if self.ui_state.current_mode != AppMode::Edit { return Ok(()); // Silently ignore in non-edit modes } - + let field_index = self.ui_state.current_field; let mut current_text = self.data_provider.field_value(field_index).to_string(); - + if self.ui_state.cursor_pos < current_text.len() { current_text.remove(self.ui_state.cursor_pos); - self.data_provider.set_field_value(field_index, current_text); + self.data_provider.set_field_value(field_index, current_text.clone()); + + // Validate the new content if validation is enabled + #[cfg(feature = "validation")] + { + let _validation_result = self.ui_state.validation.validate_field_content( + field_index, + ¤t_text, + ); + } } - + Ok(()) } /// Exit edit mode to read-only mode (vim Escape) - // TODO this is still flickering, I have no clue how to fix it pub fn exit_edit_mode(&mut self) { + // Validate current field content when exiting edit mode + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); // Convert to String to avoid borrow conflicts + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + } + // Adjust cursor position when transitioning from edit to normal mode let current_text = self.current_text(); if !current_text.is_empty() { @@ -500,44 +689,62 @@ impl FormEditor { fn clamp_cursor_to_current_field(&mut self) { let current_text = self.current_text(); let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - + use crate::canvas::actions::movement::line::safe_cursor_position; let safe_pos = safe_cursor_position( - current_text, - self.ui_state.ideal_cursor_column, + current_text, + self.ui_state.ideal_cursor_column, is_edit_mode ); - + self.ui_state.cursor_pos = safe_pos; } - + /// Set the value of the current field pub fn set_current_field_value(&mut self, value: String) { let field_index = self.ui_state.current_field; - self.data_provider.set_field_value(field_index, value); + self.data_provider.set_field_value(field_index, value.clone()); // Reset cursor to start of field self.ui_state.cursor_pos = 0; self.ui_state.ideal_cursor_column = 0; + + // Validate the new content if validation is enabled + #[cfg(feature = "validation")] + { + let _validation_result = self.ui_state.validation.validate_field_content( + field_index, + &value, + ); + } } - + /// Set the value of a specific field by index pub fn set_field_value(&mut self, field_index: usize, value: String) { if field_index < self.data_provider.field_count() { - self.data_provider.set_field_value(field_index, value); + self.data_provider.set_field_value(field_index, value.clone()); // If we're modifying the current field, reset cursor if field_index == self.ui_state.current_field { self.ui_state.cursor_pos = 0; self.ui_state.ideal_cursor_column = 0; } + + // Validate the new content if validation is enabled + #[cfg(feature = "validation")] + { + let _validation_result = self.ui_state.validation.validate_field_content( + field_index, + &value, + ); + } } } - + /// Clear the current field (set to empty string) pub fn clear_current_field(&mut self) { self.set_current_field_value(String::new()); } - + /// Get mutable access to data provider (for advanced operations) pub fn data_provider_mut(&mut self) -> &mut D { &mut self.data_provider @@ -547,16 +754,16 @@ impl FormEditor { pub fn set_cursor_position(&mut self, position: usize) { let current_text = self.current_text(); let is_edit_mode = self.ui_state.current_mode == AppMode::Edit; - + // Clamp to valid bounds for current mode let max_pos = if is_edit_mode { current_text.len() // Edit mode: can go past end } else { current_text.len().saturating_sub(1).max(0) // Read-only: stay within text }; - + let clamped_pos = position.min(max_pos); - + // Update cursor position directly self.ui_state.cursor_pos = clamped_pos; self.ui_state.ideal_cursor_column = clamped_pos; @@ -565,7 +772,7 @@ impl FormEditor { /// Get cursor position for display (respects mode-specific positioning rules) pub fn display_cursor_position(&self) -> usize { let current_text = self.current_text(); - + match self.ui_state.current_mode { AppMode::Edit => { // Edit mode: cursor can be past end of text @@ -606,7 +813,7 @@ impl FormEditor { self.ui_state.selection = SelectionState::Characterwise { anchor: (self.ui_state.current_field, self.ui_state.cursor_pos), }; - + #[cfg(feature = "cursor-style")] { let _ = CursorManager::update_for_mode(AppMode::Highlight); @@ -621,7 +828,7 @@ impl FormEditor { self.ui_state.selection = SelectionState::Linewise { anchor_field: self.ui_state.current_field, }; - + #[cfg(feature = "cursor-style")] { let _ = CursorManager::update_for_mode(AppMode::Highlight); @@ -634,7 +841,7 @@ impl FormEditor { if self.ui_state.current_mode == AppMode::Highlight { self.ui_state.current_mode = AppMode::ReadOnly; self.ui_state.selection = SelectionState::None; - + #[cfg(feature = "cursor-style")] { let _ = CursorManager::update_for_mode(AppMode::ReadOnly); diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index badc944..e3356f9 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -8,6 +8,10 @@ pub mod data_provider; #[cfg(feature = "autocomplete")] pub mod autocomplete; +// Only include validation module if feature is enabled +#[cfg(feature = "validation")] +pub mod validation; + #[cfg(feature = "cursor-style")] pub use canvas::CursorManager; @@ -26,6 +30,14 @@ pub use canvas::modes::AppMode; // Actions and results (for users who want to handle actions manually) pub use canvas::actions::{CanvasAction, ActionResult}; +// Validation exports (only when validation feature is enabled) +#[cfg(feature = "validation")] +pub use validation::{ + ValidationConfig, ValidationResult, ValidationError, + CharacterLimits, ValidationConfigBuilder, ValidationState, + ValidationSummary, +}; + // Theming and GUI #[cfg(feature = "gui")] pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme}; diff --git a/canvas/src/validation/config.rs b/canvas/src/validation/config.rs new file mode 100644 index 0000000..2ad300d --- /dev/null +++ b/canvas/src/validation/config.rs @@ -0,0 +1,208 @@ +//! Validation configuration types and builders + +use crate::validation::CharacterLimits; +use serde::{Deserialize, Serialize}; + +/// Main validation configuration for a field +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ValidationConfig { + /// Character limit configuration + pub character_limits: Option, + + /// Future: Predefined patterns + #[serde(skip)] + pub patterns: Option<()>, // Placeholder for future implementation + + /// Future: Reserved characters + #[serde(skip)] + pub reserved_chars: Option<()>, // Placeholder for future implementation + + /// Future: Custom formatting + #[serde(skip)] + pub custom_formatting: Option<()>, // Placeholder for future implementation + + /// Future: External validation + #[serde(skip)] + pub external_validation: Option<()>, // Placeholder for future implementation +} + +/// Builder for creating validation configurations +#[derive(Debug, Default)] +pub struct ValidationConfigBuilder { + config: ValidationConfig, +} + +impl ValidationConfigBuilder { + /// Create a new validation config builder + pub fn new() -> Self { + Self::default() + } + + /// Set character limits for the field + pub fn with_character_limits(mut self, limits: CharacterLimits) -> Self { + self.config.character_limits = Some(limits); + self + } + + /// Set maximum number of characters (convenience method) + pub fn with_max_length(mut self, max_length: usize) -> Self { + self.config.character_limits = Some(CharacterLimits::new(max_length)); + self + } + + /// Build the final validation configuration + pub fn build(self) -> ValidationConfig { + self.config + } +} + +/// Result of a validation operation +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationResult { + /// Validation passed + Valid, + + /// Validation failed with warning (input still accepted) + Warning { message: String }, + + /// Validation failed with error (input rejected) + Error { message: String }, +} + +impl ValidationResult { + /// Check if the validation result allows the input + pub fn is_acceptable(&self) -> bool { + matches!(self, ValidationResult::Valid | ValidationResult::Warning { .. }) + } + + /// Check if the validation result is an error + pub fn is_error(&self) -> bool { + matches!(self, ValidationResult::Error { .. }) + } + + /// Get the message if there is one + pub fn message(&self) -> Option<&str> { + match self { + ValidationResult::Valid => None, + ValidationResult::Warning { message } => Some(message), + ValidationResult::Error { message } => Some(message), + } + } + + /// Create a warning result + pub fn warning(message: impl Into) -> Self { + ValidationResult::Warning { message: message.into() } + } + + /// Create an error result + pub fn error(message: impl Into) -> Self { + ValidationResult::Error { message: message.into() } + } +} + +impl ValidationConfig { + /// Create a new empty validation configuration + pub fn new() -> Self { + Self::default() + } + + /// Create a configuration with just character limits + pub fn with_max_length(max_length: usize) -> Self { + ValidationConfigBuilder::new() + .with_max_length(max_length) + .build() + } + + /// Validate a character insertion at a specific position + pub fn validate_char_insertion( + &self, + current_text: &str, + position: usize, + character: char, + ) -> ValidationResult { + // Character limits validation + if let Some(ref limits) = self.character_limits { + if let Some(result) = limits.validate_insertion(current_text, position, character) { + if !result.is_acceptable() { + return result; + } + } + } + + // Future: Add other validation types here + + ValidationResult::Valid + } + + /// Validate the current text content + pub fn validate_content(&self, text: &str) -> ValidationResult { + // Character limits validation + if let Some(ref limits) = self.character_limits { + if let Some(result) = limits.validate_content(text) { + if !result.is_acceptable() { + return result; + } + } + } + + // Future: Add other validation types here + + ValidationResult::Valid + } + + /// Check if any validation rules are configured + pub fn has_validation(&self) -> bool { + self.character_limits.is_some() + // || self.patterns.is_some() + // || self.reserved_chars.is_some() + // || self.custom_formatting.is_some() + // || self.external_validation.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validation_config_builder() { + let config = ValidationConfigBuilder::new() + .with_max_length(10) + .build(); + + assert!(config.character_limits.is_some()); + assert_eq!(config.character_limits.unwrap().max_length(), Some(10)); + } + + #[test] + fn test_validation_result() { + let valid = ValidationResult::Valid; + assert!(valid.is_acceptable()); + assert!(!valid.is_error()); + assert_eq!(valid.message(), None); + + let warning = ValidationResult::warning("Too long"); + assert!(warning.is_acceptable()); + assert!(!warning.is_error()); + assert_eq!(warning.message(), Some("Too long")); + + let error = ValidationResult::error("Invalid"); + assert!(!error.is_acceptable()); + assert!(error.is_error()); + assert_eq!(error.message(), Some("Invalid")); + } + + #[test] + fn test_config_with_max_length() { + let config = ValidationConfig::with_max_length(5); + assert!(config.has_validation()); + + // Test valid insertion + let result = config.validate_char_insertion("test", 4, 'x'); + assert!(result.is_acceptable()); + + // Test invalid insertion (would exceed limit) + let result = config.validate_char_insertion("tests", 5, 'x'); + assert!(!result.is_acceptable()); + } +} diff --git a/canvas/src/validation/limits.rs b/canvas/src/validation/limits.rs new file mode 100644 index 0000000..fcbccd6 --- /dev/null +++ b/canvas/src/validation/limits.rs @@ -0,0 +1,365 @@ +//! Character limits validation implementation + +use crate::validation::ValidationResult; +use serde::{Deserialize, Serialize}; +use unicode_width::UnicodeWidthStr; + +/// Character limits configuration for a field +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharacterLimits { + /// Maximum number of characters allowed (None = unlimited) + max_length: Option, + + /// Minimum number of characters required (None = no minimum) + min_length: Option, + + /// Warning threshold (warn when approaching max limit) + warning_threshold: Option, + + /// Count mode: characters vs display width + count_mode: CountMode, +} + +/// How to count characters for limit checking +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum CountMode { + /// Count actual characters (default) + Characters, + + /// Count display width (useful for CJK characters) + DisplayWidth, + + /// Count bytes (rarely used, but available) + Bytes, +} + +impl Default for CountMode { + fn default() -> Self { + CountMode::Characters + } +} + +/// Result of a character limit check +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LimitCheckResult { + /// Within limits + Ok, + + /// Approaching limit (warning) + Warning { current: usize, max: usize }, + + /// At or exceeding limit (error) + Exceeded { current: usize, max: usize }, + + /// Below minimum length + TooShort { current: usize, min: usize }, +} + +impl CharacterLimits { + /// Create new character limits with just max length + pub fn new(max_length: usize) -> Self { + Self { + max_length: Some(max_length), + min_length: None, + warning_threshold: None, + count_mode: CountMode::default(), + } + } + + /// Create new character limits with min and max + pub fn new_range(min_length: usize, max_length: usize) -> Self { + Self { + max_length: Some(max_length), + min_length: Some(min_length), + warning_threshold: None, + count_mode: CountMode::default(), + } + } + + /// Set warning threshold (when to show warning before hitting limit) + pub fn with_warning_threshold(mut self, threshold: usize) -> Self { + self.warning_threshold = Some(threshold); + self + } + + /// Set count mode (characters vs display width vs bytes) + pub fn with_count_mode(mut self, mode: CountMode) -> Self { + self.count_mode = mode; + self + } + + /// Get maximum length + pub fn max_length(&self) -> Option { + self.max_length + } + + /// Get minimum length + pub fn min_length(&self) -> Option { + self.min_length + } + + /// Get warning threshold + pub fn warning_threshold(&self) -> Option { + self.warning_threshold + } + + /// Get count mode + pub fn count_mode(&self) -> CountMode { + self.count_mode + } + + /// Count characters/width/bytes according to the configured mode + fn count(&self, text: &str) -> usize { + match self.count_mode { + CountMode::Characters => text.chars().count(), + CountMode::DisplayWidth => text.width(), + CountMode::Bytes => text.len(), + } + } + + /// Check if inserting a character would exceed limits + pub fn validate_insertion( + &self, + current_text: &str, + _position: usize, + character: char, + ) -> Option { + let current_count = self.count(current_text); + let char_count = match self.count_mode { + CountMode::Characters => 1, + CountMode::DisplayWidth => { + let char_str = character.to_string(); + char_str.width() + }, + CountMode::Bytes => character.len_utf8(), + }; + let new_count = current_count + char_count; + + // Check max length + if let Some(max) = self.max_length { + if new_count > max { + return Some(ValidationResult::error(format!( + "Character limit exceeded: {}/{}", + new_count, + max + ))); + } + + // Check warning threshold + if let Some(warning_threshold) = self.warning_threshold { + if new_count >= warning_threshold && current_count < warning_threshold { + return Some(ValidationResult::warning(format!( + "Approaching character limit: {}/{}", + new_count, + max + ))); + } + } + } + + None // No validation issues + } + + /// Validate the current content + pub fn validate_content(&self, text: &str) -> Option { + let count = self.count(text); + + // Check minimum length + if let Some(min) = self.min_length { + if count < min { + return Some(ValidationResult::warning(format!( + "Minimum length not met: {}/{}", + count, + min + ))); + } + } + + // Check maximum length + if let Some(max) = self.max_length { + if count > max { + return Some(ValidationResult::error(format!( + "Character limit exceeded: {}/{}", + count, + max + ))); + } + + // Check warning threshold + if let Some(warning_threshold) = self.warning_threshold { + if count >= warning_threshold { + return Some(ValidationResult::warning(format!( + "Approaching character limit: {}/{}", + count, + max + ))); + } + } + } + + None // No validation issues + } + + /// Get the current status of the text against limits + pub fn check_limits(&self, text: &str) -> LimitCheckResult { + let count = self.count(text); + + // Check max length first + if let Some(max) = self.max_length { + if count > max { + return LimitCheckResult::Exceeded { current: count, max }; + } + + // Check warning threshold + if let Some(warning_threshold) = self.warning_threshold { + if count >= warning_threshold { + return LimitCheckResult::Warning { current: count, max }; + } + } + } + + // Check min length + if let Some(min) = self.min_length { + if count < min { + return LimitCheckResult::TooShort { current: count, min }; + } + } + + LimitCheckResult::Ok + } + + /// Get a human-readable status string + pub fn status_text(&self, text: &str) -> Option { + match self.check_limits(text) { + LimitCheckResult::Ok => { + // Show current/max if we have a max limit + if let Some(max) = self.max_length { + Some(format!("{}/{}", self.count(text), max)) + } else { + None + } + }, + LimitCheckResult::Warning { current, max } => { + Some(format!("{}/{} (approaching limit)", current, max)) + }, + LimitCheckResult::Exceeded { current, max } => { + Some(format!("{}/{} (exceeded)", current, max)) + }, + LimitCheckResult::TooShort { current, min } => { + Some(format!("{}/{} minimum", current, min)) + }, + } + } +} + +impl Default for CharacterLimits { + fn default() -> Self { + Self { + max_length: Some(30), // Default 30 character limit as specified + min_length: None, + warning_threshold: None, + count_mode: CountMode::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_character_limits_creation() { + let limits = CharacterLimits::new(10); + assert_eq!(limits.max_length(), Some(10)); + assert_eq!(limits.min_length(), None); + + let range_limits = CharacterLimits::new_range(5, 15); + assert_eq!(range_limits.min_length(), Some(5)); + assert_eq!(range_limits.max_length(), Some(15)); + } + + #[test] + fn test_default_limits() { + let limits = CharacterLimits::default(); + assert_eq!(limits.max_length(), Some(30)); + } + + #[test] + fn test_character_counting() { + let limits = CharacterLimits::new(5); + + // Test character mode (default) + assert_eq!(limits.count("hello"), 5); + assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1 + + // Test display width mode + let limits = limits.with_count_mode(CountMode::DisplayWidth); + assert_eq!(limits.count("hello"), 5); + + // Test bytes mode + let limits = limits.with_count_mode(CountMode::Bytes); + assert_eq!(limits.count("hello"), 5); + assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8 + } + + #[test] + fn test_insertion_validation() { + let limits = CharacterLimits::new(5); + + // Valid insertion + let result = limits.validate_insertion("test", 4, 'x'); + assert!(result.is_none()); // No validation issues + + // Invalid insertion (would exceed limit) + let result = limits.validate_insertion("tests", 5, 'x'); + assert!(result.is_some()); + assert!(!result.unwrap().is_acceptable()); + } + + #[test] + fn test_content_validation() { + let limits = CharacterLimits::new_range(3, 10); + + // Too short + let result = limits.validate_content("hi"); + assert!(result.is_some()); + assert!(result.unwrap().is_acceptable()); // Warning, not error + + // Just right + let result = limits.validate_content("hello"); + assert!(result.is_none()); + + // Too long + let result = limits.validate_content("hello world!"); + assert!(result.is_some()); + assert!(!result.unwrap().is_acceptable()); // Error + } + + #[test] + fn test_warning_threshold() { + let limits = CharacterLimits::new(10).with_warning_threshold(8); + + // Below warning threshold + let result = limits.validate_insertion("1234567", 7, 'x'); + assert!(result.is_none()); + + // At warning threshold + let result = limits.validate_insertion("1234567", 7, 'x'); + assert!(result.is_none()); // This brings us to 8 chars + + let result = limits.validate_insertion("12345678", 8, 'x'); + assert!(result.is_some()); + assert!(result.unwrap().is_acceptable()); // Warning, not error + } + + #[test] + fn test_status_text() { + let limits = CharacterLimits::new(10); + + assert_eq!(limits.status_text("hello"), Some("5/10".to_string())); + + let limits = limits.with_warning_threshold(8); + assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string())); + assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string())); + } +} diff --git a/canvas/src/validation/mod.rs b/canvas/src/validation/mod.rs new file mode 100644 index 0000000..d1d3261 --- /dev/null +++ b/canvas/src/validation/mod.rs @@ -0,0 +1,26 @@ +//! Validation module for canvas form fields + +pub mod config; +pub mod limits; +pub mod state; + +// Re-export main types +pub use config::{ValidationConfig, ValidationResult, ValidationConfigBuilder}; +pub use limits::{CharacterLimits, LimitCheckResult}; +pub use state::{ValidationState, ValidationSummary}; + +/// Validation error types +#[derive(Debug, Clone, thiserror::Error)] +pub enum ValidationError { + #[error("Character limit exceeded: {current}/{max}")] + CharacterLimitExceeded { current: usize, max: usize }, + + #[error("Invalid character '{char}' at position {position}")] + InvalidCharacter { char: char, position: usize }, + + #[error("Validation configuration error: {message}")] + ConfigurationError { message: String }, +} + +/// Result type for validation operations +pub type Result = std::result::Result; diff --git a/canvas/src/validation/state.rs b/canvas/src/validation/state.rs new file mode 100644 index 0000000..4f48727 --- /dev/null +++ b/canvas/src/validation/state.rs @@ -0,0 +1,374 @@ +//! Validation state management + +use crate::validation::{ValidationConfig, ValidationResult}; +use std::collections::HashMap; + +/// Validation state for all fields in a form +#[derive(Debug, Clone, Default)] +pub struct ValidationState { + /// Validation configurations per field index + field_configs: HashMap, + + /// Current validation results per field index + field_results: HashMap, + + /// Track which fields have been validated + validated_fields: std::collections::HashSet, + + /// Global validation enabled/disabled + enabled: bool, +} + +impl ValidationState { + /// Create a new validation state + pub fn new() -> Self { + Self { + field_configs: HashMap::new(), + field_results: HashMap::new(), + validated_fields: std::collections::HashSet::new(), + enabled: true, + } + } + + /// Enable or disable validation globally + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + // Clear all validation results when disabled + self.field_results.clear(); + self.validated_fields.clear(); + } + } + + /// Check if validation is enabled + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Set validation configuration for a field + pub fn set_field_config(&mut self, field_index: usize, config: ValidationConfig) { + if config.has_validation() { + self.field_configs.insert(field_index, config); + } else { + self.field_configs.remove(&field_index); + self.field_results.remove(&field_index); + self.validated_fields.remove(&field_index); + } + } + + /// Get validation configuration for a field + pub fn get_field_config(&self, field_index: usize) -> Option<&ValidationConfig> { + self.field_configs.get(&field_index) + } + + /// Remove validation configuration for a field + pub fn remove_field_config(&mut self, field_index: usize) { + self.field_configs.remove(&field_index); + self.field_results.remove(&field_index); + self.validated_fields.remove(&field_index); + } + + /// Validate character insertion for a field + pub fn validate_char_insertion( + &mut self, + field_index: usize, + current_text: &str, + position: usize, + character: char, + ) -> ValidationResult { + if !self.enabled { + return ValidationResult::Valid; + } + + if let Some(config) = self.field_configs.get(&field_index) { + let result = config.validate_char_insertion(current_text, position, character); + + // Store the validation result + self.field_results.insert(field_index, result.clone()); + self.validated_fields.insert(field_index); + + result + } else { + ValidationResult::Valid + } + } + + /// Validate field content + pub fn validate_field_content( + &mut self, + field_index: usize, + text: &str, + ) -> ValidationResult { + if !self.enabled { + return ValidationResult::Valid; + } + + if let Some(config) = self.field_configs.get(&field_index) { + let result = config.validate_content(text); + + // Store the validation result + self.field_results.insert(field_index, result.clone()); + self.validated_fields.insert(field_index); + + result + } else { + ValidationResult::Valid + } + } + + /// Get current validation result for a field + pub fn get_field_result(&self, field_index: usize) -> Option<&ValidationResult> { + self.field_results.get(&field_index) + } + + /// Check if a field has been validated + pub fn is_field_validated(&self, field_index: usize) -> bool { + self.validated_fields.contains(&field_index) + } + + /// Clear validation result for a field + pub fn clear_field_result(&mut self, field_index: usize) { + self.field_results.remove(&field_index); + self.validated_fields.remove(&field_index); + } + + /// Clear all validation results + pub fn clear_all_results(&mut self) { + self.field_results.clear(); + self.validated_fields.clear(); + } + + /// Get all field indices that have validation configured + pub fn validated_field_indices(&self) -> impl Iterator + '_ { + self.field_configs.keys().copied() + } + + /// Get all field indices with validation errors + pub fn fields_with_errors(&self) -> impl Iterator + '_ { + self.field_results + .iter() + .filter(|(_, result)| result.is_error()) + .map(|(index, _)| *index) + } + + /// Get all field indices with validation warnings + pub fn fields_with_warnings(&self) -> impl Iterator + '_ { + self.field_results + .iter() + .filter(|(_, result)| matches!(result, ValidationResult::Warning { .. })) + .map(|(index, _)| *index) + } + + /// Check if any field has validation errors + pub fn has_errors(&self) -> bool { + self.field_results.values().any(|result| result.is_error()) + } + + /// Check if any field has validation warnings + pub fn has_warnings(&self) -> bool { + self.field_results.values().any(|result| matches!(result, ValidationResult::Warning { .. })) + } + + /// Get total count of fields with validation configured + pub fn validated_field_count(&self) -> usize { + self.field_configs.len() + } + + /// Get validation summary + pub fn summary(&self) -> ValidationSummary { + let total_validated = self.validated_fields.len(); + let errors = self.fields_with_errors().count(); + let warnings = self.fields_with_warnings().count(); + let valid = total_validated - errors - warnings; + + ValidationSummary { + total_fields: self.field_configs.len(), + validated_fields: total_validated, + valid_fields: valid, + warning_fields: warnings, + error_fields: errors, + } + } +} + +/// Summary of validation state across all fields +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationSummary { + /// Total number of fields with validation configured + pub total_fields: usize, + + /// Number of fields that have been validated + pub validated_fields: usize, + + /// Number of fields with valid validation results + pub valid_fields: usize, + + /// Number of fields with warnings + pub warning_fields: usize, + + /// Number of fields with errors + pub error_fields: usize, +} + +impl ValidationSummary { + /// Check if all configured fields are valid + pub fn is_all_valid(&self) -> bool { + self.error_fields == 0 && self.validated_fields == self.total_fields + } + + /// Check if there are any errors + pub fn has_errors(&self) -> bool { + self.error_fields > 0 + } + + /// Check if there are any warnings + pub fn has_warnings(&self) -> bool { + self.warning_fields > 0 + } + + /// Get completion percentage (validated fields / total fields) + pub fn completion_percentage(&self) -> f32 { + if self.total_fields == 0 { + 1.0 + } else { + self.validated_fields as f32 / self.total_fields as f32 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::validation::{CharacterLimits, ValidationConfigBuilder}; + + #[test] + fn test_validation_state_creation() { + let state = ValidationState::new(); + assert!(state.is_enabled()); + assert_eq!(state.validated_field_count(), 0); + } + + #[test] + fn test_enable_disable() { + let mut state = ValidationState::new(); + + // Add some validation config + let config = ValidationConfigBuilder::new() + .with_max_length(10) + .build(); + state.set_field_config(0, config); + + // Validate something + let result = state.validate_field_content(0, "test"); + assert!(result.is_acceptable()); + assert!(state.is_field_validated(0)); + + // Disable validation + state.set_enabled(false); + assert!(!state.is_enabled()); + assert!(!state.is_field_validated(0)); // Should be cleared + + // Validation should now return valid regardless + let result = state.validate_field_content(0, "this is way too long for the limit"); + assert!(result.is_acceptable()); + } + + #[test] + fn test_field_config_management() { + let mut state = ValidationState::new(); + + let config = ValidationConfigBuilder::new() + .with_max_length(5) + .build(); + + // Set config + state.set_field_config(0, config); + assert_eq!(state.validated_field_count(), 1); + assert!(state.get_field_config(0).is_some()); + + // Remove config + state.remove_field_config(0); + assert_eq!(state.validated_field_count(), 0); + assert!(state.get_field_config(0).is_none()); + } + + #[test] + fn test_character_insertion_validation() { + let mut state = ValidationState::new(); + + let config = ValidationConfigBuilder::new() + .with_max_length(5) + .build(); + state.set_field_config(0, config); + + // Valid insertion + let result = state.validate_char_insertion(0, "test", 4, 'x'); + assert!(result.is_acceptable()); + + // Invalid insertion + let result = state.validate_char_insertion(0, "tests", 5, 'x'); + assert!(!result.is_acceptable()); + + // Check that result was stored + assert!(state.is_field_validated(0)); + let stored_result = state.get_field_result(0); + assert!(stored_result.is_some()); + assert!(!stored_result.unwrap().is_acceptable()); + } + + #[test] + fn test_validation_summary() { + let mut state = ValidationState::new(); + + // Configure two fields + let config1 = ValidationConfigBuilder::new().with_max_length(5).build(); + let config2 = ValidationConfigBuilder::new().with_max_length(10).build(); + state.set_field_config(0, config1); + state.set_field_config(1, config2); + + // Validate field 0 (valid) + state.validate_field_content(0, "test"); + + // Validate field 1 (error) + state.validate_field_content(1, "this is too long"); + + let summary = state.summary(); + assert_eq!(summary.total_fields, 2); + assert_eq!(summary.validated_fields, 2); + assert_eq!(summary.valid_fields, 1); + assert_eq!(summary.error_fields, 1); + assert_eq!(summary.warning_fields, 0); + + assert!(!summary.is_all_valid()); + assert!(summary.has_errors()); + assert!(!summary.has_warnings()); + assert_eq!(summary.completion_percentage(), 1.0); + } + + #[test] + fn test_error_and_warning_tracking() { + let mut state = ValidationState::new(); + + let config = ValidationConfigBuilder::new() + .with_character_limits( + CharacterLimits::new_range(3, 10).with_warning_threshold(8) + ) + .build(); + state.set_field_config(0, config); + + // Too short (warning) + state.validate_field_content(0, "hi"); + assert!(state.has_warnings()); + assert!(!state.has_errors()); + + // Just right + state.validate_field_content(0, "hello"); + assert!(!state.has_warnings()); + assert!(!state.has_errors()); + + // Too long (error) + state.validate_field_content(0, "hello world!"); + assert!(!state.has_warnings()); + assert!(state.has_errors()); + } +}