Skip to main content

busbar_sf_agentscript/graph/
dependencies.rs

1//! Dependency extraction and analysis for AgentScript files.
2//!
3//! This module identifies all external dependencies on Salesforce org configuration:
4//! - **Objects/Fields**: Referenced via record actions (create://, read://, query://, etc.)
5//! - **Flows**: Referenced via flow://FlowName
6//! - **Apex Classes**: Referenced via apex://ClassName
7//! - **Knowledge Bases**: Referenced in knowledge block
8//! - **Connections**: Referenced for escalation routing
9//!
10//! This enables offline analysis of agent dependencies without round-tripping to the org.
11
12use crate::ast::{ActionDef, ConnectionBlock, KnowledgeBlock};
13use crate::AgentFile;
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16
17/// Type of Salesforce org dependency.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum DependencyType {
20    /// Salesforce Object (e.g., Account, Contact, custom__c)
21    SObject(String),
22    /// Salesforce Field on an object (Object.Field)
23    Field { object: String, field: String },
24    /// Flow (flow://FlowName)
25    Flow(String),
26    /// Apex Class (apex://ClassName)
27    ApexClass(String),
28    /// Apex Method (apex://ClassName.methodName)
29    ApexMethod { class: String, method: String },
30    /// Knowledge Base
31    KnowledgeBase(String),
32    /// Connection for escalation
33    Connection(String),
34    /// Prompt Template
35    PromptTemplate(String),
36    /// External Service
37    ExternalService(String),
38    /// Unknown/Custom target
39    Custom(String),
40}
41
42impl DependencyType {
43    /// Get the category of this dependency.
44    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    /// Get the name of this dependency.
60    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/// A single dependency with its source location.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Dependency {
79    /// The type and name of the dependency
80    pub dep_type: DependencyType,
81    /// Where this dependency is used (topic name or "start_agent")
82    pub used_in: String,
83    /// The action name that references this dependency
84    pub action_name: String,
85    /// Source span (start, end)
86    pub span: (usize, usize),
87}
88
89/// Complete dependency report for an AgentScript file.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct DependencyReport {
92    /// All unique SObjects referenced
93    pub sobjects: HashSet<String>,
94    /// All unique fields referenced (Object.Field)
95    pub fields: HashSet<String>,
96    /// All Flows referenced
97    pub flows: HashSet<String>,
98    /// All Apex classes referenced
99    pub apex_classes: HashSet<String>,
100    /// All Knowledge bases referenced
101    pub knowledge_bases: HashSet<String>,
102    /// All Connections referenced
103    pub connections: HashSet<String>,
104    /// All Prompt Templates referenced
105    pub prompt_templates: HashSet<String>,
106    /// External services referenced
107    pub external_services: HashSet<String>,
108    /// All dependencies with full details
109    pub all_dependencies: Vec<Dependency>,
110    /// Dependencies grouped by type
111    pub by_type: HashMap<String, Vec<Dependency>>,
112    /// Dependencies grouped by topic
113    pub by_topic: HashMap<String, Vec<Dependency>>,
114}
115
116impl DependencyReport {
117    /// Check if a specific SObject is used.
118    pub fn uses_sobject(&self, name: &str) -> bool {
119        self.sobjects.contains(name)
120    }
121
122    /// Check if a specific Flow is used.
123    pub fn uses_flow(&self, name: &str) -> bool {
124        self.flows.contains(name)
125    }
126
127    /// Check if a specific Apex class is used.
128    pub fn uses_apex_class(&self, name: &str) -> bool {
129        self.apex_classes.contains(name)
130    }
131
132    /// Get all dependencies of a specific type.
133    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    /// Get all dependencies used in a specific topic.
141    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    /// Get total count of unique dependencies.
149    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
161/// Extract all Salesforce org dependencies from an AgentScript AST.
162pub fn extract_dependencies(ast: &AgentFile) -> DependencyReport {
163    let mut report = DependencyReport::default();
164
165    // Extract from knowledge block
166    if let Some(knowledge) = &ast.knowledge {
167        extract_from_knowledge(&knowledge.node, &mut report);
168    }
169
170    // Extract from connection blocks
171    for connection in &ast.connections {
172        extract_from_connection(&connection.node, &mut report);
173    }
174
175    // Extract from start_agent actions
176    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    // Extract from topic actions
190    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    // Build grouped views
205    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
223/// Parse an action target and extract dependencies.
224fn 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        // Add to appropriate set
237        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
272/// Parse an action target string into a dependency type.
273fn 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    // Record operations: create://, read://, update://, delete://, query://
297    for op in &["create://", "read://", "update://", "delete://", "query://"] {
298        if let Some(rest) = target.strip_prefix(op) {
299            // Check for field access (Object.Field)
300            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
313/// Extract dependencies from knowledge block.
314fn 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
327/// Extract dependencies from a connection block.
328fn extract_from_connection(connection: &ConnectionBlock, report: &mut DependencyReport) {
329    // Register the connection name
330    let connection_name = connection.name.node.clone();
331    report.connections.insert(connection_name.clone());
332
333    // Extract dependencies from entries
334    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        // Load and parse a real recipe from the submodule
391        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        // Check flows (multiple flow targets in this recipe)
396        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        // Check count of flows
404        assert!(report.flows.len() >= 6, "Expected at least 6 flows, got {}", report.flows.len());
405
406        // Check apex (IssueClassifier is called via apex://)
407        assert!(report.uses_apex_class("IssueClassifier"));
408
409        // Check grouping by topic - triage topic has many actions
410        let triage_deps = report.get_by_topic("triage");
411        assert!(!triage_deps.is_empty(), "Expected dependencies in triage topic");
412
413        // Check grouping by type
414        let flow_deps = report.get_by_type("flow");
415        assert!(!flow_deps.is_empty());
416
417        // Verify we can get a summary
418        assert!(report.unique_count() > 0);
419    }
420}