Skip to main content

hoist_core/
copy.rs

1//! Copy/rename support for push operations
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Result;
7use serde_json::Value;
8
9use crate::resources::ResourceKind;
10
11/// Maps old resource names to new resource names, keyed by (ResourceKind, old_name).
12#[derive(Default)]
13pub struct NameMap {
14    map: HashMap<(ResourceKind, String), String>,
15}
16
17impl NameMap {
18    pub fn new() -> Self {
19        Self {
20            map: HashMap::new(),
21        }
22    }
23
24    pub fn insert(&mut self, kind: ResourceKind, old: &str, new: &str) {
25        self.map.insert((kind, old.to_string()), new.to_string());
26    }
27
28    pub fn get(&self, kind: ResourceKind, old: &str) -> Option<&str> {
29        self.map.get(&(kind, old.to_string())).map(|s| s.as_str())
30    }
31
32    /// Create a NameMap by appending a suffix to all resource names.
33    pub fn from_suffix(resources: &[(ResourceKind, String)], suffix: &str) -> Self {
34        let mut map = Self::new();
35        for (kind, name) in resources {
36            map.insert(*kind, name, &format!("{}{}", name, suffix));
37        }
38        map
39    }
40
41    /// Load a NameMap from a JSON answers file.
42    ///
43    /// Expected format:
44    /// ```json
45    /// {
46    ///   "indexes": { "old-name": "new-name" },
47    ///   "indexers": { "old-indexer": "new-indexer" }
48    /// }
49    /// ```
50    ///
51    /// Keys are the `api_path()` values for each resource kind.
52    pub fn from_answers_file(path: &Path) -> Result<Self> {
53        let content = std::fs::read_to_string(path)?;
54        let root: Value = serde_json::from_str(&content)?;
55
56        let obj = root
57            .as_object()
58            .ok_or_else(|| anyhow::anyhow!("Answers file must be a JSON object"))?;
59
60        let mut map = Self::new();
61
62        for kind in ResourceKind::all() {
63            if let Some(mappings) = obj.get(kind.api_path()) {
64                let mappings = mappings
65                    .as_object()
66                    .ok_or_else(|| anyhow::anyhow!("'{}' must be an object", kind.api_path()))?;
67
68                for (old_name, new_name) in mappings {
69                    let new_name = new_name.as_str().ok_or_else(|| {
70                        anyhow::anyhow!(
71                            "Value for '{}' in '{}' must be a string",
72                            old_name,
73                            kind.api_path()
74                        )
75                    })?;
76                    map.insert(*kind, old_name, new_name);
77                }
78            }
79        }
80
81        Ok(map)
82    }
83
84    /// Look up a new name by searching all resource kinds.
85    /// Used for reference rewriting where we know the referenced name but not necessarily
86    /// which kind it belongs to from the reference field alone.
87    fn find_by_name(&self, name: &str) -> Option<&str> {
88        for ((_, old_name), new_name) in &self.map {
89            if old_name == name {
90                return Some(new_name.as_str());
91            }
92        }
93        None
94    }
95}
96
97/// Reference field definitions: which fields in which resource kinds contain references
98/// to other resource names.
99const REFERENCE_FIELDS: &[(ResourceKind, &[&str])] = &[
100    (
101        ResourceKind::Indexer,
102        &["dataSourceName", "targetIndexName", "skillsetName"],
103    ),
104    (
105        ResourceKind::KnowledgeSource,
106        &["indexName", "knowledgeBaseName"],
107    ),
108];
109
110/// Array reference fields: fields containing arrays of objects with a "name" key
111/// that references other resources. Used for KB → KS relationships.
112const ARRAY_REFERENCE_FIELDS: &[(ResourceKind, &str, ResourceKind)] = &[(
113    ResourceKind::KnowledgeBase,
114    "knowledgeSources",
115    ResourceKind::KnowledgeSource,
116)];
117
118/// Rewrite resource references using the name map.
119/// Returns a list of warning messages for references not found in the name map.
120pub fn rewrite_references(
121    kind: ResourceKind,
122    definition: &mut Value,
123    name_map: &NameMap,
124) -> Vec<String> {
125    let mut warnings = Vec::new();
126
127    let obj = match definition.as_object_mut() {
128        Some(obj) => obj,
129        None => return warnings,
130    };
131
132    // Rewrite string reference fields (e.g., Indexer's dataSourceName)
133    let string_fields = REFERENCE_FIELDS
134        .iter()
135        .find(|(k, _)| *k == kind)
136        .map(|(_, f)| *f)
137        .unwrap_or(&[]);
138
139    for field in string_fields {
140        if let Some(value) = obj.get(*field) {
141            if let Some(old_ref) = value.as_str() {
142                if let Some(new_ref) = name_map.find_by_name(old_ref) {
143                    obj.insert(field.to_string(), Value::String(new_ref.to_string()));
144                } else if !old_ref.is_empty() {
145                    warnings.push(format!(
146                        "{} '{}' references '{}' via '{}' which is not in the copy set",
147                        kind.display_name(),
148                        obj.get("name")
149                            .and_then(|n| n.as_str())
150                            .unwrap_or("unknown"),
151                        old_ref,
152                        field,
153                    ));
154                }
155            }
156        }
157    }
158
159    // Rewrite array reference fields (e.g., KB's knowledgeSources)
160    let resource_name = obj
161        .get("name")
162        .and_then(|n| n.as_str())
163        .unwrap_or("unknown")
164        .to_string();
165
166    for (ref_kind, field_name, target_kind) in ARRAY_REFERENCE_FIELDS {
167        if *ref_kind != kind {
168            continue;
169        }
170        if let Some(arr) = obj.get_mut(*field_name) {
171            if let Some(items) = arr.as_array_mut() {
172                for item in items {
173                    if let Some(item_obj) = item.as_object_mut() {
174                        if let Some(name_val) = item_obj.get("name") {
175                            if let Some(old_name) = name_val.as_str() {
176                                if let Some(new_name) = name_map.get(*target_kind, old_name) {
177                                    item_obj.insert(
178                                        "name".to_string(),
179                                        Value::String(new_name.to_string()),
180                                    );
181                                } else if !old_name.is_empty() {
182                                    warnings.push(format!(
183                                        "{} '{}' references '{}' via '{}' which is not in the copy set",
184                                        kind.display_name(),
185                                        resource_name,
186                                        old_name,
187                                        field_name,
188                                    ));
189                                }
190                            }
191                        }
192                    }
193                }
194            }
195        }
196    }
197
198    warnings
199}
200
201/// Compute the dependency closure: given a set of selected resources,
202/// add any resources they depend on.
203pub fn compute_dependency_closure(
204    selected: &[(ResourceKind, String, Value)],
205    all_resources: &[(ResourceKind, String, Value)],
206) -> Vec<(ResourceKind, String, Value)> {
207    let mut result: Vec<(ResourceKind, String, Value)> = selected.to_vec();
208    let mut seen: HashMap<(ResourceKind, String), bool> = HashMap::new();
209
210    for (kind, name, _) in &result {
211        seen.insert((*kind, name.clone()), true);
212    }
213
214    let mut changed = true;
215    while changed {
216        changed = false;
217        let current: Vec<_> = result.clone();
218
219        for (kind, _, definition) in &current {
220            let ref_fields = REFERENCE_FIELDS
221                .iter()
222                .find(|(k, _)| k == kind)
223                .map(|(_, f)| *f)
224                .unwrap_or(&[]);
225
226            if let Some(obj) = definition.as_object() {
227                // String reference fields
228                for field in ref_fields {
229                    if let Some(ref_name) = obj.get(*field).and_then(|v| v.as_str()) {
230                        for (ak, an, av) in all_resources {
231                            if an == ref_name && !seen.contains_key(&(*ak, an.clone())) {
232                                result.push((*ak, an.clone(), av.clone()));
233                                seen.insert((*ak, an.clone()), true);
234                                changed = true;
235                            }
236                        }
237                    }
238                }
239
240                // Array reference fields (e.g., KB's knowledgeSources)
241                for (ref_kind, field_name, target_kind) in ARRAY_REFERENCE_FIELDS {
242                    if ref_kind != kind {
243                        continue;
244                    }
245                    if let Some(arr) = obj.get(*field_name).and_then(|v| v.as_array()) {
246                        for item in arr {
247                            if let Some(ref_name) = item
248                                .as_object()
249                                .and_then(|o| o.get("name"))
250                                .and_then(|n| n.as_str())
251                            {
252                                for (ak, an, av) in all_resources {
253                                    if *ak == *target_kind
254                                        && an == ref_name
255                                        && !seen.contains_key(&(*ak, an.clone()))
256                                    {
257                                        result.push((*ak, an.clone(), av.clone()));
258                                        seen.insert((*ak, an.clone()), true);
259                                        changed = true;
260                                    }
261                                }
262                            }
263                        }
264                    }
265                }
266            }
267        }
268    }
269
270    result
271}
272
273/// Expand a resource selection recursively: include dependencies (upward)
274/// and children (downward via containment arrays).
275///
276/// `selected` — initial resources with their JSON definitions
277/// `all_local` — all locally available resources to draw from
278///
279/// Returns the expanded set (including originals), deduplicated.
280pub fn expand_recursive(
281    selected: &[(ResourceKind, String, Value)],
282    all_local: &[(ResourceKind, String, Value)],
283) -> Vec<(ResourceKind, String, Value)> {
284    let mut result: Vec<(ResourceKind, String, Value)> = selected.to_vec();
285    let mut seen: std::collections::HashSet<(ResourceKind, String)> =
286        std::collections::HashSet::new();
287
288    for (kind, name, _) in &result {
289        seen.insert((*kind, name.clone()));
290    }
291
292    let mut changed = true;
293    while changed {
294        changed = false;
295        let current: Vec<_> = result.clone();
296
297        for (kind, _, definition) in &current {
298            if let Some(obj) = definition.as_object() {
299                // Upward: string reference fields (Indexer→DS, KS→Index, etc.)
300                let ref_fields = REFERENCE_FIELDS
301                    .iter()
302                    .find(|(k, _)| k == kind)
303                    .map(|(_, f)| *f)
304                    .unwrap_or(&[]);
305
306                for field in ref_fields {
307                    if let Some(ref_name) = obj.get(*field).and_then(|v| v.as_str()) {
308                        if ref_name.is_empty() {
309                            continue;
310                        }
311                        for (ak, an, av) in all_local {
312                            if an == ref_name && !seen.contains(&(*ak, an.clone())) {
313                                result.push((*ak, an.clone(), av.clone()));
314                                seen.insert((*ak, an.clone()));
315                                changed = true;
316                            }
317                        }
318                    }
319                }
320
321                // Both directions: array reference fields (KB→KS, KS→KB)
322                for (ref_kind, field_name, target_kind) in ARRAY_REFERENCE_FIELDS {
323                    if ref_kind == kind {
324                        // Downward: parent contains children (KB→KS)
325                        if let Some(arr) = obj.get(*field_name).and_then(|v| v.as_array()) {
326                            for item in arr {
327                                if let Some(ref_name) = item
328                                    .as_object()
329                                    .and_then(|o| o.get("name"))
330                                    .and_then(|n| n.as_str())
331                                {
332                                    for (ak, an, av) in all_local {
333                                        if *ak == *target_kind
334                                            && an == ref_name
335                                            && !seen.contains(&(*ak, an.clone()))
336                                        {
337                                            result.push((*ak, an.clone(), av.clone()));
338                                            seen.insert((*ak, an.clone()));
339                                            changed = true;
340                                        }
341                                    }
342                                }
343                            }
344                        }
345                    }
346                    if target_kind == kind {
347                        // Upward: child references parent (KS→KB)
348                        // Find parents that contain this resource in their array
349                        for (ak, an, av) in all_local {
350                            if *ak == *ref_kind && !seen.contains(&(*ak, an.clone())) {
351                                if let Some(arr) = av
352                                    .as_object()
353                                    .and_then(|o| o.get(*field_name))
354                                    .and_then(|v| v.as_array())
355                                {
356                                    let resource_name =
357                                        obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
358                                    let contains = arr.iter().any(|item| {
359                                        item.as_object()
360                                            .and_then(|o| o.get("name"))
361                                            .and_then(|n| n.as_str())
362                                            == Some(resource_name)
363                                    });
364                                    if contains {
365                                        result.push((*ak, an.clone(), av.clone()));
366                                        seen.insert((*ak, an.clone()));
367                                        changed = true;
368                                    }
369                                }
370                            }
371                        }
372                    }
373                }
374            }
375        }
376    }
377
378    result
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use serde_json::json;
385
386    #[test]
387    fn test_name_map_insert_and_get() {
388        let mut map = NameMap::new();
389        map.insert(ResourceKind::Index, "old-idx", "new-idx");
390        assert_eq!(map.get(ResourceKind::Index, "old-idx"), Some("new-idx"));
391        assert_eq!(map.get(ResourceKind::Index, "missing"), None);
392        assert_eq!(map.get(ResourceKind::Indexer, "old-idx"), None);
393    }
394
395    #[test]
396    fn test_name_map_from_suffix() {
397        let resources = vec![
398            (ResourceKind::Index, "my-index".to_string()),
399            (ResourceKind::Indexer, "my-indexer".to_string()),
400            (ResourceKind::DataSource, "my-ds".to_string()),
401        ];
402        let map = NameMap::from_suffix(&resources, "-v2");
403
404        assert_eq!(
405            map.get(ResourceKind::Index, "my-index"),
406            Some("my-index-v2")
407        );
408        assert_eq!(
409            map.get(ResourceKind::Indexer, "my-indexer"),
410            Some("my-indexer-v2")
411        );
412        assert_eq!(map.get(ResourceKind::DataSource, "my-ds"), Some("my-ds-v2"));
413    }
414
415    #[test]
416    fn test_name_map_from_answers_file() {
417        let dir = tempfile::tempdir().unwrap();
418        let path = dir.path().join("answers.json");
419        std::fs::write(
420            &path,
421            r#"{
422                "indexes": { "old-idx": "new-idx" },
423                "indexers": { "old-ixer": "new-ixer" }
424            }"#,
425        )
426        .unwrap();
427
428        let map = NameMap::from_answers_file(&path).unwrap();
429        assert_eq!(map.get(ResourceKind::Index, "old-idx"), Some("new-idx"));
430        assert_eq!(map.get(ResourceKind::Indexer, "old-ixer"), Some("new-ixer"));
431        assert_eq!(map.get(ResourceKind::DataSource, "anything"), None);
432    }
433
434    #[test]
435    fn test_name_map_from_answers_file_missing_sections() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("answers.json");
438        std::fs::write(&path, r#"{ "indexes": { "a": "b" } }"#).unwrap();
439
440        let map = NameMap::from_answers_file(&path).unwrap();
441        assert_eq!(map.get(ResourceKind::Index, "a"), Some("b"));
442        assert_eq!(map.get(ResourceKind::Indexer, "anything"), None);
443    }
444
445    #[test]
446    fn test_rewrite_references_indexer() {
447        let mut name_map = NameMap::new();
448        name_map.insert(ResourceKind::DataSource, "old-ds", "new-ds");
449        name_map.insert(ResourceKind::Index, "old-idx", "new-idx");
450        name_map.insert(ResourceKind::Skillset, "old-sk", "new-sk");
451
452        let mut definition = json!({
453            "name": "my-indexer",
454            "dataSourceName": "old-ds",
455            "targetIndexName": "old-idx",
456            "skillsetName": "old-sk"
457        });
458
459        let warnings = rewrite_references(ResourceKind::Indexer, &mut definition, &name_map);
460
461        assert!(warnings.is_empty());
462        assert_eq!(definition["dataSourceName"], "new-ds");
463        assert_eq!(definition["targetIndexName"], "new-idx");
464        assert_eq!(definition["skillsetName"], "new-sk");
465    }
466
467    #[test]
468    fn test_rewrite_references_knowledge_source() {
469        let mut name_map = NameMap::new();
470        name_map.insert(ResourceKind::Index, "old-idx", "new-idx");
471        name_map.insert(ResourceKind::KnowledgeBase, "old-kb", "new-kb");
472
473        let mut definition = json!({
474            "name": "my-ks",
475            "indexName": "old-idx",
476            "knowledgeBaseName": "old-kb"
477        });
478
479        let warnings =
480            rewrite_references(ResourceKind::KnowledgeSource, &mut definition, &name_map);
481
482        assert!(warnings.is_empty());
483        assert_eq!(definition["indexName"], "new-idx");
484        assert_eq!(definition["knowledgeBaseName"], "new-kb");
485    }
486
487    #[test]
488    fn test_rewrite_references_warns_on_unmapped() {
489        let name_map = NameMap::new();
490
491        let mut definition = json!({
492            "name": "my-indexer",
493            "dataSourceName": "some-ds",
494            "targetIndexName": "some-idx",
495            "skillsetName": ""
496        });
497
498        let warnings = rewrite_references(ResourceKind::Indexer, &mut definition, &name_map);
499
500        // Two warnings: one for dataSourceName, one for targetIndexName
501        // skillsetName is empty, no warning
502        assert_eq!(warnings.len(), 2);
503        assert!(warnings[0].contains("some-ds"));
504        assert!(warnings[1].contains("some-idx"));
505    }
506
507    #[test]
508    fn test_rewrite_references_non_referencing_kind() {
509        let name_map = NameMap::new();
510        let mut definition = json!({ "name": "my-index", "fields": [] });
511
512        let warnings = rewrite_references(ResourceKind::Index, &mut definition, &name_map);
513        assert!(warnings.is_empty());
514    }
515
516    #[test]
517    fn test_compute_dependency_closure_includes_deps() {
518        let selected = vec![(
519            ResourceKind::Indexer,
520            "my-indexer".to_string(),
521            json!({
522                "name": "my-indexer",
523                "dataSourceName": "my-ds",
524                "targetIndexName": "my-idx",
525                "skillsetName": ""
526            }),
527        )];
528
529        let all = vec![
530            (
531                ResourceKind::DataSource,
532                "my-ds".to_string(),
533                json!({ "name": "my-ds", "type": "azureblob" }),
534            ),
535            (
536                ResourceKind::Index,
537                "my-idx".to_string(),
538                json!({ "name": "my-idx", "fields": [] }),
539            ),
540            (
541                ResourceKind::Indexer,
542                "my-indexer".to_string(),
543                json!({
544                    "name": "my-indexer",
545                    "dataSourceName": "my-ds",
546                    "targetIndexName": "my-idx",
547                    "skillsetName": ""
548                }),
549            ),
550            (
551                ResourceKind::Index,
552                "unrelated-idx".to_string(),
553                json!({ "name": "unrelated-idx" }),
554            ),
555        ];
556
557        let result = compute_dependency_closure(&selected, &all);
558        assert_eq!(result.len(), 3); // indexer + ds + idx
559        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
560        assert!(names.contains(&"my-indexer"));
561        assert!(names.contains(&"my-ds"));
562        assert!(names.contains(&"my-idx"));
563        assert!(!names.contains(&"unrelated-idx"));
564    }
565
566    #[test]
567    fn test_compute_dependency_closure_deduplicates() {
568        let selected = vec![
569            (
570                ResourceKind::Indexer,
571                "ixer-1".to_string(),
572                json!({ "name": "ixer-1", "dataSourceName": "shared-ds", "targetIndexName": "idx-1" }),
573            ),
574            (
575                ResourceKind::Indexer,
576                "ixer-2".to_string(),
577                json!({ "name": "ixer-2", "dataSourceName": "shared-ds", "targetIndexName": "idx-2" }),
578            ),
579        ];
580
581        let all = vec![
582            (
583                ResourceKind::DataSource,
584                "shared-ds".to_string(),
585                json!({ "name": "shared-ds" }),
586            ),
587            (
588                ResourceKind::Index,
589                "idx-1".to_string(),
590                json!({ "name": "idx-1" }),
591            ),
592            (
593                ResourceKind::Index,
594                "idx-2".to_string(),
595                json!({ "name": "idx-2" }),
596            ),
597            (
598                ResourceKind::Indexer,
599                "ixer-1".to_string(),
600                json!({ "name": "ixer-1", "dataSourceName": "shared-ds", "targetIndexName": "idx-1" }),
601            ),
602            (
603                ResourceKind::Indexer,
604                "ixer-2".to_string(),
605                json!({ "name": "ixer-2", "dataSourceName": "shared-ds", "targetIndexName": "idx-2" }),
606            ),
607        ];
608
609        let result = compute_dependency_closure(&selected, &all);
610        // 2 indexers + 1 shared ds + 2 indexes = 5
611        assert_eq!(result.len(), 5);
612
613        // shared-ds should appear exactly once
614        let ds_count = result.iter().filter(|(_, n, _)| n == "shared-ds").count();
615        assert_eq!(ds_count, 1);
616    }
617
618    #[test]
619    fn test_from_suffix_empty() {
620        let resources: Vec<(ResourceKind, String)> = vec![];
621        let map = NameMap::from_suffix(&resources, "-test");
622        assert_eq!(map.get(ResourceKind::Index, "anything"), None);
623    }
624
625    #[test]
626    fn test_rewrite_references_kb_knowledge_sources_array() {
627        let mut name_map = NameMap::new();
628        name_map.insert(ResourceKind::KnowledgeSource, "ks-1", "ks-1-v2");
629        name_map.insert(ResourceKind::KnowledgeSource, "ks-2", "ks-2-v2");
630
631        let mut definition = json!({
632            "name": "my-kb",
633            "knowledgeSources": [
634                {"name": "ks-1"},
635                {"name": "ks-2"}
636            ]
637        });
638
639        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
640
641        assert!(warnings.is_empty());
642        let sources = definition["knowledgeSources"].as_array().unwrap();
643        assert_eq!(sources[0]["name"], "ks-1-v2");
644        assert_eq!(sources[1]["name"], "ks-2-v2");
645    }
646
647    #[test]
648    fn test_rewrite_references_kb_warns_on_unmapped_ks() {
649        let name_map = NameMap::new();
650
651        let mut definition = json!({
652            "name": "my-kb",
653            "knowledgeSources": [
654                {"name": "ks-unmapped"}
655            ]
656        });
657
658        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
659
660        assert_eq!(warnings.len(), 1);
661        assert!(warnings[0].contains("ks-unmapped"));
662        assert!(warnings[0].contains("knowledgeSources"));
663    }
664
665    #[test]
666    fn test_rewrite_references_kb_no_knowledge_sources_field() {
667        let name_map = NameMap::new();
668        let mut definition = json!({
669            "name": "my-kb",
670            "description": "No KS array"
671        });
672
673        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
674
675        assert!(warnings.is_empty());
676    }
677
678    #[test]
679    fn test_compute_dependency_closure_includes_kb_knowledge_sources() {
680        let selected = vec![(
681            ResourceKind::KnowledgeBase,
682            "my-kb".to_string(),
683            json!({
684                "name": "my-kb",
685                "knowledgeSources": [
686                    {"name": "ks-1"},
687                    {"name": "ks-2"}
688                ]
689            }),
690        )];
691
692        let all = vec![
693            (
694                ResourceKind::KnowledgeBase,
695                "my-kb".to_string(),
696                json!({
697                    "name": "my-kb",
698                    "knowledgeSources": [
699                        {"name": "ks-1"},
700                        {"name": "ks-2"}
701                    ]
702                }),
703            ),
704            (
705                ResourceKind::KnowledgeSource,
706                "ks-1".to_string(),
707                json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
708            ),
709            (
710                ResourceKind::KnowledgeSource,
711                "ks-2".to_string(),
712                json!({ "name": "ks-2", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
713            ),
714            (
715                ResourceKind::Index,
716                "idx-1".to_string(),
717                json!({ "name": "idx-1", "fields": [] }),
718            ),
719            (
720                ResourceKind::KnowledgeSource,
721                "ks-other".to_string(),
722                json!({ "name": "ks-other", "indexName": "idx-2", "knowledgeBaseName": "other-kb" }),
723            ),
724        ];
725
726        let result = compute_dependency_closure(&selected, &all);
727        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
728        // KB + ks-1 + ks-2 (from array) + idx-1 (from ks-1/ks-2 deps)
729        assert!(names.contains(&"my-kb"));
730        assert!(names.contains(&"ks-1"));
731        assert!(names.contains(&"ks-2"));
732        assert!(names.contains(&"idx-1"));
733        assert!(!names.contains(&"ks-other"));
734    }
735
736    // === expand_recursive tests ===
737
738    #[test]
739    fn test_expand_recursive_includes_kb_ks_children() {
740        let selected = vec![(
741            ResourceKind::KnowledgeBase,
742            "my-kb".to_string(),
743            json!({
744                "name": "my-kb",
745                "knowledgeSources": [
746                    {"name": "ks-1"},
747                    {"name": "ks-2"}
748                ]
749            }),
750        )];
751
752        let all = vec![
753            selected[0].clone(),
754            (
755                ResourceKind::KnowledgeSource,
756                "ks-1".to_string(),
757                json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
758            ),
759            (
760                ResourceKind::KnowledgeSource,
761                "ks-2".to_string(),
762                json!({ "name": "ks-2", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
763            ),
764            (
765                ResourceKind::Index,
766                "idx-1".to_string(),
767                json!({ "name": "idx-1", "fields": [] }),
768            ),
769        ];
770
771        let result = expand_recursive(&selected, &all);
772        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
773        assert!(names.contains(&"my-kb"));
774        assert!(names.contains(&"ks-1"));
775        assert!(names.contains(&"ks-2"));
776        assert!(names.contains(&"idx-1"));
777    }
778
779    #[test]
780    fn test_expand_recursive_includes_indexer_dependencies() {
781        let selected = vec![(
782            ResourceKind::Indexer,
783            "my-ixer".to_string(),
784            json!({
785                "name": "my-ixer",
786                "dataSourceName": "my-ds",
787                "targetIndexName": "my-idx",
788                "skillsetName": "my-sk"
789            }),
790        )];
791
792        let all = vec![
793            selected[0].clone(),
794            (
795                ResourceKind::DataSource,
796                "my-ds".to_string(),
797                json!({ "name": "my-ds" }),
798            ),
799            (
800                ResourceKind::Index,
801                "my-idx".to_string(),
802                json!({ "name": "my-idx" }),
803            ),
804            (
805                ResourceKind::Skillset,
806                "my-sk".to_string(),
807                json!({ "name": "my-sk" }),
808            ),
809        ];
810
811        let result = expand_recursive(&selected, &all);
812        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
813        assert!(names.contains(&"my-ixer"));
814        assert!(names.contains(&"my-ds"));
815        assert!(names.contains(&"my-idx"));
816        assert!(names.contains(&"my-sk"));
817        assert_eq!(result.len(), 4);
818    }
819
820    #[test]
821    fn test_expand_recursive_both_directions() {
822        // Starting from KS, should expand up to KB (parent) and Index (dep)
823        let selected = vec![(
824            ResourceKind::KnowledgeSource,
825            "ks-1".to_string(),
826            json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
827        )];
828
829        let all = vec![
830            selected[0].clone(),
831            (
832                ResourceKind::KnowledgeBase,
833                "my-kb".to_string(),
834                json!({
835                    "name": "my-kb",
836                    "knowledgeSources": [{"name": "ks-1"}, {"name": "ks-2"}]
837                }),
838            ),
839            (
840                ResourceKind::KnowledgeSource,
841                "ks-2".to_string(),
842                json!({ "name": "ks-2", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
843            ),
844            (
845                ResourceKind::Index,
846                "idx-1".to_string(),
847                json!({ "name": "idx-1" }),
848            ),
849        ];
850
851        let result = expand_recursive(&selected, &all);
852        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
853        // ks-1 → idx-1 (string ref), ks-1 → my-kb (string ref: knowledgeBaseName)
854        // my-kb → ks-2 (array ref), ks-2 → idx-1 (already seen)
855        assert!(names.contains(&"ks-1"));
856        assert!(names.contains(&"idx-1"));
857        assert!(names.contains(&"my-kb"));
858        assert!(names.contains(&"ks-2"));
859    }
860
861    #[test]
862    fn test_expand_recursive_deduplicates() {
863        let selected = vec![
864            (
865                ResourceKind::KnowledgeSource,
866                "ks-1".to_string(),
867                json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
868            ),
869            (
870                ResourceKind::KnowledgeSource,
871                "ks-2".to_string(),
872                json!({ "name": "ks-2", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
873            ),
874        ];
875
876        let all = vec![
877            selected[0].clone(),
878            selected[1].clone(),
879            (
880                ResourceKind::KnowledgeBase,
881                "my-kb".to_string(),
882                json!({
883                    "name": "my-kb",
884                    "knowledgeSources": [{"name": "ks-1"}, {"name": "ks-2"}]
885                }),
886            ),
887            (
888                ResourceKind::Index,
889                "idx-1".to_string(),
890                json!({ "name": "idx-1" }),
891            ),
892        ];
893
894        let result = expand_recursive(&selected, &all);
895        // idx-1 and my-kb should appear exactly once each
896        let idx_count = result.iter().filter(|(_, n, _)| n == "idx-1").count();
897        let kb_count = result.iter().filter(|(_, n, _)| n == "my-kb").count();
898        assert_eq!(idx_count, 1);
899        assert_eq!(kb_count, 1);
900        assert_eq!(result.len(), 4);
901    }
902
903    #[test]
904    fn test_expand_recursive_no_deps_no_children() {
905        let selected = vec![(
906            ResourceKind::Index,
907            "my-idx".to_string(),
908            json!({ "name": "my-idx", "fields": [] }),
909        )];
910
911        let all = vec![
912            selected[0].clone(),
913            (
914                ResourceKind::Index,
915                "other-idx".to_string(),
916                json!({ "name": "other-idx" }),
917            ),
918        ];
919
920        let result = expand_recursive(&selected, &all);
921        assert_eq!(result.len(), 1);
922        assert_eq!(result[0].1, "my-idx");
923    }
924
925    #[test]
926    fn test_expand_recursive_transitive_chain() {
927        // KB → KS → Index (via transitive closure)
928        let selected = vec![(
929            ResourceKind::KnowledgeBase,
930            "my-kb".to_string(),
931            json!({
932                "name": "my-kb",
933                "knowledgeSources": [{"name": "ks-1"}]
934            }),
935        )];
936
937        let all = vec![
938            selected[0].clone(),
939            (
940                ResourceKind::KnowledgeSource,
941                "ks-1".to_string(),
942                json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
943            ),
944            (
945                ResourceKind::Index,
946                "idx-1".to_string(),
947                json!({ "name": "idx-1" }),
948            ),
949        ];
950
951        let result = expand_recursive(&selected, &all);
952        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
953        assert!(names.contains(&"my-kb"));
954        assert!(names.contains(&"ks-1"));
955        assert!(names.contains(&"idx-1"));
956        assert_eq!(result.len(), 3);
957    }
958
959    #[test]
960    fn test_expand_recursive_empty_selected() {
961        let all = vec![(
962            ResourceKind::Index,
963            "idx-1".to_string(),
964            json!({ "name": "idx-1" }),
965        )];
966        let result = expand_recursive(&[], &all);
967        assert!(result.is_empty());
968    }
969
970    #[test]
971    fn test_expand_recursive_missing_dep_in_all_local() {
972        // Indexer references a DS that doesn't exist in all_local — should silently skip
973        let selected = vec![(
974            ResourceKind::Indexer,
975            "my-ixer".to_string(),
976            json!({
977                "name": "my-ixer",
978                "dataSourceName": "missing-ds",
979                "targetIndexName": "also-missing",
980                "skillsetName": ""
981            }),
982        )];
983
984        let result = expand_recursive(&selected, &selected);
985        assert_eq!(result.len(), 1);
986        assert_eq!(result[0].1, "my-ixer");
987    }
988
989    #[test]
990    fn test_rewrite_references_indexer_partial_mapping() {
991        // Only DS is mapped; Index and Skillset are not → two warnings
992        let mut name_map = NameMap::new();
993        name_map.insert(ResourceKind::DataSource, "old-ds", "new-ds");
994
995        let mut definition = json!({
996            "name": "my-indexer",
997            "dataSourceName": "old-ds",
998            "targetIndexName": "unmapped-idx",
999            "skillsetName": "unmapped-sk"
1000        });
1001
1002        let warnings = rewrite_references(ResourceKind::Indexer, &mut definition, &name_map);
1003
1004        assert_eq!(definition["dataSourceName"], "new-ds");
1005        assert_eq!(definition["targetIndexName"], "unmapped-idx"); // unchanged
1006        assert_eq!(definition["skillsetName"], "unmapped-sk"); // unchanged
1007        assert_eq!(warnings.len(), 2);
1008        assert!(warnings[0].contains("unmapped-idx"));
1009        assert!(warnings[1].contains("unmapped-sk"));
1010    }
1011
1012    #[test]
1013    fn test_rewrite_references_null_definition() {
1014        let name_map = NameMap::new();
1015        let mut definition = serde_json::Value::Null;
1016
1017        let warnings = rewrite_references(ResourceKind::Indexer, &mut definition, &name_map);
1018        assert!(warnings.is_empty());
1019    }
1020
1021    #[test]
1022    fn test_rewrite_references_kb_mixed_mapped_and_unmapped() {
1023        let mut name_map = NameMap::new();
1024        name_map.insert(ResourceKind::KnowledgeSource, "ks-1", "ks-1-v2");
1025        // ks-2 is NOT in the map
1026
1027        let mut definition = json!({
1028            "name": "my-kb",
1029            "knowledgeSources": [
1030                {"name": "ks-1"},
1031                {"name": "ks-2"}
1032            ]
1033        });
1034
1035        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
1036
1037        let sources = definition["knowledgeSources"].as_array().unwrap();
1038        assert_eq!(sources[0]["name"], "ks-1-v2"); // rewritten
1039        assert_eq!(sources[1]["name"], "ks-2"); // unchanged
1040        assert_eq!(warnings.len(), 1);
1041        assert!(warnings[0].contains("ks-2"));
1042    }
1043
1044    #[test]
1045    fn test_compute_dependency_closure_empty_selected() {
1046        let all = vec![(
1047            ResourceKind::Index,
1048            "idx-1".to_string(),
1049            json!({ "name": "idx-1" }),
1050        )];
1051        let result = compute_dependency_closure(&[], &all);
1052        assert!(result.is_empty());
1053    }
1054
1055    #[test]
1056    fn test_compute_dependency_closure_empty_all() {
1057        let selected = vec![(
1058            ResourceKind::Indexer,
1059            "my-ixer".to_string(),
1060            json!({
1061                "name": "my-ixer",
1062                "dataSourceName": "my-ds",
1063                "targetIndexName": "my-idx"
1064            }),
1065        )];
1066        let result = compute_dependency_closure(&selected, &[]);
1067        // Only the originally-selected resource
1068        assert_eq!(result.len(), 1);
1069        assert_eq!(result[0].1, "my-ixer");
1070    }
1071
1072    #[test]
1073    fn test_rewrite_references_kb_empty_knowledge_sources_array() {
1074        let name_map = NameMap::new();
1075        let mut definition = json!({
1076            "name": "my-kb",
1077            "knowledgeSources": []
1078        });
1079
1080        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
1081
1082        assert!(warnings.is_empty());
1083        assert_eq!(definition["knowledgeSources"].as_array().unwrap().len(), 0);
1084    }
1085
1086    #[test]
1087    fn test_expand_recursive_ks_finds_parent_kb() {
1088        // Starting from a KS, expand should find its parent KB via knowledgeBaseName (string ref)
1089        // AND the KB should pull in sibling KS via knowledgeSources (array ref)
1090        let selected = vec![(
1091            ResourceKind::KnowledgeSource,
1092            "ks-1".to_string(),
1093            json!({ "name": "ks-1", "indexName": "idx-1", "knowledgeBaseName": "my-kb" }),
1094        )];
1095
1096        let all = vec![
1097            selected[0].clone(),
1098            (
1099                ResourceKind::KnowledgeBase,
1100                "my-kb".to_string(),
1101                json!({
1102                    "name": "my-kb",
1103                    "knowledgeSources": [{"name": "ks-1"}, {"name": "ks-sibling"}]
1104                }),
1105            ),
1106            (
1107                ResourceKind::KnowledgeSource,
1108                "ks-sibling".to_string(),
1109                json!({ "name": "ks-sibling", "indexName": "idx-2", "knowledgeBaseName": "my-kb" }),
1110            ),
1111            (
1112                ResourceKind::Index,
1113                "idx-1".to_string(),
1114                json!({ "name": "idx-1" }),
1115            ),
1116            (
1117                ResourceKind::Index,
1118                "idx-2".to_string(),
1119                json!({ "name": "idx-2" }),
1120            ),
1121        ];
1122
1123        let result = expand_recursive(&selected, &all);
1124        let names: Vec<_> = result.iter().map(|(_, n, _)| n.as_str()).collect();
1125        assert!(names.contains(&"ks-1"));
1126        assert!(names.contains(&"my-kb")); // parent KB
1127        assert!(names.contains(&"ks-sibling")); // sibling via KB's array
1128        assert!(names.contains(&"idx-1")); // ks-1's dep
1129        assert!(names.contains(&"idx-2")); // ks-sibling's dep
1130        assert_eq!(result.len(), 5);
1131    }
1132
1133    #[test]
1134    fn test_rewrite_references_kb_knowledge_sources_not_an_array() {
1135        // knowledgeSources is an object instead of array — should not panic
1136        let name_map = NameMap::new();
1137        let mut definition = json!({
1138            "name": "my-kb",
1139            "knowledgeSources": {"name": "ks-1"}
1140        });
1141
1142        let warnings = rewrite_references(ResourceKind::KnowledgeBase, &mut definition, &name_map);
1143        // Not an array, silently skipped
1144        assert!(warnings.is_empty());
1145    }
1146}