1use serde::{Deserialize, Serialize};
4use std::fmt;
5
6use crate::service::ServiceDomain;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ResourceKind {
12 Index,
14 Indexer,
15 DataSource,
16 Skillset,
17 SynonymMap,
18 Alias,
19 KnowledgeBase,
20 KnowledgeSource,
21 Agent,
23}
24
25impl ResourceKind {
26 pub fn domain(&self) -> ServiceDomain {
28 match self {
29 ResourceKind::Agent => ServiceDomain::Foundry,
30 _ => ServiceDomain::Search,
31 }
32 }
33
34 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 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 pub fn is_preview(&self) -> bool {
70 matches!(
71 self,
72 ResourceKind::Alias | ResourceKind::KnowledgeBase | ResourceKind::KnowledgeSource
73 )
74 }
75
76 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 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 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 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 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 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 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
176pub trait Resource: Serialize + for<'de> Deserialize<'de> + Clone {
178 fn kind() -> ResourceKind;
180
181 fn name(&self) -> &str;
183
184 fn volatile_fields() -> &'static [&'static str] {
187 &["@odata.etag", "@odata.context"]
188 }
189
190 fn read_only_fields() -> &'static [&'static str] {
194 &[]
195 }
196
197 fn identity_key() -> &'static str {
199 "name"
200 }
201
202 fn dependencies(&self) -> Vec<(ResourceKind, String)> {
204 Vec::new()
205 }
206
207 fn immutable_fields() -> &'static [&'static str] {
209 &[]
210 }
211}
212
213pub 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 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 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}