1use std::collections::BTreeSet;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::discovery::{DetectedProvider, DiscoveryResult};
16
17#[derive(Clone, Debug, Default, Serialize)]
19pub struct BundleDiff {
20 pub packs_added: Vec<DetectedProvider>,
22 pub packs_removed: Vec<DetectedProvider>,
24 pub packs_changed: Vec<DetectedProvider>,
26 pub providers_added: Vec<String>,
28 pub providers_removed: Vec<String>,
30 pub tenants_added: Vec<String>,
32 pub tenants_removed: Vec<String>,
34}
35
36impl BundleDiff {
37 pub fn is_empty(&self) -> bool {
39 self.packs_added.is_empty()
40 && self.packs_removed.is_empty()
41 && self.packs_changed.is_empty()
42 && self.providers_added.is_empty()
43 && self.providers_removed.is_empty()
44 && self.tenants_added.is_empty()
45 && self.tenants_removed.is_empty()
46 }
47
48 pub fn change_count(&self) -> usize {
50 self.packs_added.len()
51 + self.packs_removed.len()
52 + self.packs_changed.len()
53 + self.providers_added.len()
54 + self.providers_removed.len()
55 + self.tenants_added.len()
56 + self.tenants_removed.len()
57 }
58}
59
60#[derive(Clone, Debug, Serialize)]
62pub struct ReloadPlan {
63 pub bundle: PathBuf,
64 pub diff: BundleDiff,
65 pub actions: Vec<ReloadAction>,
66}
67
68#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71pub enum ReloadAction {
72 LoadComponent { provider_id: String, path: PathBuf },
74 UnloadComponent { provider_id: String },
76 ReloadComponent { provider_id: String, path: PathBuf },
78 UpdateRoutes,
80 RunResolver,
82 SeedSecrets { provider_id: String },
84}
85
86pub fn diff_discoveries(prev: &DiscoveryResult, curr: &DiscoveryResult) -> BundleDiff {
88 let prev_ids: BTreeSet<&str> = prev
89 .providers
90 .iter()
91 .map(|p| p.provider_id.as_str())
92 .collect();
93 let curr_ids: BTreeSet<&str> = curr
94 .providers
95 .iter()
96 .map(|p| p.provider_id.as_str())
97 .collect();
98
99 let added_ids: BTreeSet<&&str> = curr_ids.difference(&prev_ids).collect();
100 let removed_ids: BTreeSet<&&str> = prev_ids.difference(&curr_ids).collect();
101
102 let packs_added: Vec<DetectedProvider> = curr
103 .providers
104 .iter()
105 .filter(|p| added_ids.contains(&&p.provider_id.as_str()))
106 .cloned()
107 .collect();
108
109 let packs_removed: Vec<DetectedProvider> = prev
110 .providers
111 .iter()
112 .filter(|p| removed_ids.contains(&&p.provider_id.as_str()))
113 .cloned()
114 .collect();
115
116 let packs_changed: Vec<DetectedProvider> = curr
119 .providers
120 .iter()
121 .filter(|cp| {
122 if added_ids.contains(&&cp.provider_id.as_str()) {
123 return false;
124 }
125 prev.providers
126 .iter()
127 .any(|pp| pp.provider_id == cp.provider_id && pp.pack_path != cp.pack_path)
128 })
129 .cloned()
130 .collect();
131
132 BundleDiff {
133 packs_added,
134 packs_removed,
135 packs_changed,
136 providers_added: Vec::new(),
137 providers_removed: Vec::new(),
138 tenants_added: Vec::new(),
139 tenants_removed: Vec::new(),
140 }
141}
142
143pub fn plan_reload(bundle: &std::path::Path, diff: &BundleDiff) -> ReloadPlan {
145 let mut actions = Vec::new();
146
147 for pack in &diff.packs_added {
148 actions.push(ReloadAction::LoadComponent {
149 provider_id: pack.provider_id.clone(),
150 path: pack.pack_path.clone(),
151 });
152 actions.push(ReloadAction::SeedSecrets {
153 provider_id: pack.provider_id.clone(),
154 });
155 }
156
157 for pack in &diff.packs_removed {
158 actions.push(ReloadAction::UnloadComponent {
159 provider_id: pack.provider_id.clone(),
160 });
161 }
162
163 for pack in &diff.packs_changed {
164 actions.push(ReloadAction::ReloadComponent {
165 provider_id: pack.provider_id.clone(),
166 path: pack.pack_path.clone(),
167 });
168 }
169
170 if !diff.packs_added.is_empty()
171 || !diff.packs_removed.is_empty()
172 || !diff.packs_changed.is_empty()
173 {
174 actions.push(ReloadAction::UpdateRoutes);
175 }
176
177 if !diff.tenants_added.is_empty() || !diff.tenants_removed.is_empty() {
178 actions.push(ReloadAction::RunResolver);
179 }
180
181 ReloadPlan {
182 bundle: bundle.to_path_buf(),
183 diff: diff.clone(),
184 actions,
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::discovery::{DetectedDomains, ProviderIdSource};
192
193 fn make_provider(id: &str, path: &str) -> DetectedProvider {
194 DetectedProvider {
195 provider_id: id.to_string(),
196 display_name: None,
197 domain: "messaging".to_string(),
198 pack_path: PathBuf::from(path),
199 id_source: ProviderIdSource::Manifest,
200 }
201 }
202
203 fn make_discovery(providers: Vec<DetectedProvider>) -> DiscoveryResult {
204 DiscoveryResult {
205 domains: DetectedDomains {
206 messaging: true,
207 events: false,
208 oauth: false,
209 state: false,
210 secrets: false,
211 },
212 providers,
213 }
214 }
215
216 #[test]
217 fn empty_diff_when_same() {
218 let disc = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
219 let diff = diff_discoveries(&disc, &disc);
220 assert!(diff.is_empty());
221 assert_eq!(diff.change_count(), 0);
222 }
223
224 #[test]
225 fn detects_added_packs() {
226 let prev = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
227 let curr = make_discovery(vec![
228 make_provider("telegram", "/a/telegram.gtpack"),
229 make_provider("slack", "/a/slack.gtpack"),
230 ]);
231 let diff = diff_discoveries(&prev, &curr);
232 assert_eq!(diff.packs_added.len(), 1);
233 assert_eq!(diff.packs_added[0].provider_id, "slack");
234 assert!(diff.packs_removed.is_empty());
235 }
236
237 #[test]
238 fn detects_removed_packs() {
239 let prev = make_discovery(vec![
240 make_provider("telegram", "/a/telegram.gtpack"),
241 make_provider("slack", "/a/slack.gtpack"),
242 ]);
243 let curr = make_discovery(vec![make_provider("telegram", "/a/telegram.gtpack")]);
244 let diff = diff_discoveries(&prev, &curr);
245 assert!(diff.packs_added.is_empty());
246 assert_eq!(diff.packs_removed.len(), 1);
247 assert_eq!(diff.packs_removed[0].provider_id, "slack");
248 }
249
250 #[test]
251 fn detects_changed_packs() {
252 let prev = make_discovery(vec![make_provider("telegram", "/a/v1/telegram.gtpack")]);
253 let curr = make_discovery(vec![make_provider("telegram", "/a/v2/telegram.gtpack")]);
254 let diff = diff_discoveries(&prev, &curr);
255 assert!(diff.packs_added.is_empty());
256 assert!(diff.packs_removed.is_empty());
257 assert_eq!(diff.packs_changed.len(), 1);
258 }
259
260 #[test]
261 fn plan_reload_generates_actions() {
262 let diff = BundleDiff {
263 packs_added: vec![make_provider("slack", "/a/slack.gtpack")],
264 packs_removed: vec![make_provider("teams", "/a/teams.gtpack")],
265 packs_changed: vec![make_provider("telegram", "/a/telegram.gtpack")],
266 ..Default::default()
267 };
268 let plan = plan_reload(std::path::Path::new("/bundle"), &diff);
269 assert_eq!(plan.actions.len(), 5);
274 }
275
276 #[test]
277 fn empty_diff_no_actions() {
278 let diff = BundleDiff::default();
279 let plan = plan_reload(std::path::Path::new("/bundle"), &diff);
280 assert!(plan.actions.is_empty());
281 }
282}