ronin_core/undo.rs
1//! Bounded, WASM-clean CST-backed undo/redo history (E007 OBJ3, TR-010..014).
2//!
3//! [`UndoStack`] is the reusable undo/redo model the editor surfaces wire a
4//! document into. Each reversible unit is an [`UndoEntry`]: a snapshot of the
5//! **exact prior document bytes** (`source_text`), the lossless CST at that
6//! state ([`CstDocument`], cheap to clone — see below), and a caller-supplied
7//! cursor/metadata value. Restoring an entry therefore reproduces the prior
8//! buffer byte-for-byte with no reflow or normalization (TR-010, SC-005).
9//!
10//! # WASM-clean (TR-014, HINT-002)
11//!
12//! This module adds **no** filesystem / native / I/O / network dependency to
13//! `ronin-core` — it holds only CST + text + a generic cursor value, so the crate
14//! stays `rowan`-only and the `wasm32-unknown-unknown` build gate keeps passing.
15//! It also measures **no** wall-clock time: `std::time::Instant` is unavailable
16//! on `wasm32-unknown-unknown`, so the *timing* of a coalesce boundary is decided
17//! **caller-side** (the native `ronin-app` measures `Instant` elapsed against the
18//! configured window) and handed in as the `coalesce` flag on [`record`](UndoStack::record).
19//! [`coalesce_window`](UndoStack::coalesce_window) is retained only as reference
20//! metadata; this module never reads a clock. All persistence (atomic save, the
21//! recovery sidecar) lives in the native `ronin-app`; the undo model deliberately
22//! knows nothing about it. The stack is reusable by downstream epics (E005/E008,
23//! TR-013).
24//!
25//! # Cheap snapshots via structural sharing (AD-002, ADR-0001)
26//!
27//! [`CstDocument`] is `Clone`, and a rowan green tree is **immutable /
28//! structurally shared**: cloning the document bumps an `Arc` refcount on the
29//! green root, and an edit reuses every untouched subtree verbatim. Snapshotting
30//! the CST per undo unit is therefore cheap (no deep copy of the tree), while the
31//! retained `source_text` guarantees the exact-prior-byte restore. This is why
32//! the undo unit is a CST + text snapshot rather than a reverse-delta log.
33//!
34//! # Cursor / metadata generic (`C`)
35//!
36//! [`UndoStack`] and [`UndoEntry`] are generic over a cursor/metadata type `C`
37//! so `ronin-core` stays decoupled from any surface's cursor representation: the
38//! native editor supplies its own `CursorState`, a future headless surface could
39//! supply `()`. `C` is restored alongside the document on undo/redo (TR-010).
40//!
41//! # Bounds and coalescing (TR-011, TR-024, TR-027)
42//!
43//! The undo ring is bounded by **both** a count cap and a byte-size cap
44//! ([`UndoCap`]); when either binds, the oldest unit is dropped so memory stays
45//! predictable on large files (SC-006/SC-009). A rapid run of edits the caller
46//! marks `coalesce: true` collapses into a single unit (SC-006/SC-010); the first
47//! edit after a pause (`coalesce: false`) commits the prior boundary and starts a
48//! new unit (TR-027). A new edit after an undo clears the redo stack (TR-012).
49//!
50//! # State model (current / undo ring / redo)
51//!
52//! The stack tracks the **current committed document state** ([`current`](UndoStack::current))
53//! plus a bounded [`undo`](UndoStack) ring of prior boundaries and a [`redo`](UndoStack)
54//! stack. The caller owns the live buffer; the stack owns the *history*. The
55//! contract is:
56//!
57//! * [`record(entry, coalesce)`](UndoStack::record): `entry` is a snapshot of the
58//! document state **after** the edit just applied. When `coalesce` is `false`,
59//! the previous `current` is pushed onto the `undo` ring as a discrete
60//! boundary and `entry` becomes the new `current` (a new undo unit). When
61//! `coalesce` is `true`, `entry` replaces `current` **in place** without
62//! pushing a boundary, so a run of coalesced edits collapses to the single
63//! boundary captured before the run began. Either way, recording an edit clears
64//! `redo` (TR-012). The very first `record` on an empty stack just seeds
65//! `current` (there is no prior boundary to keep).
66//! * [`undo()`](UndoStack::undo): pops the most recent boundary off the `undo`
67//! ring, pushes the *current* state onto `redo`, makes the popped boundary the
68//! new `current`, and returns it for the caller to apply to the buffer. Returns
69//! `None` when there is nothing to undo.
70//! * [`redo()`](UndoStack::redo): pops the most recent state off `redo`, pushes
71//! the current state back onto the `undo` ring, makes the popped state the new
72//! `current`, and returns it. Returns `None` when there is nothing to redo.
73//!
74//! This keeps restore byte-faithful (the caller writes the returned entry's
75//! `source_text` verbatim — no reflow) and the history strictly bounded.
76
77use std::collections::VecDeque;
78use std::time::Duration;
79
80use crate::parser::CstDocument;
81
82/// Default maximum number of undo units retained (TR-024).
83///
84/// Mirrors the `ronin-app` NEW-CONFIG default so a stack constructed without an
85/// explicit cap behaves like the configured editor default.
86pub const DEFAULT_UNDO_COUNT_CAP: usize = 200;
87
88/// Default maximum total snapshot byte-size retained: 64 MiB (TR-024).
89///
90/// Bounds memory on large files independently of the unit count; whichever cap
91/// binds first triggers dropping the oldest unit.
92pub const DEFAULT_UNDO_BYTE_CAP: usize = 64 * 1024 * 1024;
93
94/// Default coalesce window: edits less than 500 ms apart fold into one unit
95/// (TR-027). A pause longer than this boundary starts a new undo unit.
96///
97/// This module never measures elapsed time against this value (it stays
98/// WASM-clean — see the module docs). It is retained as reference/config
99/// metadata; the caller measures elapsed time and supplies the coalesce decision.
100pub const DEFAULT_COALESCE_WINDOW: Duration = Duration::from_millis(500);
101
102/// The history bound for an [`UndoStack`]: a unit-count cap **and** a total
103/// snapshot byte-size cap (TR-011, TR-024).
104///
105/// Both caps are enforced — whichever binds first drops the oldest unit — so
106/// history stays bounded by count on small edits and by size on large buffers.
107/// A misconfigured (zero) cap is never honored as "unbounded": construct via
108/// [`UndoCap::new`], which falls back to the defaults for a zero field.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct UndoCap {
111 /// Maximum number of undo units retained (`>= 1`).
112 pub max_count: usize,
113 /// Maximum total retained snapshot byte-size, in bytes (`>= 1`).
114 pub max_bytes: usize,
115}
116
117impl Default for UndoCap {
118 fn default() -> Self {
119 Self {
120 max_count: DEFAULT_UNDO_COUNT_CAP,
121 max_bytes: DEFAULT_UNDO_BYTE_CAP,
122 }
123 }
124}
125
126impl UndoCap {
127 /// Build a cap, falling back to the default for any zero field so the bound
128 /// is **never** unbounded (TR-024: a misconfigured cap reverts to default).
129 #[must_use]
130 pub fn new(max_count: usize, max_bytes: usize) -> Self {
131 Self {
132 max_count: if max_count == 0 {
133 DEFAULT_UNDO_COUNT_CAP
134 } else {
135 max_count
136 },
137 max_bytes: if max_bytes == 0 {
138 DEFAULT_UNDO_BYTE_CAP
139 } else {
140 max_bytes
141 },
142 }
143 }
144}
145
146/// One reversible unit — a snapshot of a single document state (TR-010).
147///
148/// Restoring an entry reproduces the document at this state: the CST snapshot,
149/// the exact prior buffer bytes (`source_text`), and the caller's cursor value
150/// (`cursor`). Generic over the cursor/metadata type `C` so `ronin-core` stays
151/// decoupled from any surface's cursor representation (see module docs).
152///
153/// # Downstream reuse (E005 / E008 — TR-013)
154///
155/// Downstream editing epics build an entry with [`new`](Self::new) after applying
156/// an edit, then hand it to [`UndoStack::record`]. On undo/redo they read back the
157/// snapshot through [`source_text`](Self::source_text) (write to the buffer
158/// verbatim — exact-prior-byte restore), [`cst_snapshot`](Self::cst_snapshot)
159/// (reuse the structurally-shared tree without re-parsing), and
160/// [`cursor`](Self::cursor) (restore caret/selection).
161#[derive(Debug, Clone)]
162pub struct UndoEntry<C> {
163 /// The lossless CST at this state (cheap to clone — structural sharing).
164 cst_snapshot: CstDocument,
165 /// The exact prior buffer text this unit restores — the byte-faithful state
166 /// (guarantees exact-prior-byte restore with no reflow, TR-010).
167 source_text: String,
168 /// The caret/selection (or other surface metadata) restored with this state.
169 cursor: C,
170}
171
172impl<C> UndoEntry<C> {
173 /// Snapshot a document state into a new undo unit.
174 ///
175 /// `cst_snapshot` should correspond to `source_text` (the CST whose printout
176 /// is those exact bytes); `cursor` is the surface state to restore alongside.
177 #[must_use]
178 pub fn new(cst_snapshot: CstDocument, source_text: String, cursor: C) -> Self {
179 Self {
180 cst_snapshot,
181 source_text,
182 cursor,
183 }
184 }
185
186 /// The CST snapshot at this state.
187 #[must_use]
188 pub fn cst_snapshot(&self) -> &CstDocument {
189 &self.cst_snapshot
190 }
191
192 /// The exact prior buffer text this unit restores.
193 #[must_use]
194 pub fn source_text(&self) -> &str {
195 &self.source_text
196 }
197
198 /// The cursor/metadata value restored with this state.
199 #[must_use]
200 pub fn cursor(&self) -> &C {
201 &self.cursor
202 }
203
204 /// The retained byte-size this entry contributes to the stack's size cap —
205 /// the length of the snapshot's `source_text` in bytes (TR-024).
206 ///
207 /// The CST snapshot itself is structurally shared and is not counted here;
208 /// the source text is the dominant, deterministic per-unit cost.
209 #[must_use]
210 pub fn byte_size(&self) -> usize {
211 self.source_text.len()
212 }
213}
214
215/// The bounded undo/redo history for a single document (TR-010..014).
216///
217/// Newest reversible boundary is at the back/top of [`undo`](Self::undo). Generic
218/// over the cursor/metadata type `C` carried in each [`UndoEntry`]. See the module
219/// docs for the WASM-clean, structural-sharing, current/ring state model, and the
220/// caller-side coalesce decision.
221///
222/// # Downstream reuse (E005 / E008 — TR-013)
223///
224/// This is the reusable undo/redo model the downstream editing epics edit against:
225/// **E005** (smart authoring) and **E008** (structural / table editing). It lives
226/// in `ronin-core` precisely so any surface can own a per-document handle, while the
227/// editor-specific glue (cursor type, coalesce timing, dirty tracking) stays in the
228/// host. The key reuse contract:
229///
230/// * **Construct per document** via [`with_config`](Self::with_config) (host's
231/// NEW-CONFIG cap + coalesce window) or [`new`](Self::new) for the defaults.
232/// * **After each edit**, [`record`](Self::record) an [`UndoEntry`] snapshot
233/// (CST, exact bytes, cursor) with the **caller-measured** `coalesce` flag —
234/// this module reads no clock, so the host decides run boundaries.
235/// * **On undo/redo**, apply the returned entry's `source_text` to the buffer
236/// **verbatim** for an exact-prior-byte restore (no reflow / normalization,
237/// TR-010 / SC-005); redo is invalidated automatically by the next `record`.
238///
239/// Choose `C` to match the surface (the native editor uses its `CursorState`; a
240/// headless consumer can use `()`). The history is always bounded (count + bytes)
241/// and adds no filesystem / native dependency, so it is WASM-clean for every
242/// downstream surface.
243#[derive(Debug, Clone)]
244pub struct UndoStack<C> {
245 /// The current committed document state. `None` before the first
246 /// [`record`](Self::record) seeds it. Restored-into on undo/redo; this is the
247 /// state pushed onto `redo` (on undo) or back onto `undo` (on redo).
248 current: Option<UndoEntry<C>>,
249 /// The reversible history (a bounded ring), newest at the back/top. Bounded
250 /// by `cap`; the oldest unit is dropped when either cap binds (TR-011).
251 undo: VecDeque<UndoEntry<C>>,
252 /// States popped by undo and replay-able by redo. **Cleared** on a new edit
253 /// recorded after an undo (TR-012).
254 redo: Vec<UndoEntry<C>>,
255 /// The count + byte-size history bound (TR-011, TR-024).
256 cap: UndoCap,
257 /// The idle/run boundary that ends a coalesced keystroke run so one rapid
258 /// run collapses to a single unit (TR-027). Retained as reference metadata
259 /// only — this module never measures elapsed time against it; the caller
260 /// supplies the coalesce decision (see module docs / TR-014).
261 coalesce_window: Duration,
262 /// Whether a coalescing keystroke run is currently open. While `true`, a
263 /// `record(.., true)` updates `current` in place instead of pushing a new
264 /// boundary, so the whole run collapses to one undo unit (TR-027). Reset by a
265 /// non-coalescing record and by undo/redo (which close any open run).
266 pending: bool,
267}
268
269impl<C> Default for UndoStack<C> {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275impl<C> UndoStack<C> {
276 /// Create an empty stack with the default cap and coalesce window.
277 #[must_use]
278 pub fn new() -> Self {
279 Self::with_config(UndoCap::default(), DEFAULT_COALESCE_WINDOW)
280 }
281
282 /// Create an empty stack with an explicit history cap and coalesce window.
283 ///
284 /// Use this to apply the editor's NEW-CONFIG values (`ronin-app`
285 /// `AppSettings`). The cap is taken as-is; build it through [`UndoCap::new`]
286 /// first so a zero/misconfigured field reverts to default (never unbounded).
287 #[must_use]
288 pub fn with_config(cap: UndoCap, coalesce_window: Duration) -> Self {
289 Self {
290 current: None,
291 undo: VecDeque::new(),
292 redo: Vec::new(),
293 cap,
294 coalesce_window,
295 pending: false,
296 }
297 }
298
299 /// The current history bound (count + byte-size cap).
300 #[must_use]
301 pub fn cap(&self) -> UndoCap {
302 self.cap
303 }
304
305 /// The coalesce window: reference metadata for the caller's timing decision.
306 ///
307 /// This module never measures elapsed time against it (it stays WASM-clean);
308 /// the caller compares its own `Instant` elapsed against this and passes the
309 /// result as the `coalesce` flag to [`record`](Self::record).
310 #[must_use]
311 pub fn coalesce_window(&self) -> Duration {
312 self.coalesce_window
313 }
314
315 /// The current committed document state, or `None` before the first record.
316 #[must_use]
317 pub fn current(&self) -> Option<&UndoEntry<C>> {
318 self.current.as_ref()
319 }
320
321 /// Number of committed undo boundaries currently retained (the steps `undo`
322 /// can take back from the current state).
323 #[must_use]
324 pub fn len(&self) -> usize {
325 self.undo.len()
326 }
327
328 /// Whether there are no committed undo boundaries to step back to.
329 #[must_use]
330 pub fn is_empty(&self) -> bool {
331 self.undo.is_empty()
332 }
333
334 /// Whether a coalescing keystroke run is currently open. The document seam
335 /// (T036) closes it implicitly on the next non-coalescing record or on
336 /// undo/redo; exposed for tests and host wiring.
337 #[must_use]
338 pub fn has_pending(&self) -> bool {
339 self.pending
340 }
341
342 /// Whether an undo step is currently available.
343 #[must_use]
344 pub fn can_undo(&self) -> bool {
345 !self.undo.is_empty()
346 }
347
348 /// Whether a redo step is currently available.
349 #[must_use]
350 pub fn can_redo(&self) -> bool {
351 !self.redo.is_empty()
352 }
353
354 /// Number of states currently replay-able by redo (cleared on a new edit).
355 #[must_use]
356 pub fn redo_len(&self) -> usize {
357 self.redo.len()
358 }
359
360 /// Total retained snapshot byte-size across the committed undo boundaries
361 /// (compared against the byte-size cap, TR-024).
362 ///
363 /// Counts the `undo` ring only — the boundaries the cap drops oldest-first.
364 /// The live `current` is the caller's buffer (not history) and `redo` is
365 /// cleared by any new edit, so the ring is the bounded retained history.
366 #[must_use]
367 pub fn retained_bytes(&self) -> usize {
368 self.undo.iter().map(UndoEntry::byte_size).sum()
369 }
370
371 /// Total retained snapshot byte-size across **both** the undo ring and the
372 /// redo stack plus the current state (the full in-memory history footprint).
373 ///
374 /// Used by SC-009 to assert the whole undo/redo memory stays bounded by the
375 /// configured cap independent of file size; the per-cap drop is enforced
376 /// against [`retained_bytes`](Self::retained_bytes) (the bounded ring).
377 #[must_use]
378 pub fn total_bytes(&self) -> usize {
379 self.undo.iter().map(UndoEntry::byte_size).sum::<usize>()
380 + self.redo.iter().map(UndoEntry::byte_size).sum::<usize>()
381 + self.current.as_ref().map_or(0, UndoEntry::byte_size)
382 }
383
384 // -- Mutating behavior (E007 Phase 5: T031..T034) -----------------------
385
386 /// Record a new committed document state (T031/T032/T033/T034).
387 ///
388 /// `entry` is a snapshot of the document **after** the edit just applied
389 /// (its `source_text` is the new exact buffer bytes, its `cst_snapshot` the
390 /// matching CST, and `cursor` the post-edit caret). `coalesce` is the
391 /// **caller-supplied** timing decision (this module measures no clock,
392 /// TR-014):
393 ///
394 /// * `coalesce == false` — start a **new** undo unit: the previous `current`
395 /// boundary is pushed onto the bounded `undo` ring and `entry` becomes the
396 /// new `current` (TR-010). The first record on an empty stack just seeds
397 /// `current` with no boundary to keep. This is the first edit of a run (the
398 /// caller measured a pause, or a restore reset the timing anchor), so it is
399 /// its own discrete undo step that a following within-window edit may then
400 /// extend.
401 /// * `coalesce == true` — **continue** the current run: `entry` replaces
402 /// `current` in place without pushing a boundary, so the whole keystroke run
403 /// collapses to the single boundary captured before the run began (TR-027,
404 /// one unit per run — SC-006/SC-010).
405 ///
406 /// In both forms `entry` becomes the live `current` and the run stays open, so
407 /// the next within-window edit folds in. Either form **clears the redo stack**
408 /// — a new edit after an undo invalidates redo (TR-012). After pushing a
409 /// boundary the bounded ring is trimmed oldest-first by BOTH the count and
410 /// byte-size cap (TR-011/TR-024).
411 pub fn record(&mut self, entry: UndoEntry<C>, coalesce: bool) {
412 // Any new edit invalidates the redo path (TR-012).
413 self.redo.clear();
414
415 match self.current.take() {
416 // First state ever: just seed `current`; there is no prior boundary
417 // and no run is open yet (the seed is not an edit).
418 None => {
419 self.current = Some(entry);
420 self.pending = false;
421 }
422 Some(prev) => {
423 if coalesce {
424 // Continue the open run: collapse into the current unit. The
425 // unit's boundary is already on the ring; advance the live
426 // state in place. `prev` (a within-run intermediate) is dropped.
427 self.current = Some(entry);
428 } else {
429 // A new unit: the previous state becomes a discrete undo step.
430 self.push_boundary(prev);
431 self.current = Some(entry);
432 }
433 // After any real edit a coalescable run is open: a following
434 // within-window edit folds into this unit.
435 self.pending = true;
436 }
437 }
438 }
439
440 /// Undo one step: restore the most recent prior boundary (T031).
441 ///
442 /// Pops the newest boundary off the `undo` ring, pushes the current state
443 /// onto `redo`, makes the popped boundary the new `current`, and returns it so
444 /// the caller can apply its exact-prior `source_text`/`cst`/`cursor` to the
445 /// buffer byte-for-byte (no reflow — TR-010/SC-005). Closes any open coalesce
446 /// run. Returns `None` when there is nothing to undo.
447 ///
448 /// Requires `C: Clone` so the restored boundary can become the new `current`
449 /// and also be returned to the caller (`CursorState` is `Clone`).
450 #[must_use]
451 pub fn undo(&mut self) -> Option<UndoEntry<C>>
452 where
453 C: Clone,
454 {
455 // An undo closes any open coalescing run: a following edit starts fresh.
456 self.pending = false;
457 let prior = self.undo.pop_back()?;
458 if let Some(current) = self.current.take() {
459 self.redo.push(current);
460 }
461 self.current = Some(prior.clone());
462 Some(prior)
463 }
464
465 /// Redo one step: replay the most recently undone state (T031).
466 ///
467 /// Pops the newest state off `redo`, pushes the current state back onto the
468 /// `undo` ring, makes the popped state the new `current`, and returns it for
469 /// the caller to apply exactly (TR-010/SC-005). Closes any open coalesce run.
470 /// Returns `None` when there is nothing to redo.
471 ///
472 /// Requires `C: Clone` so the replayed state can become the new `current`
473 /// and also be returned to the caller (`CursorState` is `Clone`).
474 #[must_use]
475 pub fn redo(&mut self) -> Option<UndoEntry<C>>
476 where
477 C: Clone,
478 {
479 self.pending = false;
480 let next = self.redo.pop()?;
481 if let Some(current) = self.current.take() {
482 // Pushing back onto the ring re-applies the cap (trim oldest if the
483 // ring grew past the bound during a long undo/redo dance).
484 self.push_boundary(current);
485 }
486 self.current = Some(next.clone());
487 Some(next)
488 }
489
490 /// Push a boundary onto the bounded `undo` ring and trim to the cap (T034).
491 ///
492 /// Enforces BOTH the count cap AND the byte-size cap (TR-011/TR-024): after
493 /// appending, the oldest unit is dropped while EITHER the unit count exceeds
494 /// `max_count` OR the retained byte-size exceeds `max_bytes` (whichever binds
495 /// first). The cap is never unbounded — [`UndoCap::new`] reverts a zero field
496 /// to the default, so a misconfigured cap still bounds memory.
497 fn push_boundary(&mut self, entry: UndoEntry<C>) {
498 self.undo.push_back(entry);
499 // Drop oldest while over the count cap.
500 while self.undo.len() > self.cap.max_count {
501 self.undo.pop_front();
502 }
503 // Drop oldest while over the byte-size cap, but always retain at least the
504 // single newest boundary so an undo of the last edit never silently
505 // disappears even if one snapshot alone exceeds the byte cap.
506 while self.undo.len() > 1 && self.retained_bytes() > self.cap.max_bytes {
507 self.undo.pop_front();
508 }
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 /// Build an entry whose `source_text` is `src`, parsed CST, and cursor `cur`.
517 fn entry(src: &str, cur: usize) -> UndoEntry<usize> {
518 UndoEntry::new(crate::parse(src), src.to_string(), cur)
519 }
520
521 #[test]
522 fn cap_new_falls_back_to_default_on_zero() {
523 let cap = UndoCap::new(0, 0);
524 assert_eq!(cap.max_count, DEFAULT_UNDO_COUNT_CAP);
525 assert_eq!(cap.max_bytes, DEFAULT_UNDO_BYTE_CAP);
526
527 let cap = UndoCap::new(10, 0);
528 assert_eq!(cap.max_count, 10);
529 assert_eq!(cap.max_bytes, DEFAULT_UNDO_BYTE_CAP);
530
531 let cap = UndoCap::new(0, 1024);
532 assert_eq!(cap.max_count, DEFAULT_UNDO_COUNT_CAP);
533 assert_eq!(cap.max_bytes, 1024);
534 }
535
536 #[test]
537 fn cap_default_matches_constants() {
538 let cap = UndoCap::default();
539 assert_eq!(cap.max_count, 200);
540 assert_eq!(cap.max_bytes, 64 * 1024 * 1024);
541 }
542
543 #[test]
544 fn new_stack_is_empty_with_default_config() {
545 let stack: UndoStack<()> = UndoStack::new();
546 assert!(stack.is_empty());
547 assert_eq!(stack.len(), 0);
548 assert!(!stack.can_undo());
549 assert!(!stack.can_redo());
550 assert_eq!(stack.cap(), UndoCap::default());
551 assert_eq!(stack.coalesce_window(), DEFAULT_COALESCE_WINDOW);
552 assert_eq!(stack.retained_bytes(), 0);
553 assert!(stack.current().is_none());
554 }
555
556 #[test]
557 fn with_config_applies_cap_and_window() {
558 let cap = UndoCap::new(5, 4096);
559 let window = Duration::from_millis(120);
560 let stack: UndoStack<()> = UndoStack::with_config(cap, window);
561 assert_eq!(stack.cap(), cap);
562 assert_eq!(stack.coalesce_window(), window);
563 }
564
565 #[test]
566 fn undo_entry_exposes_snapshot_text_and_cursor() {
567 let src = "Foo(x: 1)\n";
568 let doc = crate::parse(src);
569 let entry = UndoEntry::new(doc, src.to_string(), 7usize);
570 assert_eq!(entry.source_text(), src);
571 assert_eq!(*entry.cursor(), 7usize);
572 assert_eq!(entry.byte_size(), src.len());
573 assert_eq!(crate::print(entry.cst_snapshot()), src);
574 }
575
576 // --- T031: snapshot push + undo/redo exact-prior-byte restore (SC-005) ---
577
578 #[test]
579 fn first_record_seeds_current_with_no_boundary() {
580 let mut stack: UndoStack<usize> = UndoStack::new();
581 stack.record(entry("(a: 1)\n", 0), false);
582 assert_eq!(stack.len(), 0, "seeding does not create an undo boundary");
583 assert!(!stack.can_undo());
584 assert_eq!(stack.current().unwrap().source_text(), "(a: 1)\n");
585 }
586
587 #[test]
588 fn undo_restores_exact_prior_bytes_and_redo_replays() {
589 let mut stack: UndoStack<usize> = UndoStack::new();
590 stack.record(entry("(a: 1)\n", 1), false);
591 stack.record(entry("(a: 12)\n", 2), false);
592 stack.record(entry("(a: 123)\n", 3), false);
593
594 // Undo restores the exact prior bytes + cursor, in order.
595 let u1 = stack.undo().expect("undo 1");
596 assert_eq!(u1.source_text(), "(a: 12)\n");
597 assert_eq!(*u1.cursor(), 2);
598 assert_eq!(crate::print(u1.cst_snapshot()), "(a: 12)\n");
599
600 let u2 = stack.undo().expect("undo 2");
601 assert_eq!(u2.source_text(), "(a: 1)\n");
602 assert_eq!(*u2.cursor(), 1);
603
604 assert!(
605 !stack.can_undo(),
606 "back to the original; nothing more to undo"
607 );
608
609 // Redo replays them exactly in the reverse order.
610 let r1 = stack.redo().expect("redo 1");
611 assert_eq!(r1.source_text(), "(a: 12)\n");
612 assert_eq!(*r1.cursor(), 2);
613
614 let r2 = stack.redo().expect("redo 2");
615 assert_eq!(r2.source_text(), "(a: 123)\n");
616 assert_eq!(*r2.cursor(), 3);
617
618 assert!(!stack.can_redo());
619 }
620
621 #[test]
622 fn undo_on_empty_history_is_none() {
623 let mut stack: UndoStack<usize> = UndoStack::new();
624 assert!(stack.undo().is_none());
625 stack.record(entry("(a: 1)\n", 0), false);
626 // Only the seed exists; there is no prior boundary to undo to.
627 assert!(stack.undo().is_none());
628 }
629
630 // --- T032: redo invalidation on a new edit (TR-012) ---------------------
631
632 #[test]
633 fn new_edit_after_undo_clears_redo() {
634 let mut stack: UndoStack<usize> = UndoStack::new();
635 stack.record(entry("(a: 1)\n", 0), false);
636 stack.record(entry("(a: 2)\n", 0), false);
637 stack.record(entry("(a: 3)\n", 0), false);
638
639 let _ = stack.undo();
640 let _ = stack.undo();
641 assert!(stack.can_redo(), "two states are now redo-able");
642 assert_eq!(stack.redo_len(), 2);
643
644 // A brand-new edit invalidates the redo stack (TR-012).
645 stack.record(entry("(a: 9)\n", 0), false);
646 assert!(!stack.can_redo());
647 assert_eq!(stack.redo_len(), 0);
648 }
649
650 // --- T033: coalescing — a run collapses to one unit (TR-027/SC-010) -----
651
652 #[test]
653 fn coalesced_run_is_a_single_undo_unit() {
654 let mut stack: UndoStack<usize> = UndoStack::new();
655 // Pre-run committed (seed) state.
656 stack.record(entry("", 0), false);
657 // A rapid keystroke run: the FIRST keystroke is a new unit (the caller
658 // measured a pause / fresh anchor → `coalesce: false`), the rest fold in.
659 stack.record(entry("h", 1), false);
660 stack.record(entry("he", 2), true);
661 stack.record(entry("hel", 3), true);
662 stack.record(entry("hell", 4), true);
663 stack.record(entry("hello", 5), true);
664
665 assert_eq!(stack.len(), 1, "the whole run is a single undo unit");
666 // One undo returns to the pre-run state (empty), not one char back.
667 let u = stack.undo().expect("undo the run");
668 assert_eq!(u.source_text(), "");
669 assert!(!stack.can_undo());
670 }
671
672 #[test]
673 fn coalesce_false_starts_a_new_unit() {
674 let mut stack: UndoStack<usize> = UndoStack::new();
675 stack.record(entry("a", 0), false); // seed
676 stack.record(entry("ab", 0), false); // run 1 opens (boundary "a" kept)
677 stack.record(entry("abc", 0), true); // same run (folds in place)
678 stack.record(entry("abc d", 0), false); // pause: new unit (boundary "abc")
679
680 assert_eq!(stack.len(), 2);
681 assert_eq!(stack.undo().unwrap().source_text(), "abc");
682 assert_eq!(stack.undo().unwrap().source_text(), "a");
683 assert!(!stack.can_undo());
684 }
685
686 #[test]
687 fn coalesce_run_closed_by_undo_then_new_run() {
688 let mut stack: UndoStack<usize> = UndoStack::new();
689 stack.record(entry("x", 0), false); // seed
690 stack.record(entry("xy", 0), false); // opens a run (a real edit)
691 stack.record(entry("xyz", 0), true); // folds into the run
692 assert!(stack.has_pending());
693 let _ = stack.undo();
694 assert!(!stack.has_pending(), "undo closes the coalescing run");
695 }
696
697 // --- T034: bounded ring — count + byte caps, oldest dropped (SC-009) ----
698
699 #[test]
700 fn count_cap_drops_oldest_boundary() {
701 let cap = UndoCap::new(3, DEFAULT_UNDO_BYTE_CAP);
702 let mut stack: UndoStack<usize> = UndoStack::with_config(cap, DEFAULT_COALESCE_WINDOW);
703 // 6 discrete edits → 5 boundaries pushed, capped to the newest 3.
704 for i in 0..6 {
705 stack.record(entry(&format!("v{i}"), 0), false);
706 }
707 assert_eq!(stack.len(), 3, "count cap binds at 3 boundaries");
708 // The retained boundaries are the newest ones (v4, v3, v2 — v0/v1 dropped).
709 assert_eq!(stack.undo().unwrap().source_text(), "v4");
710 assert_eq!(stack.undo().unwrap().source_text(), "v3");
711 assert_eq!(stack.undo().unwrap().source_text(), "v2");
712 assert!(!stack.can_undo(), "oldest (v0, v1) were dropped");
713 }
714
715 #[test]
716 fn byte_cap_drops_oldest_boundary_independent_of_count() {
717 // A tight byte cap: each boundary's source_text is 10 bytes; a 25-byte
718 // cap retains at most 2 boundaries by size even though the count cap is high.
719 let cap = UndoCap::new(1000, 25);
720 let mut stack: UndoStack<usize> = UndoStack::with_config(cap, DEFAULT_COALESCE_WINDOW);
721 for i in 0..6 {
722 // Exactly 10 bytes each: "aaaaaaaaaN".
723 stack.record(entry(&format!("aaaaaaaaa{i}"), 0), false);
724 }
725 assert!(stack.retained_bytes() <= 25, "byte cap binds memory");
726 assert!(
727 stack.len() <= 2,
728 "size cap retains at most 2 ten-byte units"
729 );
730 }
731
732 #[test]
733 fn byte_cap_retains_at_least_one_oversize_boundary() {
734 // One snapshot alone exceeds the byte cap; we still retain it so the last
735 // edit is undoable (never drop to zero on a single oversize unit).
736 let cap = UndoCap::new(1000, 4);
737 let mut stack: UndoStack<usize> = UndoStack::with_config(cap, DEFAULT_COALESCE_WINDOW);
738 stack.record(entry("aaaaaaaaaa", 0), false); // 10 bytes (seed, no boundary)
739 stack.record(entry("bbbbbbbbbb", 0), false); // pushes the 10-byte boundary
740 assert_eq!(stack.len(), 1, "the single oversize boundary is retained");
741 assert_eq!(stack.undo().unwrap().source_text(), "aaaaaaaaaa");
742 }
743
744 #[test]
745 fn misconfigured_zero_cap_falls_back_to_default() {
746 // A zero cap must never mean "unbounded": UndoCap::new reverts to default.
747 let cap = UndoCap::new(0, 0);
748 let stack: UndoStack<usize> = UndoStack::with_config(cap, DEFAULT_COALESCE_WINDOW);
749 assert_eq!(stack.cap().max_count, DEFAULT_UNDO_COUNT_CAP);
750 assert_eq!(stack.cap().max_bytes, DEFAULT_UNDO_BYTE_CAP);
751 }
752
753 #[test]
754 fn total_bytes_tracks_full_footprint_and_stays_bounded() {
755 let cap = UndoCap::new(3, DEFAULT_UNDO_BYTE_CAP);
756 let mut stack: UndoStack<usize> = UndoStack::with_config(cap, DEFAULT_COALESCE_WINDOW);
757 for i in 0..10 {
758 stack.record(entry(&format!("value-{i:03}"), 0), false);
759 }
760 // The undo ring is bounded to 3 boundaries regardless of session length.
761 assert_eq!(stack.len(), 3);
762 // total_bytes counts ring + redo + current; after the edits redo is empty.
763 let bound = 4 * 9; // (3 ring + 1 current) entries of 9 bytes "value-00N"
764 assert!(stack.total_bytes() <= bound);
765 }
766}