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#[derive(Debug, Clone)]
11pub struct SyncRecord {
12 pub timestamp: u64,
13 pub message: String,
14 pub is_error: bool,
15}
16
17impl SyncRecord {
18 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 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 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
101pub 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 pub(in crate::app) sync_done: Vec<String>,
112 pub(in crate::app) sync_had_errors: bool,
114 pub(in crate::app) batch_added: usize,
118 pub(in crate::app) batch_updated: usize,
119 pub(in crate::app) batch_stale: usize,
120 pub(in crate::app) batch_total: usize,
124 pub(in crate::app) pending_delete: Option<String>,
125 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 pub(in crate::app) expanded_providers: HashSet<String>,
133 pub(in crate::app) pending_label_migration: Option<PendingLabelMigration>,
140}
141
142#[derive(Debug, Clone)]
145pub struct PendingLabelMigration {
146 pub provider: String,
147 pub existing_label: String,
149 pub new_label: String,
151 pub focused: LabelMigrationField,
153 pub cursor_pos: usize,
155}
156
157impl PendingLabelMigration {
158 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 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#[derive(Debug, Clone)]
183pub enum ProviderRow {
184 Header { name: String, config_count: usize },
187 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 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 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 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 pub fn cancel_delete(&mut self) {
244 self.pending_delete = None;
245 self.pending_delete_id = None;
246 }
247
248 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(&names[0], "digitalocean");
591 assert_eq!(&names[1], "vultr");
592 for &known in crate::providers::PROVIDER_NAMES {
594 assert!(names.iter().any(|n| n == known), "missing {}", known);
595 }
596 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 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}