Skip to main content

rigg_core/resources/
traits.rs

1//! Resource trait definition for Azure AI Search and Microsoft Foundry resources
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6use crate::service::ServiceDomain;
7
8/// Enumeration of all supported resource types across service domains
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ResourceKind {
12    // Azure AI Search resources
13    Index,
14    Indexer,
15    DataSource,
16    Skillset,
17    SynonymMap,
18    Alias,
19    KnowledgeBase,
20    KnowledgeSource,
21    // Microsoft Foundry resources
22    Agent,
23}
24
25impl ResourceKind {
26    /// Returns the service domain this resource belongs to
27    pub fn domain(&self) -> ServiceDomain {
28        match self {
29            ResourceKind::Agent => ServiceDomain::Foundry,
30            _ => ServiceDomain::Search,
31        }
32    }
33
34    /// Returns the API path segment for this resource type
35    pub fn api_path(&self) -> &'static str {
36        match self {
37            ResourceKind::Index => "indexes",
38            ResourceKind::Indexer => "indexers",
39            ResourceKind::DataSource => "datasources",
40            ResourceKind::Skillset => "skillsets",
41            ResourceKind::SynonymMap => "synonymmaps",
42            ResourceKind::Alias => "aliases",
43            ResourceKind::KnowledgeBase => "knowledgebases",
44            ResourceKind::KnowledgeSource => "knowledgesources",
45            ResourceKind::Agent => "assistants",
46        }
47    }
48
49    /// Returns the directory path for local storage (relative to service root).
50    ///
51    /// Search resources are organized under category prefixes:
52    /// - `search-management/` for core search resources
53    /// - `agentic-retrieval/` for knowledge bases and knowledge sources
54    pub fn directory_name(&self) -> &'static str {
55        match self {
56            ResourceKind::Index => "search-management/indexes",
57            ResourceKind::Indexer => "search-management/indexers",
58            ResourceKind::DataSource => "search-management/data-sources",
59            ResourceKind::Skillset => "search-management/skillsets",
60            ResourceKind::SynonymMap => "search-management/synonym-maps",
61            ResourceKind::Alias => "search-management/aliases",
62            ResourceKind::KnowledgeBase => "agentic-retrieval/knowledge-bases",
63            ResourceKind::KnowledgeSource => "agentic-retrieval/knowledge-sources",
64            ResourceKind::Agent => "agents",
65        }
66    }
67
68    /// Returns true if this resource type uses the preview API
69    pub fn is_preview(&self) -> bool {
70        matches!(
71            self,
72            ResourceKind::Alias | ResourceKind::KnowledgeBase | ResourceKind::KnowledgeSource
73        )
74    }
75
76    /// Returns the singular CLI flag name for this resource type.
77    ///
78    /// Used in hint messages like `rigg delete --index my-index --target local`.
79    pub fn cli_flag_name(&self) -> &'static str {
80        match self {
81            ResourceKind::Index => "index",
82            ResourceKind::Indexer => "indexer",
83            ResourceKind::DataSource => "datasource",
84            ResourceKind::Skillset => "skillset",
85            ResourceKind::SynonymMap => "synonymmap",
86            ResourceKind::Alias => "alias",
87            ResourceKind::KnowledgeBase => "knowledgebase",
88            ResourceKind::KnowledgeSource => "knowledgesource",
89            ResourceKind::Agent => "agent",
90        }
91    }
92
93    /// Returns the plural CLI flag name for this resource type.
94    ///
95    /// Used in hint messages like `rigg pull --indexes`.
96    pub fn cli_flag_name_plural(&self) -> &'static str {
97        match self {
98            ResourceKind::Index => "indexes",
99            ResourceKind::Indexer => "indexers",
100            ResourceKind::DataSource => "datasources",
101            ResourceKind::Skillset => "skillsets",
102            ResourceKind::SynonymMap => "synonymmaps",
103            ResourceKind::Alias => "aliases",
104            ResourceKind::KnowledgeBase => "knowledgebases",
105            ResourceKind::KnowledgeSource => "knowledgesources",
106            ResourceKind::Agent => "agents",
107        }
108    }
109
110    /// Returns the display name for this resource type
111    pub fn display_name(&self) -> &'static str {
112        match self {
113            ResourceKind::Index => "Index",
114            ResourceKind::Indexer => "Indexer",
115            ResourceKind::DataSource => "Data Source",
116            ResourceKind::Skillset => "Skillset",
117            ResourceKind::SynonymMap => "Synonym Map",
118            ResourceKind::Alias => "Alias",
119            ResourceKind::KnowledgeBase => "Knowledge Base",
120            ResourceKind::KnowledgeSource => "Knowledge Source",
121            ResourceKind::Agent => "Agent",
122        }
123    }
124
125    /// Returns all resource kinds across all domains
126    pub fn all() -> &'static [ResourceKind] {
127        &[
128            ResourceKind::Index,
129            ResourceKind::Indexer,
130            ResourceKind::DataSource,
131            ResourceKind::Skillset,
132            ResourceKind::SynonymMap,
133            ResourceKind::Alias,
134            ResourceKind::KnowledgeBase,
135            ResourceKind::KnowledgeSource,
136            ResourceKind::Agent,
137        ]
138    }
139
140    /// Returns non-preview Search resource kinds (stable search resources only)
141    pub fn stable() -> &'static [ResourceKind] {
142        &[
143            ResourceKind::Index,
144            ResourceKind::Indexer,
145            ResourceKind::DataSource,
146            ResourceKind::Skillset,
147            ResourceKind::SynonymMap,
148        ]
149    }
150
151    /// Returns all Search resource kinds
152    pub fn search_kinds() -> Vec<ResourceKind> {
153        ResourceKind::all()
154            .iter()
155            .filter(|k| k.domain() == ServiceDomain::Search)
156            .copied()
157            .collect()
158    }
159
160    /// Returns all Foundry resource kinds
161    pub fn foundry_kinds() -> Vec<ResourceKind> {
162        ResourceKind::all()
163            .iter()
164            .filter(|k| k.domain() == ServiceDomain::Foundry)
165            .copied()
166            .collect()
167    }
168}
169
170impl fmt::Display for ResourceKind {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{}", self.display_name())
173    }
174}
175
176/// Trait for Azure AI Search resources
177pub trait Resource: Serialize + for<'de> Deserialize<'de> + Clone {
178    /// Returns the resource kind
179    fn kind() -> ResourceKind;
180
181    /// Returns the resource name (identifier)
182    fn name(&self) -> &str;
183
184    /// Returns fields that should be stripped during normalization (pull and push).
185    /// These are truly transient or sensitive: OData metadata, secrets, credentials.
186    fn volatile_fields() -> &'static [&'static str] {
187        &["@odata.etag", "@odata.context"]
188    }
189
190    /// Returns fields that are read-only — Azure returns them in GET but rejects
191    /// them in PUT. These are kept in local files for documentation (e.g. showing
192    /// which resources are connected) but stripped before pushing to Azure.
193    fn read_only_fields() -> &'static [&'static str] {
194        &[]
195    }
196
197    /// Returns the identity key for array sorting within this resource type
198    fn identity_key() -> &'static str {
199        "name"
200    }
201
202    /// Returns dependencies on other resources (resource kind, name)
203    fn dependencies(&self) -> Vec<(ResourceKind, String)> {
204        Vec::new()
205    }
206
207    /// Returns fields that are immutable after creation
208    fn immutable_fields() -> &'static [&'static str] {
209        &[]
210    }
211}
212
213/// Validate a resource name returned from Azure API responses.
214///
215/// Rejects:
216/// - Empty names
217/// - Names longer than 260 characters
218/// - Names containing `/`, `\`, or null bytes
219/// - Names that are exactly `.` or `..`
220pub fn validate_resource_name(name: &str) -> Result<(), anyhow::Error> {
221    if name.is_empty() {
222        anyhow::bail!("Resource name must not be empty");
223    }
224    if name.len() > 260 {
225        anyhow::bail!(
226            "Resource name must not exceed 260 characters (got {})",
227            name.len()
228        );
229    }
230    if name.contains('/') || name.contains('\\') || name.contains('\0') {
231        anyhow::bail!(
232            "Resource name must not contain '/', '\\', or null bytes: '{}'",
233            name
234        );
235    }
236    if name == "." || name == ".." {
237        anyhow::bail!("Resource name must not be '.' or '..'");
238    }
239    Ok(())
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_all_returns_nine_kinds() {
248        assert_eq!(ResourceKind::all().len(), 9);
249    }
250
251    #[test]
252    fn test_stable_excludes_preview() {
253        let stable = ResourceKind::stable();
254        assert_eq!(stable.len(), 5);
255        for kind in stable {
256            assert!(!kind.is_preview());
257        }
258    }
259
260    #[test]
261    fn test_preview_kinds() {
262        assert!(ResourceKind::KnowledgeBase.is_preview());
263        assert!(ResourceKind::KnowledgeSource.is_preview());
264        assert!(!ResourceKind::Index.is_preview());
265        assert!(!ResourceKind::Indexer.is_preview());
266        assert!(!ResourceKind::DataSource.is_preview());
267        assert!(!ResourceKind::Skillset.is_preview());
268        assert!(!ResourceKind::SynonymMap.is_preview());
269        assert!(ResourceKind::Alias.is_preview());
270        assert!(!ResourceKind::Agent.is_preview());
271    }
272
273    #[test]
274    fn test_api_paths() {
275        assert_eq!(ResourceKind::Index.api_path(), "indexes");
276        assert_eq!(ResourceKind::Indexer.api_path(), "indexers");
277        assert_eq!(ResourceKind::DataSource.api_path(), "datasources");
278        assert_eq!(ResourceKind::Skillset.api_path(), "skillsets");
279        assert_eq!(ResourceKind::SynonymMap.api_path(), "synonymmaps");
280        assert_eq!(ResourceKind::Alias.api_path(), "aliases");
281        assert_eq!(ResourceKind::KnowledgeBase.api_path(), "knowledgebases");
282        assert_eq!(ResourceKind::KnowledgeSource.api_path(), "knowledgesources");
283        assert_eq!(ResourceKind::Agent.api_path(), "assistants");
284    }
285
286    #[test]
287    fn test_directory_names() {
288        assert_eq!(
289            ResourceKind::Index.directory_name(),
290            "search-management/indexes"
291        );
292        assert_eq!(
293            ResourceKind::Indexer.directory_name(),
294            "search-management/indexers"
295        );
296        assert_eq!(
297            ResourceKind::DataSource.directory_name(),
298            "search-management/data-sources"
299        );
300        assert_eq!(
301            ResourceKind::Skillset.directory_name(),
302            "search-management/skillsets"
303        );
304        assert_eq!(
305            ResourceKind::SynonymMap.directory_name(),
306            "search-management/synonym-maps"
307        );
308        assert_eq!(
309            ResourceKind::Alias.directory_name(),
310            "search-management/aliases"
311        );
312        assert_eq!(
313            ResourceKind::KnowledgeBase.directory_name(),
314            "agentic-retrieval/knowledge-bases"
315        );
316        assert_eq!(
317            ResourceKind::KnowledgeSource.directory_name(),
318            "agentic-retrieval/knowledge-sources"
319        );
320        assert_eq!(ResourceKind::Agent.directory_name(), "agents");
321    }
322
323    #[test]
324    fn test_directory_names_categorized() {
325        // Search resources are organized under category prefixes
326        let agentic_kinds = [ResourceKind::KnowledgeBase, ResourceKind::KnowledgeSource];
327        for kind in ResourceKind::search_kinds() {
328            if agentic_kinds.contains(&kind) {
329                assert!(
330                    kind.directory_name().starts_with("agentic-retrieval/"),
331                    "{:?} should be under agentic-retrieval/, got: {}",
332                    kind,
333                    kind.directory_name()
334                );
335            } else {
336                assert!(
337                    kind.directory_name().starts_with("search-management/"),
338                    "{:?} should be under search-management/, got: {}",
339                    kind,
340                    kind.directory_name()
341                );
342            }
343        }
344        // Foundry agents are flat
345        assert_eq!(ResourceKind::Agent.directory_name(), "agents");
346    }
347
348    #[test]
349    fn test_display_names() {
350        assert_eq!(ResourceKind::Index.display_name(), "Index");
351        assert_eq!(ResourceKind::DataSource.display_name(), "Data Source");
352        assert_eq!(ResourceKind::KnowledgeBase.display_name(), "Knowledge Base");
353        assert_eq!(ResourceKind::Alias.display_name(), "Alias");
354        assert_eq!(ResourceKind::Agent.display_name(), "Agent");
355    }
356
357    #[test]
358    fn test_display_trait() {
359        assert_eq!(format!("{}", ResourceKind::Index), "Index");
360        assert_eq!(format!("{}", ResourceKind::Skillset), "Skillset");
361        assert_eq!(format!("{}", ResourceKind::Alias), "Alias");
362        assert_eq!(format!("{}", ResourceKind::Agent), "Agent");
363    }
364
365    #[test]
366    fn test_serde_roundtrip() {
367        let kind = ResourceKind::DataSource;
368        let json = serde_json::to_string(&kind).unwrap();
369        assert_eq!(json, "\"data-source\"");
370        let back: ResourceKind = serde_json::from_str(&json).unwrap();
371        assert_eq!(back, kind);
372    }
373
374    #[test]
375    fn test_serde_roundtrip_alias() {
376        let kind = ResourceKind::Alias;
377        let json = serde_json::to_string(&kind).unwrap();
378        assert_eq!(json, "\"alias\"");
379        let back: ResourceKind = serde_json::from_str(&json).unwrap();
380        assert_eq!(back, kind);
381    }
382
383    #[test]
384    fn test_serde_roundtrip_agent() {
385        let kind = ResourceKind::Agent;
386        let json = serde_json::to_string(&kind).unwrap();
387        assert_eq!(json, "\"agent\"");
388        let back: ResourceKind = serde_json::from_str(&json).unwrap();
389        assert_eq!(back, kind);
390    }
391
392    #[test]
393    fn test_cli_flag_names() {
394        assert_eq!(ResourceKind::Index.cli_flag_name(), "index");
395        assert_eq!(ResourceKind::Indexer.cli_flag_name(), "indexer");
396        assert_eq!(ResourceKind::DataSource.cli_flag_name(), "datasource");
397        assert_eq!(ResourceKind::Skillset.cli_flag_name(), "skillset");
398        assert_eq!(ResourceKind::SynonymMap.cli_flag_name(), "synonymmap");
399        assert_eq!(ResourceKind::Alias.cli_flag_name(), "alias");
400        assert_eq!(ResourceKind::KnowledgeBase.cli_flag_name(), "knowledgebase");
401        assert_eq!(
402            ResourceKind::KnowledgeSource.cli_flag_name(),
403            "knowledgesource"
404        );
405        assert_eq!(ResourceKind::Agent.cli_flag_name(), "agent");
406    }
407
408    #[test]
409    fn test_cli_flag_names_plural() {
410        assert_eq!(ResourceKind::Index.cli_flag_name_plural(), "indexes");
411        assert_eq!(ResourceKind::Alias.cli_flag_name_plural(), "aliases");
412        assert_eq!(ResourceKind::Agent.cli_flag_name_plural(), "agents");
413    }
414
415    #[test]
416    fn test_all_kinds_in_stable_or_preview() {
417        for kind in ResourceKind::all() {
418            if kind.domain() == ServiceDomain::Search {
419                if kind.is_preview() {
420                    assert!(!ResourceKind::stable().contains(kind));
421                } else {
422                    assert!(ResourceKind::stable().contains(kind));
423                }
424            }
425        }
426    }
427
428    #[test]
429    fn test_domain_search_kinds() {
430        let search = ResourceKind::search_kinds();
431        assert_eq!(search.len(), 8);
432        for kind in &search {
433            assert_eq!(kind.domain(), ServiceDomain::Search);
434        }
435    }
436
437    #[test]
438    fn test_domain_foundry_kinds() {
439        let foundry = ResourceKind::foundry_kinds();
440        assert_eq!(foundry.len(), 1);
441        assert_eq!(foundry[0], ResourceKind::Agent);
442        for kind in &foundry {
443            assert_eq!(kind.domain(), ServiceDomain::Foundry);
444        }
445    }
446
447    #[test]
448    fn test_agent_domain_is_foundry() {
449        assert_eq!(ResourceKind::Agent.domain(), ServiceDomain::Foundry);
450    }
451
452    #[test]
453    fn test_search_resources_domain_is_search() {
454        for kind in ResourceKind::stable() {
455            assert_eq!(kind.domain(), ServiceDomain::Search);
456        }
457        assert_eq!(ResourceKind::Alias.domain(), ServiceDomain::Search);
458        assert_eq!(ResourceKind::KnowledgeBase.domain(), ServiceDomain::Search);
459        assert_eq!(
460            ResourceKind::KnowledgeSource.domain(),
461            ServiceDomain::Search
462        );
463    }
464
465    #[test]
466    fn test_search_plus_foundry_equals_all() {
467        let mut combined = ResourceKind::search_kinds();
468        combined.extend(ResourceKind::foundry_kinds());
469        assert_eq!(combined.len(), ResourceKind::all().len());
470        for kind in ResourceKind::all() {
471            assert!(combined.contains(kind));
472        }
473    }
474
475    #[test]
476    fn test_validate_resource_name_valid() {
477        assert!(validate_resource_name("my-index").is_ok());
478        assert!(validate_resource_name("my_index_123").is_ok());
479        assert!(validate_resource_name("a").is_ok());
480        assert!(validate_resource_name("index.v2").is_ok());
481        assert!(validate_resource_name("...").is_ok());
482    }
483
484    #[test]
485    fn test_validate_resource_name_empty() {
486        let err = validate_resource_name("").unwrap_err();
487        assert!(err.to_string().contains("empty"));
488    }
489
490    #[test]
491    fn test_validate_resource_name_too_long() {
492        let long_name = "a".repeat(261);
493        let err = validate_resource_name(&long_name).unwrap_err();
494        assert!(err.to_string().contains("260"));
495    }
496
497    #[test]
498    fn test_validate_resource_name_exactly_260_ok() {
499        let name = "a".repeat(260);
500        assert!(validate_resource_name(&name).is_ok());
501    }
502
503    #[test]
504    fn test_validate_resource_name_forward_slash() {
505        let err = validate_resource_name("foo/bar").unwrap_err();
506        assert!(err.to_string().contains("/"));
507    }
508
509    #[test]
510    fn test_validate_resource_name_backslash() {
511        let err = validate_resource_name("foo\\bar").unwrap_err();
512        assert!(err.to_string().contains("\\"));
513    }
514
515    #[test]
516    fn test_validate_resource_name_null_byte() {
517        let err = validate_resource_name("foo\0bar").unwrap_err();
518        assert!(err.to_string().contains("null"));
519    }
520
521    #[test]
522    fn test_validate_resource_name_dot() {
523        let err = validate_resource_name(".").unwrap_err();
524        assert!(err.to_string().contains("'.'"));
525    }
526
527    #[test]
528    fn test_validate_resource_name_dotdot() {
529        let err = validate_resource_name("..").unwrap_err();
530        assert!(err.to_string().contains("'..'"));
531    }
532}