Skip to main content

purple_ssh/app/
snippet_state.rs

1use crate::app::SnippetFormBaseline;
2use crate::app::forms::{SnippetForm, SnippetOutputState, SnippetParamFormState};
3use crate::snippet::{Snippet, SnippetStore};
4
5/// Snippet-owned state grouped off the `App` god-struct. Holds the on-disk
6/// snippet store, the edit form, the pending execution payload, the output
7/// screen state, the param form, the terminal-submit flag, the dirty-check
8/// baseline and the pending-delete index. Pure state container.
9pub struct SnippetState {
10    pub(in crate::app) store: SnippetStore,
11    pub(in crate::app) form: SnippetForm,
12    pub(in crate::app) pending: Option<(Snippet, Vec<String>)>,
13    pub(in crate::app) output: Option<SnippetOutputState>,
14    pub(in crate::app) param_form: Option<SnippetParamFormState>,
15    pub(in crate::app) pending_terminal: bool,
16    pub(in crate::app) form_baseline: Option<SnippetFormBaseline>,
17    pub(in crate::app) pending_delete: Option<usize>,
18    /// Target host aliases for the currently-open snippet flow
19    /// (`Screen::SnippetPicker`, `SnippetForm`, `SnippetOutput`,
20    /// `SnippetParamForm`). The Screen variants are data-less; the
21    /// alias list lives here so screen transitions never clone it.
22    pub(in crate::app) flow_targets: Vec<String>,
23    /// `editing` payload for an open `Screen::SnippetForm`. `None`
24    /// when adding a new snippet, `Some(index)` when editing an
25    /// existing one. Set alongside the screen transition.
26    pub(in crate::app) form_editing: Option<usize>,
27    /// Snippet currently displayed by `Screen::SnippetParamForm`.
28    pub(in crate::app) param_snippet: Option<Snippet>,
29    /// Snippet name shown by `Screen::SnippetOutput` in its title.
30    pub(in crate::app) output_snippet_name: Option<String>,
31}
32
33impl Default for SnippetState {
34    fn default() -> Self {
35        Self {
36            store: SnippetStore::default(),
37            form: SnippetForm::new(),
38            pending: None,
39            output: None,
40            param_form: None,
41            pending_terminal: false,
42            form_baseline: None,
43            pending_delete: None,
44            flow_targets: Vec::new(),
45            form_editing: None,
46            param_snippet: None,
47            output_snippet_name: None,
48        }
49    }
50}
51
52impl SnippetState {
53    pub fn store(&self) -> &SnippetStore {
54        &self.store
55    }
56
57    pub fn store_mut(&mut self) -> &mut SnippetStore {
58        &mut self.store
59    }
60
61    pub fn form(&self) -> &SnippetForm {
62        &self.form
63    }
64
65    pub fn form_mut(&mut self) -> &mut SnippetForm {
66        &mut self.form
67    }
68
69    pub fn output(&self) -> Option<&SnippetOutputState> {
70        self.output.as_ref()
71    }
72
73    pub fn output_mut(&mut self) -> Option<&mut SnippetOutputState> {
74        self.output.as_mut()
75    }
76
77    pub fn set_output(&mut self, output: Option<SnippetOutputState>) {
78        self.output = output;
79    }
80
81    pub fn take_output(&mut self) -> Option<SnippetOutputState> {
82        self.output.take()
83    }
84
85    pub fn param_form(&self) -> Option<&SnippetParamFormState> {
86        self.param_form.as_ref()
87    }
88
89    pub fn param_form_mut(&mut self) -> Option<&mut SnippetParamFormState> {
90        self.param_form.as_mut()
91    }
92
93    pub fn set_param_form(&mut self, param_form: Option<SnippetParamFormState>) {
94        self.param_form = param_form;
95    }
96
97    pub fn pending_delete(&self) -> Option<usize> {
98        self.pending_delete
99    }
100
101    pub fn take_pending_delete(&mut self) -> Option<usize> {
102        self.pending_delete.take()
103    }
104
105    pub fn pending(&self) -> Option<&(Snippet, Vec<String>)> {
106        self.pending.as_ref()
107    }
108
109    pub fn take_pending(&mut self) -> Option<(Snippet, Vec<String>)> {
110        self.pending.take()
111    }
112
113    pub fn set_pending(&mut self, value: Option<(Snippet, Vec<String>)>) {
114        self.pending = value;
115    }
116
117    pub fn flow_targets(&self) -> &[String] {
118        &self.flow_targets
119    }
120
121    pub fn set_flow_targets(&mut self, targets: Vec<String>) {
122        self.flow_targets = targets;
123    }
124
125    pub fn clear_flow_targets(&mut self) {
126        self.flow_targets.clear();
127    }
128
129    pub fn form_editing(&self) -> Option<usize> {
130        self.form_editing
131    }
132
133    pub fn set_form_editing(&mut self, editing: Option<usize>) {
134        self.form_editing = editing;
135    }
136
137    pub fn param_snippet(&self) -> Option<&Snippet> {
138        self.param_snippet.as_ref()
139    }
140
141    pub fn set_param_snippet(&mut self, snippet: Option<Snippet>) {
142        self.param_snippet = snippet;
143    }
144
145    pub fn output_snippet_name(&self) -> Option<&str> {
146        self.output_snippet_name.as_deref()
147    }
148
149    pub fn set_output_snippet_name(&mut self, name: Option<String>) {
150        self.output_snippet_name = name;
151    }
152
153    pub fn pending_terminal(&self) -> bool {
154        self.pending_terminal
155    }
156
157    pub fn set_pending_terminal(&mut self, value: bool) {
158        self.pending_terminal = value;
159    }
160
161    pub fn form_baseline(&self) -> Option<&SnippetFormBaseline> {
162        self.form_baseline.as_ref()
163    }
164
165    /// True if the snippet form differs from its captured baseline.
166    pub fn form_is_dirty(&self) -> bool {
167        match &self.form_baseline {
168            Some(b) => {
169                self.form.name != b.name
170                    || self.form.command != b.command
171                    || self.form.description != b.description
172            }
173            None => false,
174        }
175    }
176
177    pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
178        self.form_baseline = baseline;
179    }
180
181    /// Construct with snippet store loaded from disk.
182    pub fn with_store_loaded(paths: Option<&crate::runtime::env::Paths>) -> Self {
183        Self {
184            store: crate::snippet::SnippetStore::load(paths),
185            ..Self::default()
186        }
187    }
188
189    /// Open a delete confirmation for the snippet at `idx`. The renderer
190    /// reads `pending_delete` to draw the confirm overlay.
191    pub fn request_delete(&mut self, idx: usize) {
192        self.pending_delete = Some(idx);
193    }
194
195    /// Dismiss a pending delete confirmation. Idempotent.
196    pub fn cancel_delete(&mut self) {
197        self.pending_delete = None;
198    }
199
200    /// Close the parameter substitution form. Clears the form state and
201    /// the terminal-submit flag that decide whether the next Enter sends
202    /// the resolved command to the foreground terminal or to background
203    /// output capture. Idempotent.
204    pub fn close_param_form(&mut self) {
205        self.param_form = None;
206        self.pending_terminal = false;
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn default_is_empty() {
216        let s = SnippetState::default();
217        assert!(s.pending.is_none());
218        assert!(s.output.is_none());
219        assert!(s.param_form.is_none());
220        assert!(!s.pending_terminal);
221        assert!(s.form_baseline.is_none());
222        assert!(s.pending_delete.is_none());
223    }
224
225    #[test]
226    fn request_delete_sets_pending_delete_to_some_idx() {
227        let mut s = SnippetState::default();
228        s.request_delete(3);
229        assert_eq!(s.pending_delete, Some(3));
230    }
231
232    #[test]
233    fn cancel_delete_clears_pending_delete() {
234        let mut s = SnippetState {
235            pending_delete: Some(2),
236            ..Default::default()
237        };
238        s.cancel_delete();
239        assert!(s.pending_delete.is_none());
240    }
241
242    #[test]
243    fn request_delete_overwrites_existing_pending() {
244        let mut s = SnippetState {
245            pending_delete: Some(1),
246            ..Default::default()
247        };
248        s.request_delete(7);
249        assert_eq!(s.pending_delete, Some(7));
250    }
251
252    #[test]
253    fn close_param_form_clears_param_form_and_pending_terminal() {
254        let mut s = SnippetState {
255            param_form: Some(SnippetParamFormState::new(&[])),
256            pending_terminal: true,
257            ..Default::default()
258        };
259        s.close_param_form();
260        assert!(s.param_form.is_none());
261        assert!(!s.pending_terminal);
262    }
263
264    #[test]
265    fn close_param_form_preserves_pending_output_and_store() {
266        use crate::snippet::Snippet;
267        let mut s = SnippetState {
268            param_form: Some(SnippetParamFormState::new(&[])),
269            pending_terminal: true,
270            pending: Some((
271                Snippet {
272                    name: "ls".into(),
273                    command: "ls -la".into(),
274                    description: String::new(),
275                },
276                vec!["host-a".into()],
277            )),
278            ..Default::default()
279        };
280
281        s.close_param_form();
282
283        assert!(
284            s.pending.is_some(),
285            "pending stays for the consumer to read"
286        );
287        assert!(s.pending_delete.is_none());
288    }
289
290    #[test]
291    fn close_param_form_is_idempotent_when_already_none() {
292        let mut s = SnippetState::default();
293        s.close_param_form();
294        s.close_param_form();
295        assert!(s.param_form.is_none());
296        assert!(!s.pending_terminal);
297    }
298
299    fn state_matching_baseline() -> SnippetState {
300        let mut s = SnippetState::default();
301        s.form.name = "deploy".into();
302        s.form.command = "make deploy".into();
303        s.form.description = "ship it".into();
304        s.set_form_baseline(Some(SnippetFormBaseline {
305            name: "deploy".into(),
306            command: "make deploy".into(),
307            description: "ship it".into(),
308        }));
309        s
310    }
311
312    #[test]
313    fn form_is_dirty_is_false_without_a_baseline() {
314        let mut s = SnippetState::default();
315        s.form.name = "edited".into();
316        assert!(!s.form_is_dirty());
317    }
318
319    #[test]
320    fn form_is_dirty_is_false_when_form_equals_baseline() {
321        assert!(!state_matching_baseline().form_is_dirty());
322    }
323
324    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
325        let mut s = state_matching_baseline();
326        mutate(&mut s.form);
327        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
328    }
329
330    #[test]
331    fn form_is_dirty_detects_a_change_in_each_field() {
332        assert_field_change_is_dirty("name", |f| f.name.push('x'));
333        assert_field_change_is_dirty("command", |f| f.command.push('x'));
334        assert_field_change_is_dirty("description", |f| f.description.push('x'));
335    }
336}