Skip to main content

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 _: () = ();