1use std::collections::BTreeMap;
12
13use crate::compose;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ShapeRow {
18 pub depth: u8,
21 pub kind: ShapeKind,
22 pub label: String,
24 pub descriptor: String,
26 pub is_last: bool,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ShapeKind {
32 Root,
33 Manager,
34 Worker,
35}
36
37pub fn team_shape(projects: &[&compose::Project]) -> Vec<ShapeRow> {
46 let mut out = Vec::new();
47
48 out.push(ShapeRow {
55 depth: 0,
56 kind: ShapeKind::Root,
57 label: "You".to_string(),
58 descriptor: String::new(),
59 is_last: true,
60 });
61
62 for project in projects {
63 if project.managers.is_empty() && project.workers.is_empty() {
64 continue;
65 }
66
67 let mut children: BTreeMap<&str, Vec<&String>> = BTreeMap::new();
71 let mut orphans: Vec<&String> = Vec::new();
72 for (wid, w) in &project.workers {
73 match w.reports_to.as_deref() {
74 Some(m) if project.managers.contains_key(m) => {
75 children.entry(m).or_default().push(wid)
76 }
77 _ => orphans.push(wid),
78 }
79 }
80
81 let top: Vec<(&String, ShapeKind)> = project
84 .managers
85 .keys()
86 .map(|id| (id, ShapeKind::Manager))
87 .chain(orphans.iter().map(|id| (*id, ShapeKind::Worker)))
88 .collect();
89
90 let last_top = top.len().saturating_sub(1);
91 for (i, (id, kind)) in top.iter().enumerate() {
92 let agent = project
93 .managers
94 .get(*id)
95 .or_else(|| project.workers.get(*id));
96 let label = agent.map_or_else(|| (*id).to_string(), |a| label_for(id, a));
97 let descriptor = agent.map(agent_descriptor).unwrap_or_default();
98 out.push(ShapeRow {
99 depth: 1,
100 kind: *kind,
101 label,
102 descriptor,
103 is_last: i == last_top,
104 });
105
106 let kids = children.get(id.as_str()).cloned().unwrap_or_default();
108 let last_kid = kids.len().saturating_sub(1);
109 for (j, wid) in kids.iter().enumerate() {
110 let w = project.workers.get(wid.as_str());
111 let label = w.map_or_else(|| (*wid).to_string(), |a| label_for(wid, a));
112 let descriptor = w.map(agent_descriptor).unwrap_or_default();
113 out.push(ShapeRow {
114 depth: 2,
115 kind: ShapeKind::Worker,
116 label,
117 descriptor,
118 is_last: j == last_kid,
119 });
120 }
121 }
122 }
123
124 out
125}
126
127pub fn agent_descriptor(agent: &compose::Agent) -> String {
131 let mut parts = vec![runtime_label(&agent.runtime)];
132 if let Some(model) = &agent.model {
133 parts.push(model_label(model));
134 }
135 parts.push(format!(
136 "{}×a {}×s {}×h {}×m",
137 agent.subagents.len(),
138 agent.skills.len(),
139 agent.hooks.len(),
140 agent.mcps.len(),
141 ));
142 parts.join(" · ")
143}
144
145fn label_for(id: &str, agent: &compose::Agent) -> String {
147 agent.display_name.clone().unwrap_or_else(|| id.to_string())
148}
149
150fn runtime_label(runtime: &str) -> String {
152 match runtime {
153 "claude-code" => "Claude Code".to_string(),
154 other => other.to_string(),
155 }
156}
157
158fn model_label(model: &str) -> String {
161 match model {
162 "claude-opus-4-8" => "Opus 4.8".to_string(),
163 "claude-sonnet-4-6" => "Sonnet 4.6".to_string(),
164 "claude-haiku-4-5" | "claude-haiku-4-5-20251001" => "Haiku 4.5".to_string(),
165 other => other.to_string(),
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn project(yaml: &str) -> compose::Project {
174 serde_yaml::from_str(yaml).expect("project fixture parses")
175 }
176
177 #[test]
178 fn single_manager_with_two_workers_orders_and_nests() {
179 let p = project(
183 "\
184version: 1
185project:
186 id: demo
187 name: Demo
188 cwd: .
189managers:
190 lead:
191 model: claude-opus-4-8
192workers:
193 dev2:
194 reports_to: lead
195 dev1:
196 reports_to: lead
197",
198 );
199 let rows = team_shape(&[&p]);
200 assert_eq!(
201 rows,
202 vec![
203 ShapeRow {
204 depth: 0,
205 kind: ShapeKind::Root,
206 label: "You".into(),
207 descriptor: String::new(),
208 is_last: true,
209 },
210 ShapeRow {
211 depth: 1,
212 kind: ShapeKind::Manager,
213 label: "lead".into(),
214 descriptor: "Claude Code · Opus 4.8 · 0×a 0×s 0×h 0×m".into(),
215 is_last: true,
216 },
217 ShapeRow {
218 depth: 2,
219 kind: ShapeKind::Worker,
220 label: "dev1".into(),
221 descriptor: "Claude Code · 0×a 0×s 0×h 0×m".into(),
222 is_last: false,
223 },
224 ShapeRow {
225 depth: 2,
226 kind: ShapeKind::Worker,
227 label: "dev2".into(),
228 descriptor: "Claude Code · 0×a 0×s 0×h 0×m".into(),
229 is_last: true,
230 },
231 ]
232 );
233 }
234
235 #[test]
236 fn orphan_worker_hangs_at_top_level_after_managers() {
237 let p = project(
242 "\
243version: 1
244project:
245 id: demo
246 name: Demo
247 cwd: .
248managers:
249 lead: {}
250workers:
251 solo:
252 reports_to: ghost
253",
254 );
255 let rows = team_shape(&[&p]);
256 let top: Vec<_> = rows
257 .iter()
258 .filter(|r| r.depth == 1)
259 .map(|r| (r.kind, r.label.as_str(), r.is_last))
260 .collect();
261 assert_eq!(
262 top,
263 vec![
264 (ShapeKind::Manager, "lead", false),
265 (ShapeKind::Worker, "solo", true),
266 ]
267 );
268 }
269
270 #[test]
271 fn two_managers_sort_by_id() {
272 let p = project(
275 "\
276version: 1
277project:
278 id: demo
279 name: Demo
280 cwd: .
281managers:
282 beta: {}
283 alpha: {}
284",
285 );
286 let rows = team_shape(&[&p]);
287 let managers: Vec<_> = rows
288 .iter()
289 .filter(|r| r.kind == ShapeKind::Manager)
290 .map(|r| (r.label.as_str(), r.is_last))
291 .collect();
292 assert_eq!(managers, vec![("alpha", false), ("beta", true)]);
293 }
294
295 #[test]
296 fn label_prefers_display_name_then_falls_back_to_id() {
297 let p = project(
300 "\
301version: 1
302project:
303 id: demo
304 name: Demo
305 cwd: .
306managers:
307 lead:
308 display_name: The Lead
309workers:
310 dev:
311 reports_to: lead
312",
313 );
314 let rows = team_shape(&[&p]);
315 let labels: Vec<_> = rows
316 .iter()
317 .filter(|r| r.kind != ShapeKind::Root)
318 .map(|r| r.label.as_str())
319 .collect();
320 assert_eq!(labels, vec!["The Lead", "dev"]);
321 }
322
323 #[test]
324 fn descriptor_omits_model_when_not_pinned() {
325 let a: compose::Agent = serde_yaml::from_str("runtime: claude-code\n").unwrap();
328 assert_eq!(agent_descriptor(&a), "Claude Code · 0×a 0×s 0×h 0×m");
329 }
330
331 #[test]
332 fn descriptor_includes_model_when_pinned() {
333 let a: compose::Agent =
334 serde_yaml::from_str("runtime: claude-code\nmodel: claude-sonnet-4-6\n").unwrap();
335 assert_eq!(
336 agent_descriptor(&a),
337 "Claude Code · Sonnet 4.6 · 0×a 0×s 0×h 0×m"
338 );
339 }
340
341 #[test]
342 fn descriptor_counts_subagents_skills_hooks_mcps() {
343 let a: compose::Agent = serde_yaml::from_str(
345 "\
346runtime: claude-code
347model: claude-opus-4-8
348subagents:
349 - subagents/reviewer.md
350 - subagents/planner.md
351skills:
352 - skills/research
353hooks:
354 - event: PreToolUse
355 command: hooks/guard.sh
356mcps:
357 github:
358 command: npx
359 args:
360 - -y
361 - github-mcp
362",
363 )
364 .unwrap();
365 assert_eq!(
366 agent_descriptor(&a),
367 "Claude Code · Opus 4.8 · 2×a 1×s 1×h 1×m"
368 );
369 }
370
371 #[test]
372 fn unknown_runtime_and_model_pass_through_raw() {
373 let a: compose::Agent =
374 serde_yaml::from_str("runtime: codex\nmodel: gpt-5-codex\n").unwrap();
375 assert_eq!(
376 agent_descriptor(&a),
377 "codex · gpt-5-codex · 0×a 0×s 0×h 0×m"
378 );
379 }
380
381 #[test]
382 fn all_known_model_labels_render() {
383 assert_eq!(model_label("claude-opus-4-8"), "Opus 4.8");
384 assert_eq!(model_label("claude-sonnet-4-6"), "Sonnet 4.6");
385 assert_eq!(model_label("claude-haiku-4-5"), "Haiku 4.5");
386 assert_eq!(model_label("claude-haiku-4-5-20251001"), "Haiku 4.5");
387 }
388
389 #[test]
390 fn empty_project_yields_only_the_shared_root() {
391 let p = project(
392 "\
393version: 1
394project:
395 id: empty
396 name: Empty
397 cwd: .
398",
399 );
400 let rows = team_shape(&[&p]);
401 assert_eq!(rows.len(), 1);
402 assert_eq!(rows[0].kind, ShapeKind::Root);
403 assert_eq!(rows[0].label, "You");
404 assert!(rows[0].descriptor.is_empty());
405 }
406
407 #[test]
408 fn multi_project_shares_one_root_with_per_project_sibling_groups() {
409 let a = project(
413 "\
414version: 1
415project:
416 id: a
417 name: A
418 cwd: .
419managers:
420 am: {}
421",
422 );
423 let b = project(
424 "\
425version: 1
426project:
427 id: b
428 name: B
429 cwd: .
430managers:
431 bm: {}
432",
433 );
434 let rows = team_shape(&[&a, &b]);
435 let roots = rows.iter().filter(|r| r.kind == ShapeKind::Root).count();
436 assert_eq!(roots, 1, "only one shared root across projects");
437 let managers: Vec<_> = rows
438 .iter()
439 .filter(|r| r.kind == ShapeKind::Manager)
440 .map(|r| (r.label.as_str(), r.is_last))
441 .collect();
442 assert_eq!(managers, vec![("am", true), ("bm", true)]);
444 }
445}