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            }
421            None => false,
422        }
423    }
424
425    pub fn expanded_providers(&self) -> &HashSet<String> {
426        &self.expanded_providers
427    }
428
429    pub fn expanded_providers_mut(&mut self) -> &mut HashSet<String> {
430        &mut self.expanded_providers
431    }
432
433    pub fn pending_label_migration(&self) -> Option<&PendingLabelMigration> {
434        self.pending_label_migration.as_ref()
435    }
436
437    pub fn pending_label_migration_mut(&mut self) -> Option<&mut PendingLabelMigration> {
438        self.pending_label_migration.as_mut()
439    }
440
441    pub fn set_pending_label_migration(&mut self, migration: Option<PendingLabelMigration>) {
442        self.pending_label_migration = migration;
443    }
444
445    pub fn pending_purge(&self) -> Option<&PendingPurge> {
446        self.pending_purge.as_ref()
447    }
448
449    pub fn set_pending_purge(&mut self, pending: PendingPurge) {
450        self.pending_purge = Some(pending);
451    }
452
453    pub fn take_pending_purge(&mut self) -> Option<PendingPurge> {
454        self.pending_purge.take()
455    }
456}
457
458impl Default for ProviderState {
459    /// Truly empty default. No disk I/O. Call sites that need persisted
460    /// state (App::new) construct with struct-update syntax:
461    /// `ProviderState { config: ProviderConfig::load(paths), sync_history: SyncRecord::load_all(paths), ..Default::default() }`.
462    fn default() -> Self {
463        Self {
464            config: ProviderConfig::default(),
465            form: ProviderFormFields::new(),
466            syncing: HashMap::new(),
467            sync_done: Vec::new(),
468            sync_had_errors: false,
469            batch_added: 0,
470            batch_updated: 0,
471            batch_stale: 0,
472            batch_total: 0,
473            pending_delete: None,
474            pending_delete_id: None,
475            sync_history: HashMap::new(),
476            form_baseline: None,
477            expanded_providers: HashSet::new(),
478            pending_label_migration: None,
479            pending_purge: None,
480        }
481    }
482}
483
484impl ProviderState {
485    /// Construct with persisted state loaded from disk.
486    pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
487        Self {
488            config: crate::providers::config::ProviderConfig::load(paths),
489            sync_history: SyncRecord::load_all(paths),
490            ..Self::default()
491        }
492    }
493
494    /// One row in the provider list, in display order.
495    /// Each provider is a `Header`. When the provider has 2+ labeled configs
496    /// AND is in `expanded_providers`, its `Leaf` rows follow immediately.
497    /// When the provider has 0 or 1 config, no leaves are emitted.
498    pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
499        let mut rows = Vec::new();
500        for name in self.sorted_names() {
501            let configs = self.config.sections_for_provider(&name);
502            rows.push(ProviderRow::Header {
503                name: name.clone(),
504                config_count: configs.len(),
505            });
506            if configs.len() >= 2 && self.expanded_providers.contains(&name) {
507                let mut sorted = configs.clone();
508                sorted.sort_by(|a, b| {
509                    a.id.label
510                        .as_deref()
511                        .unwrap_or("")
512                        .cmp(b.id.label.as_deref().unwrap_or(""))
513                });
514                for s in sorted {
515                    rows.push(ProviderRow::Leaf { id: s.id.clone() });
516                }
517            }
518        }
519        rows
520    }
521
522    /// Provider names sorted by last sync (most recent first), then configured,
523    /// then unconfigured. Includes any unknown provider names found in the
524    /// config file (e.g. typos or future providers).
525    pub fn sorted_names(&self) -> Vec<String> {
526        use crate::providers;
527        let mut names: Vec<String> = providers::PROVIDER_NAMES
528            .iter()
529            .map(|s| s.to_string())
530            .collect();
531        // Append configured providers not in the known list so they are visible and removable
532        for section in &self.config.sections {
533            let name = section.provider().to_string();
534            if !names.contains(&name) {
535                names.push(name);
536            }
537        }
538        // For multi-config providers the sync_history keys are the full id
539        // ("digitalocean:work"), not the bare name. Take the MAX timestamp
540        // across any history entry whose key matches this provider so the
541        // recency sort works for both single and multi-config layouts.
542        let max_ts = |provider: &str| -> u64 {
543            self.sync_history
544                .iter()
545                .filter(|(k, _)| {
546                    k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
547                })
548                .map(|(_, r)| r.timestamp)
549                .max()
550                .unwrap_or(0)
551        };
552        names.sort_by(|a, b| {
553            let conf_a = self.config.section(a.as_str()).is_some();
554            let conf_b = self.config.section(b.as_str()).is_some();
555            let ts_a = max_ts(a.as_str());
556            let ts_b = max_ts(b.as_str());
557            // Configured first (by most recent sync), then unconfigured alphabetically
558            conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
559        });
560        names
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn default_is_empty() {
570        // Must not touch disk. Constructed with ProviderConfig::default()
571        // and an empty sync_history. App::new() layers the real on-disk
572        // state on top via struct-update syntax.
573        let s = ProviderState::default();
574        assert!(s.config.sections.is_empty());
575        assert!(s.config.path_override.is_none());
576        assert!(s.syncing.is_empty());
577        assert!(s.sync_done.is_empty());
578        assert!(!s.sync_had_errors);
579        assert!(s.pending_delete.is_none());
580        assert!(s.sync_history.is_empty());
581        assert!(s.form_baseline.is_none());
582    }
583
584    #[test]
585    fn sorted_names_returns_configured_providers_before_unconfigured() {
586        use crate::providers::config::ProviderSection;
587
588        let mut state = ProviderState::default();
589        state.config.sections.push(ProviderSection {
590            id: crate::providers::config::ProviderConfigId::bare("vultr"),
591            token: "tok".to_string(),
592            alias_prefix: "vultr".to_string(),
593            ..ProviderSection::default()
594        });
595        state.config.sections.push(ProviderSection {
596            id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
597            token: "tok".to_string(),
598            alias_prefix: "do".to_string(),
599            ..ProviderSection::default()
600        });
601        state.sync_history.insert(
602            "digitalocean".to_string(),
603            SyncRecord {
604                timestamp: 2_000,
605                message: "ok".to_string(),
606                is_error: false,
607            },
608        );
609        state.sync_history.insert(
610            "vultr".to_string(),
611            SyncRecord {
612                timestamp: 1_000,
613                message: "ok".to_string(),
614                is_error: false,
615            },
616        );
617
618        let names = state.sorted_names();
619        // Configured providers (most recent sync first) precede unconfigured.
620        assert_eq!(&names[0], "digitalocean");
621        assert_eq!(&names[1], "vultr");
622        // Every known provider name must be present.
623        for &known in crate::providers::PROVIDER_NAMES {
624            assert!(names.iter().any(|n| n == known), "missing {}", known);
625        }
626        // Unconfigured tail is sorted alphabetically.
627        let unconfigured: Vec<&String> = names.iter().skip(2).collect();
628        let mut sorted = unconfigured.clone();
629        sorted.sort();
630        assert_eq!(unconfigured, sorted);
631    }
632
633    #[test]
634    fn sorted_names_includes_unknown_providers_from_config() {
635        use crate::providers::config::ProviderSection;
636
637        let mut state = ProviderState::default();
638        state.config.sections.push(ProviderSection {
639            id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
640            token: "tok".to_string(),
641            alias_prefix: "x".to_string(),
642            ..ProviderSection::default()
643        });
644
645        let names = state.sorted_names();
646        assert!(names.iter().any(|n| n == "someday_provider"));
647    }
648
649    #[test]
650    fn request_delete_sets_both_pending_fields() {
651        let mut s = ProviderState::default();
652        let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
653        s.request_delete(id.clone());
654        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
655        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
656    }
657
658    #[test]
659    fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
660        let mut s = ProviderState::default();
661        let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
662        s.request_delete(id.clone());
663        // pending_delete carries only the provider name; the full id with
664        // label is in pending_delete_id.
665        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
666        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
667    }
668
669    #[test]
670    fn request_delete_overwrites_existing_pending() {
671        let mut s = ProviderState::default();
672        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
673        let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
674        s.request_delete(new_id.clone());
675        assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
676        assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
677    }
678
679    #[test]
680    fn cancel_delete_clears_both_pending_fields() {
681        let mut s = ProviderState::default();
682        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
683        s.cancel_delete();
684        assert!(s.pending_delete.is_none());
685        assert!(s.pending_delete_id.is_none());
686    }
687
688    #[test]
689    fn cancel_delete_is_idempotent() {
690        let mut s = ProviderState::default();
691        s.cancel_delete();
692        s.cancel_delete();
693        assert!(s.pending_delete.is_none());
694        assert!(s.pending_delete_id.is_none());
695    }
696
697    #[test]
698    fn toggle_expanded_adds_when_absent_and_returns_true() {
699        let mut s = ProviderState::default();
700        assert!(!s.expanded_providers.contains("digitalocean"));
701        let added = s.toggle_expanded("digitalocean");
702        assert!(added);
703        assert!(s.expanded_providers.contains("digitalocean"));
704    }
705
706    #[test]
707    fn toggle_expanded_removes_when_present_and_returns_false() {
708        let mut s = ProviderState::default();
709        s.expanded_providers.insert("digitalocean".to_string());
710        let added = s.toggle_expanded("digitalocean");
711        assert!(!added);
712        assert!(!s.expanded_providers.contains("digitalocean"));
713    }
714
715    #[test]
716    fn cancel_label_migration_clears_pending() {
717        let mut s = ProviderState {
718            pending_label_migration: Some(PendingLabelMigration {
719                provider: "digitalocean".to_string(),
720                existing_label: "old".to_string(),
721                new_label: "new".to_string(),
722                focused: LabelMigrationField::Existing,
723                cursor_pos: 0,
724            }),
725            ..Default::default()
726        };
727        s.cancel_label_migration();
728        assert!(s.pending_label_migration.is_none());
729    }
730
731    #[test]
732    fn cancel_label_migration_is_idempotent_when_already_none() {
733        let mut s = ProviderState::default();
734        s.cancel_label_migration();
735        s.cancel_label_migration();
736        assert!(s.pending_label_migration.is_none());
737    }
738
739    #[test]
740    fn add_batch_diff_accumulates_each_counter() {
741        let mut s = ProviderState::default();
742        s.add_batch_diff(3, 1, 2);
743        s.add_batch_diff(1, 0, 4);
744        assert_eq!(s.batch_added(), 4);
745        assert_eq!(s.batch_updated(), 1);
746        assert_eq!(s.batch_stale(), 6);
747    }
748
749    #[test]
750    fn bump_batch_total_raises_to_done_plus_syncing() {
751        let mut s = ProviderState::default();
752        s.push_sync_done("aws".to_string());
753        s.syncing_mut()
754            .insert("vultr".to_string(), Arc::new(AtomicBool::new(false)));
755        s.bump_batch_total();
756        assert_eq!(s.batch_total(), 2);
757    }
758
759    #[test]
760    fn bump_batch_total_never_lowers_existing_peak() {
761        let mut s = ProviderState::default();
762        s.set_batch_total(5);
763        s.push_sync_done("aws".to_string());
764        s.bump_batch_total();
765        assert_eq!(s.batch_total(), 5);
766    }
767
768    #[test]
769    fn finish_batch_clears_all_batch_state() {
770        let mut s = ProviderState::default();
771        s.push_sync_done("aws".to_string());
772        s.set_sync_had_errors(true);
773        s.add_batch_diff(2, 3, 4);
774        s.set_batch_total(7);
775        s.finish_batch();
776        assert!(s.sync_done().is_empty());
777        assert!(!s.sync_had_errors());
778        assert_eq!(s.batch_added(), 0);
779        assert_eq!(s.batch_updated(), 0);
780        assert_eq!(s.batch_stale(), 0);
781        assert_eq!(s.batch_total(), 0);
782    }
783
784    fn state_matching_baseline() -> ProviderState {
785        let b = ProviderFormBaseline {
786            url: "https://api".into(),
787            token: "tok".into(),
788            profile: "default".into(),
789            project: "proj".into(),
790            compartment: "comp".into(),
791            regions: "eu-west".into(),
792            filter: "status=active".into(),
793            alias_prefix: "ap".into(),
794            user: "ec2-user".into(),
795            identity_file: "~/.ssh/id".into(),
796            verify_tls: true,
797            auto_sync: false,
798            vault_role: "role".into(),
799            vault_addr: "https://vault".into(),
800        };
801        let mut s = ProviderState::default();
802        s.form.url = b.url.clone();
803        s.form.token = b.token.clone();
804        s.form.profile = b.profile.clone();
805        s.form.project = b.project.clone();
806        s.form.compartment = b.compartment.clone();
807        s.form.regions = b.regions.clone();
808        s.form.filter = b.filter.clone();
809        s.form.alias_prefix = b.alias_prefix.clone();
810        s.form.user = b.user.clone();
811        s.form.identity_file = b.identity_file.clone();
812        s.form.verify_tls = b.verify_tls;
813        s.form.auto_sync = b.auto_sync;
814        s.form.vault_role = b.vault_role.clone();
815        s.form.vault_addr = b.vault_addr.clone();
816        s.set_form_baseline(Some(b));
817        s
818    }
819
820    #[test]
821    fn form_is_dirty_is_false_without_a_baseline() {
822        let mut s = ProviderState::default();
823        s.form.url = "edited".into();
824        assert!(!s.form_is_dirty());
825    }
826
827    #[test]
828    fn form_is_dirty_is_false_when_form_equals_baseline() {
829        assert!(!state_matching_baseline().form_is_dirty());
830    }
831
832    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut ProviderFormFields)) {
833        let mut s = state_matching_baseline();
834        mutate(&mut s.form);
835        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
836    }
837
838    #[test]
839    fn form_is_dirty_detects_a_change_in_each_field() {
840        assert_field_change_is_dirty("url", |f| f.url.push('x'));
841        assert_field_change_is_dirty("token", |f| f.token.push('x'));
842        assert_field_change_is_dirty("profile", |f| f.profile.push('x'));
843        assert_field_change_is_dirty("project", |f| f.project.push('x'));
844        assert_field_change_is_dirty("compartment", |f| f.compartment.push('x'));
845        assert_field_change_is_dirty("regions", |f| f.regions.push('x'));
846        assert_field_change_is_dirty("alias_prefix", |f| f.alias_prefix.push('x'));
847        assert_field_change_is_dirty("user", |f| f.user.push('x'));
848        assert_field_change_is_dirty("identity_file", |f| f.identity_file.push('x'));
849        assert_field_change_is_dirty("verify_tls", |f| f.verify_tls = !f.verify_tls);
850        assert_field_change_is_dirty("auto_sync", |f| f.auto_sync = !f.auto_sync);
851        assert_field_change_is_dirty("vault_role", |f| f.vault_role.push('x'));
852        assert_field_change_is_dirty("vault_addr", |f| f.vault_addr.push('x'));
853        assert_field_change_is_dirty("filter", |f| f.filter.push('x'));
854    }
855}