Skip to main content

purple_ssh/app/
baselines.rs

1//! Form baselines and dirty-state detection. Implements `impl App` continuation
2//! with capture/compare logic for every form kind (host, tunnel, snippet,
3//! provider) plus the mtime helpers that detect external config changes.
4
5use crate::app::App;
6use crate::app::Screen;
7use crate::app::reload_state::{get_mtime, snapshot_include_dir_mtimes, snapshot_include_mtimes};
8use crate::app::{HostForm, SnippetForm, TunnelForm};
9use crate::snippet::Snippet;
10use crate::ssh_config::model::PatternEntry;
11use crate::tunnel::TunnelRule;
12
13/// Baseline snapshot of host form content for dirty-check on Esc.
14#[derive(Clone)]
15pub struct FormBaseline {
16    pub alias: String,
17    pub hostname: String,
18    pub user: String,
19    pub port: String,
20    pub identity_file: String,
21    pub proxy_jump: String,
22    pub askpass: String,
23    pub vault_ssh: String,
24    pub vault_addr: String,
25    pub tags: String,
26}
27
28/// Baseline snapshot of tunnel form content for dirty-check on Esc.
29#[derive(Clone)]
30pub struct TunnelFormBaseline {
31    pub tunnel_type: crate::tunnel::TunnelType,
32    pub bind_port: String,
33    pub remote_host: String,
34    pub remote_port: String,
35    pub bind_address: String,
36}
37
38/// Baseline snapshot of snippet form content for dirty-check on Esc.
39#[derive(Clone)]
40pub struct SnippetFormBaseline {
41    pub name: String,
42    pub command: String,
43    pub description: String,
44}
45
46/// Baseline snapshot of provider form content for dirty-check on Esc.
47#[derive(Clone)]
48pub struct ProviderFormBaseline {
49    pub url: String,
50    pub token: String,
51    pub profile: String,
52    pub project: String,
53    pub compartment: String,
54    pub regions: String,
55    pub alias_prefix: String,
56    pub user: String,
57    pub identity_file: String,
58    pub verify_tls: bool,
59    pub auto_sync: bool,
60    pub vault_role: String,
61    pub vault_addr: String,
62}
63
64impl App {
65    /// Clear form mtime state (call on form cancel or successful submit).
66    pub fn clear_form_mtime(&mut self) {
67        self.conflict.form_mtime = None;
68        self.conflict.form_include_mtimes.clear();
69        self.conflict.form_include_dir_mtimes.clear();
70        self.conflict.provider_form_mtime = None;
71    }
72
73    /// Capture config and Include file mtimes when opening a host form.
74    pub fn capture_form_mtime(&mut self) {
75        self.conflict.form_mtime = get_mtime(&self.reload.config_path);
76        self.conflict.form_include_mtimes = snapshot_include_mtimes(&self.hosts_state.ssh_config);
77        self.conflict.form_include_dir_mtimes =
78            snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
79    }
80
81    /// Capture ~/.purple/providers mtime when opening a provider form.
82    pub fn capture_provider_form_mtime(&mut self) {
83        let path = dirs::home_dir().map(|h| h.join(".purple/providers"));
84        self.conflict.provider_form_mtime = path.as_ref().and_then(|p| get_mtime(p));
85    }
86
87    /// Capture a baseline snapshot of the host form for dirty-check on Esc.
88    pub fn capture_form_baseline(&mut self) {
89        self.forms.host_baseline = Some(FormBaseline {
90            alias: self.forms.host.alias.clone(),
91            hostname: self.forms.host.hostname.clone(),
92            user: self.forms.host.user.clone(),
93            port: self.forms.host.port.clone(),
94            identity_file: self.forms.host.identity_file.clone(),
95            proxy_jump: self.forms.host.proxy_jump.clone(),
96            askpass: self.forms.host.askpass.clone(),
97            vault_ssh: self.forms.host.vault_ssh.clone(),
98            vault_addr: self.forms.host.vault_addr.clone(),
99            tags: self.forms.host.tags.clone(),
100        });
101    }
102
103    /// Check if the host form has been modified since baseline was captured.
104    pub fn host_form_is_dirty(&self) -> bool {
105        match &self.forms.host_baseline {
106            Some(b) => {
107                self.forms.host.alias != b.alias
108                    || self.forms.host.hostname != b.hostname
109                    || self.forms.host.user != b.user
110                    || self.forms.host.port != b.port
111                    || self.forms.host.identity_file != b.identity_file
112                    || self.forms.host.proxy_jump != b.proxy_jump
113                    || self.forms.host.askpass != b.askpass
114                    || self.forms.host.vault_ssh != b.vault_ssh
115                    || self.forms.host.vault_addr != b.vault_addr
116                    || self.forms.host.tags != b.tags
117            }
118            None => false,
119        }
120    }
121
122    /// Tear down host form state and return to the host list. Flush runs
123    /// last because `flush_pending_vault_write` no-ops while a form is open.
124    pub fn close_host_form(&mut self) {
125        self.close_host_form_inner(None);
126    }
127
128    /// Close the host form and select the just-saved host. Use after a
129    /// successful submit.
130    pub fn close_host_form_after_save(&mut self, target_alias: &str) {
131        self.close_host_form_inner(Some(target_alias));
132    }
133
134    fn close_host_form_inner(&mut self, select: Option<&str>) {
135        log::debug!("[purple] close_host_form select={:?}", select);
136        self.clear_form_mtime();
137        self.forms.host_baseline = None;
138        self.set_screen(Screen::HostList);
139        if let Some(alias) = select {
140            self.select_host_by_alias(alias);
141        }
142        self.flush_pending_vault_write();
143    }
144
145    /// Tear down provider form state and return to the providers list. Same
146    /// shape as `close_host_form`; provider forms have no per-save selection.
147    pub fn close_provider_form(&mut self) {
148        log::debug!("[purple] close_provider_form");
149        self.clear_form_mtime();
150        self.providers.form_baseline = None;
151        self.set_screen(Screen::Providers);
152        self.flush_pending_vault_write();
153    }
154
155    /// Tear down tunnel form state and return to the caller's screen. The
156    /// return target varies (host detail overlay, tunnels overview, picker),
157    /// so the caller passes it.
158    pub fn close_tunnel_form(&mut self, return_to: Screen) {
159        log::debug!(
160            "[purple] close_tunnel_form return_to={:?}",
161            std::mem::discriminant(&return_to)
162        );
163        self.clear_form_mtime();
164        self.tunnels.form_baseline = None;
165        self.set_screen(return_to);
166    }
167
168    /// Tear down snippet form state and return to the snippet picker for the
169    /// given targets. Snippet forms intentionally skip clear_form_mtime; no
170    /// mtime is captured on snippet form open.
171    pub fn close_snippet_form(&mut self, target_aliases: Vec<String>) {
172        log::debug!(
173            "[purple] close_snippet_form aliases={}",
174            target_aliases.len()
175        );
176        self.snippets.form_baseline = None;
177        self.set_screen(Screen::SnippetPicker { target_aliases });
178    }
179
180    /// Open a blank host add form. Mirror is `close_host_form`.
181    pub fn open_host_add_form(&mut self) {
182        log::debug!("[purple] open_host_add_form");
183        self.forms.host = HostForm::new();
184        self.set_screen(Screen::AddHost);
185        self.capture_form_mtime();
186        self.capture_form_baseline();
187    }
188
189    /// Open a blank pattern add form. Shares Screen::AddHost; the form
190    /// constructor distinguishes pattern vs host entries internally.
191    pub fn open_host_pattern_add_form(&mut self) {
192        log::debug!("[purple] open_host_pattern_add_form");
193        self.forms.host = HostForm::new_pattern();
194        self.set_screen(Screen::AddHost);
195        self.capture_form_mtime();
196        self.capture_form_baseline();
197    }
198
199    /// Open the host edit form for `host`. Returns false (without changing
200    /// screen) if the host lives in an Include file or its raw entry cannot
201    /// be located. The caller computes `stale_hint` because it is derived
202    /// from handler-local provider-display logic.
203    pub fn open_host_edit_form(
204        &mut self,
205        host: crate::ssh_config::model::HostEntry,
206        stale_hint: Option<String>,
207    ) -> bool {
208        if let Some(ref source) = host.source_file {
209            self.notify_error(crate::messages::included_host_lives_in(
210                &host.alias,
211                &source.display(),
212            ));
213            return false;
214        }
215        // Load raw entry (no pattern inheritance) so inherited values do not
216        // appear as editable own values.
217        let raw = match self.hosts_state.ssh_config.raw_host_entry(&host.alias) {
218            Some(entry) => entry,
219            None => {
220                self.notify_warning(crate::messages::HOST_NOT_FOUND_IN_CONFIG);
221                return false;
222            }
223        };
224        let inherited = self.hosts_state.ssh_config.inherited_hints(&host.alias);
225        log::debug!("[purple] open_host_edit_form alias={}", host.alias);
226        self.forms.host = HostForm::from_entry(&raw, inherited);
227        if let Some(hint) = stale_hint {
228            self.notify_warning(crate::messages::stale_host(&hint));
229        }
230        self.set_screen(Screen::EditHost { alias: host.alias });
231        self.capture_form_mtime();
232        self.capture_form_baseline();
233        true
234    }
235
236    /// Open an edit form for an existing pattern entry.
237    pub fn open_host_pattern_edit_form(&mut self, pattern: &PatternEntry) {
238        log::debug!(
239            "[purple] open_host_pattern_edit_form pattern={}",
240            pattern.pattern
241        );
242        self.forms.host = HostForm::from_pattern_entry(pattern);
243        self.set_screen(Screen::EditHost {
244            alias: pattern.pattern.clone(),
245        });
246        self.capture_form_mtime();
247        self.capture_form_baseline();
248    }
249
250    /// Open a blank tunnel add form scoped to `alias`. The alias is set on
251    /// the screen variant so submit/cancel return to the right host context.
252    pub fn open_tunnel_add_form(&mut self, alias: String) {
253        log::debug!("[purple] open_tunnel_add_form alias={}", alias);
254        self.tunnels.form = TunnelForm::new();
255        self.set_screen(Screen::TunnelForm {
256            alias,
257            editing: None,
258        });
259        self.capture_form_mtime();
260        self.capture_tunnel_form_baseline();
261    }
262
263    /// Open an edit form for an existing tunnel rule. `editing` is the index
264    /// into `tunnels.list` that the save path mutates.
265    pub fn open_tunnel_edit_form(&mut self, alias: String, rule: &TunnelRule, editing: usize) {
266        log::debug!(
267            "[purple] open_tunnel_edit_form alias={} editing={}",
268            alias,
269            editing
270        );
271        self.tunnels.form = TunnelForm::from_rule(rule);
272        self.set_screen(Screen::TunnelForm {
273            alias,
274            editing: Some(editing),
275        });
276        self.capture_form_mtime();
277        self.capture_tunnel_form_baseline();
278    }
279
280    /// Open a blank snippet add form scoped to the given target aliases.
281    /// No mtime capture (snippet forms have no mtime tracking).
282    pub fn open_snippet_add_form(&mut self, target_aliases: Vec<String>) {
283        log::debug!(
284            "[purple] open_snippet_add_form aliases={}",
285            target_aliases.len()
286        );
287        self.snippets.form = SnippetForm::new();
288        self.set_screen(Screen::SnippetForm {
289            target_aliases,
290            editing: None,
291        });
292        self.capture_snippet_form_baseline();
293    }
294
295    /// Open an edit form for an existing snippet. `editing` is the index
296    /// into the snippet store that the save path mutates.
297    pub fn open_snippet_edit_form(
298        &mut self,
299        snippet: &Snippet,
300        target_aliases: Vec<String>,
301        editing: usize,
302    ) {
303        log::debug!(
304            "[purple] open_snippet_edit_form name={} editing={}",
305            snippet.name,
306            editing
307        );
308        self.snippets.form = SnippetForm::from_snippet(snippet);
309        self.set_screen(Screen::SnippetForm {
310            target_aliases,
311            editing: Some(editing),
312        });
313        self.capture_snippet_form_baseline();
314    }
315
316    /// Open a provider form for `id`, populating defaults for new configs
317    /// or existing data for edits. When `id.label` is `Some("")` the form
318    /// opens in label-entry mode so the user types the label first.
319    pub fn open_provider_form(&mut self, id: crate::providers::config::ProviderConfigId) {
320        let provider_impl = crate::providers::get_provider(id.provider.as_str());
321        let short_label = provider_impl
322            .as_ref()
323            .map(|p| p.short_label().to_string())
324            .unwrap_or_else(|| id.provider.clone());
325        let existing_section = self.providers.config.section_by_id(&id).cloned();
326        let label_entry = existing_section.is_none() && id.label.as_deref() == Some("");
327        let provider_first_field =
328            crate::app::ProviderFormField::fields_for(id.provider.as_str())[0];
329        let first_field = if label_entry {
330            crate::app::ProviderFormField::Label
331        } else {
332            provider_first_field
333        };
334        log::debug!(
335            "[purple] open_provider_form provider={} label_entry={}",
336            id.provider,
337            label_entry
338        );
339
340        self.providers.form = if let Some(section) = existing_section {
341            let cursor_pos = match first_field {
342                crate::app::ProviderFormField::Url => section.url.chars().count(),
343                crate::app::ProviderFormField::Token => section.token.chars().count(),
344                _ => 0,
345            };
346            crate::app::ProviderFormFields {
347                label: String::new(),
348                label_entry: false,
349                url: section.url.clone(),
350                token: section.token.clone(),
351                profile: section.profile.clone(),
352                project: section.project.clone(),
353                compartment: section.compartment.clone(),
354                regions: section.regions.clone(),
355                alias_prefix: section.alias_prefix.clone(),
356                user: section.user.clone(),
357                identity_file: section.identity_file.clone(),
358                verify_tls: section.verify_tls,
359                auto_sync: section.auto_sync,
360                vault_role: section.vault_role.clone(),
361                vault_addr: section.vault_addr.clone(),
362                focused_field: first_field,
363                cursor_pos,
364                expanded: true,
365            }
366        } else {
367            // New config: derive a sensible default alias_prefix. For a labeled
368            // config with a known label, suggest `<short>-<label>` (e.g. `do-work`);
369            // when the label is still empty (label-entry mode), fall back to the
370            // bare short prefix so the field has a stable value the user can edit.
371            let default_prefix = match id.label.as_deref() {
372                Some("") | None => short_label.clone(),
373                Some(l) => format!("{}-{}", short_label, l),
374            };
375            crate::app::ProviderFormFields {
376                label: String::new(),
377                label_entry,
378                url: String::new(),
379                token: String::new(),
380                profile: String::new(),
381                project: String::new(),
382                compartment: String::new(),
383                regions: String::new(),
384                alias_prefix: default_prefix,
385                user: "root".to_string(),
386                identity_file: String::new(),
387                verify_tls: true,
388                auto_sync: id
389                    .kind()
390                    .is_none_or(crate::providers::ProviderKind::default_auto_sync),
391                vault_role: String::new(),
392                vault_addr: String::new(),
393                focused_field: first_field,
394                cursor_pos: 0,
395                expanded: false,
396            }
397        };
398        self.set_screen(Screen::ProviderForm { id });
399        self.capture_provider_form_mtime();
400        self.capture_provider_form_baseline();
401    }
402
403    /// Capture a baseline snapshot of the tunnel form for dirty-check on Esc.
404    pub fn capture_tunnel_form_baseline(&mut self) {
405        self.tunnels.form_baseline = Some(TunnelFormBaseline {
406            tunnel_type: self.tunnels.form.tunnel_type,
407            bind_port: self.tunnels.form.bind_port.clone(),
408            remote_host: self.tunnels.form.remote_host.clone(),
409            remote_port: self.tunnels.form.remote_port.clone(),
410            bind_address: self.tunnels.form.bind_address.clone(),
411        });
412    }
413
414    /// Check if the tunnel form has been modified since baseline was captured.
415    pub fn tunnel_form_is_dirty(&self) -> bool {
416        match &self.tunnels.form_baseline {
417            Some(b) => {
418                self.tunnels.form.tunnel_type != b.tunnel_type
419                    || self.tunnels.form.bind_port != b.bind_port
420                    || self.tunnels.form.remote_host != b.remote_host
421                    || self.tunnels.form.remote_port != b.remote_port
422                    || self.tunnels.form.bind_address != b.bind_address
423            }
424            None => false,
425        }
426    }
427
428    /// Capture a baseline snapshot of the snippet form for dirty-check on Esc.
429    pub fn capture_snippet_form_baseline(&mut self) {
430        self.snippets.form_baseline = Some(SnippetFormBaseline {
431            name: self.snippets.form.name.clone(),
432            command: self.snippets.form.command.clone(),
433            description: self.snippets.form.description.clone(),
434        });
435    }
436
437    /// Check if the snippet form has been modified since baseline was captured.
438    pub fn snippet_form_is_dirty(&self) -> bool {
439        match &self.snippets.form_baseline {
440            Some(b) => {
441                self.snippets.form.name != b.name
442                    || self.snippets.form.command != b.command
443                    || self.snippets.form.description != b.description
444            }
445            None => false,
446        }
447    }
448
449    /// Capture a baseline snapshot of the provider form for dirty-check on Esc.
450    pub fn capture_provider_form_baseline(&mut self) {
451        self.providers.form_baseline = Some(ProviderFormBaseline {
452            url: self.providers.form.url.clone(),
453            token: self.providers.form.token.clone(),
454            profile: self.providers.form.profile.clone(),
455            project: self.providers.form.project.clone(),
456            compartment: self.providers.form.compartment.clone(),
457            regions: self.providers.form.regions.clone(),
458            alias_prefix: self.providers.form.alias_prefix.clone(),
459            user: self.providers.form.user.clone(),
460            identity_file: self.providers.form.identity_file.clone(),
461            verify_tls: self.providers.form.verify_tls,
462            auto_sync: self.providers.form.auto_sync,
463            vault_role: self.providers.form.vault_role.clone(),
464            vault_addr: self.providers.form.vault_addr.clone(),
465        });
466    }
467
468    /// Check if the provider form has been modified since baseline was captured.
469    pub fn provider_form_is_dirty(&self) -> bool {
470        match &self.providers.form_baseline {
471            Some(b) => {
472                self.providers.form.url != b.url
473                    || self.providers.form.token != b.token
474                    || self.providers.form.profile != b.profile
475                    || self.providers.form.project != b.project
476                    || self.providers.form.compartment != b.compartment
477                    || self.providers.form.regions != b.regions
478                    || self.providers.form.alias_prefix != b.alias_prefix
479                    || self.providers.form.user != b.user
480                    || self.providers.form.identity_file != b.identity_file
481                    || self.providers.form.verify_tls != b.verify_tls
482                    || self.providers.form.auto_sync != b.auto_sync
483                    || self.providers.form.vault_role != b.vault_role
484                    || self.providers.form.vault_addr != b.vault_addr
485            }
486            None => false,
487        }
488    }
489
490    /// Check if config or any Include file/directory has changed since the form was opened.
491    pub fn config_changed_since_form_open(&self) -> bool {
492        match self.conflict.form_mtime {
493            Some(open_mtime) => {
494                if get_mtime(&self.reload.config_path) != Some(open_mtime) {
495                    return true;
496                }
497                self.conflict
498                    .form_include_mtimes
499                    .iter()
500                    .any(|(path, old_mtime)| get_mtime(path) != *old_mtime)
501                    || self
502                        .conflict
503                        .form_include_dir_mtimes
504                        .iter()
505                        .any(|(path, old_mtime)| get_mtime(path) != *old_mtime)
506            }
507            None => false,
508        }
509    }
510
511    /// Check if ~/.purple/providers has changed since the provider form was opened.
512    pub fn provider_config_changed_since_form_open(&self) -> bool {
513        let path = dirs::home_dir().map(|h| h.join(".purple/providers"));
514        let current_mtime = path.as_ref().and_then(|p| get_mtime(p));
515        self.conflict.provider_form_mtime != current_mtime
516    }
517}