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