1use crate::provider_support::{
2 CloudflareDnsRecord, CloudflareDnsSettings, CloudflareDnssec, CloudflareZone,
3 DomainAvailability, RegisteredDomain,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct DnsInventoryCache {
11 #[serde(default)]
12 pub cloudflare: Option<CloudflareDnsInventoryCache>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct CloudflareDnsInventoryCache {
17 #[serde(default)]
18 pub account_id: Option<String>,
19 #[serde(default)]
20 pub last_updated_at: Option<DateTime<Utc>>,
21 #[serde(default)]
22 pub zones: Vec<CloudflareZone>,
23 #[serde(default)]
24 pub records_by_zone: BTreeMap<String, Vec<CloudflareDnsRecord>>,
25 #[serde(default)]
26 pub dnssec_by_zone: BTreeMap<String, CloudflareDnssec>,
27 #[serde(default)]
28 pub settings_by_zone: BTreeMap<String, CloudflareDnsSettings>,
29 #[serde(default)]
30 pub domain_availability_by_name: BTreeMap<String, DomainAvailability>,
31 #[serde(default)]
32 pub registered_domains: Vec<RegisteredDomain>,
33}
34
35impl DnsInventoryCache {
36 pub fn record_cloudflare_zones(
37 &mut self,
38 account_id: Option<String>,
39 zones: &[CloudflareZone],
40 ) {
41 let cache = self.cloudflare_mut();
42 cache.update_context(account_id);
43 for zone in zones {
44 upsert_zone(&mut cache.zones, zone.clone());
45 }
46 sort_zones(&mut cache.zones);
47 }
48
49 pub fn record_cloudflare_zone(&mut self, account_id: Option<String>, zone: &CloudflareZone) {
50 let cache = self.cloudflare_mut();
51 cache.update_context(account_id);
52 upsert_zone(&mut cache.zones, zone.clone());
53 sort_zones(&mut cache.zones);
54 }
55
56 pub fn remove_cloudflare_zone(&mut self, zone_id: &str) {
57 let cache = self.cloudflare_mut();
58 cache.touch();
59 cache.zones.retain(|zone| zone.id != zone_id);
60 cache.records_by_zone.remove(zone_id);
61 cache.dnssec_by_zone.remove(zone_id);
62 cache.settings_by_zone.remove(zone_id);
63 }
64
65 pub fn record_cloudflare_records(
66 &mut self,
67 account_id: Option<String>,
68 zone_id: &str,
69 records: &[CloudflareDnsRecord],
70 ) {
71 let cache = self.cloudflare_mut();
72 cache.update_context(account_id);
73 let mut next = records.to_vec();
74 sort_records(&mut next);
75 cache.records_by_zone.insert(zone_id.to_string(), next);
76 }
77
78 pub fn record_cloudflare_record(
79 &mut self,
80 account_id: Option<String>,
81 record: &CloudflareDnsRecord,
82 ) {
83 let cache = self.cloudflare_mut();
84 cache.update_context(account_id);
85 let records = cache
86 .records_by_zone
87 .entry(record.zone_id.clone())
88 .or_default();
89 upsert_record(records, record.clone());
90 sort_records(records);
91 }
92
93 pub fn remove_cloudflare_record(&mut self, zone_id: &str, record_id: &str) {
94 let cache = self.cloudflare_mut();
95 cache.touch();
96 if let Some(records) = cache.records_by_zone.get_mut(zone_id) {
97 records.retain(|record| record.id != record_id);
98 }
99 }
100
101 pub fn record_cloudflare_dnssec(
102 &mut self,
103 account_id: Option<String>,
104 zone_id: &str,
105 dnssec: &CloudflareDnssec,
106 ) {
107 let cache = self.cloudflare_mut();
108 cache.update_context(account_id);
109 cache
110 .dnssec_by_zone
111 .insert(zone_id.to_string(), dnssec.clone());
112 }
113
114 pub fn record_cloudflare_settings(
115 &mut self,
116 account_id: Option<String>,
117 zone_id: &str,
118 settings: &CloudflareDnsSettings,
119 ) {
120 let cache = self.cloudflare_mut();
121 cache.update_context(account_id);
122 cache
123 .settings_by_zone
124 .insert(zone_id.to_string(), settings.clone());
125 }
126
127 pub fn record_cloudflare_domain_availability(
128 &mut self,
129 account_id: Option<String>,
130 domains: &[DomainAvailability],
131 ) {
132 let cache = self.cloudflare_mut();
133 cache.update_context(account_id);
134 for domain in domains {
135 cache
136 .domain_availability_by_name
137 .insert(domain.name.clone(), domain.clone());
138 }
139 }
140
141 pub fn record_cloudflare_registered_domains(
142 &mut self,
143 account_id: Option<String>,
144 domains: &[RegisteredDomain],
145 ) {
146 let cache = self.cloudflare_mut();
147 cache.update_context(account_id);
148 for domain in domains {
149 upsert_registered_domain(&mut cache.registered_domains, domain.clone());
150 }
151 sort_registered_domains(&mut cache.registered_domains);
152 }
153
154 fn cloudflare_mut(&mut self) -> &mut CloudflareDnsInventoryCache {
155 self.cloudflare
156 .get_or_insert_with(CloudflareDnsInventoryCache::default)
157 }
158}
159
160impl CloudflareDnsInventoryCache {
161 fn touch(&mut self) {
162 self.last_updated_at = Some(Utc::now());
163 }
164
165 fn update_context(&mut self, account_id: Option<String>) {
166 if let Some(account_id) = account_id.filter(|value| !value.trim().is_empty()) {
167 self.account_id = Some(account_id);
168 }
169 self.touch();
170 }
171}
172
173fn upsert_zone(zones: &mut Vec<CloudflareZone>, zone: CloudflareZone) {
174 if let Some(existing) = zones.iter_mut().find(|existing| existing.id == zone.id) {
175 *existing = zone;
176 } else {
177 zones.push(zone);
178 }
179}
180
181fn upsert_record(records: &mut Vec<CloudflareDnsRecord>, record: CloudflareDnsRecord) {
182 if let Some(existing) = records.iter_mut().find(|existing| existing.id == record.id) {
183 *existing = record;
184 } else {
185 records.push(record);
186 }
187}
188
189fn upsert_registered_domain(domains: &mut Vec<RegisteredDomain>, domain: RegisteredDomain) {
190 if let Some(existing) = domains
191 .iter_mut()
192 .find(|existing| existing.name == domain.name)
193 {
194 *existing = domain;
195 } else {
196 domains.push(domain);
197 }
198}
199
200fn sort_zones(zones: &mut [CloudflareZone]) {
201 zones.sort_by(|left, right| {
202 left.name
203 .cmp(&right.name)
204 .then_with(|| left.id.cmp(&right.id))
205 });
206}
207
208fn sort_records(records: &mut [CloudflareDnsRecord]) {
209 records.sort_by(|left, right| {
210 left.name
211 .cmp(&right.name)
212 .then_with(|| left.record_type.cmp(&right.record_type))
213 .then_with(|| left.id.cmp(&right.id))
214 });
215}
216
217fn sort_registered_domains(domains: &mut [RegisteredDomain]) {
218 domains.sort_by(|left, right| left.name.cmp(&right.name));
219}
220
221#[cfg(test)]
222mod tests {
223 use super::DnsInventoryCache;
224 use crate::provider_support::{CloudflareDnsRecord, CloudflareZone, RegisteredDomain};
225
226 #[test]
227 fn record_upserts_zone_and_record_entries() {
228 let mut cache = DnsInventoryCache::default();
229 let zone = CloudflareZone {
230 id: "zone_1".to_string(),
231 name: "example.com".to_string(),
232 status: None,
233 r#type: None,
234 paused: None,
235 account: None,
236 owner: None,
237 tenant: None,
238 tenant_unit: None,
239 plan: None,
240 name_servers: Vec::new(),
241 vanity_name_servers: None,
242 original_dnshost: None,
243 original_name_servers: None,
244 original_registrar: None,
245 activated_on: None,
246 created_on: "2026-01-01T00:00:00Z".to_string(),
247 modified_on: "2026-01-01T00:00:00Z".to_string(),
248 development_mode: None,
249 cname_suffix: None,
250 verification_key: None,
251 permissions: None,
252 meta: Default::default(),
253 };
254 let record = CloudflareDnsRecord {
255 id: "rec_1".to_string(),
256 zone_id: "zone_1".to_string(),
257 zone_name: Some("example.com".to_string()),
258 name: "api.example.com".to_string(),
259 record_type: "A".to_string(),
260 content: Some("127.0.0.1".to_string()),
261 ttl: Some(1),
262 proxied: Some(false),
263 priority: None,
264 comment: None,
265 tags: None,
266 data: None,
267 settings: None,
268 meta: None,
269 proxiable: None,
270 created_on: None,
271 modified_on: None,
272 };
273
274 cache.record_cloudflare_zone(Some("acc_1".to_string()), &zone);
275 cache.record_cloudflare_record(Some("acc_1".to_string()), &record);
276 cache.record_cloudflare_record(Some("acc_1".to_string()), &record);
277
278 let cloudflare = cache.cloudflare.expect("cloudflare cache");
279 assert_eq!(cloudflare.zones.len(), 1);
280 assert_eq!(cloudflare.records_by_zone["zone_1"].len(), 1);
281 assert_eq!(cloudflare.account_id.as_deref(), Some("acc_1"));
282 }
283
284 #[test]
285 fn registered_domains_upsert_by_name() {
286 let mut cache = DnsInventoryCache::default();
287 let domain = RegisteredDomain {
288 name: "example.com".to_string(),
289 account_id: Some("acc_1".to_string()),
290 auto_renew: Some(true),
291 expires_at: None,
292 registered_at: None,
293 registration: None,
294 workflow: None,
295 extra: Default::default(),
296 };
297
298 cache.record_cloudflare_registered_domains(None, &[domain.clone(), domain]);
299
300 let cloudflare = cache.cloudflare.expect("cloudflare cache");
301 assert_eq!(cloudflare.registered_domains.len(), 1);
302 }
303}