1use std::collections::{BTreeMap, BTreeSet, VecDeque};
8
9use crate::agent::AgentName;
10use crate::config::Config;
11use crate::route::Join;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct JoinSpec {
16 pub upstreams: BTreeSet<AgentName>,
18 pub join: Join,
20 pub timeout_sec: Option<u64>,
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct RouteGraph {
27 edges: BTreeMap<AgentName, BTreeSet<AgentName>>,
28 spawns: BTreeMap<AgentName, BTreeSet<AgentName>>,
34 joins: BTreeMap<AgentName, JoinSpec>,
35}
36
37impl RouteGraph {
38 #[must_use]
44 pub fn from_config(config: &Config) -> Self {
45 let mut graph = Self::default();
46
47 for route in &config.routes {
48 for from in &route.from {
49 for to in &route.to {
50 graph
51 .edges
52 .entry(from.clone())
53 .or_default()
54 .insert(to.clone());
55
56 if route.is_spawn() {
57 graph
58 .spawns
59 .entry(from.clone())
60 .or_default()
61 .insert(to.clone());
62 }
63 }
64 }
65
66 if let Some(join) = route.join {
67 for to in &route.to {
68 graph.joins.insert(
69 to.clone(),
70 JoinSpec {
71 upstreams: route.from.iter().cloned().collect(),
72 join,
73 timeout_sec: route.timeout_sec,
74 },
75 );
76 }
77 }
78 }
79
80 graph
81 }
82
83 #[must_use]
85 pub fn is_spawn(&self, from: &AgentName, to: &AgentName) -> bool {
86 self.spawns.get(from).is_some_and(|tos| tos.contains(to))
87 }
88
89 #[must_use]
91 pub fn permits(&self, from: &AgentName, to: &AgentName) -> bool {
92 self.edges.get(from).is_some_and(|tos| tos.contains(to))
93 }
94
95 pub fn successors(&self, from: &AgentName) -> impl Iterator<Item = &AgentName> {
97 self.edges.get(from).into_iter().flatten()
98 }
99
100 pub fn spawn_targets(&self) -> impl Iterator<Item = &AgentName> {
105 self.spawns.values().flatten()
106 }
107
108 #[must_use]
118 pub fn workflow_from(&self, entry: &AgentName) -> BTreeSet<AgentName> {
119 let mut seen: BTreeSet<AgentName> = BTreeSet::new();
120 let mut queue = VecDeque::from([entry.clone()]);
121 seen.insert(entry.clone());
122
123 while let Some(current) = queue.pop_front() {
124 for next in self.successors(¤t) {
125 if seen.insert(next.clone()) {
126 queue.push_back(next.clone());
127 }
128 }
129 }
130
131 seen
132 }
133
134 pub fn spawn_edges(&self) -> impl Iterator<Item = (&AgentName, &AgentName)> {
136 self.spawns
137 .iter()
138 .flat_map(|(from, tos)| tos.iter().map(move |to| (from, to)))
139 }
140
141 #[must_use]
143 pub fn join_for(&self, agent: &AgentName) -> Option<&JoinSpec> {
144 self.joins.get(agent)
145 }
146
147 pub fn reachable_from<'a>(
153 &self,
154 sources: impl IntoIterator<Item = &'a AgentName>,
155 ) -> BTreeSet<AgentName> {
156 self.distances_from(sources).into_keys().collect()
157 }
158
159 pub fn distances_from<'a>(
169 &self,
170 sources: impl IntoIterator<Item = &'a AgentName>,
171 ) -> BTreeMap<AgentName, u32> {
172 let mut seen: BTreeMap<AgentName, u32> = BTreeMap::new();
173 let mut queue: VecDeque<AgentName> = VecDeque::new();
174
175 for source in sources {
176 if !seen.contains_key(source) {
177 seen.insert(source.clone(), 0);
178 queue.push_back(source.clone());
179 }
180 }
181
182 while let Some(current) = queue.pop_front() {
183 let depth = seen[¤t];
184 for next in self.successors(¤t) {
185 if self.is_spawn(¤t, next) {
191 continue;
192 }
193 if !seen.contains_key(next) {
194 seen.insert(next.clone(), depth + 1);
195 queue.push_back(next.clone());
196 }
197 }
198 }
199
200 seen
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn graph_from(toml: &str) -> RouteGraph {
209 let config = Config::from_toml(toml, "test.toml").expect("config parses");
210 RouteGraph::from_config(&config)
211 }
212
213 #[test]
214 fn edges_are_directed() {
215 let graph = graph_from(
216 r#"
217 [[routes]]
218 from = "planner"
219 to = "coder"
220 "#,
221 );
222
223 assert!(graph.permits(&"planner".into(), &"coder".into()));
224 assert!(!graph.permits(&"coder".into(), &"planner".into()));
225 }
226
227 #[test]
228 fn fan_out_expands_to_one_edge_per_target() {
229 let graph = graph_from(
230 r#"
231 [[routes]]
232 from = "planner"
233 to = ["probe_a", "probe_b"]
234 "#,
235 );
236
237 assert!(graph.permits(&"planner".into(), &"probe_a".into()));
238 assert!(graph.permits(&"planner".into(), &"probe_b".into()));
239 assert!(graph.join_for(&"probe_a".into()).is_none());
240 }
241
242 #[test]
243 fn join_route_records_every_upstream() {
244 let graph = graph_from(
245 r#"
246 [[routes]]
247 from = ["probe_a", "probe_b"]
248 to = "collector"
249 join = "all"
250 timeout_sec = 60
251 "#,
252 );
253
254 let spec = graph.join_for(&"collector".into()).expect("join recorded");
255 assert_eq!(spec.join, Join::All);
256 assert_eq!(spec.timeout_sec, Some(60));
257 assert!(spec.upstreams.contains(&"probe_a".into()));
258 assert!(spec.upstreams.contains(&"probe_b".into()));
259 }
260
261 #[test]
262 fn reachability_follows_edges_transitively() {
263 let graph = graph_from(
264 r#"
265 [[routes]]
266 from = "planner"
267 to = "probe_a"
268
269 [[routes]]
270 from = "probe_a"
271 to = "collector"
272
273 [[routes]]
274 from = "orphan"
275 to = "elsewhere"
276 "#,
277 );
278
279 let reachable = graph.reachable_from([&AgentName::from("planner")]);
280
281 assert!(reachable.contains(&"planner".into()));
282 assert!(reachable.contains(&"collector".into()));
283 assert!(!reachable.contains(&"orphan".into()));
284 }
285
286 #[test]
287 fn reachability_terminates_on_cycles() {
288 let graph = graph_from(
289 r#"
290 [[routes]]
291 from = "a"
292 to = "b"
293
294 [[routes]]
295 from = "b"
296 to = "a"
297 "#,
298 );
299
300 let reachable = graph.reachable_from([&AgentName::from("a")]);
301 assert_eq!(reachable.len(), 2);
302 }
303
304 #[test]
305 fn distance_counts_edges_from_the_source() {
306 let graph = graph_from(
307 r#"
308 [[routes]]
309 from = "planner"
310 to = "probe_a"
311
312 [[routes]]
313 from = "probe_a"
314 to = "collector"
315 "#,
316 );
317
318 let distances = graph.distances_from([&AgentName::from("planner")]);
319
320 assert_eq!(distances[&AgentName::from("planner")], 0);
321 assert_eq!(distances[&AgentName::from("probe_a")], 1);
322 assert_eq!(distances[&AgentName::from("collector")], 2);
323 }
324
325 #[test]
326 fn distance_is_the_shortest_path_not_the_longest() {
327 let graph = graph_from(
331 r#"
332 [[routes]]
333 from = "planner"
334 to = ["probe_a", "collector"]
335
336 [[routes]]
337 from = "probe_a"
338 to = "collector"
339 "#,
340 );
341
342 let distances = graph.distances_from([&AgentName::from("planner")]);
343
344 assert_eq!(distances[&AgentName::from("collector")], 1);
345 }
346
347 #[test]
348 fn distance_omits_agents_no_edge_leads_to() {
349 let graph = graph_from(
350 r#"
351 [[routes]]
352 from = "planner"
353 to = "probe_a"
354
355 [[routes]]
356 from = "orphan"
357 to = "elsewhere"
358 "#,
359 );
360
361 let distances = graph.distances_from([&AgentName::from("planner")]);
362
363 assert!(!distances.contains_key(&AgentName::from("orphan")));
364 }
365}