1use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use crate::registry::{self, Domain};
11use crate::service::ServiceDomain;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum ResourceKind {
17 DataSource,
19 Index,
20 Skillset,
21 Indexer,
22 SynonymMap,
23 Alias,
24 KnowledgeSource,
25 KnowledgeBase,
26 Agent,
28 Deployment,
29 Connection,
30 Guardrail,
31}
32
33impl ResourceKind {
34 pub fn domain(&self) -> ServiceDomain {
36 match registry::meta(*self).domain {
37 Domain::Search => ServiceDomain::Search,
38 Domain::FoundryData | Domain::FoundryArm => ServiceDomain::Foundry,
39 }
40 }
41
42 pub fn api_path(&self) -> &'static str {
44 registry::meta(*self).collection_path
45 }
46
47 pub fn directory_name(&self) -> &'static str {
50 registry::meta(*self).dir_name
51 }
52
53 pub fn from_directory_name(dir: &str) -> Option<ResourceKind> {
55 Self::all()
56 .iter()
57 .find(|k| k.directory_name() == dir)
58 .copied()
59 }
60
61 pub fn cli_name(&self) -> &'static str {
63 match self {
64 ResourceKind::DataSource => "data-source",
65 ResourceKind::Index => "index",
66 ResourceKind::Skillset => "skillset",
67 ResourceKind::Indexer => "indexer",
68 ResourceKind::SynonymMap => "synonym-map",
69 ResourceKind::Alias => "alias",
70 ResourceKind::KnowledgeSource => "knowledge-source",
71 ResourceKind::KnowledgeBase => "knowledge-base",
72 ResourceKind::Agent => "agent",
73 ResourceKind::Deployment => "deployment",
74 ResourceKind::Connection => "connection",
75 ResourceKind::Guardrail => "guardrail",
76 }
77 }
78
79 pub fn from_cli_name(s: &str) -> Option<ResourceKind> {
81 Self::all().iter().find(|k| k.cli_name() == s).copied()
82 }
83
84 pub fn display_name(&self) -> &'static str {
86 match self {
87 ResourceKind::DataSource => "Data Source",
88 ResourceKind::Index => "Index",
89 ResourceKind::Skillset => "Skillset",
90 ResourceKind::Indexer => "Indexer",
91 ResourceKind::SynonymMap => "Synonym Map",
92 ResourceKind::Alias => "Alias",
93 ResourceKind::KnowledgeSource => "Knowledge Source",
94 ResourceKind::KnowledgeBase => "Knowledge Base",
95 ResourceKind::Agent => "Agent",
96 ResourceKind::Deployment => "Model Deployment",
97 ResourceKind::Connection => "Connection",
98 ResourceKind::Guardrail => "Guardrail",
99 }
100 }
101
102 pub fn all() -> &'static [ResourceKind] {
104 registry::all_kinds()
105 }
106
107 pub fn stable() -> &'static [ResourceKind] {
110 &[
111 ResourceKind::Index,
112 ResourceKind::Indexer,
113 ResourceKind::DataSource,
114 ResourceKind::Skillset,
115 ResourceKind::SynonymMap,
116 ]
117 }
118
119 pub fn cli_flag_name(&self) -> &'static str {
121 match self {
122 ResourceKind::DataSource => "datasource",
123 ResourceKind::SynonymMap => "synonymmap",
124 ResourceKind::KnowledgeBase => "knowledgebase",
125 ResourceKind::KnowledgeSource => "knowledgesource",
126 other => other.cli_name(),
127 }
128 }
129
130 pub fn cli_flag_name_plural(&self) -> &'static str {
132 match self {
133 ResourceKind::Index => "indexes",
134 ResourceKind::Indexer => "indexers",
135 ResourceKind::DataSource => "datasources",
136 ResourceKind::Skillset => "skillsets",
137 ResourceKind::SynonymMap => "synonymmaps",
138 ResourceKind::Alias => "aliases",
139 ResourceKind::KnowledgeBase => "knowledgebases",
140 ResourceKind::KnowledgeSource => "knowledgesources",
141 ResourceKind::Agent => "agents",
142 ResourceKind::Deployment => "deployments",
143 ResourceKind::Connection => "connections",
144 ResourceKind::Guardrail => "guardrails",
145 }
146 }
147
148 pub fn search_kinds() -> Vec<ResourceKind> {
150 Self::all()
151 .iter()
152 .filter(|k| k.domain() == ServiceDomain::Search)
153 .copied()
154 .collect()
155 }
156
157 pub fn foundry_kinds() -> Vec<ResourceKind> {
159 Self::all()
160 .iter()
161 .filter(|k| k.domain() == ServiceDomain::Foundry)
162 .copied()
163 .collect()
164 }
165}
166
167impl fmt::Display for ResourceKind {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 write!(f, "{}", self.display_name())
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
175pub struct ResourceRef {
176 pub kind: ResourceKind,
177 pub name: String,
178}
179
180impl ResourceRef {
181 pub fn new(kind: ResourceKind, name: impl Into<String>) -> Self {
182 ResourceRef {
183 kind,
184 name: name.into(),
185 }
186 }
187
188 pub fn key(&self) -> String {
190 format!("{}/{}", self.kind.directory_name(), self.name)
191 }
192}
193
194impl fmt::Display for ResourceRef {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 write!(f, "{}/{}", self.kind.directory_name(), self.name)
197 }
198}
199
200pub trait Resource: Serialize + for<'de> Deserialize<'de> + Clone {
205 fn kind() -> ResourceKind;
206 fn name(&self) -> &str;
207 fn volatile_fields() -> &'static [&'static str] {
208 &["@odata.etag", "@odata.context"]
209 }
210 fn read_only_fields() -> &'static [&'static str] {
211 &[]
212 }
213 fn identity_key() -> &'static str {
214 "name"
215 }
216 fn dependencies(&self) -> Vec<(ResourceKind, String)> {
217 Vec::new()
218 }
219 fn immutable_fields() -> &'static [&'static str] {
220 &[]
221 }
222}
223
224pub fn validate_resource_name(name: &str) -> Result<(), anyhow::Error> {
232 if name.is_empty() {
233 anyhow::bail!("Resource name must not be empty");
234 }
235 if name.len() > 260 {
236 anyhow::bail!(
237 "Resource name must not exceed 260 characters (got {})",
238 name.len()
239 );
240 }
241 if name.contains('/') || name.contains('\\') || name.contains('\0') {
242 anyhow::bail!(
243 "Resource name must not contain '/', '\\', or null bytes: '{}'",
244 name
245 );
246 }
247 if name == "." || name == ".." {
248 anyhow::bail!("Resource name must not be '.' or '..'");
249 }
250 Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn all_returns_twelve_kinds() {
259 assert_eq!(ResourceKind::all().len(), 12);
260 }
261
262 #[test]
263 fn api_paths() {
264 assert_eq!(ResourceKind::Index.api_path(), "indexes");
265 assert_eq!(ResourceKind::DataSource.api_path(), "datasources");
266 assert_eq!(ResourceKind::KnowledgeBase.api_path(), "knowledgeBases");
267 assert_eq!(ResourceKind::KnowledgeSource.api_path(), "knowledgeSources");
268 assert_eq!(ResourceKind::Agent.api_path(), "agents");
269 assert_eq!(ResourceKind::Deployment.api_path(), "deployments");
270 }
271
272 #[test]
273 fn directory_names_are_flat_and_roundtrip() {
274 for kind in ResourceKind::all() {
275 let dir = kind.directory_name();
276 assert!(!dir.contains('/'), "{kind:?} dir must be flat: {dir}");
277 assert_eq!(ResourceKind::from_directory_name(dir), Some(*kind));
278 }
279 assert_eq!(
280 ResourceKind::KnowledgeSource.directory_name(),
281 "knowledge-sources"
282 );
283 assert_eq!(ResourceKind::Guardrail.directory_name(), "guardrails");
284 }
285
286 #[test]
287 fn cli_names_roundtrip() {
288 for kind in ResourceKind::all() {
289 assert_eq!(ResourceKind::from_cli_name(kind.cli_name()), Some(*kind));
290 }
291 }
292
293 #[test]
294 fn domains() {
295 assert_eq!(ResourceKind::Index.domain(), ServiceDomain::Search);
296 assert_eq!(ResourceKind::KnowledgeBase.domain(), ServiceDomain::Search);
297 assert_eq!(ResourceKind::Agent.domain(), ServiceDomain::Foundry);
298 assert_eq!(ResourceKind::Deployment.domain(), ServiceDomain::Foundry);
299 assert_eq!(ResourceKind::Connection.domain(), ServiceDomain::Foundry);
300 assert_eq!(ResourceKind::Guardrail.domain(), ServiceDomain::Foundry);
301 assert_eq!(ResourceKind::search_kinds().len(), 8);
302 assert_eq!(ResourceKind::foundry_kinds().len(), 4);
303 }
304
305 #[test]
306 fn resource_ref_key() {
307 let r = ResourceRef::new(ResourceKind::Index, "docs");
308 assert_eq!(r.key(), "indexes/docs");
309 assert_eq!(r.to_string(), "indexes/docs");
310 }
311
312 #[test]
313 fn serde_roundtrip() {
314 for kind in ResourceKind::all() {
315 let json = serde_json::to_string(kind).unwrap();
316 let back: ResourceKind = serde_json::from_str(&json).unwrap();
317 assert_eq!(back, *kind);
318 }
319 assert_eq!(
320 serde_json::to_string(&ResourceKind::DataSource).unwrap(),
321 "\"data-source\""
322 );
323 }
324
325 #[test]
326 fn validate_resource_name_rules() {
327 assert!(validate_resource_name("my-index").is_ok());
328 assert!(validate_resource_name(&"a".repeat(260)).is_ok());
329 assert!(validate_resource_name("").is_err());
330 assert!(validate_resource_name(&"a".repeat(261)).is_err());
331 assert!(validate_resource_name("foo/bar").is_err());
332 assert!(validate_resource_name("foo\\bar").is_err());
333 assert!(validate_resource_name("foo\0bar").is_err());
334 assert!(validate_resource_name(".").is_err());
335 assert!(validate_resource_name("..").is_err());
336 }
337}