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}
19
20impl Default for SnippetState {
21    fn default() -> Self {
22        Self {
23            store: SnippetStore::default(),
24            form: SnippetForm::new(),
25            pending: None,
26            output: None,
27            param_form: None,
28            pending_terminal: false,
29            form_baseline: None,
30            pending_delete: None,
31        }
32    }
33}
34
35impl SnippetState {
36    pub fn store(&self) -> &SnippetStore {
37        &self.store
38    }
39
40    pub fn store_mut(&mut self) -> &mut SnippetStore {
41        &mut self.store
42    }
43
44    pub fn form(&self) -> &SnippetForm {
45        &self.form
46    }
47
48    pub fn form_mut(&mut self) -> &mut SnippetForm {
49        &mut self.form
50    }
51
52    pub fn output(&self) -> Option<&SnippetOutputState> {
53        self.output.as_ref()
54    }
55
56    pub fn output_mut(&mut self) -> Option<&mut SnippetOutputState> {
57        self.output.as_mut()
58    }
59
60    pub fn set_output(&mut self, output: Option<SnippetOutputState>) {
61        self.output = output;
62    }
63
64    pub fn take_output(&mut self) -> Option<SnippetOutputState> {
65        self.output.take()
66    }
67
68    pub fn param_form(&self) -> Option<&SnippetParamFormState> {
69        self.param_form.as_ref()
70    }
71
72    pub fn param_form_mut(&mut self) -> Option<&mut SnippetParamFormState> {
73        self.param_form.as_mut()
74    }
75
76    pub fn set_param_form(&mut self, param_form: Option<SnippetParamFormState>) {
77        self.param_form = param_form;
78    }
79
80    pub fn pending_delete(&self) -> Option<usize> {
81        self.pending_delete
82    }
83
84    pub fn take_pending_delete(&mut self) -> Option<usize> {
85        self.pending_delete.take()
86    }
87
88    pub fn pending(&self) -> Option<&(Snippet, Vec<String>)> {
89        self.pending.as_ref()
90    }
91
92    pub fn take_pending(&mut self) -> Option<(Snippet, Vec<String>)> {
93        self.pending.take()
94    }
95
96    pub fn set_pending(&mut self, value: Option<(Snippet, Vec<String>)>) {
97        self.pending = value;
98    }
99
100    pub fn pending_terminal(&self) -> bool {
101        self.pending_terminal
102    }
103
104    pub fn set_pending_terminal(&mut self, value: bool) {
105        self.pending_terminal = value;
106    }
107
108    pub fn form_baseline(&self) -> Option<&SnippetFormBaseline> {
109        self.form_baseline.as_ref()
110    }
111
112    /// True if the snippet form differs from its captured baseline.
113    pub fn form_is_dirty(&self) -> bool {
114        match &self.form_baseline {
115            Some(b) => {
116                self.form.name != b.name
117                    || self.form.command != b.command
118                    || self.form.description != b.description
119            }
120            None => false,
121        }
122    }
123
124    pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
125        self.form_baseline = baseline;
126    }
127
128    /// Construct with snippet store loaded from disk.
129    pub fn with_store_loaded() -> Self {
130        Self {
131            store: crate::snippet::SnippetStore::load(),
132            ..Self::default()
133        }
134    }
135
136    /// Open a delete confirmation for the snippet at `idx`. The renderer
137    /// reads `pending_delete` to draw the confirm overlay.
138    pub fn request_delete(&mut self, idx: usize) {
139        self.pending_delete = Some(idx);
140    }
141
142    /// Dismiss a pending delete confirmation. Idempotent.
143    pub fn cancel_delete(&mut self) {
144        self.pending_delete = None;
145    }
146
147    /// Close the parameter substitution form. Clears the form state and
148    /// the terminal-submit flag that decide whether the next Enter sends
149    /// the resolved command to the foreground terminal or to background
150    /// output capture. Idempotent.
151    pub fn close_param_form(&mut self) {
152        self.param_form = None;
153        self.pending_terminal = false;
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn default_is_empty() {
163        let s = SnippetState::default();
164        assert!(s.pending.is_none());
165        assert!(s.output.is_none());
166        assert!(s.param_form.is_none());
167        assert!(!s.pending_terminal);
168        assert!(s.form_baseline.is_none());
169        assert!(s.pending_delete.is_none());
170    }
171
172    #[test]
173    fn request_delete_sets_pending_delete_to_some_idx() {
174        let mut s = SnippetState::default();
175        s.request_delete(3);
176        assert_eq!(s.pending_delete, Some(3));
177    }
178
179    #[test]
180    fn cancel_delete_clears_pending_delete() {
181        let mut s = SnippetState {
182            pending_delete: Some(2),
183            ..Default::default()
184        };
185        s.cancel_delete();
186        assert!(s.pending_delete.is_none());
187    }
188
189    #[test]
190    fn request_delete_overwrites_existing_pending() {
191        let mut s = SnippetState {
192            pending_delete: Some(1),
193            ..Default::default()
194        };
195        s.request_delete(7);
196        assert_eq!(s.pending_delete, Some(7));
197    }
198
199    #[test]
200    fn close_param_form_clears_param_form_and_pending_terminal() {
201        let mut s = SnippetState {
202            param_form: Some(SnippetParamFormState::new(&[])),
203            pending_terminal: true,
204            ..Default::default()
205        };
206        s.close_param_form();
207        assert!(s.param_form.is_none());
208        assert!(!s.pending_terminal);
209    }
210
211    #[test]
212    fn close_param_form_preserves_pending_output_and_store() {
213        use crate::snippet::Snippet;
214        let mut s = SnippetState {
215            param_form: Some(SnippetParamFormState::new(&[])),
216            pending_terminal: true,
217            pending: Some((
218                Snippet {
219                    name: "ls".into(),
220                    command: "ls -la".into(),
221                    description: String::new(),
222                },
223                vec!["host-a".into()],
224            )),
225            ..Default::default()
226        };
227
228        s.close_param_form();
229
230        assert!(
231            s.pending.is_some(),
232            "pending stays for the consumer to read"
233        );
234        assert!(s.pending_delete.is_none());
235    }
236
237    #[test]
238    fn close_param_form_is_idempotent_when_already_none() {
239        let mut s = SnippetState::default();
240        s.close_param_form();
241        s.close_param_form();
242        assert!(s.param_form.is_none());
243        assert!(!s.pending_terminal);
244    }
245
246    fn state_matching_baseline() -> SnippetState {
247        let mut s = SnippetState::default();
248        s.form.name = "deploy".into();
249        s.form.command = "make deploy".into();
250        s.form.description = "ship it".into();
251        s.set_form_baseline(Some(SnippetFormBaseline {
252            name: "deploy".into(),
253            command: "make deploy".into(),
254            description: "ship it".into(),
255        }));
256        s
257    }
258
259    #[test]
260    fn form_is_dirty_is_false_without_a_baseline() {
261        let mut s = SnippetState::default();
262        s.form.name = "edited".into();
263        assert!(!s.form_is_dirty());
264    }
265
266    #[test]
267    fn form_is_dirty_is_false_when_form_equals_baseline() {
268        assert!(!state_matching_baseline().form_is_dirty());
269    }
270
271    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
272        let mut s = state_matching_baseline();
273        mutate(&mut s.form);
274        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
275    }
276
277    #[test]
278    fn form_is_dirty_detects_a_change_in_each_field() {
279        assert_field_change_is_dirty("name", |f| f.name.push('x'));
280        assert_field_change_is_dirty("command", |f| f.command.push('x'));
281        assert_field_change_is_dirty("description", |f| f.description.push('x'));
282    }
283}