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.filter != b.filter
413 || self.form.alias_prefix != b.alias_prefix
414 || self.form.user != b.user
415 || self.form.identity_file != b.identity_file
416 || self.form.verify_tls != b.verify_tls
417 || self.form.auto_sync != b.auto_sync
418 || self.form.vault_role != b.vault_role
419 || self.form.vault_addr != b.vault_addr
420 || self.form.ssm != b.ssm
421 }
422 None => false,
423 }
424 }
425
426 pub fn expanded_providers(&self) -> &HashSet<String> {
427 &self.expanded_providers
428 }
429
430 pub fn expanded_providers_mut(&mut self) -> &mut HashSet<String> {
431 &mut self.expanded_providers
432 }
433
434 pub fn pending_label_migration(&self) -> Option<&PendingLabelMigration> {
435 self.pending_label_migration.as_ref()
436 }
437
438 pub fn pending_label_migration_mut(&mut self) -> Option<&mut PendingLabelMigration> {
439 self.pending_label_migration.as_mut()
440 }
441
442 pub fn set_pending_label_migration(&mut self, migration: Option<PendingLabelMigration>) {
443 self.pending_label_migration = migration;
444 }
445
446 pub fn pending_purge(&self) -> Option<&PendingPurge> {
447 self.pending_purge.as_ref()
448 }
449
450 pub fn set_pending_purge(&mut self, pending: PendingPurge) {
451 self.pending_purge = Some(pending);
452 }
453
454 pub fn take_pending_purge(&mut self) -> Option<PendingPurge> {
455 self.pending_purge.take()
456 }
457}
458
459impl Default for ProviderState {
460 fn default() -> Self {
464 Self {
465 config: ProviderConfig::default(),
466 form: ProviderFormFields::new(),
467 syncing: HashMap::new(),
468 sync_done: Vec::new(),
469 sync_had_errors: false,
470 batch_added: 0,
471 batch_updated: 0,
472 batch_stale: 0,
473 batch_total: 0,
474 pending_delete: None,
475 pending_delete_id: None,
476 sync_history: HashMap::new(),
477 form_baseline: None,
478 expanded_providers: HashSet::new(),
479 pending_label_migration: None,
480 pending_purge: None,
481 }
482 }
483}
484
485impl ProviderState {
486 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
488 Self {
489 config: crate::providers::config::ProviderConfig::load(paths),
490 sync_history: SyncRecord::load_all(paths),
491 ..Self::default()
492 }
493 }
494
495 pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
500 let mut rows = Vec::new();
501 for name in self.sorted_names() {
502 let configs = self.config.sections_for_provider(&name);
503 rows.push(ProviderRow::Header {
504 name: name.clone(),
505 config_count: configs.len(),
506 });
507 if configs.len() >= 2 && self.expanded_providers.contains(&name) {
508 let mut sorted = configs.clone();
509 sorted.sort_by(|a, b| {
510 a.id.label
511 .as_deref()
512 .unwrap_or("")
513 .cmp(b.id.label.as_deref().unwrap_or(""))
514 });
515 for s in sorted {
516 rows.push(ProviderRow::Leaf { id: s.id.clone() });
517 }
518 }
519 }
520 rows
521 }
522
523 pub fn sorted_names(&self) -> Vec<String> {
527 use crate::providers;
528 let mut names: Vec<String> = providers::PROVIDER_NAMES
529 .iter()
530 .map(|s| s.to_string())
531 .collect();
532 for section in &self.config.sections {
534 let name = section.provider().to_string();
535 if !names.contains(&name) {
536 names.push(name);
537 }
538 }
539 let max_ts = |provider: &str| -> u64 {
544 self.sync_history
545 .iter()
546 .filter(|(k, _)| {
547 k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
548 })
549 .map(|(_, r)| r.timestamp)
550 .max()
551 .unwrap_or(0)
552 };
553 names.sort_by(|a, b| {
554 let conf_a = self.config.section(a.as_str()).is_some();
555 let conf_b = self.config.section(b.as_str()).is_some();
556 let ts_a = max_ts(a.as_str());
557 let ts_b = max_ts(b.as_str());
558 conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
560 });
561 names
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn default_is_empty() {
571 let s = ProviderState::default();
575 assert!(s.config.sections.is_empty());
576 assert!(s.config.path_override.is_none());
577 assert!(s.syncing.is_empty());
578 assert!(s.sync_done.is_empty());
579 assert!(!s.sync_had_errors);
580 assert!(s.pending_delete.is_none());
581 assert!(s.sync_history.is_empty());
582 assert!(s.form_baseline.is_none());
583 }
584
585 #[test]
586 fn sorted_names_returns_configured_providers_before_unconfigured() {
587 use crate::providers::config::ProviderSection;
588
589 let mut state = ProviderState::default();
590 state.config.sections.push(ProviderSection {
591 id: crate::providers::config::ProviderConfigId::bare("vultr"),
592 token: "tok".to_string(),
593 alias_prefix: "vultr".to_string(),
594 ..ProviderSection::default()
595 });
596 state.config.sections.push(ProviderSection {
597 id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
598 token: "tok".to_string(),
599 alias_prefix: "do".to_string(),
600 ..ProviderSection::default()
601 });
602 state.sync_history.insert(
603 "digitalocean".to_string(),
604 SyncRecord {
605 timestamp: 2_000,
606 message: "ok".to_string(),
607 is_error: false,
608 },
609 );
610 state.sync_history.insert(
611 "vultr".to_string(),
612 SyncRecord {
613 timestamp: 1_000,
614 message: "ok".to_string(),
615 is_error: false,
616 },
617 );
618
619 let names = state.sorted_names();
620 assert_eq!(&names[0], "digitalocean");
622 assert_eq!(&names[1], "vultr");
623 for &known in crate::providers::PROVIDER_NAMES {
625 assert!(names.iter().any(|n| n == known), "missing {}", known);
626 }
627 let unconfigured: Vec<&String> = names.iter().skip(2).collect();
629 let mut sorted = unconfigured.clone();
630 sorted.sort();
631 assert_eq!(unconfigured, sorted);
632 }
633
634 #[test]
635 fn sorted_names_includes_unknown_providers_from_config() {
636 use crate::providers::config::ProviderSection;
637
638 let mut state = ProviderState::default();
639 state.config.sections.push(ProviderSection {
640 id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
641 token: "tok".to_string(),
642 alias_prefix: "x".to_string(),
643 ..ProviderSection::default()
644 });
645
646 let names = state.sorted_names();
647 assert!(names.iter().any(|n| n == "someday_provider"));
648 }
649
650 #[test]
651 fn request_delete_sets_both_pending_fields() {
652 let mut s = ProviderState::default();
653 let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
654 s.request_delete(id.clone());
655 assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
656 assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
657 }
658
659 #[test]
660 fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
661 let mut s = ProviderState::default();
662 let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
663 s.request_delete(id.clone());
664 assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
667 assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
668 }
669
670 #[test]
671 fn request_delete_overwrites_existing_pending() {
672 let mut s = ProviderState::default();
673 s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
674 let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
675 s.request_delete(new_id.clone());
676 assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
677 assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
678 }
679
680 #[test]
681 fn cancel_delete_clears_both_pending_fields() {
682 let mut s = ProviderState::default();
683 s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
684 s.cancel_delete();
685 assert!(s.pending_delete.is_none());
686 assert!(s.pending_delete_id.is_none());
687 }
688
689 #[test]
690 fn cancel_delete_is_idempotent() {
691 let mut s = ProviderState::default();
692 s.cancel_delete();
693 s.cancel_delete();
694 assert!(s.pending_delete.is_none());
695 assert!(s.pending_delete_id.is_none());
696 }
697
698 #[test]
699 fn toggle_expanded_adds_when_absent_and_returns_true() {
700 let mut s = ProviderState::default();
701 assert!(!s.expanded_providers.contains("digitalocean"));
702 let added = s.toggle_expanded("digitalocean");
703 assert!(added);
704 assert!(s.expanded_providers.contains("digitalocean"));
705 }
706
707 #[test]
708 fn toggle_expanded_removes_when_present_and_returns_false() {
709 let mut s = ProviderState::default();
710 s.expanded_providers.insert("digitalocean".to_string());
711 let added = s.toggle_expanded("digitalocean");
712 assert!(!added);
713 assert!(!s.expanded_providers.contains("digitalocean"));
714 }
715
716 #[test]
717 fn cancel_label_migration_clears_pending() {
718 let mut s = ProviderState {
719 pending_label_migration: Some(PendingLabelMigration {
720 provider: "digitalocean".to_string(),
721 existing_label: "old".to_string(),
722 new_label: "new".to_string(),
723 focused: LabelMigrationField::Existing,
724 cursor_pos: 0,
725 }),
726 ..Default::default()
727 };
728 s.cancel_label_migration();
729 assert!(s.pending_label_migration.is_none());
730 }
731
732 #[test]
733 fn cancel_label_migration_is_idempotent_when_already_none() {
734 let mut s = ProviderState::default();
735 s.cancel_label_migration();
736 s.cancel_label_migration();
737 assert!(s.pending_label_migration.is_none());
738 }
739
740 #[test]
741 fn add_batch_diff_accumulates_each_counter() {
742 let mut s = ProviderState::default();
743 s.add_batch_diff(3, 1, 2);
744 s.add_batch_diff(1, 0, 4);
745 assert_eq!(s.batch_added(), 4);
746 assert_eq!(s.batch_updated(), 1);
747 assert_eq!(s.batch_stale(), 6);
748 }
749
750 #[test]
751 fn bump_batch_total_raises_to_done_plus_syncing() {
752 let mut s = ProviderState::default();
753 s.push_sync_done("aws".to_string());
754 s.syncing_mut()
755 .insert("vultr".to_string(), Arc::new(AtomicBool::new(false)));
756 s.bump_batch_total();
757 assert_eq!(s.batch_total(), 2);
758 }
759
760 #[test]
761 fn bump_batch_total_never_lowers_existing_peak() {
762 let mut s = ProviderState::default();
763 s.set_batch_total(5);
764 s.push_sync_done("aws".to_string());
765 s.bump_batch_total();
766 assert_eq!(s.batch_total(), 5);
767 }
768
769 #[test]
770 fn finish_batch_clears_all_batch_state() {
771 let mut s = ProviderState::default();
772 s.push_sync_done("aws".to_string());
773 s.set_sync_had_errors(true);
774 s.add_batch_diff(2, 3, 4);
775 s.set_batch_total(7);
776 s.finish_batch();
777 assert!(s.sync_done().is_empty());
778 assert!(!s.sync_had_errors());
779 assert_eq!(s.batch_added(), 0);
780 assert_eq!(s.batch_updated(), 0);
781 assert_eq!(s.batch_stale(), 0);
782 assert_eq!(s.batch_total(), 0);
783 }
784
785 fn state_matching_baseline() -> ProviderState {
786 let b = ProviderFormBaseline {
787 url: "https://api".into(),
788 token: "tok".into(),
789 profile: "default".into(),
790 project: "proj".into(),
791 compartment: "comp".into(),
792 regions: "eu-west".into(),
793 filter: "status=active".into(),
794 alias_prefix: "ap".into(),
795 user: "ec2-user".into(),
796 identity_file: "~/.ssh/id".into(),
797 verify_tls: true,
798 auto_sync: false,
799 vault_role: "role".into(),
800 vault_addr: "https://vault".into(),
801 ssm: crate::providers::aws_ssm::SsmMode::default(),
802 };
803 let mut s = ProviderState::default();
804 s.form.url = b.url.clone();
805 s.form.token = b.token.clone();
806 s.form.profile = b.profile.clone();
807 s.form.project = b.project.clone();
808 s.form.compartment = b.compartment.clone();
809 s.form.regions = b.regions.clone();
810 s.form.filter = b.filter.clone();
811 s.form.alias_prefix = b.alias_prefix.clone();
812 s.form.user = b.user.clone();
813 s.form.identity_file = b.identity_file.clone();
814 s.form.verify_tls = b.verify_tls;
815 s.form.auto_sync = b.auto_sync;
816 s.form.vault_role = b.vault_role.clone();
817 s.form.vault_addr = b.vault_addr.clone();
818 s.set_form_baseline(Some(b));
819 s
820 }
821
822 #[test]
823 fn form_is_dirty_is_false_without_a_baseline() {
824 let mut s = ProviderState::default();
825 s.form.url = "edited".into();
826 assert!(!s.form_is_dirty());
827 }
828
829 #[test]
830 fn form_is_dirty_is_false_when_form_equals_baseline() {
831 assert!(!state_matching_baseline().form_is_dirty());
832 }
833
834 fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut ProviderFormFields)) {
835 let mut s = state_matching_baseline();
836 mutate(&mut s.form);
837 assert!(s.form_is_dirty(), "a change in {field} must read dirty");
838 }
839
840 #[test]
841 fn form_is_dirty_detects_a_change_in_each_field() {
842 assert_field_change_is_dirty("url", |f| f.url.push('x'));
843 assert_field_change_is_dirty("token", |f| f.token.push('x'));
844 assert_field_change_is_dirty("profile", |f| f.profile.push('x'));
845 assert_field_change_is_dirty("project", |f| f.project.push('x'));
846 assert_field_change_is_dirty("compartment", |f| f.compartment.push('x'));
847 assert_field_change_is_dirty("regions", |f| f.regions.push('x'));
848 assert_field_change_is_dirty("alias_prefix", |f| f.alias_prefix.push('x'));
849 assert_field_change_is_dirty("user", |f| f.user.push('x'));
850 assert_field_change_is_dirty("identity_file", |f| f.identity_file.push('x'));
851 assert_field_change_is_dirty("verify_tls", |f| f.verify_tls = !f.verify_tls);
852 assert_field_change_is_dirty("auto_sync", |f| f.auto_sync = !f.auto_sync);
853 assert_field_change_is_dirty("vault_role", |f| f.vault_role.push('x'));
854 assert_field_change_is_dirty("vault_addr", |f| f.vault_addr.push('x'));
855 assert_field_change_is_dirty("filter", |f| f.filter.push('x'));
856 assert_field_change_is_dirty("ssm", |f| f.ssm = f.ssm.next());
857 }
858}