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 web_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        let Some(other_insert) = other.as_any().downcast_ref::<Self>() else {
516            return false;
517        };
518        self.text.push_str(&other_insert.text);
519        true
520    }
521
522    fn metadata(&self) -> &CommandMetadata {
523        &self.metadata
524    }
525
526    fn target(&self) -> Option<WidgetId> {
527        Some(self.target)
528    }
529
530    fn as_any(&self) -> &dyn Any {
531        self
532    }
533
534    fn as_any_mut(&mut self) -> &mut dyn Any {
535        self
536    }
537
538    fn debug_name(&self) -> &'static str {
539        "TextInsertCmd"
540    }
541}
542
543/// Command to delete text at a position.
544pub struct TextDeleteCmd {
545    /// Target widget.
546    pub target: WidgetId,
547    /// Position to delete from (byte offset).
548    pub position: usize,
549    /// Deleted text (for undo).
550    pub deleted_text: String,
551    /// Command metadata.
552    pub metadata: CommandMetadata,
553    /// Callback to remove text.
554    remove: Option<TextRemoveFn>,
555    /// Callback to insert text (for undo).
556    insert: Option<TextApplyFn>,
557}
558
559impl fmt::Debug for TextDeleteCmd {
560    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561        f.debug_struct("TextDeleteCmd")
562            .field("target", &self.target)
563            .field("position", &self.position)
564            .field("deleted_text", &self.deleted_text)
565            .field("metadata", &self.metadata)
566            .field("has_remove", &self.remove.is_some())
567            .field("has_insert", &self.insert.is_some())
568            .finish()
569    }
570}
571
572impl TextDeleteCmd {
573    /// Create a new text delete command.
574    #[must_use]
575    pub fn new(target: WidgetId, position: usize, deleted_text: impl Into<String>) -> Self {
576        Self {
577            target,
578            position,
579            deleted_text: deleted_text.into(),
580            metadata: CommandMetadata::new("Delete text"),
581            remove: None,
582            insert: None,
583        }
584    }
585
586    /// Set the remove callback.
587    pub fn with_remove<F>(mut self, f: F) -> Self
588    where
589        F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
590    {
591        self.remove = Some(Box::new(f));
592        self
593    }
594
595    /// Set the insert callback (for undo).
596    pub fn with_insert<F>(mut self, f: F) -> Self
597    where
598        F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
599    {
600        self.insert = Some(Box::new(f));
601        self
602    }
603}
604
605impl UndoableCmd for TextDeleteCmd {
606    fn execute(&mut self) -> CommandResult {
607        if let Some(ref remove) = self.remove {
608            remove(self.target, self.position, self.deleted_text.len())
609        } else {
610            Err(CommandError::InvalidState(
611                "no remove callback set".to_string(),
612            ))
613        }
614    }
615
616    fn undo(&mut self) -> CommandResult {
617        if let Some(ref insert) = self.insert {
618            insert(self.target, self.position, &self.deleted_text)
619        } else {
620            Err(CommandError::InvalidState(
621                "no insert callback set".to_string(),
622            ))
623        }
624    }
625
626    fn description(&self) -> &str {
627        &self.metadata.description
628    }
629
630    fn size_bytes(&self) -> usize {
631        std::mem::size_of::<Self>() + self.deleted_text.len() + self.metadata.size_bytes()
632    }
633
634    fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
635        let Some(other) = other.as_any().downcast_ref::<Self>() else {
636            return false;
637        };
638
639        // Must target same widget
640        if self.target != other.target {
641            return false;
642        }
643
644        // For backspace: other.position + other.deleted_text.len() == self.position
645        // For delete key: other.position == self.position
646        let is_backspace = other.position + other.deleted_text.len() == self.position;
647        let is_delete = other.position == self.position;
648
649        if !is_backspace && !is_delete {
650            return false;
651        }
652
653        // Check time constraint
654        let elapsed = other
655            .metadata
656            .timestamp
657            .duration_since(self.metadata.timestamp);
658        if elapsed.as_millis() > config.max_delay_ms as u128 {
659            return false;
660        }
661
662        // Check size constraint
663        if self.deleted_text.len() + other.deleted_text.len() > config.max_merged_size {
664            return false;
665        }
666
667        true
668    }
669
670    fn merge_text(&self) -> Option<&str> {
671        Some(&self.deleted_text)
672    }
673
674    fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
675        let Some(other_delete) = other.as_any().downcast_ref::<Self>() else {
676            return false;
677        };
678
679        // Determine if this is a backspace or forward delete merge:
680        // - Backspace: other.position + other.deleted_text.len() == self.position
681        //   The new delete happened before our position, prepend its text
682        // - Forward delete: other.position == self.position
683        //   The new delete happened at the same position, append its text
684        let is_backspace = other_delete.position + other_delete.deleted_text.len() == self.position;
685        let is_forward = other_delete.position == self.position;
686        if !is_backspace && !is_forward {
687            return false;
688        }
689
690        if is_backspace {
691            // Backspace: prepend and move our position back
692            self.deleted_text = format!("{}{}", other_delete.deleted_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(&other_delete.deleted_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
1058    #[test]
1059    fn test_text_insert_execute_and_undo_with_callbacks() {
1060        let buf = Arc::new(Mutex::new(String::from("Hello")));
1061        let b1 = buf.clone();
1062        let b2 = buf.clone();
1063
1064        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 5, " World")
1065            .with_apply(move |_, pos, text| {
1066                let mut b = b1.lock().unwrap();
1067                b.insert_str(pos, text);
1068                Ok(())
1069            })
1070            .with_remove(move |_, pos, len| {
1071                let mut b = b2.lock().unwrap();
1072                b.drain(pos..pos + len);
1073                Ok(())
1074            });
1075
1076        cmd.execute().unwrap();
1077        assert_eq!(*buf.lock().unwrap(), "Hello World");
1078        cmd.undo().unwrap();
1079        assert_eq!(*buf.lock().unwrap(), "Hello");
1080    }
1081
1082    #[test]
1083    fn test_text_insert_execute_without_callback_errors() {
1084        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1085        let err = cmd.execute().unwrap_err();
1086        assert!(matches!(err, CommandError::InvalidState(_)));
1087    }
1088
1089    #[test]
1090    fn test_text_insert_undo_without_callback_errors() {
1091        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
1092        let err = cmd.undo().unwrap_err();
1093        assert!(matches!(err, CommandError::InvalidState(_)));
1094    }
1095
1096    #[test]
1097    fn test_text_insert_target() {
1098        let cmd = TextInsertCmd::new(WidgetId::new(42), 0, "x");
1099        assert_eq!(cmd.target(), Some(WidgetId::new(42)));
1100    }
1101
1102    #[test]
1103    fn test_text_insert_merge_text() {
1104        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "abc");
1105        assert_eq!(cmd.merge_text(), Some("abc"));
1106    }
1107
1108    #[test]
1109    fn test_text_delete_target() {
1110        let cmd = TextDeleteCmd::new(WidgetId::new(7), 0, "x");
1111        assert_eq!(cmd.target(), Some(WidgetId::new(7)));
1112    }
1113
1114    #[test]
1115    fn test_command_metadata_default() {
1116        let meta = CommandMetadata::default();
1117        assert_eq!(meta.description, "Unknown");
1118        assert_eq!(meta.source, CommandSource::User);
1119        assert_eq!(meta.batch_id, None);
1120    }
1121
1122    #[test]
1123    fn test_command_source_default_is_user() {
1124        assert_eq!(CommandSource::default(), CommandSource::User);
1125    }
1126
1127    #[test]
1128    fn test_command_error_state_drift_display() {
1129        let err = CommandError::StateDrift {
1130            expected: "foo".to_string(),
1131            actual: "bar".to_string(),
1132        };
1133        let s = err.to_string();
1134        assert!(s.contains("foo"));
1135        assert!(s.contains("bar"));
1136    }
1137
1138    #[test]
1139    fn test_command_error_other_display() {
1140        let err = CommandError::Other("something broke".to_string());
1141        assert!(err.to_string().contains("something broke"));
1142    }
1143
1144    #[test]
1145    fn test_command_batch_push_executed_tracks_index() {
1146        let buf = Arc::new(Mutex::new(String::new()));
1147        let b1 = buf.clone();
1148        let b2 = buf.clone();
1149
1150        // Pre-execute the command manually
1151        {
1152            let mut b = buf.lock().unwrap();
1153            b.push_str("Hi");
1154        }
1155
1156        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1157            .with_apply(move |_, pos, text| {
1158                let mut b = b1.lock().unwrap();
1159                b.insert_str(pos, text);
1160                Ok(())
1161            })
1162            .with_remove(move |_, pos, len| {
1163                let mut b = b2.lock().unwrap();
1164                b.drain(pos..pos + len);
1165                Ok(())
1166            });
1167
1168        let mut batch = CommandBatch::new("Pre-executed batch");
1169        batch.push_executed(Box::new(cmd));
1170        assert_eq!(batch.len(), 1);
1171        // Undo should work since push_executed advances executed_to
1172        batch.undo().unwrap();
1173        assert_eq!(*buf.lock().unwrap(), "");
1174    }
1175
1176    // ====================================================================
1177    // TextDeleteCmd execute/undo coverage
1178    // ====================================================================
1179
1180    #[test]
1181    fn test_text_delete_execute_and_undo_with_callbacks() {
1182        let buf = Arc::new(Mutex::new(String::from("Hello World")));
1183        let b1 = buf.clone();
1184        let b2 = buf.clone();
1185
1186        let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 5, " World")
1187            .with_remove(move |_, pos, len| {
1188                let mut b = b1.lock().unwrap();
1189                b.drain(pos..pos + len);
1190                Ok(())
1191            })
1192            .with_insert(move |_, pos, text| {
1193                let mut b = b2.lock().unwrap();
1194                b.insert_str(pos, text);
1195                Ok(())
1196            });
1197
1198        cmd.execute().unwrap();
1199        assert_eq!(*buf.lock().unwrap(), "Hello");
1200
1201        cmd.undo().unwrap();
1202        assert_eq!(*buf.lock().unwrap(), "Hello World");
1203    }
1204
1205    #[test]
1206    fn test_text_delete_execute_without_callback_errors() {
1207        let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1208        let err = cmd.execute().unwrap_err();
1209        assert!(matches!(err, CommandError::InvalidState(_)));
1210    }
1211
1212    #[test]
1213    fn test_text_delete_undo_without_callback_errors() {
1214        let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1215        let err = cmd.undo().unwrap_err();
1216        assert!(matches!(err, CommandError::InvalidState(_)));
1217    }
1218
1219    #[test]
1220    fn test_text_delete_size_bytes() {
1221        let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1222        let size = cmd.size_bytes();
1223        assert!(size >= std::mem::size_of::<TextDeleteCmd>() + 3);
1224    }
1225
1226    #[test]
1227    fn test_text_delete_description() {
1228        let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1229        assert_eq!(cmd.description(), "Delete text");
1230    }
1231
1232    #[test]
1233    fn test_text_delete_merge_text() {
1234        let cmd = TextDeleteCmd::new(WidgetId::new(1), 5, "xyz");
1235        assert_eq!(cmd.merge_text(), Some("xyz"));
1236    }
1237
1238    #[test]
1239    fn test_text_delete_debug() {
1240        let cmd = TextDeleteCmd::new(WidgetId::new(1), 3, "abc");
1241        let s = format!("{:?}", cmd);
1242        assert!(s.contains("TextDeleteCmd"));
1243        assert!(s.contains("abc"));
1244    }
1245
1246    #[test]
1247    fn test_text_delete_debug_name() {
1248        let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
1249        assert_eq!(cmd.debug_name(), "TextDeleteCmd");
1250    }
1251
1252    // ====================================================================
1253    // TextDeleteCmd merge edge cases
1254    // ====================================================================
1255
1256    #[test]
1257    fn test_text_delete_no_merge_different_widget() {
1258        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1259        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(2), 5, "b");
1260        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1261
1262        let config = MergeConfig::default();
1263        assert!(!cmd1.can_merge(&cmd2, &config));
1264    }
1265
1266    #[test]
1267    fn test_text_delete_no_merge_non_adjacent() {
1268        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1269        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
1270        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1271
1272        let config = MergeConfig::default();
1273        assert!(!cmd1.can_merge(&cmd2, &config));
1274    }
1275
1276    #[test]
1277    fn test_text_delete_no_merge_exceeds_max_size() {
1278        let long_text = "a".repeat(600);
1279        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
1280        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
1281        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1282
1283        let config = MergeConfig::default(); // max_merged_size = 1024
1284        assert!(!cmd1.can_merge(&cmd2, &config));
1285    }
1286
1287    #[test]
1288    fn test_text_delete_accept_merge_non_adjacent_returns_false() {
1289        let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1290        let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
1291        assert!(!cmd1.accept_merge(&cmd2));
1292    }
1293
1294    #[test]
1295    fn test_text_delete_accept_merge_wrong_type_returns_false() {
1296        let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1297        let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
1298        assert!(!cmd1.accept_merge(&cmd2));
1299    }
1300
1301    // ====================================================================
1302    // TextInsertCmd merge edge cases
1303    // ====================================================================
1304
1305    #[test]
1306    fn test_text_insert_no_merge_across_word_boundary() {
1307        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
1308        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
1309        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1310
1311        let config = MergeConfig::default(); // merge_across_words = false
1312        assert!(!cmd1.can_merge(&cmd2, &config));
1313    }
1314
1315    #[test]
1316    fn test_text_insert_merge_across_word_boundary_when_configured() {
1317        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
1318        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
1319        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1320
1321        let config = MergeConfig {
1322            merge_across_words: true,
1323            ..MergeConfig::default()
1324        };
1325        assert!(cmd1.can_merge(&cmd2, &config));
1326    }
1327
1328    #[test]
1329    fn test_text_insert_no_merge_exceeds_max_size() {
1330        let long_text = "a".repeat(600);
1331        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, &long_text);
1332        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 600, &long_text);
1333        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1334
1335        let config = MergeConfig::default(); // max_merged_size = 1024
1336        assert!(!cmd1.can_merge(&cmd2, &config));
1337    }
1338
1339    #[test]
1340    fn test_text_insert_accept_merge_wrong_type_returns_false() {
1341        let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1342        let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
1343        assert!(!cmd1.accept_merge(&cmd2));
1344    }
1345
1346    #[test]
1347    fn test_text_insert_size_bytes() {
1348        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "hello");
1349        let size = cmd.size_bytes();
1350        assert!(size >= std::mem::size_of::<TextInsertCmd>() + 5);
1351    }
1352
1353    #[test]
1354    fn test_text_insert_description() {
1355        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1356        assert_eq!(cmd.description(), "Insert text");
1357    }
1358
1359    #[test]
1360    fn test_text_insert_debug_name() {
1361        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1362        assert_eq!(cmd.debug_name(), "TextInsertCmd");
1363    }
1364
1365    // ====================================================================
1366    // TextReplaceCmd full coverage
1367    // ====================================================================
1368
1369    #[test]
1370    fn test_text_replace_execute_and_undo_with_callbacks() {
1371        let buf = Arc::new(Mutex::new(String::from("Hello World")));
1372        let b1 = buf.clone();
1373
1374        let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
1375            move |_, pos, old_len, new_text| {
1376                let mut b = b1.lock().unwrap();
1377                b.drain(pos..pos + old_len);
1378                b.insert_str(pos, new_text);
1379                Ok(())
1380            },
1381        );
1382
1383        // Also need clone for undo path (same callback)
1384        cmd.execute().unwrap();
1385        assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1386
1387        cmd.undo().unwrap();
1388        assert_eq!(*buf.lock().unwrap(), "Hello World");
1389    }
1390
1391    #[test]
1392    fn test_text_replace_execute_without_callback_errors() {
1393        let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1394        let err = cmd.execute().unwrap_err();
1395        assert!(matches!(err, CommandError::InvalidState(_)));
1396    }
1397
1398    #[test]
1399    fn test_text_replace_undo_without_callback_errors() {
1400        let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
1401        let err = cmd.undo().unwrap_err();
1402        assert!(matches!(err, CommandError::InvalidState(_)));
1403    }
1404
1405    #[test]
1406    fn test_text_replace_target() {
1407        let cmd = TextReplaceCmd::new(WidgetId::new(99), 0, "a", "b");
1408        assert_eq!(cmd.target(), Some(WidgetId::new(99)));
1409    }
1410
1411    #[test]
1412    fn test_text_replace_description() {
1413        let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1414        assert_eq!(cmd.description(), "Replace text");
1415    }
1416
1417    #[test]
1418    fn test_text_replace_metadata() {
1419        let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1420        assert_eq!(cmd.metadata().description, "Replace text");
1421        assert_eq!(cmd.metadata().source, CommandSource::User);
1422    }
1423
1424    #[test]
1425    fn test_text_replace_debug() {
1426        let cmd = TextReplaceCmd::new(WidgetId::new(1), 3, "old", "new");
1427        let s = format!("{:?}", cmd);
1428        assert!(s.contains("TextReplaceCmd"));
1429        assert!(s.contains("old"));
1430        assert!(s.contains("new"));
1431    }
1432
1433    #[test]
1434    fn test_text_replace_debug_name() {
1435        let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1436        assert_eq!(cmd.debug_name(), "TextReplaceCmd");
1437    }
1438
1439    // ====================================================================
1440    // CommandBatch edge cases
1441    // ====================================================================
1442
1443    #[test]
1444    fn test_command_batch_execute_rollback_on_failure() {
1445        let buf = Arc::new(Mutex::new(String::new()));
1446        let b1 = buf.clone();
1447        let b2 = buf.clone();
1448
1449        let mut batch = CommandBatch::new("Rollback test");
1450
1451        // First command succeeds
1452        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "OK")
1453            .with_apply(move |_, pos, text| {
1454                let mut b = b1.lock().unwrap();
1455                b.insert_str(pos, text);
1456                Ok(())
1457            })
1458            .with_remove(move |_, pos, len| {
1459                let mut b = b2.lock().unwrap();
1460                b.drain(pos..pos + len);
1461                Ok(())
1462            });
1463
1464        // Second command always fails (no callback)
1465        let cmd2 = TextInsertCmd::new(WidgetId::new(1), 2, " FAIL");
1466
1467        batch.push(Box::new(cmd1));
1468        batch.push(Box::new(cmd2));
1469
1470        // Execute should fail
1471        let err = batch.execute().unwrap_err();
1472        assert!(matches!(err, CommandError::InvalidState(_)));
1473
1474        // First command should have been rolled back
1475        assert_eq!(*buf.lock().unwrap(), "");
1476    }
1477
1478    #[test]
1479    fn test_command_batch_redo() {
1480        let buf = Arc::new(Mutex::new(String::new()));
1481        let b1 = buf.clone();
1482        let b2 = buf.clone();
1483
1484        let mut batch = CommandBatch::new("Redo test");
1485        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1486            .with_apply(move |_, pos, text| {
1487                let mut b = b1.lock().unwrap();
1488                b.insert_str(pos, text);
1489                Ok(())
1490            })
1491            .with_remove(move |_, pos, len| {
1492                let mut b = b2.lock().unwrap();
1493                b.drain(pos..pos + len);
1494                Ok(())
1495            });
1496        batch.push(Box::new(cmd));
1497
1498        batch.execute().unwrap();
1499        assert_eq!(*buf.lock().unwrap(), "Hi");
1500
1501        batch.undo().unwrap();
1502        assert_eq!(*buf.lock().unwrap(), "");
1503
1504        batch.redo().unwrap();
1505        assert_eq!(*buf.lock().unwrap(), "Hi");
1506    }
1507
1508    #[test]
1509    fn test_command_batch_size_bytes() {
1510        let batch = CommandBatch::new("Size test");
1511        let size = batch.size_bytes();
1512        assert!(size >= std::mem::size_of::<CommandBatch>());
1513    }
1514
1515    #[test]
1516    fn test_command_batch_size_bytes_with_commands() {
1517        let mut batch = CommandBatch::new("Size test");
1518        batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "hello")));
1519        let size = batch.size_bytes();
1520        // Must include the inner command's size
1521        let inner_size = TextInsertCmd::new(WidgetId::new(1), 0, "hello").size_bytes();
1522        assert!(size > inner_size);
1523    }
1524
1525    #[test]
1526    fn test_command_batch_metadata() {
1527        let batch = CommandBatch::new("Meta test");
1528        assert_eq!(batch.metadata().description, "Meta test");
1529    }
1530
1531    #[test]
1532    fn test_command_batch_debug_name() {
1533        let batch = CommandBatch::new("test");
1534        assert_eq!(batch.debug_name(), "CommandBatch");
1535    }
1536
1537    #[test]
1538    fn test_command_batch_can_merge_default_false() {
1539        let batch = CommandBatch::new("test");
1540        let other = CommandBatch::new("other");
1541        let config = MergeConfig::default();
1542        assert!(!batch.can_merge(&other, &config));
1543    }
1544
1545    #[test]
1546    fn test_command_batch_undo_empty() {
1547        let mut batch = CommandBatch::new("Empty undo");
1548        // executed_to is 0, so undo should be a no-op
1549        batch.undo().unwrap();
1550    }
1551
1552    // ====================================================================
1553    // dyn UndoableCmd Debug impl
1554    // ====================================================================
1555
1556    #[test]
1557    fn test_dyn_undoable_cmd_debug() {
1558        let cmd: Box<dyn UndoableCmd> = Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "test"));
1559        let s = format!("{:?}", cmd);
1560        assert!(s.contains("TextInsertCmd"));
1561        assert!(s.contains("Insert text"));
1562    }
1563
1564    // ====================================================================
1565    // CommandError trait coverage
1566    // ====================================================================
1567
1568    #[test]
1569    fn test_command_error_invalid_state_display() {
1570        let err = CommandError::InvalidState("bad state".to_string());
1571        let s = err.to_string();
1572        assert!(s.contains("bad state"));
1573    }
1574
1575    #[test]
1576    fn test_command_error_is_std_error() {
1577        let err: Box<dyn std::error::Error> = Box::new(CommandError::Other("test".to_string()));
1578        // Verify it implements std::error::Error
1579        assert!(err.to_string().contains("test"));
1580    }
1581
1582    // ====================================================================
1583    // WidgetId additional coverage
1584    // ====================================================================
1585
1586    #[test]
1587    fn test_widget_id_equality() {
1588        let a = WidgetId::new(1);
1589        let b = WidgetId::new(1);
1590        let c = WidgetId::new(2);
1591        assert_eq!(a, b);
1592        assert_ne!(a, c);
1593    }
1594
1595    #[test]
1596    fn test_widget_id_hash() {
1597        use std::collections::HashSet;
1598        let mut set = HashSet::new();
1599        set.insert(WidgetId::new(1));
1600        set.insert(WidgetId::new(1));
1601        set.insert(WidgetId::new(2));
1602        assert_eq!(set.len(), 2);
1603    }
1604
1605    #[test]
1606    fn test_widget_id_debug() {
1607        let id = WidgetId::new(42);
1608        let s = format!("{:?}", id);
1609        assert!(s.contains("42"));
1610    }
1611
1612    // ====================================================================
1613    // TextInsertCmd redo (default delegates to execute)
1614    // ====================================================================
1615
1616    #[test]
1617    fn test_text_insert_redo() {
1618        let buf = Arc::new(Mutex::new(String::new()));
1619        let b1 = buf.clone();
1620        let b2 = buf.clone();
1621
1622        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
1623            .with_apply(move |_, pos, text| {
1624                let mut b = b1.lock().unwrap();
1625                b.insert_str(pos, text);
1626                Ok(())
1627            })
1628            .with_remove(move |_, pos, len| {
1629                let mut b = b2.lock().unwrap();
1630                b.drain(pos..pos + len);
1631                Ok(())
1632            });
1633
1634        cmd.execute().unwrap();
1635        cmd.undo().unwrap();
1636        assert_eq!(*buf.lock().unwrap(), "");
1637
1638        cmd.redo().unwrap();
1639        assert_eq!(*buf.lock().unwrap(), "Hi");
1640    }
1641
1642    // ====================================================================
1643    // TextDeleteCmd redo
1644    // ====================================================================
1645
1646    #[test]
1647    fn test_text_delete_redo() {
1648        let buf = Arc::new(Mutex::new(String::from("Hello")));
1649        let b1 = buf.clone();
1650        let b2 = buf.clone();
1651
1652        let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "Hello")
1653            .with_remove(move |_, pos, len| {
1654                let mut b = b1.lock().unwrap();
1655                b.drain(pos..pos + len);
1656                Ok(())
1657            })
1658            .with_insert(move |_, pos, text| {
1659                let mut b = b2.lock().unwrap();
1660                b.insert_str(pos, text);
1661                Ok(())
1662            });
1663
1664        cmd.execute().unwrap();
1665        assert_eq!(*buf.lock().unwrap(), "");
1666
1667        cmd.undo().unwrap();
1668        assert_eq!(*buf.lock().unwrap(), "Hello");
1669
1670        cmd.redo().unwrap();
1671        assert_eq!(*buf.lock().unwrap(), "");
1672    }
1673
1674    // ====================================================================
1675    // CommandMetadata edge cases
1676    // ====================================================================
1677
1678    #[test]
1679    fn test_command_metadata_all_sources() {
1680        for source in [
1681            CommandSource::User,
1682            CommandSource::Programmatic,
1683            CommandSource::Macro,
1684            CommandSource::External,
1685        ] {
1686            let meta = CommandMetadata::new("test").with_source(source);
1687            assert_eq!(meta.source, source);
1688        }
1689    }
1690
1691    #[test]
1692    fn test_command_metadata_empty_description() {
1693        let meta = CommandMetadata::new("");
1694        assert_eq!(meta.size_bytes(), std::mem::size_of::<CommandMetadata>());
1695    }
1696
1697    // ====================================================================
1698    // as_any / as_any_mut coverage
1699    // ====================================================================
1700
1701    #[test]
1702    fn test_text_insert_as_any_roundtrip() {
1703        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1704        let any_ref = cmd.as_any();
1705        let downcasted = any_ref.downcast_ref::<TextInsertCmd>().unwrap();
1706        assert_eq!(downcasted.text, "x");
1707    }
1708
1709    #[test]
1710    fn test_text_insert_as_any_mut_roundtrip() {
1711        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
1712        let downcasted = cmd.as_any_mut().downcast_mut::<TextInsertCmd>().unwrap();
1713        downcasted.text = "modified".to_string();
1714        assert_eq!(cmd.text, "modified");
1715    }
1716
1717    #[test]
1718    fn test_text_delete_as_any_roundtrip() {
1719        let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1720        let downcasted = cmd.as_any().downcast_ref::<TextDeleteCmd>().unwrap();
1721        assert_eq!(downcasted.deleted_text, "abc");
1722    }
1723
1724    #[test]
1725    fn test_text_delete_as_any_mut_roundtrip() {
1726        let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
1727        let downcasted = cmd.as_any_mut().downcast_mut::<TextDeleteCmd>().unwrap();
1728        downcasted.deleted_text = "xyz".to_string();
1729        assert_eq!(cmd.deleted_text, "xyz");
1730    }
1731
1732    #[test]
1733    fn test_text_replace_as_any_roundtrip() {
1734        let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1735        let downcasted = cmd.as_any().downcast_ref::<TextReplaceCmd>().unwrap();
1736        assert_eq!(downcasted.old_text, "a");
1737        assert_eq!(downcasted.new_text, "b");
1738    }
1739
1740    #[test]
1741    fn test_text_replace_as_any_mut_roundtrip() {
1742        let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
1743        let downcasted = cmd.as_any_mut().downcast_mut::<TextReplaceCmd>().unwrap();
1744        downcasted.new_text = "replaced".to_string();
1745        assert_eq!(cmd.new_text, "replaced");
1746    }
1747
1748    #[test]
1749    fn test_command_batch_as_any_roundtrip() {
1750        let batch = CommandBatch::new("test batch");
1751        let downcasted = batch.as_any().downcast_ref::<CommandBatch>().unwrap();
1752        assert_eq!(downcasted.description(), "test batch");
1753    }
1754
1755    #[test]
1756    fn test_command_batch_as_any_mut_roundtrip() {
1757        let mut batch = CommandBatch::new("test batch");
1758        batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "x")));
1759        let downcasted = batch.as_any_mut().downcast_mut::<CommandBatch>().unwrap();
1760        assert_eq!(downcasted.len(), 1);
1761    }
1762
1763    // ====================================================================
1764    // Default trait methods on CommandBatch (UndoableCmd defaults)
1765    // ====================================================================
1766
1767    #[test]
1768    fn test_command_batch_description_matches() {
1769        let batch = CommandBatch::new("My description");
1770        assert_eq!(batch.description(), "My description");
1771    }
1772
1773    #[test]
1774    fn test_command_batch_merge_text_default_none() {
1775        let batch = CommandBatch::new("test");
1776        assert_eq!(batch.merge_text(), None);
1777    }
1778
1779    #[test]
1780    fn test_command_batch_accept_merge_default_false() {
1781        let mut batch = CommandBatch::new("test");
1782        let other = CommandBatch::new("other");
1783        assert!(!batch.accept_merge(&other));
1784    }
1785
1786    #[test]
1787    fn test_command_batch_target_default_none() {
1788        let batch = CommandBatch::new("test");
1789        assert_eq!(batch.target(), None);
1790    }
1791
1792    // ====================================================================
1793    // Cross-type can_merge rejection
1794    // ====================================================================
1795
1796    #[test]
1797    fn test_text_insert_can_merge_rejects_delete_type() {
1798        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1799        let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
1800        let config = MergeConfig::default();
1801        assert!(!cmd1.can_merge(&cmd2, &config));
1802    }
1803
1804    #[test]
1805    fn test_text_delete_can_merge_rejects_insert_type() {
1806        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 0, "a");
1807        let cmd2 = TextInsertCmd::new(WidgetId::new(1), 0, "b");
1808        let config = MergeConfig::default();
1809        assert!(!cmd1.can_merge(&cmd2, &config));
1810    }
1811
1812    // ====================================================================
1813    // Time constraint failures for can_merge
1814    // ====================================================================
1815
1816    #[test]
1817    fn test_text_insert_no_merge_time_exceeded() {
1818        let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
1819        // cmd2 created after cmd1, with a large enough delay
1820        // We can't easily create a large time gap, but we can test
1821        // the boundary by using a config with max_delay_ms = 0
1822        let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
1823        // Even with same timestamp, max_delay_ms=0 should reject if any time passed
1824        // Set timestamps equal so the duration is 0ms — but config allows 0ms max
1825        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1826
1827        let config = MergeConfig {
1828            max_delay_ms: 0,
1829            merge_across_words: true,
1830            max_merged_size: 1024,
1831        };
1832        // 0 elapsed <= 0 max: should still pass (0 <= 0 is not >)
1833        assert!(cmd1.can_merge(&cmd2, &config));
1834    }
1835
1836    #[test]
1837    fn test_text_delete_no_merge_time_exceeded() {
1838        let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
1839        let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
1840        cmd2.metadata.timestamp = cmd1.metadata.timestamp;
1841
1842        let config = MergeConfig {
1843            max_delay_ms: 0,
1844            merge_across_words: true,
1845            max_merged_size: 1024,
1846        };
1847        // 0 elapsed, 0 max: 0 > 0 is false, so merge allowed
1848        assert!(cmd1.can_merge(&cmd2, &config));
1849    }
1850
1851    // ====================================================================
1852    // TextReplaceCmd redo (default delegates to execute)
1853    // ====================================================================
1854
1855    #[test]
1856    fn test_text_replace_redo() {
1857        let buf = Arc::new(Mutex::new(String::from("Hello World")));
1858        let b1 = buf.clone();
1859
1860        let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
1861            move |_, pos, old_len, new_text| {
1862                let mut b = b1.lock().unwrap();
1863                b.replace_range(pos..pos + old_len, new_text);
1864                Ok(())
1865            },
1866        );
1867
1868        cmd.execute().unwrap();
1869        assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1870
1871        cmd.undo().unwrap();
1872        assert_eq!(*buf.lock().unwrap(), "Hello World");
1873
1874        cmd.redo().unwrap();
1875        assert_eq!(*buf.lock().unwrap(), "Hello Rust");
1876    }
1877
1878    // ====================================================================
1879    // CommandBatch execute on empty
1880    // ====================================================================
1881
1882    #[test]
1883    fn test_command_batch_execute_empty_is_noop() {
1884        let mut batch = CommandBatch::new("Empty execute");
1885        assert!(batch.execute().is_ok());
1886        assert_eq!(batch.executed_to, 0);
1887    }
1888
1889    // ====================================================================
1890    // CommandBatch multiple push_executed
1891    // ====================================================================
1892
1893    #[test]
1894    fn test_command_batch_multiple_push_executed() {
1895        let mut batch = CommandBatch::new("Multi pre-exec");
1896        batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "a")));
1897        batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 1, "b")));
1898        assert_eq!(batch.len(), 2);
1899        assert_eq!(batch.executed_to, 2);
1900    }
1901
1902    // ====================================================================
1903    // CommandError clone and equality
1904    // ====================================================================
1905
1906    #[test]
1907    fn test_command_error_clone_and_equality() {
1908        let err1 = CommandError::TargetNotFound(WidgetId::new(5));
1909        let err2 = err1.clone();
1910        assert_eq!(err1, err2);
1911
1912        let err3 = CommandError::PositionOutOfBounds {
1913            position: 10,
1914            length: 5,
1915        };
1916        let err4 = err3.clone();
1917        assert_eq!(err3, err4);
1918    }
1919
1920    // ====================================================================
1921    // MergeConfig clone and copy
1922    // ====================================================================
1923
1924    #[test]
1925    fn test_merge_config_clone_and_copy() {
1926        let config = MergeConfig {
1927            max_delay_ms: 1000,
1928            merge_across_words: true,
1929            max_merged_size: 2048,
1930        };
1931        let config2 = config;
1932        assert_eq!(config2.max_delay_ms, 1000);
1933        assert!(config2.merge_across_words);
1934        assert_eq!(config2.max_merged_size, 2048);
1935    }
1936
1937    // ====================================================================
1938    // WidgetId copy semantics
1939    // ====================================================================
1940
1941    #[test]
1942    fn test_widget_id_copy() {
1943        let a = WidgetId::new(42);
1944        let b = a; // Copy
1945        assert_eq!(a, b); // original still usable
1946        assert_eq!(a.raw(), 42);
1947    }
1948
1949    // ====================================================================
1950    // CommandMetadata clone
1951    // ====================================================================
1952
1953    #[test]
1954    fn test_command_metadata_clone() {
1955        let meta = CommandMetadata::new("test")
1956            .with_source(CommandSource::Programmatic)
1957            .with_batch(99);
1958        let cloned = meta.clone();
1959        assert_eq!(cloned.description, "test");
1960        assert_eq!(cloned.source, CommandSource::Programmatic);
1961        assert_eq!(cloned.batch_id, Some(99));
1962    }
1963}