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