use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::app::ProviderFormBaseline;
use crate::app::forms::ProviderFormFields;
use crate::providers::config::{ProviderConfig, ProviderConfigId};
#[derive(Debug, Clone)]
pub struct SyncRecord {
pub timestamp: u64,
pub message: String,
pub is_error: bool,
}
impl SyncRecord {
pub fn load_all() -> HashMap<String, SyncRecord> {
let mut map = HashMap::new();
let Some(home) = dirs::home_dir() else {
return map;
};
let path = home.join(".purple").join("sync_history.tsv");
let Ok(content) = std::fs::read_to_string(&path) else {
return map;
};
for line in content.lines() {
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() < 4 {
continue;
}
let Some(ts) = parts[1].parse::<u64>().ok() else {
continue;
};
let is_error = parts[2] == "1";
map.insert(
parts[0].to_string(),
SyncRecord {
timestamp: ts,
message: parts[3].to_string(),
is_error,
},
);
}
map
}
pub fn save_all(history: &HashMap<String, SyncRecord>) {
if crate::demo_flag::is_demo() {
return;
}
let Some(home) = dirs::home_dir() else { return };
let dir = home.join(".purple");
let path = dir.join("sync_history.tsv");
let mut lines = Vec::new();
for (provider, record) in history {
lines.push(format!(
"{}\t{}\t{}\t{}",
provider,
record.timestamp,
if record.is_error { "1" } else { "0" },
record.message
));
}
if let Err(e) = crate::fs_util::atomic_write(&path, lines.join("\n").as_bytes()) {
log::warn!(
"[config] failed to save sync_history.tsv at {}: {e}",
path.display()
);
}
}
pub fn load_from_content(content: &str) -> HashMap<String, SyncRecord> {
let mut map = HashMap::new();
for line in content.lines() {
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() < 4 {
continue;
}
let Some(ts) = parts[1].parse::<u64>().ok() else {
continue;
};
let is_error = parts[2] == "1";
map.insert(
parts[0].to_string(),
SyncRecord {
timestamp: ts,
message: parts[3].to_string(),
is_error,
},
);
}
map
}
}
pub struct ProviderState {
pub(in crate::app) config: ProviderConfig,
pub(in crate::app) form: ProviderFormFields,
pub(in crate::app) syncing: HashMap<String, Arc<AtomicBool>>,
pub(in crate::app) sync_done: Vec<String>,
pub(in crate::app) sync_had_errors: bool,
pub(in crate::app) batch_added: usize,
pub(in crate::app) batch_updated: usize,
pub(in crate::app) batch_stale: usize,
pub(in crate::app) batch_total: usize,
pub(in crate::app) pending_delete: Option<String>,
pub(in crate::app) pending_delete_id: Option<ProviderConfigId>,
pub(in crate::app) sync_history: HashMap<String, SyncRecord>,
pub(in crate::app) form_baseline: Option<ProviderFormBaseline>,
pub(in crate::app) expanded_providers: HashSet<String>,
pub(in crate::app) pending_label_migration: Option<PendingLabelMigration>,
}
#[derive(Debug, Clone)]
pub struct PendingLabelMigration {
pub provider: String,
pub existing_label: String,
pub new_label: String,
pub focused: LabelMigrationField,
pub cursor_pos: usize,
}
impl PendingLabelMigration {
pub fn focused_value(&self) -> &str {
match self.focused {
LabelMigrationField::Existing => &self.existing_label,
LabelMigrationField::New => &self.new_label,
}
}
pub fn focused_value_mut(&mut self) -> &mut String {
match self.focused {
LabelMigrationField::Existing => &mut self.existing_label,
LabelMigrationField::New => &mut self.new_label,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelMigrationField {
Existing,
New,
}
#[derive(Debug, Clone)]
pub enum ProviderRow {
Header { name: String, config_count: usize },
Leaf { id: ProviderConfigId },
}
impl ProviderRow {
pub fn provider_name(&self) -> &str {
match self {
ProviderRow::Header { name, .. } => name,
ProviderRow::Leaf { id } => &id.provider,
}
}
}
impl ProviderState {
pub fn reset_batch_if_idle(&mut self) {
if self.syncing.is_empty() && self.sync_done.is_empty() {
self.batch_total = 0;
self.batch_added = 0;
self.batch_updated = 0;
self.batch_stale = 0;
self.sync_had_errors = false;
}
}
pub fn finish_batch(&mut self) {
self.sync_done.clear();
self.sync_had_errors = false;
self.batch_added = 0;
self.batch_updated = 0;
self.batch_stale = 0;
self.batch_total = 0;
}
pub fn request_delete(&mut self, id: ProviderConfigId) {
self.pending_delete = Some(id.provider.clone());
self.pending_delete_id = Some(id);
}
pub fn cancel_delete(&mut self) {
self.pending_delete = None;
self.pending_delete_id = None;
}
pub fn toggle_expanded(&mut self, name: &str) -> bool {
let added = !self.expanded_providers.contains(name);
if added {
self.expanded_providers.insert(name.to_string());
} else {
self.expanded_providers.remove(name);
}
added
}
pub fn cancel_label_migration(&mut self) {
self.pending_label_migration = None;
}
pub fn config(&self) -> &ProviderConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut ProviderConfig {
&mut self.config
}
pub fn form(&self) -> &ProviderFormFields {
&self.form
}
pub fn form_mut(&mut self) -> &mut ProviderFormFields {
&mut self.form
}
pub fn syncing(&self) -> &HashMap<String, Arc<AtomicBool>> {
&self.syncing
}
pub fn syncing_mut(&mut self) -> &mut HashMap<String, Arc<AtomicBool>> {
&mut self.syncing
}
pub fn sync_done(&self) -> &[String] {
&self.sync_done
}
pub fn push_sync_done(&mut self, name: String) {
self.sync_done.push(name);
}
pub fn clear_sync_done(&mut self) {
self.sync_done.clear();
}
pub fn sync_had_errors(&self) -> bool {
self.sync_had_errors
}
pub fn set_sync_had_errors(&mut self, value: bool) {
self.sync_had_errors = value;
}
pub fn batch_added(&self) -> usize {
self.batch_added
}
pub fn batch_updated(&self) -> usize {
self.batch_updated
}
pub fn batch_stale(&self) -> usize {
self.batch_stale
}
pub fn add_batch_diff(&mut self, added: usize, updated: usize, stale: usize) {
self.batch_added += added;
self.batch_updated += updated;
self.batch_stale += stale;
}
pub fn batch_total(&self) -> usize {
self.batch_total
}
pub fn set_batch_total(&mut self, value: usize) {
self.batch_total = value;
}
pub fn bump_batch_total(&mut self) {
self.batch_total = self
.batch_total
.max(self.sync_done.len() + self.syncing.len());
}
pub fn pending_delete(&self) -> Option<&str> {
self.pending_delete.as_deref()
}
pub fn take_pending_delete(&mut self) -> Option<String> {
self.pending_delete.take()
}
pub fn pending_delete_id(&self) -> Option<&ProviderConfigId> {
self.pending_delete_id.as_ref()
}
pub fn take_pending_delete_id(&mut self) -> Option<ProviderConfigId> {
self.pending_delete_id.take()
}
pub fn sync_history(&self) -> &HashMap<String, SyncRecord> {
&self.sync_history
}
pub fn sync_history_mut(&mut self) -> &mut HashMap<String, SyncRecord> {
&mut self.sync_history
}
pub fn record_sync(&mut self, key: String, record: SyncRecord) {
self.sync_history.insert(key, record);
}
pub fn form_baseline(&self) -> Option<&ProviderFormBaseline> {
self.form_baseline.as_ref()
}
pub fn set_form_baseline(&mut self, baseline: Option<ProviderFormBaseline>) {
self.form_baseline = baseline;
}
pub fn expanded_providers(&self) -> &HashSet<String> {
&self.expanded_providers
}
pub fn expanded_providers_mut(&mut self) -> &mut HashSet<String> {
&mut self.expanded_providers
}
pub fn pending_label_migration(&self) -> Option<&PendingLabelMigration> {
self.pending_label_migration.as_ref()
}
pub fn pending_label_migration_mut(&mut self) -> Option<&mut PendingLabelMigration> {
self.pending_label_migration.as_mut()
}
pub fn set_pending_label_migration(&mut self, migration: Option<PendingLabelMigration>) {
self.pending_label_migration = migration;
}
}
impl Default for ProviderState {
fn default() -> Self {
Self {
config: ProviderConfig::default(),
form: ProviderFormFields::new(),
syncing: HashMap::new(),
sync_done: Vec::new(),
sync_had_errors: false,
batch_added: 0,
batch_updated: 0,
batch_stale: 0,
batch_total: 0,
pending_delete: None,
pending_delete_id: None,
sync_history: HashMap::new(),
form_baseline: None,
expanded_providers: HashSet::new(),
pending_label_migration: None,
}
}
}
impl ProviderState {
pub fn load() -> Self {
Self {
config: crate::providers::config::ProviderConfig::load(),
sync_history: SyncRecord::load_all(),
..Self::default()
}
}
pub fn provider_list_rows(&self) -> Vec<ProviderRow> {
let mut rows = Vec::new();
for name in self.sorted_names() {
let configs = self.config.sections_for_provider(&name);
rows.push(ProviderRow::Header {
name: name.clone(),
config_count: configs.len(),
});
if configs.len() >= 2 && self.expanded_providers.contains(&name) {
let mut sorted = configs.clone();
sorted.sort_by(|a, b| {
a.id.label
.as_deref()
.unwrap_or("")
.cmp(b.id.label.as_deref().unwrap_or(""))
});
for s in sorted {
rows.push(ProviderRow::Leaf { id: s.id.clone() });
}
}
}
rows
}
pub fn sorted_names(&self) -> Vec<String> {
use crate::providers;
let mut names: Vec<String> = providers::PROVIDER_NAMES
.iter()
.map(|s| s.to_string())
.collect();
for section in &self.config.sections {
let name = section.provider().to_string();
if !names.contains(&name) {
names.push(name);
}
}
let max_ts = |provider: &str| -> u64 {
self.sync_history
.iter()
.filter(|(k, _)| {
k.as_str() == provider || k.split_once(':').is_some_and(|(p, _)| p == provider)
})
.map(|(_, r)| r.timestamp)
.max()
.unwrap_or(0)
};
names.sort_by(|a, b| {
let conf_a = self.config.section(a.as_str()).is_some();
let conf_b = self.config.section(b.as_str()).is_some();
let ts_a = max_ts(a.as_str());
let ts_b = max_ts(b.as_str());
conf_b.cmp(&conf_a).then(ts_b.cmp(&ts_a)).then(a.cmp(b))
});
names
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_empty() {
let s = ProviderState::default();
assert!(s.config.sections.is_empty());
assert!(s.config.path_override.is_none());
assert!(s.syncing.is_empty());
assert!(s.sync_done.is_empty());
assert!(!s.sync_had_errors);
assert!(s.pending_delete.is_none());
assert!(s.sync_history.is_empty());
assert!(s.form_baseline.is_none());
}
#[test]
fn sorted_names_returns_configured_providers_before_unconfigured() {
use crate::providers::config::ProviderSection;
let mut state = ProviderState::default();
state.config.sections.push(ProviderSection {
id: crate::providers::config::ProviderConfigId::bare("vultr"),
token: "tok".to_string(),
alias_prefix: "vultr".to_string(),
..ProviderSection::default()
});
state.config.sections.push(ProviderSection {
id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
token: "tok".to_string(),
alias_prefix: "do".to_string(),
..ProviderSection::default()
});
state.sync_history.insert(
"digitalocean".to_string(),
SyncRecord {
timestamp: 2_000,
message: "ok".to_string(),
is_error: false,
},
);
state.sync_history.insert(
"vultr".to_string(),
SyncRecord {
timestamp: 1_000,
message: "ok".to_string(),
is_error: false,
},
);
let names = state.sorted_names();
assert_eq!(&names[0], "digitalocean");
assert_eq!(&names[1], "vultr");
for &known in crate::providers::PROVIDER_NAMES {
assert!(names.iter().any(|n| n == known), "missing {}", known);
}
let unconfigured: Vec<&String> = names.iter().skip(2).collect();
let mut sorted = unconfigured.clone();
sorted.sort();
assert_eq!(unconfigured, sorted);
}
#[test]
fn sorted_names_includes_unknown_providers_from_config() {
use crate::providers::config::ProviderSection;
let mut state = ProviderState::default();
state.config.sections.push(ProviderSection {
id: crate::providers::config::ProviderConfigId::bare("someday_provider"),
token: "tok".to_string(),
alias_prefix: "x".to_string(),
..ProviderSection::default()
});
let names = state.sorted_names();
assert!(names.iter().any(|n| n == "someday_provider"));
}
#[test]
fn request_delete_sets_both_pending_fields() {
let mut s = ProviderState::default();
let id = crate::providers::config::ProviderConfigId::bare("digitalocean");
s.request_delete(id.clone());
assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
}
#[test]
fn request_delete_with_labeled_id_keeps_provider_name_in_pending_delete() {
let mut s = ProviderState::default();
let id = crate::providers::config::ProviderConfigId::labeled("digitalocean", "work");
s.request_delete(id.clone());
assert_eq!(s.pending_delete.as_deref(), Some("digitalocean"));
assert_eq!(s.pending_delete_id.as_ref(), Some(&id));
}
#[test]
fn request_delete_overwrites_existing_pending() {
let mut s = ProviderState::default();
s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
let new_id = crate::providers::config::ProviderConfigId::bare("hetzner");
s.request_delete(new_id.clone());
assert_eq!(s.pending_delete.as_deref(), Some("hetzner"));
assert_eq!(s.pending_delete_id.as_ref(), Some(&new_id));
}
#[test]
fn cancel_delete_clears_both_pending_fields() {
let mut s = ProviderState::default();
s.request_delete(crate::providers::config::ProviderConfigId::bare("vultr"));
s.cancel_delete();
assert!(s.pending_delete.is_none());
assert!(s.pending_delete_id.is_none());
}
#[test]
fn cancel_delete_is_idempotent() {
let mut s = ProviderState::default();
s.cancel_delete();
s.cancel_delete();
assert!(s.pending_delete.is_none());
assert!(s.pending_delete_id.is_none());
}
#[test]
fn toggle_expanded_adds_when_absent_and_returns_true() {
let mut s = ProviderState::default();
assert!(!s.expanded_providers.contains("digitalocean"));
let added = s.toggle_expanded("digitalocean");
assert!(added);
assert!(s.expanded_providers.contains("digitalocean"));
}
#[test]
fn toggle_expanded_removes_when_present_and_returns_false() {
let mut s = ProviderState::default();
s.expanded_providers.insert("digitalocean".to_string());
let added = s.toggle_expanded("digitalocean");
assert!(!added);
assert!(!s.expanded_providers.contains("digitalocean"));
}
#[test]
fn cancel_label_migration_clears_pending() {
let mut s = ProviderState {
pending_label_migration: Some(PendingLabelMigration {
provider: "digitalocean".to_string(),
existing_label: "old".to_string(),
new_label: "new".to_string(),
focused: LabelMigrationField::Existing,
cursor_pos: 0,
}),
..Default::default()
};
s.cancel_label_migration();
assert!(s.pending_label_migration.is_none());
}
#[test]
fn cancel_label_migration_is_idempotent_when_already_none() {
let mut s = ProviderState::default();
s.cancel_label_migration();
s.cancel_label_migration();
assert!(s.pending_label_migration.is_none());
}
#[test]
fn add_batch_diff_accumulates_each_counter() {
let mut s = ProviderState::default();
s.add_batch_diff(3, 1, 2);
s.add_batch_diff(1, 0, 4);
assert_eq!(s.batch_added(), 4);
assert_eq!(s.batch_updated(), 1);
assert_eq!(s.batch_stale(), 6);
}
#[test]
fn bump_batch_total_raises_to_done_plus_syncing() {
let mut s = ProviderState::default();
s.push_sync_done("aws".to_string());
s.syncing_mut()
.insert("vultr".to_string(), Arc::new(AtomicBool::new(false)));
s.bump_batch_total();
assert_eq!(s.batch_total(), 2);
}
#[test]
fn bump_batch_total_never_lowers_existing_peak() {
let mut s = ProviderState::default();
s.set_batch_total(5);
s.push_sync_done("aws".to_string());
s.bump_batch_total();
assert_eq!(s.batch_total(), 5);
}
#[test]
fn finish_batch_clears_all_batch_state() {
let mut s = ProviderState::default();
s.push_sync_done("aws".to_string());
s.set_sync_had_errors(true);
s.add_batch_diff(2, 3, 4);
s.set_batch_total(7);
s.finish_batch();
assert!(s.sync_done().is_empty());
assert!(!s.sync_had_errors());
assert_eq!(s.batch_added(), 0);
assert_eq!(s.batch_updated(), 0);
assert_eq!(s.batch_stale(), 0);
assert_eq!(s.batch_total(), 0);
}
}