edit_session/lib.rs
1//! A local draft of a value, independent of rendering and the application's undo engine.
2//!
3//! The host validates proposals and owns all document mutations. Dropping a session cancels
4//! only its local draft. Store small parameters here, not a full document per pointer event.
5//! A context must include stable document/target identity and a revision that changes on
6//! every relevant edit (including undo/redo). Comparing values alone cannot detect ABA edits.
7//! Values must be independent snapshots: `Clone` alone does not guarantee isolation if a
8//! value shares mutable storage with the host. Equality must cover all relevant fields.
9#![no_std]
10#![forbid(unsafe_code)]
11
12/// Why a draft cannot be committed to the current host state.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum FinishError {
15 /// The most recent proposal was rejected. Correct it before finishing.
16 InvalidProposal,
17 /// Document, target, selection or relevant revision has changed.
18 ContextChanged,
19 /// The live value changed without the context being updated.
20 ValueChanged,
21}
22
23impl core::fmt::Display for FinishError {
24 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
25 formatter.write_str(match self {
26 Self::InvalidProposal => "the latest proposal is invalid",
27 Self::ContextChanged => "the edit context has changed",
28 Self::ValueChanged => "the live value has changed",
29 })
30 }
31}
32
33impl core::error::Error for FinishError {}
34
35/// A proposed change. The caller must apply it through its existing command engine.
36#[derive(Debug, PartialEq, Eq)]
37pub struct Change<T> {
38 /// The snapshot captured when the session began.
39 pub before: T,
40 /// The last accepted proposal, to be applied by the host.
41 pub after: T,
42}
43
44/// A single-use edit. It never writes to a document or records history itself.
45///
46/// This type intentionally does not implement `Clone`. `finish` consumes it, so the same
47/// session cannot accidentally emit two commits. A failed finish also consumes the draft;
48/// call `check` first if invalid input should remain editable in the UI.
49#[must_use = "a draft must be retained, finished, or deliberately cancelled"]
50#[derive(Debug)]
51pub struct EditSession<T, C> {
52 original: T,
53 preview: T,
54 context: C,
55 valid: bool,
56}
57
58impl<T: Clone, C> EditSession<T, C> {
59 /// Begin from a value already validated by the application.
60 ///
61 /// Cloning `original` must produce an independently editable snapshot. Shared mutable
62 /// pointers such as `Rc<RefCell<_>>` can let edits change the original or live document
63 /// and defeat cancellation/conflict checks. Use owned fields or immutable shared data.
64 /// The context must also remain a stable snapshot until the session ends.
65 pub fn new(original: T, context: C) -> Self {
66 Self {
67 preview: original.clone(),
68 original,
69 context,
70 valid: true,
71 }
72 }
73
74 /// Restore the original preview and clear invalid input, keeping the draft open.
75 ///
76 /// The original value and context are retained: this does not rebase a stale draft
77 /// or change the live document. The host must refresh its displayed preview itself.
78 pub fn reset(&mut self) {
79 self.preview = self.original.clone();
80 self.valid = true;
81 }
82}
83
84impl<T, C> EditSession<T, C> {
85 /// The validated value captured when editing began.
86 pub fn original(&self) -> &T {
87 &self.original
88 }
89
90 /// Last valid preview, even when a later incomplete/invalid input is present.
91 pub fn preview(&self) -> &T {
92 &self.preview
93 }
94
95 /// The host context captured when editing began; never changed by this session.
96 pub fn context(&self) -> &C {
97 &self.context
98 }
99
100 /// Whether the latest proposal was accepted. This does not check live host state.
101 pub fn is_valid(&self) -> bool {
102 self.valid
103 }
104
105 /// Accept a host-validated proposal. Errors invalidate commit without replacing the
106 /// last valid preview. Units, mixed values, finite checks and bounds belong to the host.
107 pub fn propose<E>(&mut self, proposal: Result<T, E>) -> Result<(), E> {
108 match proposal {
109 Ok(value) => {
110 self.preview = value;
111 self.valid = true;
112 Ok(())
113 }
114 Err(error) => {
115 self.valid = false;
116 Err(error)
117 }
118 }
119 }
120
121 /// Discard the local draft. No rollback commands or history entries are generated.
122 pub fn cancel(self) {}
123}
124
125impl<T: PartialEq, C: PartialEq> EditSession<T, C> {
126 /// Check whether the draft still belongs to the current host state, independently
127 /// of invalid input. Useful when deciding whether an open editor must be dismissed.
128 ///
129 /// This never returns `InvalidProposal` and does not authorize a commit. Use `check`
130 /// or `finish` immediately before applying on the host's serialized command path.
131 /// Context conflicts take precedence over value conflicts. No lock is acquired.
132 pub fn check_live(&self, live: &T, context: &C) -> Result<(), FinishError> {
133 if *context != self.context {
134 Err(FinishError::ContextChanged)
135 } else if *live != self.original {
136 Err(FinishError::ValueChanged)
137 } else {
138 Ok(())
139 }
140 }
141
142 /// Check live host state and proposal validity immediately before applying on the
143 /// host's serialized command path. Live conflicts take precedence over invalid input.
144 /// This comparison is not a lock and is not a distributed transaction.
145 pub fn check(&self, live: &T, context: &C) -> Result<(), FinishError> {
146 self.check_live(live, context)?;
147 if !self.valid {
148 Err(FinishError::InvalidProposal)
149 } else {
150 Ok(())
151 }
152 }
153
154 /// Emit at most one change; a no-op emits none, preserving the host's redo stack.
155 /// The host still validates and applies the command and handles execution failure.
156 pub fn finish(self, live: &T, context: &C) -> Result<Option<Change<T>>, FinishError> {
157 self.check(live, context)?;
158 if self.original == self.preview {
159 Ok(None)
160 } else {
161 Ok(Some(Change {
162 before: self.original,
163 after: self.preview,
164 }))
165 }
166 }
167}
168
169/// A session cannot be finished twice.
170///
171/// ```compile_fail
172/// use edit_session::EditSession;
173/// let draft = EditSession::new(100, ("document", 7));
174/// let _ = draft.finish(&100, &("document", 7));
175/// let _ = draft.finish(&100, &("document", 7));
176/// ```
177const _: () = ();