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 config: ProviderConfig,
108 pub form: ProviderFormFields,
109 pub syncing: HashMap<String, Arc<AtomicBool>>,
110 pub sync_done: Vec<String>,
112 pub sync_had_errors: bool,
114 pub batch_added: usize,
118 pub batch_updated: usize,
119 pub batch_stale: usize,
120 pub batch_total: usize,
124 pub pending_delete: Option<String>,
125 pub pending_delete_id: Option<ProviderConfigId>,
128 pub sync_history: HashMap<String, SyncRecord>,
129 pub form_baseline: Option<ProviderFormBaseline>,
130 pub expanded_providers: HashSet<String>,
133 pub 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 request_delete(&mut self, id: ProviderConfigId) {
226 self.pending_delete = Some(id.provider.clone());
227 self.pending_delete_id = Some(id);
228 }
229
230 pub fn cancel_delete(&mut self) {
232 self.pending_delete = None;
233 self.pending_delete_id = None;
234 }
235
236 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 pub fn cancel_label_migration(&mut self) {
252 self.pending_label_migration = None;
253 }
254}
255
256impl Default for ProviderState {
257 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 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 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 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 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 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 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 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 assert_eq!(&names[0], "digitalocean");
418 assert_eq!(&names[1], "vultr");
419 for &known in crate::providers::PROVIDER_NAMES {
421 assert!(names.iter().any(|n| n == known), "missing {}", known);
422 }
423 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 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}