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 instances: 1,
127 prompt: None,
128 talks_to: vec![],
129 channel: None,
130 channel_config: None,
131 nudge_interval_secs: None,
132 receives_standup: None,
133 standup_interval_secs: None,
134 owns: vec![],
135 barrier_group: None,
136 use_worktrees: false,
137 },
138 RoleDef {
139 name: "manager".into(),
140 role_type: RoleType::Manager,
141 agent: None,
142 instances: manager_instances,
143 prompt: None,
144 talks_to: vec![],
145 channel: None,
146 channel_config: None,
147 nudge_interval_secs: None,
148 receives_standup: None,
149 standup_interval_secs: None,
150 owns: vec![],
151 barrier_group: None,
152 use_worktrees: false,
153 },
154 RoleDef {
155 name: "engineer".into(),
156 role_type: RoleType::Engineer,
157 agent: None,
158 instances: engineer_instances,
159 prompt: None,
160 talks_to: vec![],
161 channel: None,
162 channel_config: None,
163 nudge_interval_secs: None,
164 receives_standup: None,
165 standup_interval_secs: None,
166 owns: vec![],
167 barrier_group: None,
168 use_worktrees: true,
169 },
170 ],
171 }
172 }
173
174 #[test]
175 fn no_change_produces_empty_diff() {
176 let config = minimal_config(3, 1);
177 let diff = diff_configs(&config, &config).unwrap();
178 assert!(diff.is_empty());
179 assert_eq!(diff.change_count(), 0);
180 assert_eq!(diff.unchanged.len(), 5);
182 }
183
184 #[test]
185 fn scale_up_engineers_shows_added() {
186 let old = minimal_config(2, 1);
187 let new = minimal_config(4, 1);
188 let diff = diff_configs(&old, &new).unwrap();
189 assert_eq!(diff.added.len(), 2);
190 assert!(diff.removed.is_empty());
191 let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
193 assert!(added_names.contains("eng-1-3"));
194 assert!(added_names.contains("eng-1-4"));
195 }
196
197 #[test]
198 fn scale_down_engineers_shows_removed() {
199 let old = minimal_config(4, 1);
200 let new = minimal_config(2, 1);
201 let diff = diff_configs(&old, &new).unwrap();
202 assert!(diff.added.is_empty());
203 assert_eq!(diff.removed.len(), 2);
204 let removed_names: HashSet<&str> = diff.removed.iter().map(|c| c.name.as_str()).collect();
205 assert!(removed_names.contains("eng-1-3"));
206 assert!(removed_names.contains("eng-1-4"));
207 }
208
209 #[test]
210 fn add_manager_shows_added_manager_and_engineers() {
211 let old = minimal_config(2, 1);
212 let new = minimal_config(2, 2);
213 let diff = diff_configs(&old, &new).unwrap();
214 assert!(!diff.added.is_empty());
222 let added_names: HashSet<&str> = diff.added.iter().map(|c| c.name.as_str()).collect();
223 assert!(added_names.contains("manager-2"));
224 }
225
226 #[test]
227 fn diff_members_direct() {
228 let old = vec![
229 MemberInstance {
230 name: "architect".into(),
231 role_name: "architect".into(),
232 role_type: RoleType::Architect,
233 agent: Some("claude".into()),
234 prompt: None,
235 reports_to: None,
236 use_worktrees: false,
237 },
238 MemberInstance {
239 name: "eng-1-1".into(),
240 role_name: "engineer".into(),
241 role_type: RoleType::Engineer,
242 agent: Some("claude".into()),
243 prompt: None,
244 reports_to: Some("manager".into()),
245 use_worktrees: true,
246 },
247 ];
248 let new = vec![
249 MemberInstance {
250 name: "architect".into(),
251 role_name: "architect".into(),
252 role_type: RoleType::Architect,
253 agent: Some("claude".into()),
254 prompt: None,
255 reports_to: None,
256 use_worktrees: false,
257 },
258 MemberInstance {
259 name: "eng-1-1".into(),
260 role_name: "engineer".into(),
261 role_type: RoleType::Engineer,
262 agent: Some("claude".into()),
263 prompt: None,
264 reports_to: Some("manager".into()),
265 use_worktrees: true,
266 },
267 MemberInstance {
268 name: "eng-1-2".into(),
269 role_name: "engineer".into(),
270 role_type: RoleType::Engineer,
271 agent: Some("claude".into()),
272 prompt: None,
273 reports_to: Some("manager".into()),
274 use_worktrees: true,
275 },
276 ];
277 let diff = diff_members(&old, &new);
278 assert_eq!(diff.added.len(), 1);
279 assert_eq!(diff.added[0].name, "eng-1-2");
280 assert!(diff.removed.is_empty());
281 assert_eq!(diff.unchanged.len(), 2);
282 }
283}