busbar_sf_agentscript/graph/
dependencies.rs1use crate::ast::{ActionDef, ConnectionBlock, KnowledgeBlock};
13use crate::AgentFile;
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum DependencyType {
20 SObject(String),
22 Field { object: String, field: String },
24 Flow(String),
26 ApexClass(String),
28 ApexMethod { class: String, method: String },
30 KnowledgeBase(String),
32 Connection(String),
34 PromptTemplate(String),
36 ExternalService(String),
38 Custom(String),
40}
41
42impl DependencyType {
43 pub fn category(&self) -> &'static str {
45 match self {
46 DependencyType::SObject(_) => "sobject",
47 DependencyType::Field { .. } => "field",
48 DependencyType::Flow(_) => "flow",
49 DependencyType::ApexClass(_) => "apex_class",
50 DependencyType::ApexMethod { .. } => "apex_method",
51 DependencyType::KnowledgeBase(_) => "knowledge",
52 DependencyType::Connection(_) => "connection",
53 DependencyType::PromptTemplate(_) => "prompt_template",
54 DependencyType::ExternalService(_) => "external_service",
55 DependencyType::Custom(_) => "custom",
56 }
57 }
58
59 pub fn name(&self) -> String {
61 match self {
62 DependencyType::SObject(name) => name.clone(),
63 DependencyType::Field { object, field } => format!("{}.{}", object, field),
64 DependencyType::Flow(name) => name.clone(),
65 DependencyType::ApexClass(name) => name.clone(),
66 DependencyType::ApexMethod { class, method } => format!("{}.{}", class, method),
67 DependencyType::KnowledgeBase(name) => name.clone(),
68 DependencyType::Connection(name) => name.clone(),
69 DependencyType::PromptTemplate(name) => name.clone(),
70 DependencyType::ExternalService(name) => name.clone(),
71 DependencyType::Custom(target) => target.clone(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Dependency {
79 pub dep_type: DependencyType,
81 pub used_in: String,
83 pub action_name: String,
85 pub span: (usize, usize),
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct DependencyReport {
92 pub sobjects: HashSet<String>,
94 pub fields: HashSet<String>,
96 pub flows: HashSet<String>,
98 pub apex_classes: HashSet<String>,
100 pub knowledge_bases: HashSet<String>,
102 pub connections: HashSet<String>,
104 pub prompt_templates: HashSet<String>,
106 pub external_services: HashSet<String>,
108 pub all_dependencies: Vec<Dependency>,
110 pub by_type: HashMap<String, Vec<Dependency>>,
112 pub by_topic: HashMap<String, Vec<Dependency>>,
114}
115
116impl DependencyReport {
117 pub fn uses_sobject(&self, name: &str) -> bool {
119 self.sobjects.contains(name)
120 }
121
122 pub fn uses_flow(&self, name: &str) -> bool {
124 self.flows.contains(name)
125 }
126
127 pub fn uses_apex_class(&self, name: &str) -> bool {
129 self.apex_classes.contains(name)
130 }
131
132 pub fn get_by_type(&self, category: &str) -> Vec<&Dependency> {
134 self.by_type
135 .get(category)
136 .map(|deps| deps.iter().collect())
137 .unwrap_or_default()
138 }
139
140 pub fn get_by_topic(&self, topic: &str) -> Vec<&Dependency> {
142 self.by_topic
143 .get(topic)
144 .map(|deps| deps.iter().collect())
145 .unwrap_or_default()
146 }
147
148 pub fn unique_count(&self) -> usize {
150 self.sobjects.len()
151 + self.fields.len()
152 + self.flows.len()
153 + self.apex_classes.len()
154 + self.knowledge_bases.len()
155 + self.connections.len()
156 + self.prompt_templates.len()
157 + self.external_services.len()
158 }
159}
160
161pub fn extract_dependencies(ast: &AgentFile) -> DependencyReport {
163 let mut report = DependencyReport::default();
164
165 if let Some(knowledge) = &ast.knowledge {
167 extract_from_knowledge(&knowledge.node, &mut report);
168 }
169
170 for connection in &ast.connections {
172 extract_from_connection(&connection.node, &mut report);
173 }
174
175 if let Some(start) = &ast.start_agent {
177 if let Some(actions) = &start.node.actions {
178 for action in &actions.node.actions {
179 extract_from_action(
180 &action.node,
181 "start_agent",
182 (action.span.start, action.span.end),
183 &mut report,
184 );
185 }
186 }
187 }
188
189 for topic in &ast.topics {
191 let topic_name = &topic.node.name.node;
192 if let Some(actions) = &topic.node.actions {
193 for action in &actions.node.actions {
194 extract_from_action(
195 &action.node,
196 topic_name,
197 (action.span.start, action.span.end),
198 &mut report,
199 );
200 }
201 }
202 }
203
204 for dep in &report.all_dependencies {
206 let category = dep.dep_type.category().to_string();
207 report
208 .by_type
209 .entry(category)
210 .or_default()
211 .push(dep.clone());
212
213 report
214 .by_topic
215 .entry(dep.used_in.clone())
216 .or_default()
217 .push(dep.clone());
218 }
219
220 report
221}
222
223fn extract_from_action(
225 action: &ActionDef,
226 topic: &str,
227 span: (usize, usize),
228 report: &mut DependencyReport,
229) {
230 let action_name = action.name.node.clone();
231
232 if let Some(target) = &action.target {
233 let target_str = &target.node;
234 let dep_type = parse_action_target(target_str);
235
236 match &dep_type {
238 DependencyType::SObject(name) => {
239 report.sobjects.insert(name.clone());
240 }
241 DependencyType::Field { object, field } => {
242 report.sobjects.insert(object.clone());
243 report.fields.insert(format!("{}.{}", object, field));
244 }
245 DependencyType::Flow(name) => {
246 report.flows.insert(name.clone());
247 }
248 DependencyType::ApexClass(name) => {
249 report.apex_classes.insert(name.clone());
250 }
251 DependencyType::ApexMethod { class, .. } => {
252 report.apex_classes.insert(class.clone());
253 }
254 DependencyType::PromptTemplate(name) => {
255 report.prompt_templates.insert(name.clone());
256 }
257 DependencyType::ExternalService(name) => {
258 report.external_services.insert(name.clone());
259 }
260 _ => {}
261 }
262
263 report.all_dependencies.push(Dependency {
264 dep_type,
265 used_in: topic.to_string(),
266 action_name,
267 span,
268 });
269 }
270}
271
272fn parse_action_target(target: &str) -> DependencyType {
274 if let Some(name) = target.strip_prefix("flow://") {
275 return DependencyType::Flow(name.to_string());
276 }
277
278 if let Some(name) = target.strip_prefix("apex://") {
279 if let Some((class, method)) = name.split_once('.') {
280 return DependencyType::ApexMethod {
281 class: class.to_string(),
282 method: method.to_string(),
283 };
284 }
285 return DependencyType::ApexClass(name.to_string());
286 }
287
288 if let Some(name) = target.strip_prefix("prompt://") {
289 return DependencyType::PromptTemplate(name.to_string());
290 }
291
292 if let Some(name) = target.strip_prefix("service://") {
293 return DependencyType::ExternalService(name.to_string());
294 }
295
296 for op in &["create://", "read://", "update://", "delete://", "query://"] {
298 if let Some(rest) = target.strip_prefix(op) {
299 if let Some((object, field)) = rest.split_once('.') {
301 return DependencyType::Field {
302 object: object.to_string(),
303 field: field.to_string(),
304 };
305 }
306 return DependencyType::SObject(rest.to_string());
307 }
308 }
309
310 DependencyType::Custom(target.to_string())
311}
312
313fn extract_from_knowledge(knowledge: &KnowledgeBlock, report: &mut DependencyReport) {
315 for entry in &knowledge.entries {
316 let name = entry.node.name.node.clone();
317 report.knowledge_bases.insert(name.clone());
318 report.all_dependencies.push(Dependency {
319 dep_type: DependencyType::KnowledgeBase(name.clone()),
320 used_in: "knowledge".to_string(),
321 action_name: name,
322 span: (entry.span.start, entry.span.end),
323 });
324 }
325}
326
327fn extract_from_connection(connection: &ConnectionBlock, report: &mut DependencyReport) {
329 let connection_name = connection.name.node.clone();
331 report.connections.insert(connection_name.clone());
332
333 for entry in &connection.entries {
335 let name = entry.node.name.node.clone();
336 report.all_dependencies.push(Dependency {
337 dep_type: DependencyType::Connection(connection_name.clone()),
338 used_in: format!("connection:{}", connection_name),
339 action_name: name,
340 span: (entry.span.start, entry.span.end),
341 });
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_parse_flow_target() {
351 let dep = parse_action_target("flow://Get_Customer_Details");
352 assert!(matches!(dep, DependencyType::Flow(name) if name == "Get_Customer_Details"));
353 }
354
355 #[test]
356 fn test_parse_apex_target() {
357 let dep = parse_action_target("apex://OrderService");
358 assert!(matches!(dep, DependencyType::ApexClass(name) if name == "OrderService"));
359
360 let dep = parse_action_target("apex://OrderService.createOrder");
361 assert!(matches!(dep, DependencyType::ApexMethod { class, method }
362 if class == "OrderService" && method == "createOrder"));
363 }
364
365 #[test]
366 fn test_parse_record_target() {
367 let dep = parse_action_target("query://Account");
368 assert!(matches!(dep, DependencyType::SObject(name) if name == "Account"));
369
370 let dep = parse_action_target("read://Contact.Email");
371 assert!(matches!(dep, DependencyType::Field { object, field }
372 if object == "Contact" && field == "Email"));
373 }
374
375 #[test]
376 fn test_parse_prompt_template() {
377 let dep = parse_action_target("prompt://Customer_Greeting");
378 assert!(matches!(dep, DependencyType::PromptTemplate(name) if name == "Customer_Greeting"));
379 }
380
381 #[test]
382 fn test_parse_external_service() {
383 let dep = parse_action_target("service://WeatherAPI");
384 assert!(matches!(dep, DependencyType::ExternalService(name) if name == "WeatherAPI"));
385 }
386
387 #[test]
388 #[ignore = "Recipe file uses {} empty object literal which is not valid AgentScript"]
389 fn test_full_dependency_extraction() {
390 let source = include_str!("../../agent-script-recipes/force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent");
392 let ast = crate::parse(source).unwrap();
393 let report = extract_dependencies(&ast);
394
395 assert!(report.uses_flow("FetchCustomer"));
397 assert!(report.uses_flow("SearchKnowledgeBase"));
398 assert!(report.uses_flow("CreateCase"));
399 assert!(report.uses_flow("UpdateCase"));
400 assert!(report.uses_flow("EscalateCase"));
401 assert!(report.uses_flow("SendSatisfactionSurvey"));
402
403 assert!(report.flows.len() >= 6, "Expected at least 6 flows, got {}", report.flows.len());
405
406 assert!(report.uses_apex_class("IssueClassifier"));
408
409 let triage_deps = report.get_by_topic("triage");
411 assert!(!triage_deps.is_empty(), "Expected dependencies in triage topic");
412
413 let flow_deps = report.get_by_type("flow");
415 assert!(!flow_deps.is_empty());
416
417 assert!(report.unique_count() > 0);
419 }
420}