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(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 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 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
103pub 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 pub(in crate::app) sync_done: Vec<String>,
114 pub(in crate::app) sync_had_errors: bool,
116 pub(in crate::app) batch_added: usize,
120 pub(in crate::app) batch_updated: usize,
121 pub(in crate::app) batch_stale: usize,
122 pub(in crate::app) batch_total: usize,
126 pub(in crate::app) pending_delete: Option<String>,
127 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 pub(in crate::app) expanded_providers: HashSet<String>,
135 pub(in crate::app) pending_label_migration: Option<PendingLabelMigration>,
142}
143
144#[derive(Debug, Clone)]
147pub struct PendingLabelMigration {
148 pub provider: String,
149 pub existing_label: String,
151 pub new_label: String,
153 pub focused: LabelMigrationField,
155 pub cursor_pos: usize,
157}
158
159impl PendingLabelMigration {
160 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 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#[derive(Debug, Clone)]
185pub enum ProviderRow {
186 Header { name: String, config_count: usize },
189 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 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 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 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 pub fn cancel_delete(&mut self) {
246 self.pending_delete = None;
247 self.pending_delete_id = None;
248 }
249
250 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(&names[0], "digitalocean");
593 assert_eq!(&names[1], "vultr");
594 for &known in crate::providers::PROVIDER_NAMES {
596 assert!(names.iter().any(|n| n == known), "missing {}", known);
597 }
598 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 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}