Skip to main content

retry_command/
retry_command.rs

1//! A synchronous host-owned command boundary: retain the draft until the command succeeds.
2//! This small model demonstrates recovery; it is not a PhotoCraft/PdfCraft UI regression.
3use edit_session::{EditSession, FinishError};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6struct Context {
7    document: u64,
8    target: u64,
9    revision: u64,
10}
11
12#[derive(Debug, PartialEq, Eq)]
13enum ConfirmError {
14    Closed,
15    Draft(FinishError),
16    PermissionDenied,
17    RevisionExhausted,
18}
19
20#[derive(Debug, PartialEq, Eq)]
21enum Outcome {
22    Applied,
23    Unchanged,
24}
25
26struct Host {
27    value: String,
28    context: Context,
29    allowed: bool,
30    undo: Vec<String>,
31    redo: Vec<String>,
32}
33
34impl Host {
35    fn new() -> Self {
36        Self {
37            value: "original".into(),
38            context: Context {
39                document: 7,
40                target: 2,
41                revision: 42,
42            },
43            allowed: true,
44            undo: Vec::new(),
45            redo: Vec::new(),
46        }
47    }
48
49    fn apply(&mut self, proposed: &str) -> Result<(), ConfirmError> {
50        // All fallible host checks precede domain/history writes. Real command engines
51        // must provide the same atomic rejection guarantee, or their own rollback.
52        if !self.allowed {
53            return Err(ConfirmError::PermissionDenied);
54        }
55        let next_revision = self
56            .context
57            .revision
58            .checked_add(1)
59            .ok_or(ConfirmError::RevisionExhausted)?;
60        self.undo.push(self.value.clone());
61        self.value = proposed.to_owned();
62        self.redo.clear();
63        self.context.revision = next_revision;
64        Ok(())
65    }
66}
67
68struct Dialog {
69    // Keep the raw field too: an invalid proposal retains only the last valid preview.
70    text: String,
71    draft: Option<EditSession<String, Context>>,
72}
73
74impl Dialog {
75    fn open(host: &Host) -> Self {
76        Self {
77            text: host.value.clone(),
78            draft: Some(EditSession::new(host.value.clone(), host.context)),
79        }
80    }
81
82    fn set_text(&mut self, text: &str) -> Result<(), &'static str> {
83        let draft = self.draft.as_mut().ok_or("dialog is closed")?;
84        self.text = text.to_owned();
85        draft.propose(if text.trim().is_empty() {
86            Err("enter a title")
87        } else {
88            Ok(self.text.clone())
89        })
90    }
91
92    fn confirm(&mut self, host: &mut Host) -> Result<Outcome, ConfirmError> {
93        // This whole method runs on the host's serialized edit path, without an await
94        // or a callback that can change the target between check and command dispatch.
95        let draft = self.draft.as_ref().ok_or(ConfirmError::Closed)?;
96        draft
97            .check(&host.value, &host.context)
98            .map_err(ConfirmError::Draft)?;
99        let outcome = if draft.preview() == draft.original() {
100            Outcome::Unchanged // Do not dispatch a command or clear Redo for a no-op.
101        } else {
102            // Borrow the proposal: finish() would consume it before this fallible call.
103            host.apply(draft.preview())?;
104            Outcome::Applied
105        };
106        self.cancel(); // Close only after a successful command or a checked no-op.
107        Ok(outcome)
108    }
109
110    fn cancel(&mut self) {
111        if let Some(draft) = self.draft.take() {
112            draft.cancel();
113        }
114    }
115}
116
117fn main() -> Result<(), ConfirmError> {
118    let mut host = Host::new();
119    let mut dialog = Dialog::open(&host);
120    assert_eq!(dialog.set_text("edited title"), Ok(()));
121    host.allowed = false;
122    assert_eq!(
123        dialog.confirm(&mut host),
124        Err(ConfirmError::PermissionDenied)
125    );
126    assert_eq!(dialog.text, "edited title");
127    assert!(dialog.draft.is_some());
128    host.allowed = true;
129    assert_eq!(dialog.confirm(&mut host)?, Outcome::Applied);
130    assert_eq!(host.value, "edited title");
131    assert_eq!(host.undo, ["original"]);
132    assert_eq!(dialog.confirm(&mut host), Err(ConfirmError::Closed));
133    println!("Denied command retained the draft; retry applied once and closed the dialog.");
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn denied_edit() -> (Host, Dialog) {
142        let mut host = Host::new();
143        host.redo.push("future".into());
144        let mut dialog = Dialog::open(&host);
145        assert_eq!(dialog.set_text("edited"), Ok(()));
146        host.allowed = false;
147        assert_eq!(
148            dialog.confirm(&mut host),
149            Err(ConfirmError::PermissionDenied)
150        );
151        (host, dialog)
152    }
153
154    #[test]
155    fn rejection_preserves_fields_document_revision_and_history_then_retry_commits_once() {
156        let (mut host, mut dialog) = denied_edit();
157        assert_eq!(host.value, "original");
158        assert_eq!(host.context.revision, 42);
159        assert!(host.undo.is_empty());
160        assert_eq!(host.redo, ["future"]);
161        assert_eq!(dialog.text, "edited");
162        assert!(dialog.draft.is_some());
163        host.allowed = true;
164        assert_eq!(dialog.confirm(&mut host), Ok(Outcome::Applied));
165        assert_eq!(host.value, "edited");
166        assert_eq!(host.context.revision, 43);
167        assert_eq!(host.undo, ["original"]);
168        assert!(host.redo.is_empty());
169        assert!(dialog.draft.is_none());
170    }
171
172    #[test]
173    fn repeated_confirm_cannot_duplicate_the_command() {
174        let (mut host, mut dialog) = denied_edit();
175        host.allowed = true;
176        assert_eq!(dialog.confirm(&mut host), Ok(Outcome::Applied));
177        assert_eq!(dialog.confirm(&mut host), Err(ConfirmError::Closed));
178        assert_eq!(host.undo, ["original"]);
179        assert_eq!(host.context.revision, 43);
180    }
181
182    #[test]
183    fn invalid_correction_keeps_raw_input_and_does_not_apply_last_valid_preview() {
184        let (mut host, mut dialog) = denied_edit();
185        host.allowed = true;
186        assert!(dialog.set_text("  ").is_err());
187        assert_eq!(
188            dialog.confirm(&mut host),
189            Err(ConfirmError::Draft(FinishError::InvalidProposal))
190        );
191        assert_eq!(dialog.text, "  ");
192        assert_eq!(host.value, "original");
193        assert!(host.undo.is_empty());
194        assert_eq!(dialog.set_text("corrected"), Ok(()));
195        assert_eq!(dialog.confirm(&mut host), Ok(Outcome::Applied));
196        assert_eq!(host.value, "corrected");
197    }
198
199    #[test]
200    fn retry_never_retargets_a_different_document_or_target() {
201        for change_document in [true, false] {
202            let (mut host, mut dialog) = denied_edit();
203            host.allowed = true;
204            if change_document {
205                host.context.document += 1;
206            } else {
207                host.context.target += 1;
208            }
209            assert_eq!(
210                dialog.confirm(&mut host),
211                Err(ConfirmError::Draft(FinishError::ContextChanged))
212            );
213            assert_eq!(host.value, "original");
214            assert!(host.undo.is_empty());
215            assert_eq!(host.redo, ["future"]);
216            assert_eq!(dialog.text, "edited");
217        }
218    }
219
220    #[test]
221    fn edit_then_undo_before_retry_is_stale_even_at_the_original_value() {
222        let (mut host, mut dialog) = denied_edit();
223        host.allowed = true;
224        host.context.revision += 2; // A host edit and Undo, back to "original".
225        assert_eq!(
226            dialog.confirm(&mut host),
227            Err(ConfirmError::Draft(FinishError::ContextChanged))
228        );
229        assert_eq!(host.value, "original");
230        assert!(host.undo.is_empty());
231        assert_eq!(host.redo, ["future"]);
232    }
233
234    #[test]
235    fn unversioned_host_value_change_still_blocks_retry() {
236        let (mut host, mut dialog) = denied_edit();
237        host.allowed = true;
238        host.value = "newer value".into();
239        assert_eq!(
240            dialog.confirm(&mut host),
241            Err(ConfirmError::Draft(FinishError::ValueChanged))
242        );
243        assert_eq!(host.value, "newer value");
244        assert!(host.undo.is_empty());
245    }
246
247    #[test]
248    fn noop_skips_the_denied_command_and_preserves_redo() {
249        let (mut host, mut dialog) = denied_edit();
250        assert_eq!(dialog.set_text("original"), Ok(()));
251        assert_eq!(dialog.confirm(&mut host), Ok(Outcome::Unchanged));
252        assert!(dialog.draft.is_none());
253        assert_eq!(host.context.revision, 42);
254        assert!(host.undo.is_empty());
255        assert_eq!(host.redo, ["future"]);
256    }
257
258    #[test]
259    fn cancel_after_rejection_discards_only_the_local_draft() {
260        let (mut host, mut dialog) = denied_edit();
261        dialog.cancel();
262        assert_eq!(dialog.confirm(&mut host), Err(ConfirmError::Closed));
263        assert_eq!(host.value, "original");
264        assert_eq!(host.context.revision, 42);
265        assert!(host.undo.is_empty());
266        assert_eq!(host.redo, ["future"]);
267    }
268}