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 config: ProviderConfig,
108    pub form: ProviderFormFields,
109    pub syncing: HashMap<String, Arc<AtomicBool>>,
110    /// Names of providers that completed during this sync batch.
111    pub sync_done: Vec<String>,
112    /// Whether any provider in the current batch had errors.
113    pub 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 batch_added: usize,
118    pub batch_updated: usize,
119    pub 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 batch_total: usize,
124    pub 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 pending_delete_id: Option<ProviderConfigId>,
128    pub sync_history: HashMap<String, SyncRecord>,
129    pub 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 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 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    /// Open a delete confirmation for a provider config. `pending_delete`
221    /// carries the bare provider name for the renderer; `pending_delete_id`
222    /// carries the full id (including optional label) used by the confirm
223    /// handler to scope the removal to a single config when the provider
224    /// has multiple labeled configs.
225    pub fn request_delete(&mut self, id: ProviderConfigId) {
226        self.pending_delete = Some(id.provider.clone());
227        self.pending_delete_id = Some(id);
228    }
229
230    /// Dismiss a pending provider delete confirmation. Idempotent.
231    pub fn cancel_delete(&mut self) {
232        self.pending_delete = None;
233        self.pending_delete_id = None;
234    }
235
236    /// Toggle the expanded state of a provider group in the tree-style
237    /// provider list. Returns `true` when the provider is now expanded
238    /// (was added) and `false` when it is now collapsed (was removed)
239    /// so the caller can log the transition without re-reading state.
240    pub fn toggle_expanded(&mut self, name: &str) -> bool {
241        let added = !self.expanded_providers.contains(name);
242        if added {
243            self.expanded_providers.insert(name.to_string());
244        } else {
245            self.expanded_providers.remove(name);
246        }
247        added
248    }
249
250    /// Dismiss an in-progress lazy label-migration. Idempotent.
251    pub fn cancel_label_migration(&mut self) {
252        self.pending_label_migration = None;
253    }
254}
255
256impl Default for ProviderState {
257    /// Truly empty default. No disk I/O. Call sites that need persisted
258    /// state (App::new) construct with struct-update syntax:
259    /// `ProviderState { config: ProviderConfig::load(), sync_history: SyncRecord::load_all(), ..Default::default() }`.
260    fn default() -> Self {
261        Self {
262            config: ProviderConfig::default(),
263            form: ProviderFormFields::new(),
264            syncing: HashMap::new(),
265            sync_done: Vec::new(),
266            sync_had_errors: false,
267            batch_added: 0,
268            batch_updated: 0,
269            batch_stale: 0,
270            batch_total: 0,
271            pending_delete: None,
272            pending_delete_id: None,
273            sync_history: HashMap::new(),
274            form_baseline: None,
275            expanded_providers: HashSet::new(),
276            pending_label_migration: None,
277        }
278    }
279}
280
281impl ProviderState {
282    /// Construct with persisted state loaded from disk.
283    pub fn load() -> Self {
284        Self {
285            config: crate::providers::config::ProviderConfig::load(),
286            sync_history: SyncRecord::load_all(),
287            ..Self::default()
288        }
289    }
290
291    /// One row in the provider list, in display order.
292    /// Each provider is a `Header`. When the provider has 2+ labeled configs
293    /// AND is in `expanded_providers`, its `Leaf` rows follow immediately.
294    /// When the provider has 0 or 1 config, no leaves are emitted.
295    pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
296        let mut rows = Vec::new();
297        for name in self.sorted_names() {
298            let configs = self.config.sections_for_provider(&name);
299            rows.push(ProviderRow::Header {
300                name: name.clone(),
301                config_count: configs.len(),
302            });
303            if configs.len() >= 2 && self.expanded_providers.contains(&name) {
304                let mut sorted = configs.clone();
305                sorted.sort_by(|a, b| {
306                    a.id.label
307                        .as_deref()
308                        .unwrap_or("")
309                        .cmp(b.id.label.as_deref().unwrap_or(""))
310                });
311                for s in sorted {
312                    rows.push(ProviderRow::Leaf { id: s.id.clone() });
313                }
314            }
315        }
316        rows
317    }
318
319    /// Provider names sorted by last sync (most recent first), then configured,
320    /// then unconfigured. Includes any unknown provider names found in the
321    /// config file (e.g. typos or future providers).
322    pub fn sorted_names(&self) -> Vec<String> {
323        use crate::providers;
324        let mut names: Vec<String> = providers::PROVIDER_NAMES
325            .iter()
326            .map(|s| s.to_string())
327            .collect();
328        // Append configured providers not in the known list so they are visible and removable
329        for section in &self.config.sections {
330            let name = section.provider().to_string();
331            if !names.contains(&name) {
332                names.push(name);
333            }
334        }
335        // For multi-config providers the sync_history keys are the full id
336        // ("digitalocean:work"), not the bare name. Take the MAX timestamp
337        // across any history entry whose key matches this provider so the
338        // recency sort works for both single and multi-config layouts.
339        let max_ts = |provider: &str| -> u64 {
340            self.sync_history
341                .iter()
342                .filter(|(k, _)| {
343                    k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
344                })
345                .map(|(_, r)| r.timestamp)
346                .max()
347                .unwrap_or(0)
348        };
349        names.sort_by(|a, b| {
350            let conf_a = self.config.section(a.as_str()).is_some();
351            let conf_b = self.config.section(b.as_str()).is_some();
352            let ts_a = max_ts(a.as_str());
353            let ts_b = max_ts(b.as_str());
354            // Configured first (by most recent sync), then unconfigured alphabetically
355            conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
356        });
357        names
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn default_is_empty() {
367        // Must not touch disk. Constructed with ProviderConfig::default()
368        // and an empty sync_history. App::new() layers the real on-disk
369        // state on top via struct-update syntax.
370        let s = ProviderState::default();
371        assert!(s.config.sections.is_empty());
372        assert!(s.config.path_override.is_none());
373        assert!(s.syncing.is_empty());
374        assert!(s.sync_done.is_empty());
375        assert!(!s.sync_had_errors);
376        assert!(s.pending_delete.is_none());
377        assert!(s.sync_history.is_empty());
378        assert!(s.form_baseline.is_none());
379    }
380
381    #[test]
382    fn sorted_names_returns_configured_providers_before_unconfigured() {
383        use crate::providers::config::ProviderSection;
384
385        let mut state = ProviderState::default();
386        state.config.sections.push(ProviderSection {
387            id: crate::providers::config::ProviderConfigId::bare("vultr"),
388            token: "tok".to_string(),
389            alias_prefix: "vultr".to_string(),
390            ..ProviderSection::default()
391        });
392        state.config.sections.push(ProviderSection {
393            id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
394            token: "tok".to_string(),
395            alias_prefix: "do".to_string(),
396            ..ProviderSection::default()
397        });
398        state.sync_history.insert(
399            "digitalocean".to_string(),
400            SyncRecord {
401                timestamp: 2_000,
402                message: "ok".to_string(),
403                is_error: false,
404            },
405        );
406        state.sync_history.insert(
407            "vultr".to_string(),
408            SyncRecord {
409                timestamp: 1_000,
410                message: "ok".to_string(),
411                is_error: false,
412            },
413        );
414
415        let names = state.sorted_names();
416        // Configured providers (most recent sync first) precede unconfigured.
417        assert_eq!(&names[0], "digitalocean");
418        assert_eq!(&names[1], "vultr");
419        // Every known provider name must be present.
420        for &known in crate::providers::PROVIDER_NAMES {
421            assert!(names.iter().any(|n| n == known), "missing {}", known);
422        }
423        // Unconfigured tail is sorted alphabetically.
424        let unconfigured: Vec<&String> = names.iter().skip(2).collect();
425        let mut sorted = unconfigured.clone();
426        sorted.sort();
427        assert_eq!(unconfigured, sorted);
428    }
429
430    #[test]
431    fn sorted_names_includes_unknown_providers_from_config() {
432        use crate::providers::config::ProviderSection;
433
434        let mut state = ProviderState::default();
435        state.config.sections.push(ProviderSection {
436            id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
437            token: "tok".to_string(),
438            alias_prefix: "x".to_string(),
439            ..ProviderSection::default()
440        });
441
442        let names = state.sorted_names();
443        assert!(names.iter().any(|n| n == "someday_provider"));
444    }
445
446    #[test]
447    fn request_delete_sets_both_pending_fields() {
448        let mut s = ProviderState::default();
449        let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
450        s.request_delete(id.clone());
451        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
452        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
453    }
454
455    #[test]
456    fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
457        let mut s = ProviderState::default();
458        let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
459        s.request_delete(id.clone());
460        // pending_delete carries only the provider name; the full id with
461        // label is in pending_delete_id.
462        assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
463        assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
464    }
465
466    #[test]
467    fn request_delete_overwrites_existing_pending() {
468        let mut s = ProviderState::default();
469        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
470        let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
471        s.request_delete(new_id.clone());
472        assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
473        assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
474    }
475
476    #[test]
477    fn cancel_delete_clears_both_pending_fields() {
478        let mut s = ProviderState::default();
479        s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
480        s.cancel_delete();
481        assert!(s.pending_delete.is_none());
482        assert!(s.pending_delete_id.is_none());
483    }
484
485    #[test]
486    fn cancel_delete_is_idempotent() {
487        let mut s = ProviderState::default();
488        s.cancel_delete();
489        s.cancel_delete();
490        assert!(s.pending_delete.is_none());
491        assert!(s.pending_delete_id.is_none());
492    }
493
494    #[test]
495    fn toggle_expanded_adds_when_absent_and_returns_true() {
496        let mut s = ProviderState::default();
497        assert!(!s.expanded_providers.contains("digitalocean"));
498        let added = s.toggle_expanded("digitalocean");
499        assert!(added);
500        assert!(s.expanded_providers.contains("digitalocean"));
501    }
502
503    #[test]
504    fn toggle_expanded_removes_when_present_and_returns_false() {
505        let mut s = ProviderState::default();
506        s.expanded_providers.insert("digitalocean".to_string());
507        let added = s.toggle_expanded("digitalocean");
508        assert!(!added);
509        assert!(!s.expanded_providers.contains("digitalocean"));
510    }
511
512    #[test]
513    fn cancel_label_migration_clears_pending() {
514        let mut s = ProviderState {
515            pending_label_migration: Some(PendingLabelMigration {
516                provider: "digitalocean".to_string(),
517                existing_label: "old".to_string(),
518                new_label: "new".to_string(),
519                focused: LabelMigrationField::Existing,
520                cursor_pos: 0,
521            }),
522            ..Default::default()
523        };
524        s.cancel_label_migration();
525        assert!(s.pending_label_migration.is_none());
526    }
527
528    #[test]
529    fn cancel_label_migration_is_idempotent_when_already_none() {
530        let mut s = ProviderState::default();
531        s.cancel_label_migration();
532        s.cancel_label_migration();
533        assert!(s.pending_label_migration.is_none());
534    }
535}