Skip to main content

team_core/
preview.rs

1//! Front-end-agnostic reporting-structure shape for a team.
2//!
3//! Computes the "You → managers → workers" reporting tree as structured
4//! data — no ANSI, no ratatui — so both the CLI `init` preview and the
5//! TUI detail panel can render it from a single source of truth. The
6//! grouping and ordering mirror `teamctl init`'s team-structure preview
7//! exactly (managers id-sorted via the `BTreeMap` key order, then orphan
8//! workers at the top level, workers nested under the manager they
9//! `reports_to`); the only thing dropped here is the color.
10
11use std::collections::BTreeMap;
12
13use crate::compose;
14
15/// One row of a team's reporting tree — depth-indented, front-end-agnostic.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ShapeRow {
18    /// 0 = "You" root, 1 = manager / top-level orphan worker,
19    /// 2 = worker under a manager.
20    pub depth: u8,
21    pub kind: ShapeKind,
22    /// `display_name` when set, else the agent id.
23    pub label: String,
24    /// E.g. `"Claude Code · Opus 4.8 · 8×a 0×s 0×h 0×m"` (empty for Root).
25    pub descriptor: String,
26    /// Last sibling at this depth — for tree connectors.
27    pub is_last: bool,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ShapeKind {
32    Root,
33    Manager,
34    Worker,
35}
36
37/// Walk the given projects into one "You → managers → workers" tree.
38///
39/// One shared Root row, then per project: managers (id-sorted via the
40/// `BTreeMap` key order) each followed by the workers that `reports_to`
41/// them (id-sorted); workers whose `reports_to` isn't a manager in their
42/// project hang at the top level (depth 1) after the managers. Mirrors
43/// `init.rs::team_structure_lines`'s grouping/ordering exactly, minus
44/// color.
45pub fn team_shape(projects: &[&compose::Project]) -> Vec<ShapeRow> {
46    let mut out = Vec::new();
47
48    // One shared "You" root — the operator — emitted once at the top, so
49    // the whole tree reads as a reporting hierarchy (managers report to
50    // you; workers to their manager). init.rs emits this per project
51    // because today's templates each ship a single agent-bearing
52    // project; here we hoist it to a single root that spans every
53    // project's top-level group.
54    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        // Group workers under the manager they report to; any worker
68        // whose `reports_to` isn't a manager in this project hangs at the
69        // top level. BTreeMap iteration keeps each group id-sorted.
70        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        // Top level: every manager (id-sorted), then any orphan workers.
82        // The two kinds are siblings at depth 1 within this project's span.
83        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            // Workers reporting to this manager, nested one level in.
107            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
127/// One-line descriptor for an agent: runtime label, model label (only
128/// when pinned), then `N×a N×s N×h N×m` counts (subagents/skills/hooks/
129/// mcps). Identical output to `init.rs::agent_descriptor`.
130pub 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
145/// Roster label for an agent: its `display_name` when set, else the id.
146fn label_for(id: &str, agent: &compose::Agent) -> String {
147    agent.display_name.clone().unwrap_or_else(|| id.to_string())
148}
149
150/// Human-friendly runtime name; unknown runtimes show their raw id.
151fn runtime_label(runtime: &str) -> String {
152    match runtime {
153        "claude-code" => "Claude Code".to_string(),
154        other => other.to_string(),
155    }
156}
157
158/// Human-friendly model name for the known Claude ids; anything else
159/// shows the raw model string the operator pinned.
160fn 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        // Manager `lead`; workers `dev1` + `dev2` both report to it. The
180        // root is emitted once; the manager sits at depth 1 (last sibling),
181        // its two workers nest at depth 2, id-sorted with the last flagged.
182        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        // `solo` reports to `ghost`, which is not a manager in this
238        // project, so it hangs at depth 1 after the (single) manager.
239        // Managers come first, then orphans — and the orphan is the last
240        // top-level sibling.
241        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        // BTreeMap key order = id-sorted: `alpha` before `beta`, regardless
273        // of YAML declaration order. The last manager is flagged `is_last`.
274        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        // `lead` has a display_name; `dev` does not. Label uses the
298        // display_name when present, else the agent id.
299        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        // No `model:` → the model segment is dropped entirely; the
326        // descriptor is just runtime + counts.
327        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        // Two subagents, one skill, one hook, one mcp → `2×a 1×s 1×h 1×m`.
344        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        // Two agent-bearing projects share a single "You" root. Each
410        // project's top-level managers form their own sibling group, so
411        // each project's last manager is flagged `is_last` independently.
412        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        // Each project's single manager is the last sibling in its own group.
443        assert_eq!(managers, vec![("am", true), ("bm", true)]);
444    }
445}