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