tandem_server/
preset_summary.rs1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct CapabilitySetInput {
7 #[serde(default)]
8 pub required: Vec<String>,
9 #[serde(default)]
10 pub optional: Vec<String>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct CapabilitySummaryInput {
15 #[serde(default)]
16 pub agent: CapabilitySetInput,
17 #[serde(default)]
18 pub tasks: Vec<CapabilitySetInput>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CapabilitySummaryOutput {
23 pub agent: CapabilitySetInput,
24 pub automation: CapabilitySetInput,
25 pub totals: CapabilityTotals,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CapabilityTotals {
30 pub required_count: usize,
31 pub optional_count: usize,
32 pub task_count: usize,
33}
34
35pub fn summarize(input: CapabilitySummaryInput) -> CapabilitySummaryOutput {
36 let agent = normalize(input.agent);
37 let task_count = input.tasks.len();
38 let mut automation_required = BTreeSet::<String>::new();
39 let mut automation_optional = BTreeSet::<String>::new();
40 for task in input.tasks {
41 let normalized = normalize(task);
42 for cap in normalized.required {
43 automation_required.insert(cap);
44 }
45 for cap in normalized.optional {
46 if !automation_required.contains(&cap) {
47 automation_optional.insert(cap);
48 }
49 }
50 }
51 for cap in &agent.required {
53 automation_required.insert(cap.clone());
54 automation_optional.remove(cap);
55 }
56 for cap in &agent.optional {
57 if !automation_required.contains(cap) {
58 automation_optional.insert(cap.clone());
59 }
60 }
61 let automation = CapabilitySetInput {
62 required: automation_required.iter().cloned().collect(),
63 optional: automation_optional.iter().cloned().collect(),
64 };
65 let totals = CapabilityTotals {
66 required_count: automation.required.len(),
67 optional_count: automation.optional.len(),
68 task_count,
69 };
70 CapabilitySummaryOutput {
71 agent,
72 automation,
73 totals,
74 }
75}
76
77fn normalize(input: CapabilitySetInput) -> CapabilitySetInput {
78 let mut required = BTreeSet::<String>::new();
79 let mut optional = BTreeSet::<String>::new();
80 for cap in input.required {
81 let id = cap.trim();
82 if !id.is_empty() {
83 required.insert(id.to_string());
84 }
85 }
86 for cap in input.optional {
87 let id = cap.trim();
88 if id.is_empty() {
89 continue;
90 }
91 if !required.contains(id) {
92 optional.insert(id.to_string());
93 }
94 }
95 CapabilitySetInput {
96 required: required.into_iter().collect(),
97 optional: optional.into_iter().collect(),
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
106 fn required_dominates_optional_across_agent_and_tasks() {
107 let out = summarize(CapabilitySummaryInput {
108 agent: CapabilitySetInput {
109 required: vec!["github.create_pull_request".to_string()],
110 optional: vec!["slack.post_message".to_string()],
111 },
112 tasks: vec![
113 CapabilitySetInput {
114 required: vec!["slack.post_message".to_string()],
115 optional: vec!["github.create_pull_request".to_string()],
116 },
117 CapabilitySetInput {
118 required: vec![],
119 optional: vec!["jira.create_issue".to_string()],
120 },
121 ],
122 });
123 assert_eq!(
124 out.automation.required,
125 vec![
126 "github.create_pull_request".to_string(),
127 "slack.post_message".to_string()
128 ]
129 );
130 assert_eq!(
131 out.automation.optional,
132 vec!["jira.create_issue".to_string()]
133 );
134 }
135}