Skip to main content

purple_ssh/app/
provider_state.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3use std::sync::atomic::AtomicBool;
4
5use crate::app::ProviderFormBaseline;
6use crate::app::forms::ProviderFormFields;
7use crate::providers::config::{ProviderConfig, ProviderConfigId};
8
9/// Record of the last sync result for a provider.
10#[derive(Debug, Clone)]
11pub struct SyncRecord {
12    pub timestamp: u64,
13    pub message: String,
14    pub is_error: bool,
15}
16
17impl SyncRecord {
18    /// Load sync history from ~/.purple/sync_history.tsv.
19    /// Format: provider\ttimestamp\tis_error\tmessage
20    pub fn load_all(paths: Option<&crate::runtime::env::Paths>) -> HashMap<String, SyncRecord> {
21        let mut map = HashMap::new();
22        let Some(path) = paths.map(crate::runtime::env::Paths::sync_history) else {
23            return map;
24        };
25        let Ok(content) = std::fs::read_to_string(&path) else {
26            return map;
27        };
28        for line in content.lines() {
29            let parts: Vec<&str> = line.splitn(4, '\t').collect();
30            if parts.len() < 4 {
31                continue;
32            }
33            let Some(ts) = parts[1].parse::<u64>().ok() else {
34                continue;
35            };
36            let is_error = parts[2] == "1";
37            map.insert(
38                parts[0].to_string(),
39                SyncRecord {
40                    timestamp: ts,
41                    message: parts[3].to_string(),
42                    is_error,
43                },
44            );
45        }
46        map
47    }
48
49    /// Save sync history to ~/.purple/sync_history.tsv.
50    pub fn save_all(
51        history: &HashMap<String, SyncRecord>,
52        paths: Option<&crate::runtime::env::Paths>,
53    ) {
54        if crate::demo_flag::is_demo() {
55            return;
56        }
57        let Some(path) = paths.map(crate::runtime::env::Paths::sync_history) else {
58            return;
59        };
60        let mut lines = Vec::new();
61        for (provider, record) in history {
62            lines.push(format!(
63                "{}\t{}\t{}\t{}",
64                provider,
65                record.timestamp,
66                if record.is_error { "1" } else { "0" },
67                record.message
68            ));
69        }
70        if let Err(e) = crate::fs_util::atomic_write(&path, lines.join("\n").as_bytes()) {
71            log::warn!(
72                "[config] failed to save sync_history.tsv at {}: {e}",
73                path.display()
74            );
75        }
76    }
77
78    /// Parse sync history from TSV content string (for demo/test use).
79    pub fn load_from_content(content: &str) -> HashMap<String, SyncRecord> {
80        let mut map = HashMap::new();
81        for line in content.lines() {
82            let parts: Vec<&str> = line.splitn(4, '\t').collect();
83            if parts.len() < 4 {
84                continue;
85            }
86            let Some(ts) = parts[1].parse::<u64>().ok() else {
87                continue;
88            };
89            let is_error = parts[2] == "1";
90            map.insert(
91                parts[0].to_string(),
92                SyncRecord {
93                    timestamp: ts,
94                    message: parts[3].to_string(),
95                    is_error,
96                },
97            );
98        }
99        map
100    }
101}
102
103/// Provider-owned state grouped off the `App` god-struct. Holds the
104/// provider config, the edit form, the in-flight sync tracking
105/// (cancel flags, completed names, error aggregate), the pending
106/// delete alias, the on-disk sync history and the dirty-check baseline.
107/// Pure state container.
108pub struct ProviderState {
109    pub(in crate::app) config: ProviderConfig,
110    pub(in crate::app) form: ProviderFormFields,
111    pub(in crate::app) syncing: HashMap<String, Arc<AtomicBool>>,
112    /// Names of providers that completed during this sync batch.
113    pub(in crate::app) sync_done: Vec<String>,
114    /// Whether any provider in the current batch had errors.
115    pub(in crate::app) sync_had_errors: bool,
116    /// Aggregate diff counts across the current sync batch. Reset when the
117    /// batch finishes (no providers left in `syncing`). Used by the footer
118    /// background status to render `(+3 ~1 -2)` next to the provider list.
119    pub(in crate::app) batch_added: usize,
120    pub(in crate::app) batch_updated: usize,
121    pub(in crate::app) batch_stale: usize,
122    /// Total provider count for the current batch (done + still syncing).
123    /// Captured when sync starts so the `n/total` counter does not jump
124    /// when providers complete and leave `syncing`.
125    pub(in crate::app) batch_total: usize,
126    pub(in crate::app) pending_delete: Option<String>,
127    /// When deleting a single labeled config, this carries the full id.
128    /// `pending_delete` is used for whole-provider delete (header confirm).
129    pub(in crate::app) pending_delete_id: Option<ProviderConfigId>,
130    pub(in crate::app) sync_history: HashMap<String, SyncRecord>,
131    pub(in crate::app) form_baseline: Option<ProviderFormBaseline>,
132    /// Provider names that are expanded in the tree-style provider list.
133    /// Only matters when a provider has 2+ labeled configs.
134    pub(in crate::app) expanded_providers: HashSet<String>,
135    /// In-progress lazy migration: when adding a 2nd config of a provider
136    /// that currently has a single bare config, we first prompt for a label
137    /// for the existing config. The chosen label lives here until the new
138    /// config form is saved (then both writes happen atomically). When the
139    /// user cancels the new config form, this is dropped and nothing is
140    /// written.
141    pub(in crate::app) pending_label_migration: Option<PendingLabelMigration>,
142    /// Payload of an open `Screen::ConfirmPurgeStale` dialog. The Screen
143    /// variant is data-less; the alias list and provider scope live here
144    /// so the dialog can be opened with hundreds of stale aliases
145    /// without paying for an enum clone on every transition.
146    pub(in crate::app) pending_purge: Option<PendingPurge>,
147}
148
149/// Payload of the stale-host purge confirm dialog.
150#[derive(Debug, Clone)]
151pub struct PendingPurge {
152    /// Aliases the user will be told they are about to remove.
153    pub aliases: Vec<String>,
154    /// Provider scope filter; `None` purges across providers.
155    pub provider: Option<String>,
156}
157
158/// State carried between step 1 (label both configs) and step 2
159/// (fill in the new labeled config form) of the lazy-migration add flow.
160#[derive(Debug, Clone)]
161pub struct PendingLabelMigration {
162    pub provider: String,
163    /// User-chosen label for the EXISTING (currently bare) config.
164    pub existing_label: String,
165    /// User-chosen label for the NEW config being added.
166    pub new_label: String,
167    /// Which field has focus in the label-migration screen.
168    pub focused: LabelMigrationField,
169    /// Cursor position (char index) within the focused field's value.
170    pub cursor_pos: usize,
171}
172
173impl PendingLabelMigration {
174    /// Get the focused field's value.
175    pub fn focused_value(&self) -> &str {
176        match self.focused {
177            LabelMigrationField::Existing => &self.existing_label,
178            LabelMigrationField::New => &self.new_label,
179        }
180    }
181
182    /// Get the focused field's value mutably.
183    pub fn focused_value_mut(&mut self) -> &mut String {
184        match self.focused {
185            LabelMigrationField::Existing => &mut self.existing_label,
186            LabelMigrationField::New => &mut self.new_label,
187        }
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum LabelMigrationField {
193    Existing,
194    New,
195}
196
197/// One row in the tree-style provider list.
198#[derive(Debug, Clone)]
199pub enum ProviderRow {
200    /// Provider group header. `config_count` 0 = unconfigured, 1 = single
201    /// config (bare or labeled), 2+ = group that can be expanded.
202    Header { name: String, config_count: usize },
203    /// One labeled config under an expanded header.
204    Leaf { id: ProviderConfigId },
205}
206
207impl ProviderRow {
208    pub fn provider_name(&self) -> &str {
209        match self {
210            ProviderRow::Header { name, .. } => name,
211            ProviderRow::Leaf { id } => &id.provider,
212        }
213    }
214}
215
216impl ProviderState {
217    /// Reset batch counters when a completely new sync run begins.
218    ///
219    /// Call before inserting into `syncing` on every spawn path. When both
220    /// `syncing` and `sync_done` are empty a fresh batch is starting, so
221    /// stale `batch_total` / `batch_added` / `batch_updated` / `batch_stale`
222    /// values from a previous (non-completed) run are cleared. Without this
223    /// guard a rare edge case could leak state from an interrupted batch
224    /// into a smaller follow-up batch and show "Syncing 1/5" while only
225    /// one provider is actually in flight.
226    pub fn reset_batch_if_idle(&mut self) {
227        if self.syncing.is_empty() && self.sync_done.is_empty() {
228            self.batch_total = 0;
229            self.batch_added = 0;
230            self.batch_updated = 0;
231            self.batch_stale = 0;
232            self.sync_had_errors = false;
233        }
234    }
235
236    /// Clear all batch tracking once a sync run has fully completed (no
237    /// providers left in `syncing`). Drops the completed-name list, the
238    /// error flag and every batch counter so the next run starts clean.
239    pub fn finish_batch(&mut self) {
240        self.sync_done.clear();
241        self.sync_had_errors = false;
242        self.batch_added = 0;
243        self.batch_updated = 0;
244        self.batch_stale = 0;
245        self.batch_total = 0;
246    }
247
248    /// Open a delete confirmation for a provider config. `pending_delete`
249    /// carries the bare provider name for the renderer; `pending_delete_id`
250    /// carries the full id (including optional label) used by the confirm
251    /// handler to scope the removal to a single config when the provider
252    /// has multiple labeled configs.
253    pub fn request_delete(&mut self, id: ProviderConfigId) {
254        self.pending_delete = Some(id.provider.clone());
255        self.pending_delete_id = Some(id);
256    }
257
258    /// Dismiss a pending provider delete confirmation. Idempotent.
259    pub fn cancel_delete(&mut self) {
260        self.pending_delete = None;
261        self.pending_delete_id = None;
262    }
263
264    /// Toggle the expanded state of a provider group in the tree-style
265    /// provider list. Returns `true` when the provider is now expanded
266    /// (was added) and `false` when it is now collapsed (was removed)
267    /// so the caller can log the transition without re-reading state.
268    pub fn toggle_expanded(&mut self, name: &str) -> bool {
269        let added = !self.expanded_providers.contains(name);
270        if added {
271            self.expanded_providers.insert(name.to_string());
272        } else {
273            self.expanded_providers.remove(name);
274        }
275        added
276    }
277
278    /// Dismiss an in-progress lazy label-migration. Idempotent.
279    pub fn cancel_label_migration(&mut self) {
280        self.pending_label_migration = None;
281    }
282
283    pub fn config(&self) -> &ProviderConfig {
284        &self.config
285    }
286
287    pub fn config_mut(&mut self) -> &mut ProviderConfig {
288        &mut self.config
289    }
290
291    pub fn form(&self) -> &ProviderFormFields {
292        &self.form
293    }
294
295    pub fn form_mut(&mut self) -> &mut ProviderFormFields {
296        &mut self.form
297    }
298
299    pub fn syncing(&self) -> &HashMap<String, Arc<AtomicBool>> {
300        &self.syncing
301    }
302
303    pub fn syncing_mut(&mut self) -> &mut HashMap<String, Arc<AtomicBool>> {
304        &mut self.syncing
305    }
306
307    pub fn sync_done(&self) -> &[String] {
308        &self.sync_done
309    }
310
311    pub fn push_sync_done(&mut self, name: String) {
312        self.sync_done.push(name);
313    }
314
315    pub fn clear_sync_done(&mut self) {
316        self.sync_done.clear();
317    }
318
319    pub fn sync_had_errors(&self) -> bool {
320        self.sync_had_errors
321    }
322
323    pub fn set_sync_had_errors(&mut self, value: bool) {
324        self.sync_had_errors = value;
325    }
326
327    pub fn batch_added(&self) -> usize {
328        self.batch_added
329    }
330
331    pub fn batch_updated(&self) -> usize {
332        self.batch_updated
333    }
334
335    pub fn batch_stale(&self) -> usize {
336        self.batch_stale
337    }
338
339    /// Fold one provider's diff counts into the current batch aggregate
340    /// rendered by the footer summary.
341    pub fn add_batch_diff(&mut self, added: usize, updated: usize, stale: usize) {
342        self.batch_added += added;
343        self.batch_updated += updated;
344        self.batch_stale += stale;
345    }
346
347    pub fn batch_total(&self) -> usize {
348        self.batch_total
349    }
350
351    pub fn set_batch_total(&mut self, value: usize) {
352        self.batch_total = value;
353    }
354
355    /// Raise `batch_total` to at least the number of providers known to be
356    /// part of the current batch (done plus still syncing). Never lowers it
357    /// so the `n/total` counter stays stable as providers complete.
358    pub fn bump_batch_total(&mut self) {
359        self.batch_total = self
360            .batch_total
361            .max(self.sync_done.len() + self.syncing.len());
362    }
363
364    pub fn pending_delete(&self) -> Option<&str> {
365        self.pending_delete.as_deref()
366    }
367
368    pub fn take_pending_delete(&mut self) -> Option<String> {
369        self.pending_delete.take()
370    }
371
372    pub fn pending_delete_id(&self) -> Option<&ProviderConfigId> {
373        self.pending_delete_id.as_ref()
374    }
375
376    pub fn take_pending_delete_id(&mut self) -> Option<ProviderConfigId> {
377        self.pending_delete_id.take()
378    }
379
380    pub fn sync_history(&self) -> &HashMap<String, SyncRecord> {
381        &self.sync_history
382    }
383
384    pub fn sync_history_mut(&mut self) -> &mut HashMap<String, SyncRecord> {
385        &mut self.sync_history
386    }
387
388    /// Record a provider's sync outcome in the on-disk-backed history map,
389    /// overwriting any previous record for the same key.
390    pub fn record_sync(&mut self, key: String, record: SyncRecord) {
391        self.sync_history.insert(key, record);
392    }
393
394    pub fn form_baseline(&self) -> Option<&ProviderFormBaseline> {
395        self.form_baseline.as_ref()
396    }
397
398    pub fn set_form_baseline(&mut self, baseline: Option<ProviderFormBaseline>) {
399        self.form_baseline = baseline;
400    }
401
402    /// True if the provider form differs from its captured baseline.
403    pub fn form_is_dirty(&self) -> bool {
404        match &self.form_baseline {
405            Some(b) => {
406                self.form.url != b.url
407                    || self.form.token != b.token
408                    || self.form.profile != b.profile
409                    || self.form.project != b.project
410                    || self.form.compartment != b.compartment
411                    || self.form.regions != b.regions
412                    || self.form.filter != b.filter
413                    || self.form.alias_prefix != b.alias_prefix
414                    || self.form.user != b.user
415                    || self.form.identity_file != b.identity_file
416                    || self.form.verify_tls != b.verify_tls
417                    || self.form.auto_sync != b.auto_sync
418                    || self.form.vault_role != b.vault_role
419                    || self.form.vault_addr != b.vault_addr
420                    || self.form.ssm != b.ssm
421            }
422            None => false,
423        }
424    }
425
426    pub fn expanded_providers(&self) -> &HashSet<String> {
427        &self.expanded_providers
428    }
429
430    pub fn expanded_providers_mut(&mut self) -> &mut HashSet<String> {
431        &mut self.expanded_providers
432    }
433
434    pub fn pending_label_migration(&self) -> Option<&PendingLabelMigration> {
435        self.pending_label_migration.as_ref()
436    }
437
438    pub fn pending_label_migration_mut(&mut self) -> Option<&mut PendingLabelMigration> {
439        self.pending_label_migration.as_mut()
440    }
441
442    pub fn set_pending_label_migration(&mut self, migration: Option<PendingLabelMigration>) {
443        self.pending_label_migration = migration;
444    }
445
446    pub fn pending_purge(&self) -> Option<&PendingPurge> {
447        self.pending_purge.as_ref()
448    }
449
450    pub fn set_pending_purge(&mut self, pending: PendingPurge) {
451        self.pending_purge = Some(pending);
452    }
453
454    pub fn take_pending_purge(&mut self) -> Option<PendingPurge> {
455        self.pending_purge.take()
456    }
457}
458
459impl Default for ProviderState {
460    /// Truly empty default. No disk I/O. Call sites that need persisted
461    /// state (App::new) construct with struct-update syntax:
462    /// `ProviderState { config: ProviderConfig::load(paths), sync_history: SyncRecord::load_all(paths), ..Default::default() }`.
463    fn default() -> Self {
464        Self {
465            config: ProviderConfig::default(),
466            form: ProviderFormFields::new(),
467            syncing: HashMap::new(),
468            sync_done: Vec::new(),
469            sync_had_errors: false,
470            batch_added: 0,
471            batch_updated: 0,
472            batch_stale: 0,
473            batch_total: 0,
474            pending_delete: None,
475            pending_delete_id: None,
476            sync_history: HashMap::new(),
477            form_baseline: None,
478            expanded_providers: HashSet::new(),
479            pending_label_migration: None,
480            pending_purge: None,
481        }
482    }
483}
484
485impl ProviderState {
486    /// Construct with persisted state loaded from disk.
487    pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
488        Self {
489            config: crate::providers::config::ProviderConfig::load(paths),
490            sync_history: SyncRecord::load_all(paths),
491            ..Self::default()
492        }
493    }
494
495    /// One row in the provider list, in display order.
496    /// Each provider is a `Header`. When the provider has 2+ labeled configs
497    /// AND is in `expanded_providers`, its `Leaf` rows follow immediately.
498    /// When the provider has 0 or 1 config, no leaves are emitted.
499    pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
500        let mut rows = Vec::new();
501        for name in self.sorted_names() {
502            let configs = self.config.sections_for_provider(&name);
503            rows.push(ProviderRow::Header {
504                name: name.clone(),
505                config_count: configs.len(),
506            });
507            if configs.len() >= 2 && self.expanded_providers.contains(&name) {
508                let mut sorted = configs.clone();
509                sorted.sort_by(|a, b| {
510                    a.id.label
511                        .as_deref()
512                        .unwrap_or("")
513                        .cmp(b.id.label.as_deref().unwrap_or(""))
514                });
515                for s in sorted {
516                    rows.push(ProviderRow::Leaf { id: s.id.clone() });
517                }
518            }
519        }
520        rows
521    }
522
523    /// Provider names sorted by last sync (most recent first), then configured,
524    /// then unconfigured. Includes any unknown provider names found in the
525    /// config file (e.g. typos or future providers).
526    pub fn sorted_names(&self) -> Vec<String> {
527        use crate::providers;
528        let mut names: Vec<String> = providers::PROVIDER_NAMES
529            .iter()
530            .map(|s| s.to_string())
531            .collect();
532        // Append configured providers not in the known list so they are visible and removable
533        for section in &self.config.sections {
534            let name = section.provider().to_string();
535            if !names.contains(&name) {
536                names.push(name);
537            }
538        }
539        // For multi-config providers the sync_history keys are the full id
540        // ("digitalocean:work"), not the bare name. Take the MAX timestamp
541        // across any history entry whose key matches this provider so the
542        // recency sort works for both single and multi-config layouts.
543        let max_ts = |provider: &str| -> u64 {
544            self.sync_history
545                .iter()
546                .filter(|(k, _)| {
547                    k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
548                })
549                .map(|(_, r)| r.timestamp)
550                .max()
551                .unwrap_or(0)
552        };
553        names.sort_by(|a, b| {
554            let conf_a = self.config.section(a.as_str()).is_some();
555            let conf_b = self.config.section(b.as_str()).is_some();
556            let ts_a = max_ts(a.as_str());
557            let ts_b = max_ts(b.as_str());
558            // Configured first (by most recent sync), then unconfigured alphabetically
559            conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
560        });
561        names
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn default_is_empty() {
571        // Must not touch disk. Constructed with ProviderConfig::default()
572        // and an empty sync_history. App::new() layers the real on-disk
573        // state on top via struct-update syntax.
574        let s = ProviderState::default();
575        assert!(s.config.sections.is_empty());
576        assert!(s.config.path_override.is_none());
577        assert!(s.syncing.is_empty());
578        assert!(s.sync_done.is_empty());
579        assert!(!s.sync_had_errors);
580        assert!(s.pending_delete.is_none());
581        assert!(s.sync_history.is_empty());
582        assert!(s.form_baseline.is_none());
583    }
584
585    #[test]
586    fn sorted_names_returns_configured_providers_before_unconfigured() {
587        use crate::providers::config::ProviderSection;
588
589        let mut state = ProviderState::default();
590        state.config.sections.push(ProviderSection {
591            id: crate::providers::config::ProviderConfigId::bare("vultr"),
592            token: "tok".to_string(),
593            alias_prefix: "vultr".to_string(),
594            ..ProviderSection::default()
595        });
596        state.config.sections.push(ProviderSection {
597            id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
598            token: "tok".to_string(),
599            alias_prefix: "do".to_string(),
600            ..ProviderSection::default()
601        });
602        state.sync_history.insert(
603            "digitalocean".to_string(),
604            SyncRecord {
605                timestamp: 2_000,
606                message: "ok".to_string(),
607                is_error: false,
608            },
609        );
610        state.sync_history.insert(
611            "vultr".to_string(),
612            SyncRecord {
613                timestamp: 1_000,
614                message: "ok".to_string(),
615                is_error: false,
616            },
617        );
618
619        let names = state.sorted_names();
620        // Configured providers (most recent sync first) precede unconfigured.
621        assert_eq!(&names[0], "digitalocean");
622        assert_eq!(&names[1], "vultr");
623        // Every known provider name must be present.
624        for &known in crate::providers::PROVIDER_NAMES {
625            assert!(names.iter().any(|n| n == known), "missing {}", known);
626        }
627        // Unconfigured tail is sorted alphabetically.
628        let unconfigured: Vec<&String> = names.iter().skip(2).collect();
629        let mut sorted = unconfigured.clone();
630        sorted.sort();
631        assert_eq!(unconfigured, sorted);
632    }
633
634    #[test]
635    fn sorted_names_includes_unknown_providers_from_config() {
636        use crate::providers::config::ProviderSection;
637
638        let mut state = ProviderState::default();
639        state.config.sections.push(ProviderSection {
640            id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
641            token: "tok".to_string(),
642            alias_prefix: "x".to_string(),
643            ..ProviderSection::default()
644        });
645
646        let names = state.sorted_names();
647        assert!(names.iter().any(|n| n == "someday_provider"));
648    }
649
650    #[test]
651    fn request_delete_sets_both_pending_fields() {
652        let mut s = ProviderState::default();
653        let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
654        s.request_delete(id.clone());
655        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
656        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
657    }
658
659    #[test]
660    fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
661        let mut s = ProviderState::default();
662        let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
663        s.request_delete(id.clone());
664        // pending_delete carries only the provider name; the full id with
665        // label is in pending_delete_id.
666        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
667        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
668    }
669
670    #[test]
671    fn request_delete_overwrites_existing_pending() {
672        let mut s = ProviderState::default();
673        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
674        let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
675        s.request_delete(new_id.clone());
676        assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
677        assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
678    }
679
680    #[test]
681    fn cancel_delete_clears_both_pending_fields() {
682        let mut s = ProviderState::default();
683        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
684        s.cancel_delete();
685        assert!(s.pending_delete.is_none());
686        assert!(s.pending_delete_id.is_none());
687    }
688
689    #[test]
690    fn cancel_delete_is_idempotent() {
691        let mut s = ProviderState::default();
692        s.cancel_delete();
693        s.cancel_delete();
694        assert!(s.pending_delete.is_none());
695        assert!(s.pending_delete_id.is_none());
696    }
697
698    #[test]
699    fn toggle_expanded_adds_when_absent_and_returns_true() {
700        let mut s = ProviderState::default();
701        assert!(!s.expanded_providers.contains("digitalocean"));
702        let added = s.toggle_expanded("digitalocean");
703        assert!(added);
704        assert!(s.expanded_providers.contains("digitalocean"));
705    }
706
707    #[test]
708    fn toggle_expanded_removes_when_present_and_returns_false() {
709        let mut s = ProviderState::default();
710        s.expanded_providers.insert("digitalocean".to_string());
711        let added = s.toggle_expanded("digitalocean");
712        assert!(!added);
713        assert!(!s.expanded_providers.contains("digitalocean"));
714    }
715
716    #[test]
717    fn cancel_label_migration_clears_pending() {
718        let mut s = ProviderState {
719            pending_label_migration: Some(PendingLabelMigration {
720                provider: "digitalocean".to_string(),
721                existing_label: "old".to_string(),
722                new_label: "new".to_string(),
723                focused: LabelMigrationField::Existing,
724                cursor_pos: 0,
725            }),
726            ..Default::default()
727        };
728        s.cancel_label_migration();
729        assert!(s.pending_label_migration.is_none());
730    }
731
732    #[test]
733    fn cancel_label_migration_is_idempotent_when_already_none() {
734        let mut s = ProviderState::default();
735        s.cancel_label_migration();
736        s.cancel_label_migration();
737        assert!(s.pending_label_migration.is_none());
738    }
739
740    #[test]
741    fn add_batch_diff_accumulates_each_counter() {
742        let mut s = ProviderState::default();
743        s.add_batch_diff(3, 1, 2);
744        s.add_batch_diff(1, 0, 4);
745        assert_eq!(s.batch_added(), 4);
746        assert_eq!(s.batch_updated(), 1);
747        assert_eq!(s.batch_stale(), 6);
748    }
749
750    #[test]
751    fn bump_batch_total_raises_to_done_plus_syncing() {
752        let mut s = ProviderState::default();
753        s.push_sync_done("aws".to_string());
754        s.syncing_mut()
755            .insert("vultr".to_string(), Arc::new(AtomicBool::new(false)));
756        s.bump_batch_total();
757        assert_eq!(s.batch_total(), 2);
758    }
759
760    #[test]
761    fn bump_batch_total_never_lowers_existing_peak() {
762        let mut s = ProviderState::default();
763        s.set_batch_total(5);
764        s.push_sync_done("aws".to_string());
765        s.bump_batch_total();
766        assert_eq!(s.batch_total(), 5);
767    }
768
769    #[test]
770    fn finish_batch_clears_all_batch_state() {
771        let mut s = ProviderState::default();
772        s.push_sync_done("aws".to_string());
773        s.set_sync_had_errors(true);
774        s.add_batch_diff(2, 3, 4);
775        s.set_batch_total(7);
776        s.finish_batch();
777        assert!(s.sync_done().is_empty());
778        assert!(!s.sync_had_errors());
779        assert_eq!(s.batch_added(), 0);
780        assert_eq!(s.batch_updated(), 0);
781        assert_eq!(s.batch_stale(), 0);
782        assert_eq!(s.batch_total(), 0);
783    }
784
785    fn state_matching_baseline() -> ProviderState {
786        let b = ProviderFormBaseline {
787            url: "https://api".into(),
788            token: "tok".into(),
789            profile: "default".into(),
790            project: "proj".into(),
791            compartment: "comp".into(),
792            regions: "eu-west".into(),
793            filter: "status=active".into(),
794            alias_prefix: "ap".into(),
795            user: "ec2-user".into(),
796            identity_file: "~/.ssh/id".into(),
797            verify_tls: true,
798            auto_sync: false,
799            vault_role: "role".into(),
800            vault_addr: "https://vault".into(),
801            ssm: crate::providers::aws_ssm::SsmMode::default(),
802        };
803        let mut s = ProviderState::default();
804        s.form.url = b.url.clone();
805        s.form.token = b.token.clone();
806        s.form.profile = b.profile.clone();
807        s.form.project = b.project.clone();
808        s.form.compartment = b.compartment.clone();
809        s.form.regions = b.regions.clone();
810        s.form.filter = b.filter.clone();
811        s.form.alias_prefix = b.alias_prefix.clone();
812        s.form.user = b.user.clone();
813        s.form.identity_file = b.identity_file.clone();
814        s.form.verify_tls = b.verify_tls;
815        s.form.auto_sync = b.auto_sync;
816        s.form.vault_role = b.vault_role.clone();
817        s.form.vault_addr = b.vault_addr.clone();
818        s.set_form_baseline(Some(b));
819        s
820    }
821
822    #[test]
823    fn form_is_dirty_is_false_without_a_baseline() {
824        let mut s = ProviderState::default();
825        s.form.url = "edited".into();
826        assert!(!s.form_is_dirty());
827    }
828
829    #[test]
830    fn form_is_dirty_is_false_when_form_equals_baseline() {
831        assert!(!state_matching_baseline().form_is_dirty());
832    }
833
834    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut ProviderFormFields)) {
835        let mut s = state_matching_baseline();
836        mutate(&mut s.form);
837        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
838    }
839
840    #[test]
841    fn form_is_dirty_detects_a_change_in_each_field() {
842        assert_field_change_is_dirty("url", |f| f.url.push('x'));
843        assert_field_change_is_dirty("token", |f| f.token.push('x'));
844        assert_field_change_is_dirty("profile", |f| f.profile.push('x'));
845        assert_field_change_is_dirty("project", |f| f.project.push('x'));
846        assert_field_change_is_dirty("compartment", |f| f.compartment.push('x'));
847        assert_field_change_is_dirty("regions", |f| f.regions.push('x'));
848        assert_field_change_is_dirty("alias_prefix", |f| f.alias_prefix.push('x'));
849        assert_field_change_is_dirty("user", |f| f.user.push('x'));
850        assert_field_change_is_dirty("identity_file", |f| f.identity_file.push('x'));
851        assert_field_change_is_dirty("verify_tls", |f| f.verify_tls = !f.verify_tls);
852        assert_field_change_is_dirty("auto_sync", |f| f.auto_sync = !f.auto_sync);
853        assert_field_change_is_dirty("vault_role", |f| f.vault_role.push('x'));
854        assert_field_change_is_dirty("vault_addr", |f| f.vault_addr.push('x'));
855        assert_field_change_is_dirty("filter", |f| f.filter.push('x'));
856        assert_field_change_is_dirty("ssm", |f| f.ssm = f.ssm.next());
857    }
858}