Skip to main content

ftui_runtime/undo/
command.rs

1#![forbid(unsafe_code)]
2
3//! Undoable command infrastructure for the undo/redo system.
4//!
5//! This module provides the [`UndoableCmd`] trait for reversible operations
6//! and common command implementations for text editing and UI interactions.
7//!
8//! # Design Principles
9//!
10//! 1. **Explicit state**: Commands capture all state needed for undo/redo
11//! 2. **Memory-efficient**: Commands report their size for budget management
12//! 3. **Mergeable**: Consecutive similar commands can merge (e.g., typing)
13//! 4. **Traceable**: Commands include metadata for debugging and UI display
14//!
15//! # Invariants
16//!
17//! - `execute()` followed by `undo()` restores prior state exactly
18//! - `undo()` followed by `redo()` restores the executed state exactly
19//! - Commands with `can_merge() == true` MUST successfully merge
20//! - `size_bytes()` MUST be accurate for memory budgeting
21//!
22//! # Failure Modes
23//!
24//! - **Stale reference**: Command holds reference to deleted target
25//!   - Mitigation: Validate target existence in execute/undo
26//! - **State drift**: External changes invalidate undo data
27//!   - Mitigation: Clear undo stack on external modifications
28//! - **Memory exhaustion**: Unbounded history growth
29//!   - Mitigation: History stack enforces size limits via `size_bytes()`
30
31use std::any::Any;
32use std::fmt;
33use std::time::Instant;
34
35/// Unique identifier for a widget that commands operate on.
36///
37/// Commands targeting widgets store this ID to locate their target
38/// during execute/undo operations.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct WidgetId(pub u64);
41
42impl WidgetId {
43    /// Create a new widget ID from a raw value.
44    #[must_use]
45    pub const fn new(id: u64) -> Self {
46        Self(id)
47    }
48
49    /// Get the raw ID value.
50    #[must_use]
51    pub const fn raw(self) -> u64 {
52        self.0
53    }
54}
55
56/// Source of a command - who/what triggered it.
57///
58/// Used for filtering undo history and debugging.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum CommandSource {
61    /// Direct user action (keyboard, mouse).
62    #[default]
63    User,
64    /// Triggered programmatically by application code.
65    Programmatic,
66    /// Replayed from a recorded macro.
67    Macro,
68    /// Triggered by an external system/API.
69    External,
70}
71
72/// Metadata attached to every command for tracing and UI display.
73#[derive(Debug, Clone)]
74pub struct CommandMetadata {
75    /// Human-readable description for UI (e.g., "Insert text").
76    pub description: String,
77    /// When the command was created.
78    pub timestamp: Instant,
79    /// Who/what triggered the command.
80    pub source: CommandSource,
81    /// Optional batch ID for grouping related commands.
82    pub batch_id: Option<u64>,
83}
84
85impl CommandMetadata {
86    /// Create new metadata with the given description.
87    #[must_use]
88    pub fn new(description: impl Into<String>) -> Self {
89        Self {
90            description: description.into(),
91            timestamp: Instant::now(),
92            source: CommandSource::User,
93            batch_id: None,
94        }
95    }
96
97    /// Set the command source.
98    #[must_use]
99    pub fn with_source(mut self, source: CommandSource) -> Self {
100        self.source = source;
101        self
102    }
103
104    /// Set the batch ID for grouping.
105    #[must_use]
106    pub fn with_batch(mut self, batch_id: u64) -> Self {
107        self.batch_id = Some(batch_id);
108        self
109    }
110
111    /// Size in bytes for memory accounting.
112    #[must_use]
113    pub fn size_bytes(&self) -> usize {
114        std::mem::size_of::<Self>() + self.description.len()
115    }
116}
117
118impl Default for CommandMetadata {
119    fn default() -> Self {
120        Self::new("Unknown")
121    }
122}
123
124/// Result of command execution or undo.
125///
126/// Commands may fail if targets are invalid or state has drifted.
127pub type CommandResult = Result<(), CommandError>;
128
129/// Errors that can occur during command execution.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum CommandError {
132    /// Target widget no longer exists.
133    TargetNotFound(WidgetId),
134    /// Position is out of bounds.
135    PositionOutOfBounds { position: usize, length: usize },
136    /// State has changed since command was created.
137    StateDrift { expected: String, actual: String },
138    /// Command cannot be executed in current state.
139    InvalidState(String),
140    /// Generic error with message.
141    Other(String),
142}
143
144impl fmt::Display for CommandError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::TargetNotFound(id) => write!(f, "target widget {:?} not found", id),
148            Self::PositionOutOfBounds { position, length } => {
149                write!(f, "position {} out of bounds (length {})", position, length)
150            }
151            Self::StateDrift { expected, actual } => {
152                write!(f, "state drift: expected '{}', got '{}'", expected, actual)
153            }
154            Self::InvalidState(msg) => write!(f, "invalid state: {}", msg),
155            Self::Other(msg) => write!(f, "{}", msg),
156        }
157    }
158}
159
160impl std::error::Error for CommandError {}
161
162/// Configuration for command merging behavior.
163#[derive(Debug, Clone, Copy)]
164pub struct MergeConfig {
165    /// Maximum time between commands to allow merging (milliseconds).
166    pub max_delay_ms: u64,
167    /// Whether to merge across word boundaries.
168    pub merge_across_words: bool,
169    /// Maximum merged command size before forcing a split.
170    pub max_merged_size: usize,
171}
172
173impl Default for MergeConfig {
174    fn default() -> Self {
175        Self {
176            max_delay_ms: 500,
177            merge_across_words: false,
178            max_merged_size: 1024,
179        }
180    }
181}
182
183/// A reversible command that can be undone and redone.
184///
185/// Commands capture all state needed to execute, undo, and redo an operation.
186/// They support merging for batching related operations (like consecutive typing).
187pub trait UndoableCmd: Send + Sync {
188    /// Execute the command, applying its effect.
189    fn execute(&mut self) -> CommandResult;
190
191    /// Undo the command, reverting its effect.
192    fn undo(&mut self) -> CommandResult;
193
194    /// Redo the command after it was undone.
195    fn redo(&mut self) -> CommandResult {
196        self.execute()
197    }
198
199    /// Human-readable description for UI display.
200    fn description(&self) -> &str;
201
202    /// Size of this command in bytes for memory budgeting.
203    fn size_bytes(&self) -> usize;
204
205    /// Check if this command can merge with another.
206    fn can_merge(&self, _other: &dyn UndoableCmd, _config: &MergeConfig) -> bool {
207        false
208    }
209
210    /// Merge another command into this one.
211    ///
212    /// Returns the text to append if merging is possible.
213    /// The default implementation returns None (no merge).
214    fn merge_text(&self) -> Option<&str> {
215        None
216    }
217
218    /// Accept a merge from another command.
219    ///
220    /// The full command reference is passed to allow implementations to
221    /// extract position or other context needed for correct merge behavior.
222    fn accept_merge(&mut self, _other: &dyn UndoableCmd) -> bool {
223        false
224    }
225
226    /// Get the command metadata.
227    fn metadata(&self) -> &CommandMetadata;
228
229    /// Get the target widget ID, if any.
230    fn target(&self) -> Option<WidgetId> {
231        None
232    }
233
234    /// Downcast to concrete type for merging.
235    fn as_any(&self) -> &dyn Any;
236
237    /// Downcast to mutable concrete type for merging.
238    fn as_any_mut(&mut self) -> &mut dyn Any;
239
240    /// Debug description of the command.
241    fn debug_name(&self) -> &'static str {
242        "UndoableCmd"
243    }
244}
245
246impl fmt::Debug for dyn UndoableCmd {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.debug_struct(self.debug_name())
249            .field("description", &self.description())
250            .field("size_bytes", &self.size_bytes())
251            .finish()
252    }
253}
254
255/// A batch of commands that execute and undo together.
256///
257/// Useful for operations that span multiple widgets or steps
258/// but should appear as a single undo entry.
259pub struct CommandBatch {
260    /// Commands in execution order.
261    commands: Vec<Box<dyn UndoableCmd>>,
262    /// Batch metadata.
263    metadata: CommandMetadata,
264    /// Index of last successfully executed command.
265    executed_to: usize,
266}
267
268impl fmt::Debug for CommandBatch {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.debug_struct("CommandBatch")
271            .field("commands_count", &self.commands.len())
272            .field("metadata", &self.metadata)
273            .field("executed_to", &self.executed_to)
274            .finish()
275    }
276}
277
278impl CommandBatch {
279    /// Create a new command batch.
280    #[must_use]
281    pub fn new(description: impl Into<String>) -> Self {
282        Self {
283            commands: Vec::new(),
284            metadata: CommandMetadata::new(description),
285            executed_to: 0,
286        }
287    }
288
289    /// Add a command to the batch.
290    pub fn push(&mut self, cmd: Box<dyn UndoableCmd>) {
291        self.commands.push(cmd);
292    }
293
294    /// Add a pre-executed command to the batch.
295    ///
296    /// Use this for commands that have already been executed externally.
297    /// The command will be properly undone when the batch is undone.
298    pub fn push_executed(&mut self, cmd: Box<dyn UndoableCmd>) {
299        self.commands.push(cmd);
300        self.executed_to = self.commands.len();
301    }
302
303    /// Number of commands in the batch.
304    #[must_use]
305    pub fn len(&self) -> usize {
306        self.commands.len()
307    }
308
309    /// Check if the batch is empty.
310    #[must_use]
311    pub fn is_empty(&self) -> bool {
312        self.commands.is_empty()
313    }
314}
315
316impl UndoableCmd for CommandBatch {
317    fn execute(&mut self) -> CommandResult {
318        for (i, cmd) in self.commands.iter_mut().enumerate() {
319            if let Err(e) = cmd.execute() {
320                // Rollback executed commands on failure
321                for j in (0..i).rev() {
322                    let _ = self.commands[j].undo();
323                }
324                return Err(e);
325            }
326            self.executed_to = i + 1;
327        }
328        Ok(())
329    }
330
331    fn undo(&mut self) -> CommandResult {
332        // Undo in reverse order
333        for i in (0..self.executed_to).rev() {
334            self.commands[i].undo()?;
335        }
336        self.executed_to = 0;
337        Ok(())
338    }
339
340    fn redo(&mut self) -> CommandResult {
341        self.execute()
342    }
343
344    fn description(&self) -> &str {
345        &self.metadata.description
346    }
347
348    fn size_bytes(&self) -> usize {
349        std::mem::size_of::<Self>()
350            + self.metadata.size_bytes()
351            + self.commands.iter().map(|c| c.size_bytes()).sum::<usize>()
352    }
353
354    fn metadata(&self) -> &CommandMetadata {
355        &self.metadata
356    }
357
358    fn as_any(&self) -> &dyn Any {
359        self
360    }
361
362    fn as_any_mut(&mut self) -> &mut dyn Any {
363        self
364    }
365
366    fn debug_name(&self) -> &'static str {
367        "CommandBatch"
368    }
369}
370
371// ============================================================================
372// Built-in Text Commands
373// ============================================================================
374
375/// Callback type for applying text operations.
376pub type TextApplyFn = Box<dyn Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync>;
377/// Callback type for removing text.
378pub type TextRemoveFn = Box<dyn Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync>;
379/// Callback type for replacing text.
380pub type TextReplaceFn = Box<dyn Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync>;
381
382/// Command to insert text at a position.
383pub struct TextInsertCmd {
384    /// Target widget.
385    pub target: WidgetId,
386    /// Position to insert at (byte offset).
387    pub position: usize,
388    /// Text to insert.
389    pub text: String,
390    /// Command metadata.
391    pub metadata: CommandMetadata,
392    /// Callback to apply the insertion (set by the widget).
393    apply: Option<TextApplyFn>,
394    /// Callback to remove the insertion (set by the widget).
395    remove: Option<TextRemoveFn>,
396}
397
398impl fmt::Debug for TextInsertCmd {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        f.debug_struct("TextInsertCmd")
401            .field("target", &self.target)
402            .field("position", &self.position)
403            .field("text", &self.text)
404            .field("metadata", &self.metadata)
405            .field("has_apply", &self.apply.is_some())
406            .field("has_remove", &self.remove.is_some())
407            .finish()
408    }
409}
410
411impl TextInsertCmd {
412    /// Create a new text insert command.
413    #[must_use]
414    pub fn new(target: WidgetId, position: usize, text: impl Into<String>) -> Self {
415        Self {
416            target,
417            position,
418            text: text.into(),
419            metadata: CommandMetadata::new("Insert text"),
420            apply: None,
421            remove: None,
422        }
423    }
424
425    /// Set the apply callback.
426    pub fn with_apply<F>(mut self, f: F) -> Self
427    where
428        F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
429    {
430        self.apply = Some(Box::new(f));
431        self
432    }
433
434    /// Set the remove callback.
435    pub fn with_remove<F>(mut self, f: F) -> Self
436    where
437        F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
438    {
439        self.remove = Some(Box::new(f));
440        self
441    }
442}
443
444impl UndoableCmd for TextInsertCmd {
445    fn execute(&mut self) -> CommandResult {
446        if let Some(ref apply) = self.apply {
447            apply(self.target, self.position, &self.text)
448        } else {
449            Err(CommandError::InvalidState(
450                "no apply callback set".to_string(),
451            ))
452        }
453    }
454
455    fn undo(&mut self) -> CommandResult {
456        if let Some(ref remove) = self.remove {
457            remove(self.target, self.position, self.text.len())
458        } else {
459            Err(CommandError::InvalidState(
460                "no remove callback set".to_string(),
461            ))
462        }
463    }
464
465    fn description(&self) -> &str {
466        &self.metadata.description
467    }
468
469    fn size_bytes(&self) -> usize {
470        std::mem::size_of::<Self>() + self.text.len() + self.metadata.size_bytes()
471    }
472
473    fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
474        let Some(other) = other.as_any().downcast_ref::<Self>() else {
475            return false;
476        };
477
478        // Must target same widget
479        if self.target != other.target {
480            return false;
481        }
482
483        // Must be consecutive
484        if other.position != self.position + self.text.len() {
485            return false;
486        }
487
488        // Check time constraint
489        let elapsed = other
490            .metadata
491            .timestamp
492            .duration_since(self.metadata.timestamp);
493        if elapsed.as_millis() > config.max_delay_ms as u128 {
494            return false;
495        }
496
497        // Check size constraint
498        if self.text.len() + other.text.len() > config.max_merged_size {
499            return false;
500        }
501
502        // Don't merge across word boundaries unless configured
503        if !config.merge_across_words && self.text.ends_with(' ') {
504            return false;
505        }
506
507        true
508    }
509
510    fn merge_text(&self) -> Option<&str> {
511        Some(&self.text)
512    }
513
514    fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
515        if let Some(text) = other.merge_text() {
516            self.text.push_str(text);
517            true
518        } else {
519            false
520        }
521    }
522
523    fn metadata(&self) -> &CommandMetadata {
524        &self.metadata
525    }
526
527    fn target(&self) -> Option<WidgetId> {
528        Some(self.target)
529    }
530
531    fn as_any(&self) -> &dyn Any {
532        self
533    }
534
535    fn as_any_mut(&mut self) -> &mut dyn Any {
536        self
537    }
538
539    fn debug_name(&self) -> &'static str {
540        "TextInsertCmd"
541    }
542}
543
544/// Command to delete text at a position.
545pub struct TextDeleteCmd {
546    /// Target widget.
547    pub target: WidgetId,
548    /// Position to delete from (byte offset).
549    pub position: usize,
550    /// Deleted text (for undo).
551    pub deleted_text: String,
552    /// Command metadata.
553    pub metadata: CommandMetadata,
554    /// Callback to remove text.
555    remove: Option<TextRemoveFn>,
556    /// Callback to insert text (for undo).
557    insert: Option<TextApplyFn>,
558}
559
560impl fmt::Debug for TextDeleteCmd {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        f.debug_struct("TextDeleteCmd")
563            .field("target", &self.target)
564            .field("position", &self.position)
565            .field("deleted_text", &self.deleted_text)
566            .field("metadata", &self.metadata)
567            .field("has_remove", &self.remove.is_some())
568            .field("has_insert", &self.insert.is_some())
569            .finish()
570    }
571}
572
573impl TextDeleteCmd {
574    /// Create a new text delete command.
575    #[must_use]
576    pub fn new(target: WidgetId, position: usize, deleted_text: impl Into<String>) -> Self {
577        Self {
578            target,
579            position,
580            deleted_text: deleted_text.into(),
581            metadata: CommandMetadata::new("Delete text"),
582            remove: None,
583            insert: None,
584        }
585    }
586
587    /// Set the remove callback.
588    pub fn with_remove<F>(mut self, f: F) -> Self
589    where
590        F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
591    {
592        self.remove = Some(Box::new(f));
593        self
594    }
595
596    /// Set the insert callback (for undo).
597    pub fn with_insert<F>(mut self, f: F) -> Self
598    where
599        F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
600    {
601        self.insert = Some(Box::new(f));
602        self
603    }
604}
605
606impl UndoableCmd for TextDeleteCmd {
607    fn execute(&mut self) -> CommandResult {
608        if let Some(ref remove) = self.remove {
609            remove(self.target, self.position, self.deleted_text.len())
610        } else {
611            Err(CommandError::InvalidState(
612                "no remove callback set".to_string(),
613            ))
614        }
615    }
616
617    fn undo(&mut self) -> CommandResult {
618        if let Some(ref insert) = self.insert {
619            insert(self.target, self.position, &self.deleted_text)
620        } else {
621            Err(CommandError::InvalidState(
622                "no insert callback set".to_string(),
623            ))
624        }
625    }
626
627    fn description(&self) -> &str {
628        &self.metadata.description
629    }
630
631    fn size_bytes(&self) -> usize {
632        std::mem::size_of::<Self>() + self.deleted_text.len() + self.metadata.size_bytes()
633    }
634
635    fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
636        let Some(other) = other.as_any().downcast_ref::<Self>() else {
637            return false;
638        };
639
640        // Must target same widget
641        if self.target != other.target {
642            return false;
643        }
644
645        // For backspace: other.position + other.deleted_text.len() == self.position
646        // For delete key: other.position == self.position
647        let is_backspace = other.position + other.deleted_text.len() == self.position;
648        let is_delete = other.position == self.position;
649
650        if !is_backspace && !is_delete {
651            return false;
652        }
653
654        // Check time constraint
655        let elapsed = other
656            .metadata
657            .timestamp
658            .duration_since(self.metadata.timestamp);
659        if elapsed.as_millis() > config.max_delay_ms as u128 {
660            return false;
661        }
662
663        // Check size constraint
664        if self.deleted_text.len() + other.deleted_text.len() > config.max_merged_size {
665            return false;
666        }
667
668        true
669    }
670
671    fn merge_text(&self) -> Option<&str> {
672        Some(&self.deleted_text)
673    }
674
675    fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
676        let Some(text) = other.merge_text() else {
677            return false;
678        };
679        let Some(other_delete) = other.as_any().downcast_ref::<Self>() else {
680            return false;
681        };
682
683        // Determine if this is a backspace or forward delete merge:
684        // - Backspace: other.position + other.deleted_text.len() == self.position
685        //   The new delete happened before our position, prepend its text
686        // - Forward delete: other.position == self.position
687        //   The new delete happened at the same position, append its text
688        let is_backspace = other_delete.position + other_delete.deleted_text.len() == self.position;
689
690        if is_backspace {
691            // Backspace: prepend and move our position back
692            self.deleted_text = format!("{}{}", text, self.deleted_text);
693            self.position = other_delete.position;
694        } else {
695            // Forward delete: append (text was after original deleted text)
696            self.deleted_text.push_str(text);
697        }
698        true
699    }
700
701    fn metadata(&self) -> &CommandMetadata {
702        &self.metadata
703    }
704
705    fn target(&self) -> Option<WidgetId> {
706        Some(self.target)
707    }
708
709    fn as_any(&self) -> &dyn Any {
710        self
711    }
712
713    fn as_any_mut(&mut self) -> &mut dyn Any {
714        self
715    }
716
717    fn debug_name(&self) -> &'static str {
718        "TextDeleteCmd"
719    }
720}
721
722/// Command to replace text at a position.
723pub struct TextReplaceCmd {
724    /// Target widget.
725    pub target: WidgetId,
726    /// Position to replace at (byte offset).
727    pub position: usize,
728    /// Original text that was replaced.
729    pub old_text: String,
730    /// New text that replaced it.
731    pub new_text: String,
732    /// Command metadata.
733    pub metadata: CommandMetadata,
734    /// Callback to apply replacement.
735    replace: Option<TextReplaceFn>,
736}
737
738impl fmt::Debug for TextReplaceCmd {
739    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
740        f.debug_struct("TextReplaceCmd")
741            .field("target", &self.target)
742            .field("position", &self.position)
743            .field("old_text", &self.old_text)
744            .field("new_text", &self.new_text)
745            .field("metadata", &self.metadata)
746            .field("has_replace", &self.replace.is_some())
747            .finish()
748    }
749}
750
751impl TextReplaceCmd {
752    /// Create a new text replace command.
753    #[must_use]
754    pub fn new(
755        target: WidgetId,
756        position: usize,
757        old_text: impl Into<String>,
758        new_text: impl Into<String>,
759    ) -> Self {
760        Self {
761            target,
762            position,
763            old_text: old_text.into(),
764            new_text: new_text.into(),
765            metadata: CommandMetadata::new("Replace text"),
766            replace: None,
767        }
768    }
769
770    /// Set the replace callback.
771    pub fn with_replace<F>(mut self, f: F) -> Self
772    where
773        F: Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync + 'static,
774    {
775        self.replace = Some(Box::new(f));
776        self
777    }
778}
779
780impl UndoableCmd for TextReplaceCmd {
781    fn execute(&mut self) -> CommandResult {
782        if let Some(ref replace) = self.replace {
783            replace(
784                self.target,
785                self.position,
786                self.old_text.len(),
787                &self.new_text,
788            )
789        } else {
790            Err(CommandError::InvalidState(
791                "no replace callback set".to_string(),
792            ))
793        }
794    }
795
796    fn undo(&mut self) -> CommandResult {
797        if let Some(ref replace) = self.replace {
798            replace(
799                self.target,
800                self.position,
801                self.new_text.len(),
802                &self.old_text,
803            )
804        } else {
805            Err(CommandError::InvalidState(
806                "no replace callback set".to_string(),
807            ))
808        }
809    }
810
811    fn description(&self) -> &str {
812        &self.metadata.description
813    }
814
815    fn size_bytes(&self) -> usize {
816        std::mem::size_of::<Self>()
817            + self.old_text.len()
818            + self.new_text.len()
819            + self.metadata.size_bytes()
820    }
821
822    fn metadata(&self) -> &CommandMetadata {
823        &self.metadata
824    }
825
826    fn target(&self) -> Option<WidgetId> {
827        Some(self.target)
828    }
829
830    fn as_any(&self) -> &dyn Any {
831        self
832    }
833
834    fn as_any_mut(&mut self) -> &mut dyn Any {
835        self
836    }
837
838    fn debug_name(&self) -> &'static str {
839        "TextReplaceCmd"
840    }
841}
842
843// ============================================================================
844// Tests
845// ============================================================================
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use std::sync::Arc;
851    use std::sync::Mutex;
852
853    #[test]
854    fn test_widget_id_creation() {
855        let id = WidgetId::new(42);
856        assert_eq!(id.raw(), 42);
857    }
858
859    #[test]
860    fn test_command_metadata_size() {
861        let meta = CommandMetadata::new("Test command");
862        let size = meta.size_bytes();
863        assert!(size > std::mem::size_of::<CommandMetadata>());
864        assert!(size >= std::mem::size_of::<CommandMetadata>() + "Test command".len());
865    }
866
867    #[test]
868    fn test_command_metadata_with_source() {
869        let meta = CommandMetadata::new("Test").with_source(CommandSource::Macro);
870        assert_eq!(meta.source, CommandSource::Macro);
871    }
872
873    #[test]
874    fn test_command_metadata_with_batch() {
875        let meta = CommandMetadata::new("Test").with_batch(123);
876        assert_eq!(meta.batch_id, Some(123));
877    }
878
879    #[test]
880    fn test_command_batch_execute_undo() {
881        // Create a simple test buffer
882        let buffer = Arc::new(Mutex::new(String::new()));
883
884        let mut batch = CommandBatch::new("Test batch");
885
886        // Add two insert commands with callbacks
887        let b1 = buffer.clone();
888        let b2 = buffer.clone();
889        let b3 = buffer.clone();
890        let b4 = buffer.clone();
891
892        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello")
893            .with_apply(move |_, pos, text| {
894                let mut buf = b1.lock().unwrap();
895                buf.insert_str(pos, text);
896                Ok(())
897            })
898            .with_remove(move |_, pos, len| {
899                let mut buf = b2.lock().unwrap();
900                buf.drain(pos..pos + len);
901                Ok(())
902            });
903
904        let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World")
905            .with_apply(move |_, pos, text| {
906                let mut buf = b3.lock().unwrap();
907                buf.insert_str(pos, text);
908                Ok(())
909            })
910            .with_remove(move |_, pos, len| {
911                let mut buf = b4.lock().unwrap();
912                buf.drain(pos..pos + len);
913                Ok(())
914            });
915
916        batch.push(Box::new(cmd1));
917        batch.push(Box::new(cmd2));
918
919        // Execute batch
920        batch.execute().unwrap();
921        assert_eq!(*buffer.lock().unwrap(), "Hello World");
922
923        // Undo batch
924        batch.undo().unwrap();
925        assert_eq!(*buffer.lock().unwrap(), "");
926    }
927
928    #[test]
929    fn test_command_batch_empty() {
930        let batch = CommandBatch::new("Empty");
931        assert!(batch.is_empty());
932        assert_eq!(batch.len(), 0);
933    }
934
935    #[test]
936    fn test_text_insert_can_merge_consecutive() {
937        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
938        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
939        // Set timestamp to be within merge window
940        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
941
942        let config = MergeConfig::default();
943        assert!(cmd1.can_merge(&cmd2, &config));
944    }
945
946    #[test]
947    fn test_text_insert_no_merge_different_widget() {
948        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
949        let mut cmd2 = TextInsertCmd::new(WidgetId::new(2), 1, "b");
950        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
951
952        let config = MergeConfig::default();
953        assert!(!cmd1.can_merge(&cmd2, &config));
954    }
955
956    #[test]
957    fn test_text_insert_no_merge_non_consecutive() {
958        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
959        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
960        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
961
962        let config = MergeConfig::default();
963        assert!(!cmd1.can_merge(&cmd2, &config));
964    }
965
966    #[test]
967    fn test_text_delete_can_merge_backspace() {
968        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
969        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "a");
970        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
971
972        let config = MergeConfig::default();
973        assert!(cmd1.can_merge(&cmd2, &config));
974    }
975
976    #[test]
977    fn test_text_delete_can_merge_delete_key() {
978        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
979        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
980        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
981
982        let config = MergeConfig::default();
983        assert!(cmd1.can_merge(&cmd2, &config));
984    }
985
986    #[test]
987    fn test_command_error_display() {
988        let err = CommandError::TargetNotFound(WidgetId::new(42));
989        assert!(err.to_string().contains("42"));
990
991        let err = CommandError::PositionOutOfBounds {
992            position: 10,
993            length: 5,
994        };
995        assert!(err.to_string().contains("10"));
996        assert!(err.to_string().contains("5"));
997    }
998
999    #[test]
1000    fn test_merge_config_default() {
1001        let config = MergeConfig::default();
1002        assert_eq!(config.max_delay_ms, 500);
1003        assert!(!config.merge_across_words);
1004        assert_eq!(config.max_merged_size, 1024);
1005    }
1006
1007    #[test]
1008    fn test_text_replace_size_bytes() {
1009        let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1010        let size = cmd.size_bytes();
1011        assert!(size >= std::mem::size_of::<TextReplaceCmd>() + 3 + 3);
1012    }
1013
1014    #[test]
1015    fn test_text_insert_accept_merge() {
1016        let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello");
1017        let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World");
1018        assert!(cmd1.accept_merge(&cmd2));
1019        assert_eq!(cmd1.text, "Hello World");
1020    }
1021
1022    #[test]
1023    fn test_text_delete_accept_merge_backspace() {
1024        // Simulate backspace: user deleted "b" at position 4, then "a" at position 3
1025        // Backspace detection: other.position + other.len == self.position
1026        // 3 + 1 == 4, so this is backspace, should prepend
1027        let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
1028        let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
1029        assert!(cmd1.accept_merge(&cmd2));
1030        assert_eq!(cmd1.deleted_text, "ab");
1031        assert_eq!(cmd1.position, 3); // Position moves back for backspace
1032    }
1033
1034    #[test]
1035    fn test_text_delete_accept_merge_forward_delete() {
1036        // Simulate forward delete: user deleted "a" at position 3, then "b" at position 3
1037        // Forward delete detection: other.position == self.position
1038        // Both at position 3, so this is forward delete, should append
1039        let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
1040        let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "b");
1041        assert!(cmd1.accept_merge(&cmd2));
1042        assert_eq!(cmd1.deleted_text, "ab");
1043        assert_eq!(cmd1.position, 3); // Position stays the same for forward delete
1044    }
1045
1046    #[test]
1047    fn test_debug_implementations() {
1048        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1049        let debug_str = format!("{:?}", cmd);
1050        assert!(debug_str.contains("TextInsertCmd"));
1051        assert!(debug_str.contains("test"));
1052
1053        let batch = CommandBatch::new("Test batch");
1054        let debug_str = format!("{:?}", batch);
1055        assert!(debug_str.contains("CommandBatch"));
1056    }
1057}