1use std::collections::HashSet;
7
8use super::config::TeamConfig;
9use super::hierarchy::{self, MemberInstance};
10
11#[derive(Debug, Clone)]
13pub struct MemberChange {
14 pub name: String,
15 pub member: MemberInstance,
16}
17
18#[derive(Debug, Clone)]
20pub struct TopologyDiff {
21 pub added: Vec<MemberChange>,
23 pub removed: Vec<MemberChange>,
25 pub unchanged: Vec<String>,
27}
28
29impl TopologyDiff {
30 pub fn is_empty(&self) -> bool {
32 self.added.is_empty() && self.removed.is_empty()
33 }
34
35 pub fn change_count(&self) -> usize {
37 self.added.len() + self.removed.len()
38 }
39}
40
41pub fn diff_configs(old: &TeamConfig, new: &TeamConfig) -> anyhow::Result<TopologyDiff> {
45 let old_members = hierarchy::resolve_hierarchy(old)?;
46 let new_members = hierarchy::resolve_hierarchy(new)?;
47 Ok(diff_members(&old_members, &new_members))
48}
49
50pub fn diff_members(old: &[MemberInstance], new: &[MemberInstance]) -> TopologyDiff {
52 let old_names: HashSet<&str> = old.iter().map(|m| m.name.as_str()).collect();
53 let new_names: HashSet<&str> = new.iter().map(|m| m.name.as_str()).collect();
54
55 let added: Vec<MemberChange> = new
56 .iter()
57 .filter(|m| !old_names.contains(m.name.as_str()))
58 .map(|m| MemberChange {
59 name: m.name.clone(),
60 member: m.clone(),
61 })
62 .collect();
63
64 let removed: Vec<MemberChange> = old
65 .iter()
66 .filter(|m| !new_names.contains(m.name.as_str()))
67 .map(|m| MemberChange {
68 name: m.name.clone(),
69 member: m.clone(),
70 })
71 .collect();
72
73 let unchanged: Vec<String> = old
74 .iter()
75 .filter(|m| new_names.contains(m.name.as_str()))
76 .map(|m| m.name.clone())
77 .collect();
78
79 TopologyDiff {
80 added,
81 removed,
82 unchanged,
83 }
84}
85
86#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::team::config::{RoleDef, RoleType};
94
95 fn minimal_config(engineer_instances: u32, manager_instances: u32) -> TeamConfig {
96 TeamConfig {
97 name: "test".into(),
98 agent: None,
99 workflow_mode: Default::default(),
100 board: Default::default(),
101 standup: Default::default(),
102 automation: Default::default(),
103 automation_sender: None,
104 external_senders: vec![],
105 orchestrator_pane: false,
106 orchestrator_position: Default::default(),
107 layout: None,
108 workflow_policy: Default::default(),
109 cost: Default::default(),
110 grafana: Default::default(),
111 use_shim: true,
112 use_sdk_mode: false,
113 auto_respawn_on_crash: false,
114 shim_health_check_interval_secs: 60,
115 shim_health_timeout_secs: 120,
116 shim_shutdown_timeout_secs: 30,
117 shim_working_state_timeout_secs: 1800,
118 pending_queue_max_age_secs: 600,
119 event_log_max_bytes: 10 * 1024 * 1024,
120 retro_min_duration_secs: 60,
121 roles: vec![
122 RoleDef {
123 name: "architect".into(),
124 role_type: RoleType::Architect,
125 agent: None,
126 auth_mode: None,
127 auth_env: vec![],
128 instances: 1,
129 prompt: None,
130 talks_to: vec![],
131 channel: None,
132 channel_config: None,
133 nudge_interval_secs: None,
134 receives_standup: None,
135 standup_interval_secs: None,
136 owns: vec![],
137 barrier_group: None,
138 use_worktrees: false,
139 ..Default::default()
140 },
141 RoleDef {
142 name: "manager".into(),
143 role_type: RoleType::Manager,
144 agent: None,
145 auth_mode: None,
146 auth_env: vec![],
147 instances: manager_instances,
148 prompt: None,
149 talks_to: vec![],
150 channel: None,
151 channel_config: None,
152 nudge_interval_secs: None,
153 receives_standup: None,
154 standup_interval_secs: None,
155 owns: vec![],
156 barrier_group: None,
157 use_worktrees: false,
158 ..Default::default()
159 },
160 RoleDef {
161 name: "engineer".into(),
162 role_type: RoleType::Engineer,
163 agent: None,
164 auth_mode: None,
165 auth_env: vec![],
166 instances: engineer_instances,
167 prompt: None,
168 talks_to: vec![],
169 channel: None,
170 channel_config: None,
171 nudge_interval_secs: None,
172 receives_standup: None,
173 standup_interval_secs: None,
174 owns: vec![],
175 barrier_group: None,
176 use_worktrees: true,
177 ..Default::default()
178 },
179 ],
180 }
181 }
182
183 #[test]
184 fn no_change_produces_empty_diff() {
185 let config = minimal_config(3, 1);
186 let diff = diff_configs(&config, &config).unwrap();
187 assert!(diff.is_empty());
188 assert_eq!(diff.change_count(), 0);
189 assert_eq!(diff.unchanged.len(), 5);
191 }
192
193 #[test]
194 fn scale_up_engineers_shows_added() {
195 let old = minimal_config(2, 1);
196 let new = minimal_config(4, 1);
197 let diff = diff_configs(&old, &new).unwrap();
198 assert_eq!(diff.added.len(), 2);
199 assert!(diff.removed.is_empty());
200 let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
202 assert!(added_names.contains("eng-1-3"));
203 assert!(added_names.contains("eng-1-4"));
204 }
205
206 #[test]
207 fn scale_down_engineers_shows_removed() {
208 let old = minimal_config(4, 1);
209 let new = minimal_config(2, 1);
210 let diff = diff_configs(&old, &new).unwrap();
211 assert!(diff.added.is_empty());
212 assert_eq!(diff.removed.len(), 2);
213 let removed_names: HashSet<&str> = diff.removed.iter().map(|c| c.name.as_str()).collect();
214 assert!(removed_names.contains("eng-1-3"));
215 assert!(removed_names.contains("eng-1-4"));
216 }
217
218 #[test]
219 fn add_manager_shows_added_manager_and_engineers() {
220 let old = minimal_config(2, 1);
221 let new = minimal_config(2, 2);
222 let diff = diff_configs(&old, &new).unwrap();
223 assert!(!diff.added.is_empty());
231 let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
232 assert!(added_names.contains("manager-2"));
233 }
234
235 #[test]
236 fn diff_members_direct() {
237 let old = vec![
238 MemberInstance {
239 name: "architect".into(),
240 role_name: "architect".into(),
241 role_type: RoleType::Architect,
242 agent: Some("claude".into()),
243 prompt: None,
244 reports_to: None,
245 use_worktrees: false,
246 ..Default::default()
247 },
248 MemberInstance {
249 name: "eng-1-1".into(),
250 role_name: "engineer".into(),
251 role_type: RoleType::Engineer,
252 agent: Some("claude".into()),
253 prompt: None,
254 reports_to: Some("manager".into()),
255 use_worktrees: true,
256 ..Default::default()
257 },
258 ];
259 let new = vec![
260 MemberInstance {
261 name: "architect".into(),
262 role_name: "architect".into(),
263 role_type: RoleType::Architect,
264 agent: Some("claude".into()),
265 prompt: None,
266 reports_to: None,
267 use_worktrees: false,
268 ..Default::default()
269 },
270 MemberInstance {
271 name: "eng-1-1".into(),
272 role_name: "engineer".into(),
273 role_type: RoleType::Engineer,
274 agent: Some("claude".into()),
275 prompt: None,
276 reports_to: Some("manager".into()),
277 use_worktrees: true,
278 ..Default::default()
279 },
280 MemberInstance {
281 name: "eng-1-2".into(),
282 role_name: "engineer".into(),
283 role_type: RoleType::Engineer,
284 agent: Some("claude".into()),
285 prompt: None,
286 reports_to: Some("manager".into()),
287 use_worktrees: true,
288 ..Default::default()
289 },
290 ];
291 let diff = diff_members(&old, &new);
292 assert_eq!(diff.added.len(), 1);
293 assert_eq!(diff.added[0].name, "eng-1-2");
294 assert!(diff.removed.is_empty());
295 assert_eq!(diff.unchanged.len(), 2);
296 }
297}