#![forbid(unsafe_code)]
use std::any::Any;
use std::fmt;
use web_time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WidgetId(pub u64);
impl WidgetId {
#[must_use]
pub const fn new(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommandSource {
#[default]
User,
Programmatic,
Macro,
External,
}
#[derive(Debug, Clone)]
pub struct CommandMetadata {
pub description: String,
pub timestamp: Instant,
pub source: CommandSource,
pub batch_id: Option<u64>,
}
impl CommandMetadata {
#[must_use]
pub fn new(description: impl Into<String>) -> Self {
Self {
description: description.into(),
timestamp: Instant::now(),
source: CommandSource::User,
batch_id: None,
}
}
#[must_use]
pub fn with_source(mut self, source: CommandSource) -> Self {
self.source = source;
self
}
#[must_use]
pub fn with_batch(mut self, batch_id: u64) -> Self {
self.batch_id = Some(batch_id);
self
}
#[must_use]
pub fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.description.len()
}
}
impl Default for CommandMetadata {
fn default() -> Self {
Self::new("Unknown")
}
}
pub type CommandResult = Result<(), CommandError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandError {
TargetNotFound(WidgetId),
PositionOutOfBounds { position: usize, length: usize },
StateDrift { expected: String, actual: String },
InvalidState(String),
Other(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TargetNotFound(id) => write!(f, "target widget {:?} not found", id),
Self::PositionOutOfBounds { position, length } => {
write!(f, "position {} out of bounds (length {})", position, length)
}
Self::StateDrift { expected, actual } => {
write!(f, "state drift: expected '{}', got '{}'", expected, actual)
}
Self::InvalidState(msg) => write!(f, "invalid state: {}", msg),
Self::Other(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for CommandError {}
#[derive(Debug, Clone, Copy)]
pub struct MergeConfig {
pub max_delay_ms: u64,
pub merge_across_words: bool,
pub max_merged_size: usize,
}
impl Default for MergeConfig {
fn default() -> Self {
Self {
max_delay_ms: 500,
merge_across_words: false,
max_merged_size: 1024,
}
}
}
pub trait UndoableCmd: Send + Sync {
fn execute(&mut self) -> CommandResult;
fn undo(&mut self) -> CommandResult;
fn redo(&mut self) -> CommandResult {
self.execute()
}
fn description(&self) -> &str;
fn size_bytes(&self) -> usize;
fn can_merge(&self, _other: &dyn UndoableCmd, _config: &MergeConfig) -> bool {
false
}
fn merge_text(&self) -> Option<&str> {
None
}
fn accept_merge(&mut self, _other: &dyn UndoableCmd) -> bool {
false
}
fn metadata(&self) -> &CommandMetadata;
fn target(&self) -> Option<WidgetId> {
None
}
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn debug_name(&self) -> &'static str {
"UndoableCmd"
}
}
impl fmt::Debug for dyn UndoableCmd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct(self.debug_name())
.field("description", &self.description())
.field("size_bytes", &self.size_bytes())
.finish()
}
}
pub struct CommandBatch {
commands: Vec<Box<dyn UndoableCmd>>,
metadata: CommandMetadata,
executed_to: usize,
}
impl fmt::Debug for CommandBatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandBatch")
.field("commands_count", &self.commands.len())
.field("metadata", &self.metadata)
.field("executed_to", &self.executed_to)
.finish()
}
}
impl CommandBatch {
#[must_use]
pub fn new(description: impl Into<String>) -> Self {
Self {
commands: Vec::new(),
metadata: CommandMetadata::new(description),
executed_to: 0,
}
}
pub fn push(&mut self, cmd: Box<dyn UndoableCmd>) {
self.commands.push(cmd);
}
pub fn push_executed(&mut self, cmd: Box<dyn UndoableCmd>) {
self.commands.push(cmd);
self.executed_to = self.commands.len();
}
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
impl UndoableCmd for CommandBatch {
fn execute(&mut self) -> CommandResult {
for (i, cmd) in self.commands.iter_mut().enumerate() {
if let Err(e) = cmd.execute() {
for j in (0..i).rev() {
let _ = self.commands[j].undo();
}
return Err(e);
}
self.executed_to = i + 1;
}
Ok(())
}
fn undo(&mut self) -> CommandResult {
for i in (0..self.executed_to).rev() {
self.commands[i].undo()?;
}
self.executed_to = 0;
Ok(())
}
fn redo(&mut self) -> CommandResult {
self.execute()
}
fn description(&self) -> &str {
&self.metadata.description
}
fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>()
+ self.metadata.size_bytes()
+ self.commands.iter().map(|c| c.size_bytes()).sum::<usize>()
}
fn metadata(&self) -> &CommandMetadata {
&self.metadata
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn debug_name(&self) -> &'static str {
"CommandBatch"
}
}
pub type TextApplyFn = Box<dyn Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync>;
pub type TextRemoveFn = Box<dyn Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync>;
pub type TextReplaceFn = Box<dyn Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync>;
pub struct TextInsertCmd {
pub target: WidgetId,
pub position: usize,
pub text: String,
pub metadata: CommandMetadata,
apply: Option<TextApplyFn>,
remove: Option<TextRemoveFn>,
}
impl fmt::Debug for TextInsertCmd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TextInsertCmd")
.field("target", &self.target)
.field("position", &self.position)
.field("text", &self.text)
.field("metadata", &self.metadata)
.field("has_apply", &self.apply.is_some())
.field("has_remove", &self.remove.is_some())
.finish()
}
}
impl TextInsertCmd {
#[must_use]
pub fn new(target: WidgetId, position: usize, text: impl Into<String>) -> Self {
Self {
target,
position,
text: text.into(),
metadata: CommandMetadata::new("Insert text"),
apply: None,
remove: None,
}
}
pub fn with_apply<F>(mut self, f: F) -> Self
where
F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
{
self.apply = Some(Box::new(f));
self
}
pub fn with_remove<F>(mut self, f: F) -> Self
where
F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
{
self.remove = Some(Box::new(f));
self
}
}
impl UndoableCmd for TextInsertCmd {
fn execute(&mut self) -> CommandResult {
if let Some(ref apply) = self.apply {
apply(self.target, self.position, &self.text)
} else {
Err(CommandError::InvalidState(
"no apply callback set".to_string(),
))
}
}
fn undo(&mut self) -> CommandResult {
if let Some(ref remove) = self.remove {
remove(self.target, self.position, self.text.len())
} else {
Err(CommandError::InvalidState(
"no remove callback set".to_string(),
))
}
}
fn description(&self) -> &str {
&self.metadata.description
}
fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.text.len() + self.metadata.size_bytes()
}
fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
let Some(other) = other.as_any().downcast_ref::<Self>() else {
return false;
};
if self.target != other.target {
return false;
}
if other.position != self.position + self.text.len() {
return false;
}
let elapsed = other
.metadata
.timestamp
.saturating_duration_since(self.metadata.timestamp);
if elapsed.as_millis() > config.max_delay_ms as u128 {
return false;
}
if self.text.len() + other.text.len() > config.max_merged_size {
return false;
}
if !config.merge_across_words && self.text.ends_with(' ') {
return false;
}
true
}
fn merge_text(&self) -> Option<&str> {
Some(&self.text)
}
fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
let Some(other_insert) = other.as_any().downcast_ref::<Self>() else {
return false;
};
self.text.push_str(&other_insert.text);
true
}
fn metadata(&self) -> &CommandMetadata {
&self.metadata
}
fn target(&self) -> Option<WidgetId> {
Some(self.target)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn debug_name(&self) -> &'static str {
"TextInsertCmd"
}
}
pub struct TextDeleteCmd {
pub target: WidgetId,
pub position: usize,
pub deleted_text: String,
pub metadata: CommandMetadata,
remove: Option<TextRemoveFn>,
insert: Option<TextApplyFn>,
}
impl fmt::Debug for TextDeleteCmd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TextDeleteCmd")
.field("target", &self.target)
.field("position", &self.position)
.field("deleted_text", &self.deleted_text)
.field("metadata", &self.metadata)
.field("has_remove", &self.remove.is_some())
.field("has_insert", &self.insert.is_some())
.finish()
}
}
impl TextDeleteCmd {
#[must_use]
pub fn new(target: WidgetId, position: usize, deleted_text: impl Into<String>) -> Self {
Self {
target,
position,
deleted_text: deleted_text.into(),
metadata: CommandMetadata::new("Delete text"),
remove: None,
insert: None,
}
}
pub fn with_remove<F>(mut self, f: F) -> Self
where
F: Fn(WidgetId, usize, usize) -> CommandResult + Send + Sync + 'static,
{
self.remove = Some(Box::new(f));
self
}
pub fn with_insert<F>(mut self, f: F) -> Self
where
F: Fn(WidgetId, usize, &str) -> CommandResult + Send + Sync + 'static,
{
self.insert = Some(Box::new(f));
self
}
}
impl UndoableCmd for TextDeleteCmd {
fn execute(&mut self) -> CommandResult {
if let Some(ref remove) = self.remove {
remove(self.target, self.position, self.deleted_text.len())
} else {
Err(CommandError::InvalidState(
"no remove callback set".to_string(),
))
}
}
fn undo(&mut self) -> CommandResult {
if let Some(ref insert) = self.insert {
insert(self.target, self.position, &self.deleted_text)
} else {
Err(CommandError::InvalidState(
"no insert callback set".to_string(),
))
}
}
fn description(&self) -> &str {
&self.metadata.description
}
fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.deleted_text.len() + self.metadata.size_bytes()
}
fn can_merge(&self, other: &dyn UndoableCmd, config: &MergeConfig) -> bool {
let Some(other) = other.as_any().downcast_ref::<Self>() else {
return false;
};
if self.target != other.target {
return false;
}
let is_backspace = other.position + other.deleted_text.len() == self.position;
let is_delete = other.position == self.position;
if !is_backspace && !is_delete {
return false;
}
let elapsed = other
.metadata
.timestamp
.saturating_duration_since(self.metadata.timestamp);
if elapsed.as_millis() > config.max_delay_ms as u128 {
return false;
}
if self.deleted_text.len() + other.deleted_text.len() > config.max_merged_size {
return false;
}
true
}
fn merge_text(&self) -> Option<&str> {
Some(&self.deleted_text)
}
fn accept_merge(&mut self, other: &dyn UndoableCmd) -> bool {
let Some(other_delete) = other.as_any().downcast_ref::<Self>() else {
return false;
};
let is_backspace = other_delete.position + other_delete.deleted_text.len() == self.position;
let is_forward = other_delete.position == self.position;
if !is_backspace && !is_forward {
return false;
}
if is_backspace {
self.deleted_text = format!("{}{}", other_delete.deleted_text, self.deleted_text);
self.position = other_delete.position;
} else {
self.deleted_text.push_str(&other_delete.deleted_text);
}
true
}
fn metadata(&self) -> &CommandMetadata {
&self.metadata
}
fn target(&self) -> Option<WidgetId> {
Some(self.target)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn debug_name(&self) -> &'static str {
"TextDeleteCmd"
}
}
pub struct TextReplaceCmd {
pub target: WidgetId,
pub position: usize,
pub old_text: String,
pub new_text: String,
pub metadata: CommandMetadata,
replace: Option<TextReplaceFn>,
}
impl fmt::Debug for TextReplaceCmd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TextReplaceCmd")
.field("target", &self.target)
.field("position", &self.position)
.field("old_text", &self.old_text)
.field("new_text", &self.new_text)
.field("metadata", &self.metadata)
.field("has_replace", &self.replace.is_some())
.finish()
}
}
impl TextReplaceCmd {
#[must_use]
pub fn new(
target: WidgetId,
position: usize,
old_text: impl Into<String>,
new_text: impl Into<String>,
) -> Self {
Self {
target,
position,
old_text: old_text.into(),
new_text: new_text.into(),
metadata: CommandMetadata::new("Replace text"),
replace: None,
}
}
pub fn with_replace<F>(mut self, f: F) -> Self
where
F: Fn(WidgetId, usize, usize, &str) -> CommandResult + Send + Sync + 'static,
{
self.replace = Some(Box::new(f));
self
}
}
impl UndoableCmd for TextReplaceCmd {
fn execute(&mut self) -> CommandResult {
if let Some(ref replace) = self.replace {
replace(
self.target,
self.position,
self.old_text.len(),
&self.new_text,
)
} else {
Err(CommandError::InvalidState(
"no replace callback set".to_string(),
))
}
}
fn undo(&mut self) -> CommandResult {
if let Some(ref replace) = self.replace {
replace(
self.target,
self.position,
self.new_text.len(),
&self.old_text,
)
} else {
Err(CommandError::InvalidState(
"no replace callback set".to_string(),
))
}
}
fn description(&self) -> &str {
&self.metadata.description
}
fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>()
+ self.old_text.len()
+ self.new_text.len()
+ self.metadata.size_bytes()
}
fn metadata(&self) -> &CommandMetadata {
&self.metadata
}
fn target(&self) -> Option<WidgetId> {
Some(self.target)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn debug_name(&self) -> &'static str {
"TextReplaceCmd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::Mutex;
#[test]
fn test_widget_id_creation() {
let id = WidgetId::new(42);
assert_eq!(id.raw(), 42);
}
#[test]
fn test_command_metadata_size() {
let meta = CommandMetadata::new("Test command");
let size = meta.size_bytes();
assert!(size > std::mem::size_of::<CommandMetadata>());
assert!(size >= std::mem::size_of::<CommandMetadata>() + "Test command".len());
}
#[test]
fn test_command_metadata_with_source() {
let meta = CommandMetadata::new("Test").with_source(CommandSource::Macro);
assert_eq!(meta.source, CommandSource::Macro);
}
#[test]
fn test_command_metadata_with_batch() {
let meta = CommandMetadata::new("Test").with_batch(123);
assert_eq!(meta.batch_id, Some(123));
}
#[test]
fn test_command_batch_execute_undo() {
let buffer = Arc::new(Mutex::new(String::new()));
let mut batch = CommandBatch::new("Test batch");
let b1 = buffer.clone();
let b2 = buffer.clone();
let b3 = buffer.clone();
let b4 = buffer.clone();
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello")
.with_apply(move |_, pos, text| {
let mut buf = b1.lock().unwrap();
buf.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut buf = b2.lock().unwrap();
buf.drain(pos..pos + len);
Ok(())
});
let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World")
.with_apply(move |_, pos, text| {
let mut buf = b3.lock().unwrap();
buf.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut buf = b4.lock().unwrap();
buf.drain(pos..pos + len);
Ok(())
});
batch.push(Box::new(cmd1));
batch.push(Box::new(cmd2));
batch.execute().unwrap();
assert_eq!(*buffer.lock().unwrap(), "Hello World");
batch.undo().unwrap();
assert_eq!(*buffer.lock().unwrap(), "");
}
#[test]
fn test_command_batch_empty() {
let batch = CommandBatch::new("Empty");
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
}
#[test]
fn test_text_insert_can_merge_consecutive() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_no_merge_different_widget() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(2), 1, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_no_merge_non_consecutive() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_can_merge_backspace() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "a");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_can_merge_delete_key() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 5, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_command_error_display() {
let err = CommandError::TargetNotFound(WidgetId::new(42));
assert!(err.to_string().contains("42"));
let err = CommandError::PositionOutOfBounds {
position: 10,
length: 5,
};
assert!(err.to_string().contains("10"));
assert!(err.to_string().contains("5"));
}
#[test]
fn test_merge_config_default() {
let config = MergeConfig::default();
assert_eq!(config.max_delay_ms, 500);
assert!(!config.merge_across_words);
assert_eq!(config.max_merged_size, 1024);
}
#[test]
fn test_text_replace_size_bytes() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
let size = cmd.size_bytes();
assert!(size >= std::mem::size_of::<TextReplaceCmd>() + 3 + 3);
}
#[test]
fn test_text_insert_accept_merge() {
let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "Hello");
let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, " World");
assert!(cmd1.accept_merge(&cmd2));
assert_eq!(cmd1.text, "Hello World");
}
#[test]
fn test_text_delete_accept_merge_backspace() {
let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
assert!(cmd1.accept_merge(&cmd2));
assert_eq!(cmd1.deleted_text, "ab");
assert_eq!(cmd1.position, 3); }
#[test]
fn test_text_delete_accept_merge_forward_delete() {
let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 3, "a");
let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 3, "b");
assert!(cmd1.accept_merge(&cmd2));
assert_eq!(cmd1.deleted_text, "ab");
assert_eq!(cmd1.position, 3); }
#[test]
fn test_debug_implementations() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("TextInsertCmd"));
assert!(debug_str.contains("test"));
let batch = CommandBatch::new("Test batch");
let debug_str = format!("{:?}", batch);
assert!(debug_str.contains("CommandBatch"));
}
#[test]
fn test_text_insert_execute_and_undo_with_callbacks() {
let buf = Arc::new(Mutex::new(String::from("Hello")));
let b1 = buf.clone();
let b2 = buf.clone();
let mut cmd = TextInsertCmd::new(WidgetId::new(1), 5, " World")
.with_apply(move |_, pos, text| {
let mut b = b1.lock().unwrap();
b.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut b = b2.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
});
cmd.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello World");
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello");
}
#[test]
fn test_text_insert_execute_without_callback_errors() {
let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
let err = cmd.execute().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_insert_undo_without_callback_errors() {
let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "test");
let err = cmd.undo().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_insert_target() {
let cmd = TextInsertCmd::new(WidgetId::new(42), 0, "x");
assert_eq!(cmd.target(), Some(WidgetId::new(42)));
}
#[test]
fn test_text_insert_merge_text() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "abc");
assert_eq!(cmd.merge_text(), Some("abc"));
}
#[test]
fn test_text_delete_target() {
let cmd = TextDeleteCmd::new(WidgetId::new(7), 0, "x");
assert_eq!(cmd.target(), Some(WidgetId::new(7)));
}
#[test]
fn test_command_metadata_default() {
let meta = CommandMetadata::default();
assert_eq!(meta.description, "Unknown");
assert_eq!(meta.source, CommandSource::User);
assert_eq!(meta.batch_id, None);
}
#[test]
fn test_command_source_default_is_user() {
assert_eq!(CommandSource::default(), CommandSource::User);
}
#[test]
fn test_command_error_state_drift_display() {
let err = CommandError::StateDrift {
expected: "foo".to_string(),
actual: "bar".to_string(),
};
let s = err.to_string();
assert!(s.contains("foo"));
assert!(s.contains("bar"));
}
#[test]
fn test_command_error_other_display() {
let err = CommandError::Other("something broke".to_string());
assert!(err.to_string().contains("something broke"));
}
#[test]
fn test_command_batch_push_executed_tracks_index() {
let buf = Arc::new(Mutex::new(String::new()));
let b1 = buf.clone();
let b2 = buf.clone();
{
let mut b = buf.lock().unwrap();
b.push_str("Hi");
}
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
.with_apply(move |_, pos, text| {
let mut b = b1.lock().unwrap();
b.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut b = b2.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
});
let mut batch = CommandBatch::new("Pre-executed batch");
batch.push_executed(Box::new(cmd));
assert_eq!(batch.len(), 1);
batch.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "");
}
#[test]
fn test_text_delete_execute_and_undo_with_callbacks() {
let buf = Arc::new(Mutex::new(String::from("Hello World")));
let b1 = buf.clone();
let b2 = buf.clone();
let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 5, " World")
.with_remove(move |_, pos, len| {
let mut b = b1.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
})
.with_insert(move |_, pos, text| {
let mut b = b2.lock().unwrap();
b.insert_str(pos, text);
Ok(())
});
cmd.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello");
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello World");
}
#[test]
fn test_text_delete_execute_without_callback_errors() {
let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
let err = cmd.execute().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_delete_undo_without_callback_errors() {
let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
let err = cmd.undo().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_delete_size_bytes() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
let size = cmd.size_bytes();
assert!(size >= std::mem::size_of::<TextDeleteCmd>() + 3);
}
#[test]
fn test_text_delete_description() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
assert_eq!(cmd.description(), "Delete text");
}
#[test]
fn test_text_delete_merge_text() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 5, "xyz");
assert_eq!(cmd.merge_text(), Some("xyz"));
}
#[test]
fn test_text_delete_debug() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 3, "abc");
let s = format!("{:?}", cmd);
assert!(s.contains("TextDeleteCmd"));
assert!(s.contains("abc"));
}
#[test]
fn test_text_delete_debug_name() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "x");
assert_eq!(cmd.debug_name(), "TextDeleteCmd");
}
#[test]
fn test_text_delete_no_merge_different_widget() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(2), 5, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_no_merge_non_adjacent() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_no_merge_exceeds_max_size() {
let long_text = "a".repeat(600);
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 600, &long_text);
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_accept_merge_non_adjacent_returns_false() {
let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 10, "b");
assert!(!cmd1.accept_merge(&cmd2));
}
#[test]
fn test_text_delete_accept_merge_wrong_type_returns_false() {
let mut cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let cmd2 = TextInsertCmd::new(WidgetId::new(1), 5, "b");
assert!(!cmd1.accept_merge(&cmd2));
}
#[test]
fn test_text_insert_no_merge_across_word_boundary() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_merge_across_word_boundary_when_configured() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "hello ");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 6, "world");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig {
merge_across_words: true,
..MergeConfig::default()
};
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_no_merge_exceeds_max_size() {
let long_text = "a".repeat(600);
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, &long_text);
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 600, &long_text);
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig::default(); assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_accept_merge_wrong_type_returns_false() {
let mut cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
assert!(!cmd1.accept_merge(&cmd2));
}
#[test]
fn test_text_insert_size_bytes() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "hello");
let size = cmd.size_bytes();
assert!(size >= std::mem::size_of::<TextInsertCmd>() + 5);
}
#[test]
fn test_text_insert_description() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
assert_eq!(cmd.description(), "Insert text");
}
#[test]
fn test_text_insert_debug_name() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
assert_eq!(cmd.debug_name(), "TextInsertCmd");
}
#[test]
fn test_text_replace_execute_and_undo_with_callbacks() {
let buf = Arc::new(Mutex::new(String::from("Hello World")));
let b1 = buf.clone();
let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
move |_, pos, old_len, new_text| {
let mut b = b1.lock().unwrap();
b.drain(pos..pos + old_len);
b.insert_str(pos, new_text);
Ok(())
},
);
cmd.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello Rust");
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello World");
}
#[test]
fn test_text_replace_execute_without_callback_errors() {
let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
let err = cmd.execute().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_replace_undo_without_callback_errors() {
let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "old", "new");
let err = cmd.undo().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
}
#[test]
fn test_text_replace_target() {
let cmd = TextReplaceCmd::new(WidgetId::new(99), 0, "a", "b");
assert_eq!(cmd.target(), Some(WidgetId::new(99)));
}
#[test]
fn test_text_replace_description() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
assert_eq!(cmd.description(), "Replace text");
}
#[test]
fn test_text_replace_metadata() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
assert_eq!(cmd.metadata().description, "Replace text");
assert_eq!(cmd.metadata().source, CommandSource::User);
}
#[test]
fn test_text_replace_debug() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 3, "old", "new");
let s = format!("{:?}", cmd);
assert!(s.contains("TextReplaceCmd"));
assert!(s.contains("old"));
assert!(s.contains("new"));
}
#[test]
fn test_text_replace_debug_name() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
assert_eq!(cmd.debug_name(), "TextReplaceCmd");
}
#[test]
fn test_command_batch_execute_rollback_on_failure() {
let buf = Arc::new(Mutex::new(String::new()));
let b1 = buf.clone();
let b2 = buf.clone();
let mut batch = CommandBatch::new("Rollback test");
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "OK")
.with_apply(move |_, pos, text| {
let mut b = b1.lock().unwrap();
b.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut b = b2.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
});
let cmd2 = TextInsertCmd::new(WidgetId::new(1), 2, " FAIL");
batch.push(Box::new(cmd1));
batch.push(Box::new(cmd2));
let err = batch.execute().unwrap_err();
assert!(matches!(err, CommandError::InvalidState(_)));
assert_eq!(*buf.lock().unwrap(), "");
}
#[test]
fn test_command_batch_redo() {
let buf = Arc::new(Mutex::new(String::new()));
let b1 = buf.clone();
let b2 = buf.clone();
let mut batch = CommandBatch::new("Redo test");
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
.with_apply(move |_, pos, text| {
let mut b = b1.lock().unwrap();
b.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut b = b2.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
});
batch.push(Box::new(cmd));
batch.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hi");
batch.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "");
batch.redo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hi");
}
#[test]
fn test_command_batch_size_bytes() {
let batch = CommandBatch::new("Size test");
let size = batch.size_bytes();
assert!(size >= std::mem::size_of::<CommandBatch>());
}
#[test]
fn test_command_batch_size_bytes_with_commands() {
let mut batch = CommandBatch::new("Size test");
batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "hello")));
let size = batch.size_bytes();
let inner_size = TextInsertCmd::new(WidgetId::new(1), 0, "hello").size_bytes();
assert!(size > inner_size);
}
#[test]
fn test_command_batch_metadata() {
let batch = CommandBatch::new("Meta test");
assert_eq!(batch.metadata().description, "Meta test");
}
#[test]
fn test_command_batch_debug_name() {
let batch = CommandBatch::new("test");
assert_eq!(batch.debug_name(), "CommandBatch");
}
#[test]
fn test_command_batch_can_merge_default_false() {
let batch = CommandBatch::new("test");
let other = CommandBatch::new("other");
let config = MergeConfig::default();
assert!(!batch.can_merge(&other, &config));
}
#[test]
fn test_command_batch_undo_empty() {
let mut batch = CommandBatch::new("Empty undo");
batch.undo().unwrap();
}
#[test]
fn test_dyn_undoable_cmd_debug() {
let cmd: Box<dyn UndoableCmd> = Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "test"));
let s = format!("{:?}", cmd);
assert!(s.contains("TextInsertCmd"));
assert!(s.contains("Insert text"));
}
#[test]
fn test_command_error_invalid_state_display() {
let err = CommandError::InvalidState("bad state".to_string());
let s = err.to_string();
assert!(s.contains("bad state"));
}
#[test]
fn test_command_error_is_std_error() {
let err: Box<dyn std::error::Error> = Box::new(CommandError::Other("test".to_string()));
assert!(err.to_string().contains("test"));
}
#[test]
fn test_widget_id_equality() {
let a = WidgetId::new(1);
let b = WidgetId::new(1);
let c = WidgetId::new(2);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn test_widget_id_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(WidgetId::new(1));
set.insert(WidgetId::new(1));
set.insert(WidgetId::new(2));
assert_eq!(set.len(), 2);
}
#[test]
fn test_widget_id_debug() {
let id = WidgetId::new(42);
let s = format!("{:?}", id);
assert!(s.contains("42"));
}
#[test]
fn test_text_insert_redo() {
let buf = Arc::new(Mutex::new(String::new()));
let b1 = buf.clone();
let b2 = buf.clone();
let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "Hi")
.with_apply(move |_, pos, text| {
let mut b = b1.lock().unwrap();
b.insert_str(pos, text);
Ok(())
})
.with_remove(move |_, pos, len| {
let mut b = b2.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
});
cmd.execute().unwrap();
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "");
cmd.redo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hi");
}
#[test]
fn test_text_delete_redo() {
let buf = Arc::new(Mutex::new(String::from("Hello")));
let b1 = buf.clone();
let b2 = buf.clone();
let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "Hello")
.with_remove(move |_, pos, len| {
let mut b = b1.lock().unwrap();
b.drain(pos..pos + len);
Ok(())
})
.with_insert(move |_, pos, text| {
let mut b = b2.lock().unwrap();
b.insert_str(pos, text);
Ok(())
});
cmd.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "");
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello");
cmd.redo().unwrap();
assert_eq!(*buf.lock().unwrap(), "");
}
#[test]
fn test_command_metadata_all_sources() {
for source in [
CommandSource::User,
CommandSource::Programmatic,
CommandSource::Macro,
CommandSource::External,
] {
let meta = CommandMetadata::new("test").with_source(source);
assert_eq!(meta.source, source);
}
}
#[test]
fn test_command_metadata_empty_description() {
let meta = CommandMetadata::new("");
assert_eq!(meta.size_bytes(), std::mem::size_of::<CommandMetadata>());
}
#[test]
fn test_text_insert_as_any_roundtrip() {
let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
let any_ref = cmd.as_any();
let downcasted = any_ref.downcast_ref::<TextInsertCmd>().unwrap();
assert_eq!(downcasted.text, "x");
}
#[test]
fn test_text_insert_as_any_mut_roundtrip() {
let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x");
let downcasted = cmd.as_any_mut().downcast_mut::<TextInsertCmd>().unwrap();
downcasted.text = "modified".to_string();
assert_eq!(cmd.text, "modified");
}
#[test]
fn test_text_delete_as_any_roundtrip() {
let cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
let downcasted = cmd.as_any().downcast_ref::<TextDeleteCmd>().unwrap();
assert_eq!(downcasted.deleted_text, "abc");
}
#[test]
fn test_text_delete_as_any_mut_roundtrip() {
let mut cmd = TextDeleteCmd::new(WidgetId::new(1), 0, "abc");
let downcasted = cmd.as_any_mut().downcast_mut::<TextDeleteCmd>().unwrap();
downcasted.deleted_text = "xyz".to_string();
assert_eq!(cmd.deleted_text, "xyz");
}
#[test]
fn test_text_replace_as_any_roundtrip() {
let cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
let downcasted = cmd.as_any().downcast_ref::<TextReplaceCmd>().unwrap();
assert_eq!(downcasted.old_text, "a");
assert_eq!(downcasted.new_text, "b");
}
#[test]
fn test_text_replace_as_any_mut_roundtrip() {
let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 0, "a", "b");
let downcasted = cmd.as_any_mut().downcast_mut::<TextReplaceCmd>().unwrap();
downcasted.new_text = "replaced".to_string();
assert_eq!(cmd.new_text, "replaced");
}
#[test]
fn test_command_batch_as_any_roundtrip() {
let batch = CommandBatch::new("test batch");
let downcasted = batch.as_any().downcast_ref::<CommandBatch>().unwrap();
assert_eq!(downcasted.description(), "test batch");
}
#[test]
fn test_command_batch_as_any_mut_roundtrip() {
let mut batch = CommandBatch::new("test batch");
batch.push(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "x")));
let downcasted = batch.as_any_mut().downcast_mut::<CommandBatch>().unwrap();
assert_eq!(downcasted.len(), 1);
}
#[test]
fn test_command_batch_description_matches() {
let batch = CommandBatch::new("My description");
assert_eq!(batch.description(), "My description");
}
#[test]
fn test_command_batch_merge_text_default_none() {
let batch = CommandBatch::new("test");
assert_eq!(batch.merge_text(), None);
}
#[test]
fn test_command_batch_accept_merge_default_false() {
let mut batch = CommandBatch::new("test");
let other = CommandBatch::new("other");
assert!(!batch.accept_merge(&other));
}
#[test]
fn test_command_batch_target_default_none() {
let batch = CommandBatch::new("test");
assert_eq!(batch.target(), None);
}
#[test]
fn test_text_insert_can_merge_rejects_delete_type() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let cmd2 = TextDeleteCmd::new(WidgetId::new(1), 0, "b");
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_can_merge_rejects_insert_type() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 0, "a");
let cmd2 = TextInsertCmd::new(WidgetId::new(1), 0, "b");
let config = MergeConfig::default();
assert!(!cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_insert_no_merge_time_exceeded() {
let cmd1 = TextInsertCmd::new(WidgetId::new(1), 0, "a");
let mut cmd2 = TextInsertCmd::new(WidgetId::new(1), 1, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig {
max_delay_ms: 0,
merge_across_words: true,
max_merged_size: 1024,
};
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_delete_no_merge_time_exceeded() {
let cmd1 = TextDeleteCmd::new(WidgetId::new(1), 5, "a");
let mut cmd2 = TextDeleteCmd::new(WidgetId::new(1), 4, "b");
cmd2.metadata.timestamp = cmd1.metadata.timestamp;
let config = MergeConfig {
max_delay_ms: 0,
merge_across_words: true,
max_merged_size: 1024,
};
assert!(cmd1.can_merge(&cmd2, &config));
}
#[test]
fn test_text_replace_redo() {
let buf = Arc::new(Mutex::new(String::from("Hello World")));
let b1 = buf.clone();
let mut cmd = TextReplaceCmd::new(WidgetId::new(1), 6, "World", "Rust").with_replace(
move |_, pos, old_len, new_text| {
let mut b = b1.lock().unwrap();
b.replace_range(pos..pos + old_len, new_text);
Ok(())
},
);
cmd.execute().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello Rust");
cmd.undo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello World");
cmd.redo().unwrap();
assert_eq!(*buf.lock().unwrap(), "Hello Rust");
}
#[test]
fn test_command_batch_execute_empty_is_noop() {
let mut batch = CommandBatch::new("Empty execute");
assert!(batch.execute().is_ok());
assert_eq!(batch.executed_to, 0);
}
#[test]
fn test_command_batch_multiple_push_executed() {
let mut batch = CommandBatch::new("Multi pre-exec");
batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 0, "a")));
batch.push_executed(Box::new(TextInsertCmd::new(WidgetId::new(1), 1, "b")));
assert_eq!(batch.len(), 2);
assert_eq!(batch.executed_to, 2);
}
#[test]
fn test_command_error_clone_and_equality() {
let err1 = CommandError::TargetNotFound(WidgetId::new(5));
let err2 = err1.clone();
assert_eq!(err1, err2);
let err3 = CommandError::PositionOutOfBounds {
position: 10,
length: 5,
};
let err4 = err3.clone();
assert_eq!(err3, err4);
}
#[test]
fn test_merge_config_clone_and_copy() {
let config = MergeConfig {
max_delay_ms: 1000,
merge_across_words: true,
max_merged_size: 2048,
};
let config2 = config;
assert_eq!(config2.max_delay_ms, 1000);
assert!(config2.merge_across_words);
assert_eq!(config2.max_merged_size, 2048);
}
#[test]
fn test_widget_id_copy() {
let a = WidgetId::new(42);
let b = a; assert_eq!(a, b); assert_eq!(a.raw(), 42);
}
#[test]
fn test_command_metadata_clone() {
let meta = CommandMetadata::new("test")
.with_source(CommandSource::Programmatic)
.with_batch(99);
let cloned = meta.clone();
assert_eq!(cloned.description, "test");
assert_eq!(cloned.source, CommandSource::Programmatic);
assert_eq!(cloned.batch_id, Some(99));
}
}