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 pub(in crate::app) pending_purge: Option<PendingPurge>,
147}
148
149#[derive(Debug, Clone)]
151pub struct PendingPurge {
152 pub aliases: Vec<String>,
154 pub provider: Option<String>,
156}
157
158#[derive(Debug, Clone)]
161pub struct PendingLabelMigration {
162 pub provider: String,
163 pub existing_label: String,
165 pub new_label: String,
167 pub focused: LabelMigrationField,
169 pub cursor_pos: usize,
171}
172
173impl PendingLabelMigration {
174 pub fn focused_value(&self) -> &str {
176 match self.focused {
177 LabelMigrationField::Existing => &self.existing_label,
178 LabelMigrationField::New => &self.new_label,
179 }
180 }
181
182 pub fn focused_value_mut(&mut self) -> &mut String {
184 match self.focused {
185 LabelMigrationField::Existing => &mut self.existing_label,
186 LabelMigrationField::New => &mut self.new_label,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum LabelMigrationField {
193 Existing,
194 New,
195}
196
197#[derive(Debug, Clone)]
199pub enum ProviderRow {
200 Header { name: String, config_count: usize },
203 Leaf { id: ProviderConfigId },
205}
206
207impl ProviderRow {
208 pub fn provider_name(&self) -> &str {
209 match self {
210 ProviderRow::Header { name, .. } => name,
211 ProviderRow::Leaf { id } => &id.provider,
212 }
213 }
214}
215
216impl ProviderState {
217 pub fn reset_batch_if_idle(&mut self) {
227 if self.syncing.is_empty() && self.sync_done.is_empty() {
228 self.batch_total = 0;
229 self.batch_added = 0;
230 self.batch_updated = 0;
231 self.batch_stale = 0;
232 self.sync_had_errors = false;
233 }
234 }
235
236 pub fn finish_batch(&mut self) {
240 self.sync_done.clear();
241 self.sync_had_errors = false;
242 self.batch_added = 0;
243 self.batch_updated = 0;
244 self.batch_stale = 0;
245 self.batch_total = 0;
246 }
247
248 pub fn request_delete(&mut self, id: ProviderConfigId) {
254 self.pending_delete = Some(id.provider.clone());
255 self.pending_delete_id = Some(id);
256 }
257
258 pub fn cancel_delete(&mut self) {
260 self.pending_delete = None;
261 self.pending_delete_id = None;
262 }
263
264 pub fn toggle_expanded(&mut self, name: &str) -> bool {
269 let added = !self.expanded_providers.contains(name);
270 if added {
271 self.expanded_providers.insert(name.to_string());
272 } else {
273 self.expanded_providers.remove(name);
274 }
275 added
276 }
277
278 pub fn cancel_label_migration(&mut self) {
280 self.pending_label_migration = None;
281 }
282
283 pub fn config(&self) -> &ProviderConfig {
284 &self.config
285 }
286
287 pub fn config_mut(&mut self) -> &mut ProviderConfig {
288 &mut self.config
289 }
290
291 pub fn form(&self) -> &ProviderFormFields {
292 &self.form
293 }
294
295 pub fn form_mut(&mut self) -> &mut ProviderFormFields {
296 &mut self.form
297 }
298
299 pub fn syncing(&self) -> &HashMap<String, Arc<AtomicBool>> {
300 &self.syncing
301 }
302
303 pub fn syncing_mut(&mut self) -> &mut HashMap<String, Arc<AtomicBool>> {
304 &mut self.syncing
305 }
306
307 pub fn sync_done(&self) -> &[String] {
308 &self.sync_done
309 }
310
311 pub fn push_sync_done(&mut self, name: String) {
312 self.sync_done.push(name);
313 }
314
315 pub fn clear_sync_done(&mut self) {
316 self.sync_done.clear();
317 }
318
319 pub fn sync_had_errors(&self) -> bool {
320 self.sync_had_errors
321 }
322
323 pub fn set_sync_had_errors(&mut self, value: bool) {
324 self.sync_had_errors = value;
325 }
326
327 pub fn batch_added(&self) -> usize {
328 self.batch_added
329 }
330
331 pub fn batch_updated(&self) -> usize {
332 self.batch_updated
333 }
334
335 pub fn batch_stale(&self) -> usize {
336 self.batch_stale
337 }
338
339 pub fn add_batch_diff(&mut self, added: usize, updated: usize, stale: usize) {
342 self.batch_added += added;
343 self.batch_updated += updated;
344 self.batch_stale += stale;
345 }
346
347 pub fn batch_total(&self) -> usize {
348 self.batch_total
349 }
350
351 pub fn set_batch_total(&mut self, value: usize) {
352 self.batch_total = value;
353 }
354
355 pub fn bump_batch_total(&mut self) {
359 self.batch_total = self
360 .batch_total
361 .max(self.sync_done.len() + self.syncing.len());
362 }
363
364 pub fn pending_delete(&self) -> Option<&str> {
365 self.pending_delete.as_deref()
366 }
367
368 pub fn take_pending_delete(&mut self) -> Option<String> {
369 self.pending_delete.take()
370 }
371
372 pub fn pending_delete_id(&self) -> Option<&ProviderConfigId> {
373 self.pending_delete_id.as_ref()
374 }
375
376 pub fn take_pending_delete_id(&mut self) -> Option<ProviderConfigId> {
377 self.pending_delete_id.take()
378 }
379
380 pub fn sync_history(&self) -> &HashMap<String, SyncRecord> {
381 &self.sync_history
382 }
383
384 pub fn sync_history_mut(&mut self) -> &mut HashMap<String, SyncRecord> {
385 &mut self.sync_history
386 }
387
388 pub fn record_sync(&mut self, key: String, record: SyncRecord) {
391 self.sync_history.insert(key, record);
392 }
393
394 pub fn form_baseline(&self) -> Option<&ProviderFormBaseline> {
395 self.form_baseline.as_ref()
396 }
397
398 pub fn set_form_baseline(&mut self, baseline: Option<ProviderFormBaseline>) {
399 self.form_baseline = baseline;
400 }
401
402 pub fn form_is_dirty(&self) -> bool {
404 match &self.form_baseline {
405 Some(b) => {
406 self.form.url != b.url
407 || self.form.token != b.token
408 || self.form.profile != b.profile
409 || self.form.project != b.project
410 || self.form.compartment != b.compartment
411 || self.form.regions != b.regions
412 || self.form.alias_prefix != b.alias_prefix
413 || self.form.user != b.user
414 || self.form.identity_file != b.identity_file
415 || self.form.verify_tls != b.verify_tls
416 || self.form.auto_sync != b.auto_sync
417 || self.form.vault_role != b.vault_role
418 || self.form.vault_addr != b.vault_addr
419 }
420 None => false,
421 }
422 }
423
424 pub fn expanded_providers(&self) -> &HashSet<String> {
425 &self.expanded_providers
426 }
427
428 pub fn expanded_providers_mut(&mut self) -> &mut HashSet<String> {
429 &mut self.expanded_providers
430 }
431
432 pub fn pending_label_migration(&self) -> Option<&PendingLabelMigration> {
433 self.pending_label_migration.as_ref()
434 }
435
436 pub fn pending_label_migration_mut(&mut self) -> Option<&mut PendingLabelMigration> {
437 self.pending_label_migration.as_mut()
438 }
439
440 pub fn set_pending_label_migration(&mut self, migration: Option<PendingLabelMigration>) {
441 self.pending_label_migration = migration;
442 }
443
444 pub fn pending_purge(&self) -> Option<&PendingPurge> {
445 self.pending_purge.as_ref()
446 }
447
448 pub fn set_pending_purge(&mut self, pending: PendingPurge) {
449 self.pending_purge = Some(pending);
450 }
451
452 pub fn take_pending_purge(&mut self) -> Option<PendingPurge> {
453 self.pending_purge.take()
454 }
455}
456
457impl Default for ProviderState {
458 fn default() -> Self {
462 Self {
463 config: ProviderConfig::default(),
464 form: ProviderFormFields::new(),
465 syncing: HashMap::new(),
466 sync_done: Vec::new(),
467 sync_had_errors: false,
468 batch_added: 0,
469 batch_updated: 0,
470 batch_stale: 0,
471 batch_total: 0,
472 pending_delete: None,
473 pending_delete_id: None,
474 sync_history: HashMap::new(),
475 form_baseline: None,
476 expanded_providers: HashSet::new(),
477 pending_label_migration: None,
478 pending_purge: None,
479 }
480 }
481}
482
483impl ProviderState {
484 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
486 Self {
487 config: crate::providers::config::ProviderConfig::load(paths),
488 sync_history: SyncRecord::load_all(paths),
489 ..Self::default()
490 }
491 }
492
493 pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
498 let mut rows = Vec::new();
499 for name in self.sorted_names() {
500 let configs = self.config.sections_for_provider(&name);
501 rows.push(ProviderRow::Header {
502 name: name.clone(),
503 config_count: configs.len(),
504 });
505 if configs.len() >= 2 && self.expanded_providers.contains(&name) {
506 let mut sorted = configs.clone();
507 sorted.sort_by(|a, b| {
508 a.id.label
509 .as_deref()
510 .unwrap_or("")
511 .cmp(b.id.label.as_deref().unwrap_or(""))
512 });
513 for s in sorted {
514 rows.push(ProviderRow::Leaf { id: s.id.clone() });
515 }
516 }
517 }
518 rows
519 }
520
521 pub fn sorted_names(&self) -> Vec<String> {
525 use crate::providers;
526 let mut names: Vec<String> = providers::PROVIDER_NAMES
527 .iter()
528 .map(|s| s.to_string())
529 .collect();
530 for section in &self.config.sections {
532 let name = section.provider().to_string();
533 if !names.contains(&name) {
534 names.push(name);
535 }
536 }
537 let max_ts = |provider: &str| -> u64 {
542 self.sync_history
543 .iter()
544 .filter(|(k, _)| {
545 k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
546 })
547 .map(|(_, r)| r.timestamp)
548 .max()
549 .unwrap_or(0)
550 };
551 names.sort_by(|a, b| {
552 let conf_a = self.config.section(a.as_str()).is_some();
553 let conf_b = self.config.section(b.as_str()).is_some();
554 let ts_a = max_ts(a.as_str());
555 let ts_b = max_ts(b.as_str());
556 conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
558 });
559 names
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn default_is_empty() {
569 let s = ProviderState::default();
573 assert!(s.config.sections.is_empty());
574 assert!(s.config.path_override.is_none());
575 assert!(s.syncing.is_empty());
576 assert!(s.sync_done.is_empty());
577 assert!(!s.sync_had_errors);
578 assert!(s.pending_delete.is_none());
579 assert!(s.sync_history.is_empty());
580 assert!(s.form_baseline.is_none());
581 }
582
583 #[test]
584 fn sorted_names_returns_configured_providers_before_unconfigured() {
585 use crate::providers::config::ProviderSection;
586
587 let mut state = ProviderState::default();
588 state.config.sections.push(ProviderSection {
589 id: crate::providers::config::ProviderConfigId::bare("vultr"),
590 token: "tok".to_string(),
591 alias_prefix: "vultr".to_string(),
592 ..ProviderSection::default()
593 });
594 state.config.sections.push(ProviderSection {
595 id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
596 token: "tok".to_string(),
597 alias_prefix: "do".to_string(),
598 ..ProviderSection::default()
599 });
600 state.sync_history.insert(
601 "digitalocean".to_string(),
602 SyncRecord {
603 timestamp: 2_000,
604 message: "ok".to_string(),
605 is_error: false,
606 },
607 );
608 state.sync_history.insert(
609 "vultr".to_string(),
610 SyncRecord {
611 timestamp: 1_000,
612 message: "ok".to_string(),
613 is_error: false,
614 },
615 );
616
617 let names = state.sorted_names();
618 assert_eq!(&names[0], "digitalocean");
620 assert_eq!(&names[1], "vultr");
621 for &known in crate::providers::PROVIDER_NAMES {
623 assert!(names.iter().any(|n| n == known), "missing {}", known);
624 }
625 let unconfigured: Vec<&String> = names.iter().skip(2).collect();
627 let mut sorted = unconfigured.clone();
628 sorted.sort();
629 assert_eq!(unconfigured, sorted);
630 }
631
632 #[test]
633 fn sorted_names_includes_unknown_providers_from_config() {
634 use crate::providers::config::ProviderSection;
635
636 let mut state = ProviderState::default();
637 state.config.sections.push(ProviderSection {
638 id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
639 token: "tok".to_string(),
640 alias_prefix: "x".to_string(),
641 ..ProviderSection::default()
642 });
643
644 let names = state.sorted_names();
645 assert!(names.iter().any(|n| n == "someday_provider"));
646 }
647
648 #[test]
649 fn request_delete_sets_both_pending_fields() {
650 let mut s = ProviderState::default();
651 let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
652 s.request_delete(id.clone());
653 assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
654 assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
655 }
656
657 #[test]
658 fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
659 let mut s = ProviderState::default();
660 let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
661 s.request_delete(id.clone());
662 assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
665 assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
666 }
667
668 #[test]
669 fn request_delete_overwrites_existing_pending() {
670 let mut s = ProviderState::default();
671 s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
672 let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
673 s.request_delete(new_id.clone());
674 assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
675 assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
676 }
677
678 #[test]
679 fn cancel_delete_clears_both_pending_fields() {
680 let mut s = ProviderState::default();
681 s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
682 s.cancel_delete();
683 assert!(s.pending_delete.is_none());
684 assert!(s.pending_delete_id.is_none());
685 }
686
687 #[test]
688 fn cancel_delete_is_idempotent() {
689 let mut s = ProviderState::default();
690 s.cancel_delete();
691 s.cancel_delete();
692 assert!(s.pending_delete.is_none());
693 assert!(s.pending_delete_id.is_none());
694 }
695
696 #[test]
697 fn toggle_expanded_adds_when_absent_and_returns_true() {
698 let mut s = ProviderState::default();
699 assert!(!s.expanded_providers.contains("digitalocean"));
700 let added = s.toggle_expanded("digitalocean");
701 assert!(added);
702 assert!(s.expanded_providers.contains("digitalocean"));
703 }
704
705 #[test]
706 fn toggle_expanded_removes_when_present_and_returns_false() {
707 let mut s = ProviderState::default();
708 s.expanded_providers.insert("digitalocean".to_string());
709 let added = s.toggle_expanded("digitalocean");
710 assert!(!added);
711 assert!(!s.expanded_providers.contains("digitalocean"));
712 }
713
714 #[test]
715 fn cancel_label_migration_clears_pending() {
716 let mut s = ProviderState {
717 pending_label_migration: Some(PendingLabelMigration {
718 provider: "digitalocean".to_string(),
719 existing_label: "old".to_string(),
720 new_label: "new".to_string(),
721 focused: LabelMigrationField::Existing,
722 cursor_pos: 0,
723 }),
724 ..Default::default()
725 };
726 s.cancel_label_migration();
727 assert!(s.pending_label_migration.is_none());
728 }
729
730 #[test]
731 fn cancel_label_migration_is_idempotent_when_already_none() {
732 let mut s = ProviderState::default();
733 s.cancel_label_migration();
734 s.cancel_label_migration();
735 assert!(s.pending_label_migration.is_none());
736 }
737
738 #[test]
739 fn add_batch_diff_accumulates_each_counter() {
740 let mut s = ProviderState::default();
741 s.add_batch_diff(3, 1, 2);
742 s.add_batch_diff(1, 0, 4);
743 assert_eq!(s.batch_added(), 4);
744 assert_eq!(s.batch_updated(), 1);
745 assert_eq!(s.batch_stale(), 6);
746 }
747
748 #[test]
749 fn bump_batch_total_raises_to_done_plus_syncing() {
750 let mut s = ProviderState::default();
751 s.push_sync_done("aws".to_string());
752 s.syncing_mut()
753 .insert("vultr".to_string(), Arc::new(AtomicBool::new(false)));
754 s.bump_batch_total();
755 assert_eq!(s.batch_total(), 2);
756 }
757
758 #[test]
759 fn bump_batch_total_never_lowers_existing_peak() {
760 let mut s = ProviderState::default();
761 s.set_batch_total(5);
762 s.push_sync_done("aws".to_string());
763 s.bump_batch_total();
764 assert_eq!(s.batch_total(), 5);
765 }
766
767 #[test]
768 fn finish_batch_clears_all_batch_state() {
769 let mut s = ProviderState::default();
770 s.push_sync_done("aws".to_string());
771 s.set_sync_had_errors(true);
772 s.add_batch_diff(2, 3, 4);
773 s.set_batch_total(7);
774 s.finish_batch();
775 assert!(s.sync_done().is_empty());
776 assert!(!s.sync_had_errors());
777 assert_eq!(s.batch_added(), 0);
778 assert_eq!(s.batch_updated(), 0);
779 assert_eq!(s.batch_stale(), 0);
780 assert_eq!(s.batch_total(), 0);
781 }
782
783 fn state_matching_baseline() -> ProviderState {
784 let b = ProviderFormBaseline {
785 url: "https://api".into(),
786 token: "tok".into(),
787 profile: "default".into(),
788 project: "proj".into(),
789 compartment: "comp".into(),
790 regions: "eu-west".into(),
791 alias_prefix: "ap".into(),
792 user: "ec2-user".into(),
793 identity_file: "~/.ssh/id".into(),
794 verify_tls: true,
795 auto_sync: false,
796 vault_role: "role".into(),
797 vault_addr: "https://vault".into(),
798 };
799 let mut s = ProviderState::default();
800 s.form.url = b.url.clone();
801 s.form.token = b.token.clone();
802 s.form.profile = b.profile.clone();
803 s.form.project = b.project.clone();
804 s.form.compartment = b.compartment.clone();
805 s.form.regions = b.regions.clone();
806 s.form.alias_prefix = b.alias_prefix.clone();
807 s.form.user = b.user.clone();
808 s.form.identity_file = b.identity_file.clone();
809 s.form.verify_tls = b.verify_tls;
810 s.form.auto_sync = b.auto_sync;
811 s.form.vault_role = b.vault_role.clone();
812 s.form.vault_addr = b.vault_addr.clone();
813 s.set_form_baseline(Some(b));
814 s
815 }
816
817 #[test]
818 fn form_is_dirty_is_false_without_a_baseline() {
819 let mut s = ProviderState::default();
820 s.form.url = "edited".into();
821 assert!(!s.form_is_dirty());
822 }
823
824 #[test]
825 fn form_is_dirty_is_false_when_form_equals_baseline() {
826 assert!(!state_matching_baseline().form_is_dirty());
827 }
828
829 fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut ProviderFormFields)) {
830 let mut s = state_matching_baseline();
831 mutate(&mut s.form);
832 assert!(s.form_is_dirty(), "a change in {field} must read dirty");
833 }
834
835 #[test]
836 fn form_is_dirty_detects_a_change_in_each_field() {
837 assert_field_change_is_dirty("url", |f| f.url.push('x'));
838 assert_field_change_is_dirty("token", |f| f.token.push('x'));
839 assert_field_change_is_dirty("profile", |f| f.profile.push('x'));
840 assert_field_change_is_dirty("project", |f| f.project.push('x'));
841 assert_field_change_is_dirty("compartment", |f| f.compartment.push('x'));
842 assert_field_change_is_dirty("regions", |f| f.regions.push('x'));
843 assert_field_change_is_dirty("alias_prefix", |f| f.alias_prefix.push('x'));
844 assert_field_change_is_dirty("user", |f| f.user.push('x'));
845 assert_field_change_is_dirty("identity_file", |f| f.identity_file.push('x'));
846 assert_field_change_is_dirty("verify_tls", |f| f.verify_tls = !f.verify_tls);
847 assert_field_change_is_dirty("auto_sync", |f| f.auto_sync = !f.auto_sync);
848 assert_field_change_is_dirty("vault_role", |f| f.vault_role.push('x'));
849 assert_field_change_is_dirty("vault_addr", |f| f.vault_addr.push('x'));
850 }
851}