Skip to main content

rigg_core/
migrate.rs

1//! Knowledge-source migration transforms: convert an indexed knowledge
2//! source (azureBlob, azureSql, oneLake, ...) with an Azure-generated
3//! pipeline into the explicit `searchIndex` shape plus first-class
4//! data-source/index/skillset/indexer definitions.
5//!
6//! Everything here is a pure document transform — the `rigg migrate` command
7//! writes files, and `rigg push` performs the actual replace.
8
9use std::collections::BTreeMap;
10
11use serde_json::{Value, json};
12
13use crate::registry;
14use crate::resources::ResourceKind;
15
16/// The generated sub-resources named by a knowledge source's
17/// `createdResources` object (found at any nesting depth — the live shape
18/// nests it under `<kind>Parameters`). Unknown member names are ignored.
19pub fn created_resources(ks_doc: &Value) -> BTreeMap<ResourceKind, String> {
20    let mut out = BTreeMap::new();
21    walk_created(ks_doc, &mut out);
22    out
23}
24
25fn walk_created(v: &Value, out: &mut BTreeMap<ResourceKind, String>) {
26    match v {
27        Value::Object(map) => {
28            if let Some(Value::Object(created)) = map.get("createdResources") {
29                for (member, name) in created {
30                    let kind = match member.as_str() {
31                        "datasource" => Some(ResourceKind::DataSource),
32                        "indexer" => Some(ResourceKind::Indexer),
33                        "skillset" => Some(ResourceKind::Skillset),
34                        "index" => Some(ResourceKind::Index),
35                        _ => None, // future member names: ignore
36                    };
37                    if let (Some(kind), Some(name)) = (kind, name.as_str()) {
38                        out.insert(kind, name.to_string());
39                    }
40                }
41            }
42            for val in map.values() {
43                walk_created(val, out);
44            }
45        }
46        Value::Array(arr) => {
47            for item in arr {
48                walk_created(item, out);
49            }
50        }
51        _ => {}
52    }
53}
54
55/// Whether this knowledge source is an indexed kind with a generated
56/// pipeline that can be migrated (i.e. carries a non-empty
57/// `createdResources`). Remote kinds (web, MCP, ...) and `searchIndex`
58/// sources have nothing to migrate.
59pub fn is_indexed_with_created(ks_doc: &Value) -> bool {
60    ks_doc.get("kind").and_then(Value::as_str) != Some("searchIndex")
61        && !created_resources(ks_doc).is_empty()
62}
63
64/// Build the explicit `searchIndex` knowledge-source document replacing an
65/// indexed one: preserves `name` and `description`, points at `index_name`,
66/// and drops every `<kind>Parameters` payload.
67pub fn to_search_index_ks(ks_doc: &Value, index_name: &str) -> Value {
68    let mut out = json!({
69        "name": ks_doc.get("name").cloned().unwrap_or(Value::Null),
70        "kind": "searchIndex",
71        "searchIndexParameters": { "searchIndexName": index_name }
72    });
73    if let Some(desc) = ks_doc.get("description").filter(|d| !d.is_null()) {
74        out["description"] = desc.clone();
75    }
76    out
77}
78
79/// Derive side-by-side names for the new pipeline: when a generated name
80/// starts with the old knowledge-source name, that prefix is swapped for the
81/// new name (`regulatory-index` → `reg2-index`); otherwise a conventional
82/// `<new>-<kind>` name is used.
83pub fn derive_names(
84    old_ks: &str,
85    new_ks: &str,
86    created: &BTreeMap<ResourceKind, String>,
87) -> BTreeMap<ResourceKind, String> {
88    created
89        .iter()
90        .map(|(kind, name)| {
91            let derived = match name.strip_prefix(old_ks) {
92                Some(rest) => format!("{new_ks}{rest}"),
93                None => {
94                    let suffix = match kind {
95                        ResourceKind::DataSource => "datasource",
96                        ResourceKind::Index => "index",
97                        ResourceKind::Skillset => "skillset",
98                        ResourceKind::Indexer => "indexer",
99                        other => registry::meta(*other).dir_name,
100                    };
101                    format!("{new_ks}-{suffix}")
102                }
103            };
104            (*kind, derived)
105        })
106        .collect()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn blob_ks() -> Value {
114        json!({
115            "name": "regulatory",
116            "kind": "azureBlob",
117            "description": "Legal docs.",
118            "azureBlobParameters": {
119                "containerName": "regulatory",
120                "createdResources": {
121                    "datasource": "regulatory-datasource",
122                    "indexer": "regulatory-indexer",
123                    "skillset": "regulatory-skillset",
124                    "index": "regulatory-index",
125                    "somethingFuture": "ignored-name"
126                }
127            }
128        })
129    }
130
131    #[test]
132    fn created_resources_reads_nested_map() {
133        let map = created_resources(&blob_ks());
134        assert_eq!(map.len(), 4);
135        assert_eq!(map[&ResourceKind::Index], "regulatory-index");
136        assert_eq!(map[&ResourceKind::DataSource], "regulatory-datasource");
137        assert_eq!(map[&ResourceKind::Skillset], "regulatory-skillset");
138        assert_eq!(map[&ResourceKind::Indexer], "regulatory-indexer");
139    }
140
141    #[test]
142    fn is_indexed_with_created_true_for_blob_false_otherwise() {
143        assert!(is_indexed_with_created(&blob_ks()));
144        let search_index = json!({"name": "ks", "kind": "searchIndex",
145            "searchIndexParameters": {"searchIndexName": "docs"}});
146        assert!(!is_indexed_with_created(&search_index));
147        let web = json!({"name": "ks", "kind": "web", "webParameters": {}});
148        assert!(!is_indexed_with_created(&web));
149    }
150
151    #[test]
152    fn to_search_index_ks_preserves_name_and_description() {
153        let ks = to_search_index_ks(&blob_ks(), "regulatory-index");
154        assert_eq!(
155            ks,
156            json!({
157                "name": "regulatory",
158                "kind": "searchIndex",
159                "description": "Legal docs.",
160                "searchIndexParameters": {"searchIndexName": "regulatory-index"}
161            })
162        );
163    }
164
165    #[test]
166    fn to_search_index_ks_omits_missing_description() {
167        let ks = json!({"name": "n", "kind": "azureBlob", "description": null});
168        let out = to_search_index_ks(&ks, "i");
169        assert!(out.get("description").is_none());
170    }
171
172    #[test]
173    fn derive_names_swaps_prefix() {
174        let created = created_resources(&blob_ks());
175        let names = derive_names("regulatory", "reg2", &created);
176        assert_eq!(names[&ResourceKind::Index], "reg2-index");
177        assert_eq!(names[&ResourceKind::DataSource], "reg2-datasource");
178        assert_eq!(names[&ResourceKind::Skillset], "reg2-skillset");
179        assert_eq!(names[&ResourceKind::Indexer], "reg2-indexer");
180    }
181
182    #[test]
183    fn derive_names_falls_back_to_conventional_suffix() {
184        let mut created = BTreeMap::new();
185        created.insert(ResourceKind::Index, "weird-name".to_string());
186        let names = derive_names("regulatory", "reg2", &created);
187        assert_eq!(names[&ResourceKind::Index], "reg2-index");
188    }
189}