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