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