1use std::collections::HashSet;
2
3use ratatui::widgets::ListState;
4
5use crate::app::SnippetFormBaseline;
6use crate::app::forms::{SnippetForm, SnippetOutputState, SnippetParamFormState};
7use crate::snippet::{Snippet, SnippetStore};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SnippetHostPickPurpose {
14 #[default]
16 Run,
17 EditDefault,
20}
21
22#[derive(Default)]
26pub struct SnippetHostPick {
27 pub purpose: SnippetHostPickPurpose,
29 pub selected: HashSet<String>,
32 pub list_state: ListState,
34 pub query: String,
36 pub filtering: bool,
39}
40
41impl SnippetHostPick {
42 pub fn reset(&mut self) {
45 self.selected.clear();
46 self.query.clear();
47 self.filtering = false;
48 self.list_state.select(Some(0));
49 self.purpose = SnippetHostPickPurpose::Run;
50 }
51}
52
53pub struct SnippetState {
58 pub(in crate::app) store: SnippetStore,
59 pub(in crate::app) form: SnippetForm,
60 pub(in crate::app) pending: Option<(Snippet, Vec<String>)>,
61 pub(in crate::app) output: Option<SnippetOutputState>,
62 pub(in crate::app) param_form: Option<SnippetParamFormState>,
63 pub(in crate::app) pending_terminal: bool,
64 pub(in crate::app) form_baseline: Option<SnippetFormBaseline>,
65 pub(in crate::app) pending_delete: Option<usize>,
66 pub(in crate::app) flow_targets: Vec<String>,
71 pub(in crate::app) form_editing: Option<usize>,
75 pub(in crate::app) param_snippet: Option<Snippet>,
77 pub(in crate::app) output_snippet_name: Option<String>,
79 pub(in crate::app) list_state: ListState,
83 pub(in crate::app) view_mode: crate::app::ViewMode,
86 pub(in crate::app) host_pick: SnippetHostPick,
88 pub(in crate::app) flow_snippet: Option<Snippet>,
91 pub(in crate::app) flow_terminal: bool,
94 pub(in crate::app) form_return_to_tab: bool,
98 pub(in crate::app) runs: crate::snippet_runs::SnippetRunLog,
101}
102
103impl Default for SnippetState {
104 fn default() -> Self {
105 Self {
106 store: SnippetStore::default(),
107 form: SnippetForm::new(),
108 pending: None,
109 output: None,
110 param_form: None,
111 pending_terminal: false,
112 form_baseline: None,
113 pending_delete: None,
114 flow_targets: Vec::new(),
115 form_editing: None,
116 param_snippet: None,
117 output_snippet_name: None,
118 list_state: ListState::default(),
119 view_mode: crate::app::ViewMode::Detailed,
120 host_pick: SnippetHostPick::default(),
121 flow_snippet: None,
122 flow_terminal: false,
123 form_return_to_tab: false,
124 runs: crate::snippet_runs::SnippetRunLog::default(),
125 }
126 }
127}
128
129impl SnippetState {
130 pub fn store(&self) -> &SnippetStore {
131 &self.store
132 }
133
134 pub fn store_mut(&mut self) -> &mut SnippetStore {
135 &mut self.store
136 }
137
138 pub fn form(&self) -> &SnippetForm {
139 &self.form
140 }
141
142 pub fn form_mut(&mut self) -> &mut SnippetForm {
143 &mut self.form
144 }
145
146 pub fn output(&self) -> Option<&SnippetOutputState> {
147 self.output.as_ref()
148 }
149
150 pub fn output_mut(&mut self) -> Option<&mut SnippetOutputState> {
151 self.output.as_mut()
152 }
153
154 pub fn set_output(&mut self, output: Option<SnippetOutputState>) {
155 self.output = output;
156 }
157
158 pub fn take_output(&mut self) -> Option<SnippetOutputState> {
159 self.output.take()
160 }
161
162 pub fn param_form(&self) -> Option<&SnippetParamFormState> {
163 self.param_form.as_ref()
164 }
165
166 pub fn param_form_mut(&mut self) -> Option<&mut SnippetParamFormState> {
167 self.param_form.as_mut()
168 }
169
170 pub fn set_param_form(&mut self, param_form: Option<SnippetParamFormState>) {
171 self.param_form = param_form;
172 }
173
174 pub fn pending_delete(&self) -> Option<usize> {
175 self.pending_delete
176 }
177
178 pub fn take_pending_delete(&mut self) -> Option<usize> {
179 self.pending_delete.take()
180 }
181
182 pub fn pending(&self) -> Option<&(Snippet, Vec<String>)> {
183 self.pending.as_ref()
184 }
185
186 pub fn take_pending(&mut self) -> Option<(Snippet, Vec<String>)> {
187 self.pending.take()
188 }
189
190 pub fn set_pending(&mut self, value: Option<(Snippet, Vec<String>)>) {
191 self.pending = value;
192 }
193
194 pub fn flow_targets(&self) -> &[String] {
195 &self.flow_targets
196 }
197
198 pub fn set_flow_targets(&mut self, targets: Vec<String>) {
199 self.flow_targets = targets;
200 }
201
202 pub fn clear_flow_targets(&mut self) {
203 self.flow_targets.clear();
204 }
205
206 pub fn form_editing(&self) -> Option<usize> {
207 self.form_editing
208 }
209
210 pub fn set_form_editing(&mut self, editing: Option<usize>) {
211 self.form_editing = editing;
212 }
213
214 pub fn param_snippet(&self) -> Option<&Snippet> {
215 self.param_snippet.as_ref()
216 }
217
218 pub fn set_param_snippet(&mut self, snippet: Option<Snippet>) {
219 self.param_snippet = snippet;
220 }
221
222 pub fn output_snippet_name(&self) -> Option<&str> {
223 self.output_snippet_name.as_deref()
224 }
225
226 pub fn set_output_snippet_name(&mut self, name: Option<String>) {
227 self.output_snippet_name = name;
228 }
229
230 pub fn pending_terminal(&self) -> bool {
231 self.pending_terminal
232 }
233
234 pub fn set_pending_terminal(&mut self, value: bool) {
235 self.pending_terminal = value;
236 }
237
238 pub fn form_baseline(&self) -> Option<&SnippetFormBaseline> {
239 self.form_baseline.as_ref()
240 }
241
242 pub fn form_is_dirty(&self) -> bool {
244 match &self.form_baseline {
245 Some(b) => {
246 self.form.name != b.name
247 || self.form.command != b.command
248 || self.form.description != b.description
249 || self.form.interactive != b.interactive
250 || self.form.default_hosts != b.default_hosts
251 }
252 None => false,
253 }
254 }
255
256 pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
257 self.form_baseline = baseline;
258 }
259
260 pub fn with_store_loaded(paths: Option<&crate::runtime::env::Paths>) -> Self {
262 Self {
263 store: crate::snippet::SnippetStore::load(paths),
264 runs: crate::snippet_runs::SnippetRunLog::load(paths),
265 ..Self::default()
266 }
267 }
268
269 pub fn runs(&self) -> &crate::snippet_runs::SnippetRunLog {
270 &self.runs
271 }
272
273 pub fn runs_mut(&mut self) -> &mut crate::snippet_runs::SnippetRunLog {
274 &mut self.runs
275 }
276
277 pub fn request_delete(&mut self, idx: usize) {
280 self.pending_delete = Some(idx);
281 }
282
283 pub fn cancel_delete(&mut self) {
285 self.pending_delete = None;
286 }
287
288 pub fn close_param_form(&mut self) {
293 self.param_form = None;
294 self.pending_terminal = false;
295 }
296
297 pub fn list_state(&self) -> &ListState {
298 &self.list_state
299 }
300
301 pub fn list_state_mut(&mut self) -> &mut ListState {
302 &mut self.list_state
303 }
304
305 pub fn view_mode(&self) -> crate::app::ViewMode {
306 self.view_mode
307 }
308
309 pub fn toggle_view_mode(&mut self) {
310 self.view_mode = match self.view_mode {
311 crate::app::ViewMode::Detailed => crate::app::ViewMode::Compact,
312 crate::app::ViewMode::Compact => crate::app::ViewMode::Detailed,
313 };
314 }
315
316 pub fn host_pick(&self) -> &SnippetHostPick {
317 &self.host_pick
318 }
319
320 pub fn host_pick_mut(&mut self) -> &mut SnippetHostPick {
321 &mut self.host_pick
322 }
323
324 pub fn reset_host_pick(&mut self) {
325 self.host_pick.reset();
326 }
327
328 pub fn flow_snippet(&self) -> Option<&Snippet> {
329 self.flow_snippet.as_ref()
330 }
331
332 pub fn set_flow_snippet(&mut self, snippet: Option<Snippet>) {
333 self.flow_snippet = snippet;
334 }
335
336 pub fn take_flow_snippet(&mut self) -> Option<Snippet> {
337 self.flow_snippet.take()
338 }
339
340 pub fn flow_terminal(&self) -> bool {
341 self.flow_terminal
342 }
343
344 pub fn set_flow_terminal(&mut self, value: bool) {
345 self.flow_terminal = value;
346 }
347
348 pub fn form_return_to_tab(&self) -> bool {
349 self.form_return_to_tab
350 }
351
352 pub fn set_form_return_to_tab(&mut self, value: bool) {
353 self.form_return_to_tab = value;
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn default_is_empty() {
363 let s = SnippetState::default();
364 assert!(s.pending.is_none());
365 assert!(s.output.is_none());
366 assert!(s.param_form.is_none());
367 assert!(!s.pending_terminal);
368 assert!(s.form_baseline.is_none());
369 assert!(s.pending_delete.is_none());
370 }
371
372 #[test]
373 fn request_delete_sets_pending_delete_to_some_idx() {
374 let mut s = SnippetState::default();
375 s.request_delete(3);
376 assert_eq!(s.pending_delete, Some(3));
377 }
378
379 #[test]
380 fn cancel_delete_clears_pending_delete() {
381 let mut s = SnippetState {
382 pending_delete: Some(2),
383 ..Default::default()
384 };
385 s.cancel_delete();
386 assert!(s.pending_delete.is_none());
387 }
388
389 #[test]
390 fn request_delete_overwrites_existing_pending() {
391 let mut s = SnippetState {
392 pending_delete: Some(1),
393 ..Default::default()
394 };
395 s.request_delete(7);
396 assert_eq!(s.pending_delete, Some(7));
397 }
398
399 #[test]
400 fn close_param_form_clears_param_form_and_pending_terminal() {
401 let mut s = SnippetState {
402 param_form: Some(SnippetParamFormState::new(&[])),
403 pending_terminal: true,
404 ..Default::default()
405 };
406 s.close_param_form();
407 assert!(s.param_form.is_none());
408 assert!(!s.pending_terminal);
409 }
410
411 #[test]
412 fn close_param_form_preserves_pending_output_and_store() {
413 use crate::snippet::Snippet;
414 let mut s = SnippetState {
415 param_form: Some(SnippetParamFormState::new(&[])),
416 pending_terminal: true,
417 pending: Some((
418 Snippet {
419 name: "ls".into(),
420 command: "ls -la".into(),
421 description: String::new(),
422 interactive: false,
423 },
424 vec!["host-a".into()],
425 )),
426 ..Default::default()
427 };
428
429 s.close_param_form();
430
431 assert!(
432 s.pending.is_some(),
433 "pending stays for the consumer to read"
434 );
435 assert!(s.pending_delete.is_none());
436 }
437
438 #[test]
439 fn close_param_form_is_idempotent_when_already_none() {
440 let mut s = SnippetState::default();
441 s.close_param_form();
442 s.close_param_form();
443 assert!(s.param_form.is_none());
444 assert!(!s.pending_terminal);
445 }
446
447 fn state_matching_baseline() -> SnippetState {
448 let mut s = SnippetState::default();
449 s.form.name = "deploy".into();
450 s.form.command = "make deploy".into();
451 s.form.description = "ship it".into();
452 s.set_form_baseline(Some(SnippetFormBaseline {
453 name: "deploy".into(),
454 command: "make deploy".into(),
455 description: "ship it".into(),
456 interactive: false,
457 default_hosts: Vec::new(),
458 }));
459 s
460 }
461
462 #[test]
463 fn form_is_dirty_is_false_without_a_baseline() {
464 let mut s = SnippetState::default();
465 s.form.name = "edited".into();
466 assert!(!s.form_is_dirty());
467 }
468
469 #[test]
470 fn form_is_dirty_is_false_when_form_equals_baseline() {
471 assert!(!state_matching_baseline().form_is_dirty());
472 }
473
474 fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
475 let mut s = state_matching_baseline();
476 mutate(&mut s.form);
477 assert!(s.form_is_dirty(), "a change in {field} must read dirty");
478 }
479
480 #[test]
481 fn form_is_dirty_detects_a_change_in_each_field() {
482 assert_field_change_is_dirty("name", |f| f.name.push('x'));
483 assert_field_change_is_dirty("command", |f| f.command.push('x'));
484 assert_field_change_is_dirty("description", |f| f.description.push('x'));
485 assert_field_change_is_dirty("interactive", |f| f.interactive = !f.interactive);
486 }
487
488 #[test]
489 fn default_view_mode_is_detailed() {
490 let s = SnippetState::default();
491 assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
492 }
493
494 #[test]
495 fn toggle_view_mode_flips_between_detailed_and_compact() {
496 let mut s = SnippetState::default();
497 s.toggle_view_mode();
498 assert_eq!(s.view_mode(), crate::app::ViewMode::Compact);
499 s.toggle_view_mode();
500 assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
501 }
502
503 #[test]
504 fn reset_host_pick_clears_selection_and_resets_cursor() {
505 let mut s = SnippetState::default();
506 s.host_pick_mut().selected.insert("h1".into());
507 s.host_pick_mut().list_state.select(Some(4));
508 s.reset_host_pick();
509 assert!(s.host_pick().selected.is_empty());
510 assert_eq!(s.host_pick().list_state.selected(), Some(0));
511 }
512
513 #[test]
514 fn flow_snippet_set_and_take_round_trips() {
515 let mut s = SnippetState::default();
516 assert!(s.flow_snippet().is_none());
517 s.set_flow_snippet(Some(Snippet {
518 name: "deploy".into(),
519 command: "make deploy".into(),
520 description: String::new(),
521 interactive: false,
522 }));
523 assert_eq!(s.flow_snippet().map(|s| s.name.as_str()), Some("deploy"));
524 let taken = s.take_flow_snippet();
525 assert_eq!(taken.map(|s| s.name), Some("deploy".to_string()));
526 assert!(s.flow_snippet().is_none());
527 }
528
529 #[test]
530 fn flow_terminal_defaults_false_and_sets() {
531 let mut s = SnippetState::default();
532 assert!(!s.flow_terminal());
533 s.set_flow_terminal(true);
534 assert!(s.flow_terminal());
535 }
536}