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.default_hosts != b.default_hosts
250 }
251 None => false,
252 }
253 }
254
255 pub fn set_form_baseline(&mut self, baseline: Option<SnippetFormBaseline>) {
256 self.form_baseline = baseline;
257 }
258
259 pub fn with_store_loaded(paths: Option<&crate::runtime::env::Paths>) -> Self {
261 Self {
262 store: crate::snippet::SnippetStore::load(paths),
263 runs: crate::snippet_runs::SnippetRunLog::load(paths),
264 ..Self::default()
265 }
266 }
267
268 pub fn runs(&self) -> &crate::snippet_runs::SnippetRunLog {
269 &self.runs
270 }
271
272 pub fn runs_mut(&mut self) -> &mut crate::snippet_runs::SnippetRunLog {
273 &mut self.runs
274 }
275
276 pub fn request_delete(&mut self, idx: usize) {
279 self.pending_delete = Some(idx);
280 }
281
282 pub fn cancel_delete(&mut self) {
284 self.pending_delete = None;
285 }
286
287 pub fn close_param_form(&mut self) {
292 self.param_form = None;
293 self.pending_terminal = false;
294 }
295
296 pub fn list_state(&self) -> &ListState {
297 &self.list_state
298 }
299
300 pub fn list_state_mut(&mut self) -> &mut ListState {
301 &mut self.list_state
302 }
303
304 pub fn view_mode(&self) -> crate::app::ViewMode {
305 self.view_mode
306 }
307
308 pub fn toggle_view_mode(&mut self) {
309 self.view_mode = match self.view_mode {
310 crate::app::ViewMode::Detailed => crate::app::ViewMode::Compact,
311 crate::app::ViewMode::Compact => crate::app::ViewMode::Detailed,
312 };
313 }
314
315 pub fn host_pick(&self) -> &SnippetHostPick {
316 &self.host_pick
317 }
318
319 pub fn host_pick_mut(&mut self) -> &mut SnippetHostPick {
320 &mut self.host_pick
321 }
322
323 pub fn reset_host_pick(&mut self) {
324 self.host_pick.reset();
325 }
326
327 pub fn flow_snippet(&self) -> Option<&Snippet> {
328 self.flow_snippet.as_ref()
329 }
330
331 pub fn set_flow_snippet(&mut self, snippet: Option<Snippet>) {
332 self.flow_snippet = snippet;
333 }
334
335 pub fn take_flow_snippet(&mut self) -> Option<Snippet> {
336 self.flow_snippet.take()
337 }
338
339 pub fn flow_terminal(&self) -> bool {
340 self.flow_terminal
341 }
342
343 pub fn set_flow_terminal(&mut self, value: bool) {
344 self.flow_terminal = value;
345 }
346
347 pub fn form_return_to_tab(&self) -> bool {
348 self.form_return_to_tab
349 }
350
351 pub fn set_form_return_to_tab(&mut self, value: bool) {
352 self.form_return_to_tab = value;
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn default_is_empty() {
362 let s = SnippetState::default();
363 assert!(s.pending.is_none());
364 assert!(s.output.is_none());
365 assert!(s.param_form.is_none());
366 assert!(!s.pending_terminal);
367 assert!(s.form_baseline.is_none());
368 assert!(s.pending_delete.is_none());
369 }
370
371 #[test]
372 fn request_delete_sets_pending_delete_to_some_idx() {
373 let mut s = SnippetState::default();
374 s.request_delete(3);
375 assert_eq!(s.pending_delete, Some(3));
376 }
377
378 #[test]
379 fn cancel_delete_clears_pending_delete() {
380 let mut s = SnippetState {
381 pending_delete: Some(2),
382 ..Default::default()
383 };
384 s.cancel_delete();
385 assert!(s.pending_delete.is_none());
386 }
387
388 #[test]
389 fn request_delete_overwrites_existing_pending() {
390 let mut s = SnippetState {
391 pending_delete: Some(1),
392 ..Default::default()
393 };
394 s.request_delete(7);
395 assert_eq!(s.pending_delete, Some(7));
396 }
397
398 #[test]
399 fn close_param_form_clears_param_form_and_pending_terminal() {
400 let mut s = SnippetState {
401 param_form: Some(SnippetParamFormState::new(&[])),
402 pending_terminal: true,
403 ..Default::default()
404 };
405 s.close_param_form();
406 assert!(s.param_form.is_none());
407 assert!(!s.pending_terminal);
408 }
409
410 #[test]
411 fn close_param_form_preserves_pending_output_and_store() {
412 use crate::snippet::Snippet;
413 let mut s = SnippetState {
414 param_form: Some(SnippetParamFormState::new(&[])),
415 pending_terminal: true,
416 pending: Some((
417 Snippet {
418 name: "ls".into(),
419 command: "ls -la".into(),
420 description: String::new(),
421 },
422 vec!["host-a".into()],
423 )),
424 ..Default::default()
425 };
426
427 s.close_param_form();
428
429 assert!(
430 s.pending.is_some(),
431 "pending stays for the consumer to read"
432 );
433 assert!(s.pending_delete.is_none());
434 }
435
436 #[test]
437 fn close_param_form_is_idempotent_when_already_none() {
438 let mut s = SnippetState::default();
439 s.close_param_form();
440 s.close_param_form();
441 assert!(s.param_form.is_none());
442 assert!(!s.pending_terminal);
443 }
444
445 fn state_matching_baseline() -> SnippetState {
446 let mut s = SnippetState::default();
447 s.form.name = "deploy".into();
448 s.form.command = "make deploy".into();
449 s.form.description = "ship it".into();
450 s.set_form_baseline(Some(SnippetFormBaseline {
451 name: "deploy".into(),
452 command: "make deploy".into(),
453 description: "ship it".into(),
454 default_hosts: Vec::new(),
455 }));
456 s
457 }
458
459 #[test]
460 fn form_is_dirty_is_false_without_a_baseline() {
461 let mut s = SnippetState::default();
462 s.form.name = "edited".into();
463 assert!(!s.form_is_dirty());
464 }
465
466 #[test]
467 fn form_is_dirty_is_false_when_form_equals_baseline() {
468 assert!(!state_matching_baseline().form_is_dirty());
469 }
470
471 fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut SnippetForm)) {
472 let mut s = state_matching_baseline();
473 mutate(&mut s.form);
474 assert!(s.form_is_dirty(), "a change in {field} must read dirty");
475 }
476
477 #[test]
478 fn form_is_dirty_detects_a_change_in_each_field() {
479 assert_field_change_is_dirty("name", |f| f.name.push('x'));
480 assert_field_change_is_dirty("command", |f| f.command.push('x'));
481 assert_field_change_is_dirty("description", |f| f.description.push('x'));
482 }
483
484 #[test]
485 fn default_view_mode_is_detailed() {
486 let s = SnippetState::default();
487 assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
488 }
489
490 #[test]
491 fn toggle_view_mode_flips_between_detailed_and_compact() {
492 let mut s = SnippetState::default();
493 s.toggle_view_mode();
494 assert_eq!(s.view_mode(), crate::app::ViewMode::Compact);
495 s.toggle_view_mode();
496 assert_eq!(s.view_mode(), crate::app::ViewMode::Detailed);
497 }
498
499 #[test]
500 fn reset_host_pick_clears_selection_and_resets_cursor() {
501 let mut s = SnippetState::default();
502 s.host_pick_mut().selected.insert("h1".into());
503 s.host_pick_mut().list_state.select(Some(4));
504 s.reset_host_pick();
505 assert!(s.host_pick().selected.is_empty());
506 assert_eq!(s.host_pick().list_state.selected(), Some(0));
507 }
508
509 #[test]
510 fn flow_snippet_set_and_take_round_trips() {
511 let mut s = SnippetState::default();
512 assert!(s.flow_snippet().is_none());
513 s.set_flow_snippet(Some(Snippet {
514 name: "deploy".into(),
515 command: "make deploy".into(),
516 description: String::new(),
517 }));
518 assert_eq!(s.flow_snippet().map(|s| s.name.as_str()), Some("deploy"));
519 let taken = s.take_flow_snippet();
520 assert_eq!(taken.map(|s| s.name), Some("deploy".to_string()));
521 assert!(s.flow_snippet().is_none());
522 }
523
524 #[test]
525 fn flow_terminal_defaults_false_and_sets() {
526 let mut s = SnippetState::default();
527 assert!(!s.flow_terminal());
528 s.set_flow_terminal(true);
529 assert!(s.flow_terminal());
530 }
531}