Skip to main content

rigg_core/
graph.rs

1//! Reference-graph ordering for push and delete.
2//!
3//! Order is computed from actual references extracted via the registry
4//! (`registry::extract_references`), never declared. References to resources
5//! outside the given set are ignored — they may already exist in Azure or in
6//! another project.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde_json::Value;
11use thiserror::Error;
12
13use crate::registry;
14use crate::resources::traits::ResourceRef;
15
16#[derive(Debug, Error)]
17pub enum GraphError {
18    #[error("dependency cycle involving: {}", .0.join(" -> "))]
19    Cycle(Vec<String>),
20}
21
22/// Topological order for pushing: dependencies before dependents.
23/// Independent resources are ordered deterministically (kind, then name).
24pub fn push_order(items: &[(ResourceRef, Value)]) -> Result<Vec<ResourceRef>, GraphError> {
25    let in_set: BTreeSet<&ResourceRef> = items.iter().map(|(r, _)| r).collect();
26
27    // edges: node -> set of dependencies (within the set)
28    let mut deps: BTreeMap<&ResourceRef, BTreeSet<&ResourceRef>> = BTreeMap::new();
29    for (r, body) in items {
30        let entry = deps.entry(r).or_default();
31        for (kind, name) in registry::extract_references(r.kind, body) {
32            let dep = ResourceRef { kind, name };
33            if let Some(&found) = in_set.get(&dep) {
34                if found != r {
35                    entry.insert(found);
36                }
37            }
38        }
39    }
40
41    // Kahn's algorithm over the BTreeMap (deterministic iteration order).
42    let mut order = Vec::with_capacity(items.len());
43    let mut remaining: BTreeMap<&ResourceRef, BTreeSet<&ResourceRef>> = deps;
44    while !remaining.is_empty() {
45        let ready: Vec<&ResourceRef> = remaining
46            .iter()
47            .filter(|(_, d)| d.is_empty())
48            .map(|(r, _)| *r)
49            .collect();
50        if ready.is_empty() {
51            // Cycle: report the smallest strongly-connected remainder.
52            let cycle: Vec<String> = remaining.keys().map(|r| r.to_string()).collect();
53            return Err(GraphError::Cycle(cycle));
54        }
55        for r in &ready {
56            remaining.remove(*r);
57        }
58        for d in remaining.values_mut() {
59            for r in &ready {
60                d.remove(*r);
61            }
62        }
63        order.extend(ready.into_iter().cloned());
64    }
65    Ok(order)
66}
67
68/// Topological order for deleting: dependents before dependencies.
69pub fn delete_order(items: &[(ResourceRef, Value)]) -> Result<Vec<ResourceRef>, GraphError> {
70    let mut order = push_order(items)?;
71    order.reverse();
72    Ok(order)
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::resources::traits::ResourceKind;
79    use serde_json::json;
80
81    fn r(kind: ResourceKind, name: &str) -> ResourceRef {
82        ResourceRef::new(kind, name)
83    }
84
85    fn full_chain() -> Vec<(ResourceRef, Value)> {
86        vec![
87            (
88                r(ResourceKind::KnowledgeBase, "kb"),
89                json!({"name": "kb", "knowledgeSources": [{"name": "ks"}]}),
90            ),
91            (
92                r(ResourceKind::Indexer, "idxr"),
93                json!({
94                    "name": "idxr",
95                    "dataSourceName": "ds",
96                    "targetIndexName": "idx",
97                    "skillsetName": "skills"
98                }),
99            ),
100            (
101                r(ResourceKind::KnowledgeSource, "ks"),
102                json!({
103                    "name": "ks",
104                    "kind": "searchIndex",
105                    "searchIndexParameters": {"searchIndexName": "idx"}
106                }),
107            ),
108            (r(ResourceKind::Index, "idx"), json!({"name": "idx"})),
109            (
110                r(ResourceKind::Skillset, "skills"),
111                json!({"name": "skills"}),
112            ),
113            (r(ResourceKind::DataSource, "ds"), json!({"name": "ds"})),
114            (
115                r(ResourceKind::Agent, "agent"),
116                json!({
117                    "name": "agent",
118                    "model": "gpt-5-mini",
119                    "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb"}]
120                }),
121            ),
122            (
123                r(ResourceKind::Deployment, "gpt-5-mini"),
124                json!({"name": "gpt-5-mini", "properties": {"raiPolicyName": "rai"}}),
125            ),
126            (r(ResourceKind::Guardrail, "rai"), json!({"name": "rai"})),
127        ]
128    }
129
130    fn pos(order: &[ResourceRef], kind: ResourceKind, name: &str) -> usize {
131        order
132            .iter()
133            .position(|x| x.kind == kind && x.name == name)
134            .unwrap_or_else(|| panic!("{name} not in order"))
135    }
136
137    #[test]
138    fn push_order_respects_all_edges() {
139        let items = full_chain();
140        let order = push_order(&items).unwrap();
141        assert_eq!(order.len(), items.len());
142        let p = |k, n| pos(&order, k, n);
143        assert!(p(ResourceKind::DataSource, "ds") < p(ResourceKind::Indexer, "idxr"));
144        assert!(p(ResourceKind::Index, "idx") < p(ResourceKind::Indexer, "idxr"));
145        assert!(p(ResourceKind::Skillset, "skills") < p(ResourceKind::Indexer, "idxr"));
146        assert!(p(ResourceKind::Index, "idx") < p(ResourceKind::KnowledgeSource, "ks"));
147        assert!(p(ResourceKind::KnowledgeSource, "ks") < p(ResourceKind::KnowledgeBase, "kb"));
148        assert!(p(ResourceKind::KnowledgeBase, "kb") < p(ResourceKind::Agent, "agent"));
149        assert!(p(ResourceKind::Guardrail, "rai") < p(ResourceKind::Deployment, "gpt-5-mini"));
150        assert!(p(ResourceKind::Deployment, "gpt-5-mini") < p(ResourceKind::Agent, "agent"));
151    }
152
153    #[test]
154    fn delete_order_is_reversed() {
155        let items = full_chain();
156        let push = push_order(&items).unwrap();
157        let mut del = delete_order(&items).unwrap();
158        del.reverse();
159        assert_eq!(push, del);
160    }
161
162    #[test]
163    fn external_references_ignored() {
164        let items = vec![(
165            r(ResourceKind::Indexer, "idxr"),
166            json!({"name": "idxr", "dataSourceName": "not-in-set", "targetIndexName": "idx"}),
167        )];
168        let order = push_order(&items).unwrap();
169        assert_eq!(order.len(), 1);
170    }
171
172    #[test]
173    fn deterministic_order_for_independent_nodes() {
174        let items = vec![
175            (r(ResourceKind::Index, "zebra"), json!({"name": "zebra"})),
176            (r(ResourceKind::Index, "apple"), json!({"name": "apple"})),
177            (
178                r(ResourceKind::DataSource, "mango"),
179                json!({"name": "mango"}),
180            ),
181        ];
182        let order = push_order(&items).unwrap();
183        // BTree ordering: kind declaration order (DataSource < Index), then name.
184        assert_eq!(order[0].name, "mango");
185        assert_eq!(order[1].name, "apple");
186        assert_eq!(order[2].name, "zebra");
187    }
188
189    #[test]
190    fn cycle_detected_with_names() {
191        // Two aliases pointing at each other is impossible in Azure, but the
192        // graph must not hang; simulate a cycle with x-rigg-ref.
193        let items = vec![
194            (
195                r(ResourceKind::Agent, "a"),
196                json!({"name": "a", "x-rigg-ref": "agents/b"}),
197            ),
198            (
199                r(ResourceKind::Agent, "b"),
200                json!({"name": "b", "x-rigg-ref": "agents/a"}),
201            ),
202        ];
203        let err = push_order(&items).unwrap_err();
204        let msg = err.to_string();
205        assert!(msg.contains("agents/a") && msg.contains("agents/b"));
206    }
207}