Skip to main content

openapi_forge/
spec.rs

1use std::path::Path;
2use std::str::FromStr;
3
4use indexmap::IndexMap;
5
6use crate::error::ForgeError;
7use crate::types::{
8    OpenApiSpec, Operation, SchemaObject, SchemaOrRef, TypeInfo, ref_name_from_path,
9};
10
11/// The CRUD verb detected from an RPC-style operation path.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum RpcCrudVerb {
15    /// Resource creation (e.g. `create-*`, `add-*`).
16    Create,
17    /// Resource retrieval (e.g. `get-*`, `describe-*`).
18    Read,
19    /// Resource mutation (e.g. `update-*`).
20    Update,
21    /// Resource removal (e.g. `delete-*`, `remove-*`).
22    Delete,
23    /// Collection listing (e.g. `list-*`).
24    List,
25}
26
27impl std::fmt::Display for RpcCrudVerb {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::Create => f.write_str("create"),
31            Self::Read => f.write_str("read"),
32            Self::Update => f.write_str("update"),
33            Self::Delete => f.write_str("delete"),
34            Self::List => f.write_str("list"),
35        }
36    }
37}
38
39impl FromStr for RpcCrudVerb {
40    type Err = ForgeError;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s.to_lowercase().as_str() {
44            "create" => Ok(Self::Create),
45            "read" | "get" | "describe" => Ok(Self::Read),
46            "update" => Ok(Self::Update),
47            "delete" | "remove" => Ok(Self::Delete),
48            "list" => Ok(Self::List),
49            _ => Err(ForgeError::InvalidInput(format!("unknown CRUD verb: {s}"))),
50        }
51    }
52}
53
54/// A pattern that matches RPC-style operation paths to CRUD verbs and resource names.
55///
56/// Each pattern has a verb, a regex-like prefix, and an optional suffix that together
57/// extract the resource base name from a path like `/auth-method-create-aws-iam`.
58#[derive(Debug, Clone)]
59pub struct RpcPattern {
60    pub verb: RpcCrudVerb,
61    /// The path prefix to match (e.g., `/auth-method-create-`). Use `{resource}` as
62    /// placeholder for the variable part.
63    pub template: String,
64    /// The resulting group base name template. Use `{0}` for the captured variable part.
65    pub group_name: String,
66}
67
68impl RpcPattern {
69    /// Create a new RPC pattern.
70    #[must_use]
71    pub fn new(verb: RpcCrudVerb, template: &str, group_name: &str) -> Self {
72        Self {
73            verb,
74            template: template.to_string(),
75            group_name: group_name.to_string(),
76        }
77    }
78
79    /// Try to match a path against this pattern. Returns the group base name if matched.
80    fn try_match(&self, path: &str) -> Option<String> {
81        let path_lower = path.to_lowercase();
82        let template_lower = self.template.to_lowercase();
83
84        if let Some(idx) = template_lower.find("{resource}") {
85            let prefix = &template_lower[..idx];
86            let suffix = &template_lower[idx + "{resource}".len()..];
87
88            if !path_lower.starts_with(prefix) {
89                return None;
90            }
91
92            let rest = &path_lower[prefix.len()..];
93
94            let captured = if suffix.is_empty() {
95                rest.to_string()
96            } else if let Some(end_idx) = rest.find(suffix) {
97                rest[..end_idx].to_string()
98            } else {
99                return None;
100            };
101
102            if captured.is_empty() {
103                return None;
104            }
105
106            let group = self.group_name.replace("{0}", &captured);
107            Some(group.replace('-', "_"))
108        } else {
109            // Exact match (no resource variable)
110            if path_lower == template_lower {
111                Some(self.group_name.replace('-', "_"))
112            } else {
113                None
114            }
115        }
116    }
117}
118
119/// Configurable grouper for RPC-style CRUD APIs.
120///
121/// Unlike REST APIs where the HTTP method determines the CRUD verb,
122/// RPC APIs encode the verb in the path or operation ID. This grouper
123/// uses configurable patterns to detect verbs and group endpoints
124/// by resource name.
125///
126/// # Example
127///
128/// ```
129/// use openapi_forge::{RpcCrudGrouper, RpcCrudVerb, RpcPattern};
130///
131/// let grouper = RpcCrudGrouper::new()
132///     .pattern(RpcPattern::new(
133///         RpcCrudVerb::Create,
134///         "/create-{resource}",
135///         "{0}",
136///     ))
137///     .pattern(RpcPattern::new(
138///         RpcCrudVerb::Delete,
139///         "/delete-{resource}",
140///         "{0}",
141///     ));
142/// ```
143#[derive(Debug, Clone)]
144pub struct RpcCrudGrouper {
145    patterns: Vec<RpcPattern>,
146}
147
148impl Default for RpcCrudGrouper {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl RpcCrudGrouper {
155    /// Create a new empty grouper with no patterns.
156    #[must_use]
157    pub fn new() -> Self {
158        Self {
159            patterns: Vec::new(),
160        }
161    }
162
163    /// Add a pattern to this grouper. Patterns are tried in order; first match wins.
164    #[must_use]
165    pub fn pattern(mut self, pat: RpcPattern) -> Self {
166        self.patterns.push(pat);
167        self
168    }
169
170    /// Add multiple patterns at once.
171    #[must_use]
172    pub fn patterns(mut self, pats: Vec<RpcPattern>) -> Self {
173        self.patterns.extend(pats);
174        self
175    }
176
177    /// Create a grouper with default patterns for common RPC APIs.
178    ///
179    /// Handles `create-X`, `get-X`, `update-X`, `delete-X`, `list-X`,
180    /// `describe-X`, `add-X`, `remove-X` patterns.
181    #[must_use]
182    pub fn default_patterns() -> Self {
183        Self::new().patterns(Self::generic_patterns())
184    }
185
186    /// The generic verb-resource patterns shared by [`default_patterns`] and
187    /// used as fallbacks at the end of [`akeyless_patterns`].
188    fn generic_patterns() -> Vec<RpcPattern> {
189        vec![
190            RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}"),
191            RpcPattern::new(RpcCrudVerb::Create, "/add-{resource}", "{0}"),
192            RpcPattern::new(RpcCrudVerb::Read, "/get-{resource}", "{0}"),
193            RpcPattern::new(RpcCrudVerb::Read, "/describe-{resource}", "{0}"),
194            RpcPattern::new(RpcCrudVerb::Update, "/update-{resource}", "{0}"),
195            RpcPattern::new(RpcCrudVerb::Delete, "/delete-{resource}", "{0}"),
196            RpcPattern::new(RpcCrudVerb::Delete, "/remove-{resource}", "{0}"),
197            RpcPattern::new(RpcCrudVerb::List, "/list-{resource}", "{0}"),
198        ]
199    }
200
201    /// Create a grouper with Akeyless-specific patterns.
202    ///
203    /// Handles all the Akeyless API naming conventions:
204    /// - Auth methods: `auth-method-create-X`, `create-auth-method-X`
205    /// - Targets: `target-create-X`, `create-X-target`
206    /// - Dynamic secrets: `dynamic-secret-create-X`, `create-dynamic-secret`
207    /// - Rotated secrets: `rotated-secret-create-X`, `create-rotated-secret`
208    /// - Gateway producers: `gateway-create-producer-X`, `gateway-update-producer-X`
209    /// - Static secrets: `create-secret`, `get-secret-value`, etc.
210    /// - Items: `describe-item`, `delete-item`, `list-items`
211    #[must_use]
212    #[allow(clippy::too_many_lines)]
213    pub fn akeyless_patterns() -> Self {
214        Self::new()
215            .patterns(vec![
216                // --- Auth methods (Pattern A: auth-method-{verb}-{variant}) ---
217                RpcPattern::new(
218                    RpcCrudVerb::Create,
219                    "/auth-method-create-{resource}",
220                    "auth_method_{0}",
221                ),
222                RpcPattern::new(
223                    RpcCrudVerb::Update,
224                    "/auth-method-update-{resource}",
225                    "auth_method_{0}",
226                ),
227                // --- Auth methods (Pattern B: {verb}-auth-method-{variant}) ---
228                RpcPattern::new(
229                    RpcCrudVerb::Create,
230                    "/create-auth-method-{resource}",
231                    "auth_method_{0}",
232                ),
233                RpcPattern::new(
234                    RpcCrudVerb::Update,
235                    "/update-auth-method-{resource}",
236                    "auth_method_{0}",
237                ),
238                // Auth method shared read/delete (no variant)
239                RpcPattern::new(RpcCrudVerb::Read, "/get-auth-method", "auth_method"),
240                RpcPattern::new(RpcCrudVerb::Delete, "/delete-auth-method", "auth_method"),
241                RpcPattern::new(RpcCrudVerb::List, "/list-auth-methods", "auth_method"),
242                // --- Targets (Pattern A: target-{verb}-{variant}) ---
243                RpcPattern::new(
244                    RpcCrudVerb::Create,
245                    "/target-create-{resource}",
246                    "target_{0}",
247                ),
248                RpcPattern::new(
249                    RpcCrudVerb::Update,
250                    "/target-update-{resource}",
251                    "target_{0}",
252                ),
253                // --- Targets (Pattern B: {verb}-{variant}-target) ---
254                RpcPattern::new(
255                    RpcCrudVerb::Create,
256                    "/create-{resource}-target",
257                    "target_{0}",
258                ),
259                RpcPattern::new(
260                    RpcCrudVerb::Update,
261                    "/update-{resource}-target",
262                    "target_{0}",
263                ),
264                // Target shared read/delete
265                RpcPattern::new(RpcCrudVerb::Read, "/target-get", "target"),
266                RpcPattern::new(RpcCrudVerb::Delete, "/target-delete", "target"),
267                RpcPattern::new(RpcCrudVerb::List, "/list-targets", "target"),
268                // --- Dynamic secrets (Pattern A: dynamic-secret-{verb}-{variant}) ---
269                RpcPattern::new(
270                    RpcCrudVerb::Create,
271                    "/dynamic-secret-create-{resource}",
272                    "dynamic_secret_{0}",
273                ),
274                RpcPattern::new(
275                    RpcCrudVerb::Update,
276                    "/dynamic-secret-update-{resource}",
277                    "dynamic_secret_{0}",
278                ),
279                // --- Dynamic secrets (Pattern B: {verb}-dynamic-secret-{variant}) ---
280                RpcPattern::new(
281                    RpcCrudVerb::Create,
282                    "/create-dynamic-secret-{resource}",
283                    "dynamic_secret_{0}",
284                ),
285                RpcPattern::new(
286                    RpcCrudVerb::Update,
287                    "/update-dynamic-secret-{resource}",
288                    "dynamic_secret_{0}",
289                ),
290                // Dynamic secret shared read/delete
291                RpcPattern::new(RpcCrudVerb::Read, "/dynamic-secret-get", "dynamic_secret"),
292                RpcPattern::new(
293                    RpcCrudVerb::Delete,
294                    "/dynamic-secret-delete",
295                    "dynamic_secret",
296                ),
297                RpcPattern::new(RpcCrudVerb::List, "/list-dynamic-secrets", "dynamic_secret"),
298                // --- Rotated secrets (Pattern A: rotated-secret-{verb}-{variant}) ---
299                RpcPattern::new(
300                    RpcCrudVerb::Create,
301                    "/rotated-secret-create-{resource}",
302                    "rotated_secret_{0}",
303                ),
304                RpcPattern::new(
305                    RpcCrudVerb::Update,
306                    "/rotated-secret-update-{resource}",
307                    "rotated_secret_{0}",
308                ),
309                // --- Rotated secrets (Pattern B: {verb}-rotated-secret-{variant}) ---
310                RpcPattern::new(
311                    RpcCrudVerb::Create,
312                    "/create-rotated-secret-{resource}",
313                    "rotated_secret_{0}",
314                ),
315                RpcPattern::new(
316                    RpcCrudVerb::Update,
317                    "/update-rotated-secret-{resource}",
318                    "rotated_secret_{0}",
319                ),
320                // Rotated secret shared read/delete
321                RpcPattern::new(RpcCrudVerb::Read, "/rotated-secret-get", "rotated_secret"),
322                RpcPattern::new(
323                    RpcCrudVerb::Delete,
324                    "/rotated-secret-delete",
325                    "rotated_secret",
326                ),
327                RpcPattern::new(RpcCrudVerb::List, "/list-rotated-secrets", "rotated_secret"),
328                // --- Gateway producers: gateway-{verb}-producer-{variant} ---
329                RpcPattern::new(
330                    RpcCrudVerb::Create,
331                    "/gateway-create-producer-{resource}",
332                    "gateway_producer_{0}",
333                ),
334                RpcPattern::new(
335                    RpcCrudVerb::Update,
336                    "/gateway-update-producer-{resource}",
337                    "gateway_producer_{0}",
338                ),
339                RpcPattern::new(
340                    RpcCrudVerb::Delete,
341                    "/gateway-delete-producer-{resource}",
342                    "gateway_producer_{0}",
343                ),
344                // --- Static secrets ---
345                RpcPattern::new(RpcCrudVerb::Create, "/create-secret", "static_secret"),
346                RpcPattern::new(RpcCrudVerb::Update, "/update-secret-val", "static_secret"),
347                RpcPattern::new(RpcCrudVerb::Read, "/get-secret-value", "static_secret"),
348                // --- Items (generic) ---
349                RpcPattern::new(RpcCrudVerb::Read, "/describe-item", "item"),
350                RpcPattern::new(RpcCrudVerb::Delete, "/delete-item", "item"),
351                RpcPattern::new(RpcCrudVerb::List, "/list-items", "item"),
352            ])
353            .patterns(Self::generic_patterns())
354    }
355
356    /// Group the given endpoints into CRUD groups using the configured patterns.
357    ///
358    /// Each endpoint's path is tested against the patterns in order. The first
359    /// matching pattern determines the verb and group name.
360    #[must_use]
361    pub fn group(&self, endpoints: &[Endpoint]) -> Vec<CrudGroup> {
362        let mut groups: IndexMap<String, CrudGroup> = IndexMap::new();
363
364        for ep in endpoints {
365            if let Some((verb, group_name)) = self.match_endpoint(ep) {
366                let group = groups
367                    .entry(group_name.clone())
368                    .or_insert_with(|| CrudGroup {
369                        base_name: group_name,
370                        ..CrudGroup::default()
371                    });
372
373                match verb {
374                    RpcCrudVerb::Create => group.create = Some(ep.clone()),
375                    RpcCrudVerb::Read => group.read = Some(ep.clone()),
376                    RpcCrudVerb::Update => group.update = Some(ep.clone()),
377                    RpcCrudVerb::Delete => group.delete = Some(ep.clone()),
378                    RpcCrudVerb::List => group.list = Some(ep.clone()),
379                }
380            }
381        }
382
383        groups.into_values().collect()
384    }
385
386    /// Group endpoints directly from a Spec.
387    #[must_use]
388    pub fn group_spec(&self, spec: &Spec) -> Vec<CrudGroup> {
389        self.group(&spec.endpoints())
390    }
391
392    /// Try to match an endpoint against the configured patterns.
393    /// Returns the verb and group name if a pattern matches.
394    fn match_endpoint(&self, ep: &Endpoint) -> Option<(RpcCrudVerb, String)> {
395        for pat in &self.patterns {
396            if let Some(group_name) = pat.try_match(&ep.path) {
397                return Some((pat.verb, group_name));
398            }
399        }
400        None
401    }
402}
403
404/// High-level representation of a parsed and queryable `OpenAPI` spec.
405#[derive(Debug, Clone)]
406pub struct Spec {
407    raw: OpenApiSpec,
408}
409
410/// A resolved endpoint with its request/response schema names.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct Endpoint {
413    /// The URL path (e.g. `/create-secret`).
414    pub path: String,
415    /// The HTTP method in lowercase (e.g. `post`, `get`).
416    pub method: String,
417    /// The `operationId` declared in the spec, if any.
418    pub operation_id: Option<String>,
419    /// A human-readable summary from the spec.
420    pub summary: Option<String>,
421    /// Tags associated with this operation.
422    pub tags: Vec<String>,
423    /// The schema name extracted from the request body `$ref`, if present.
424    pub request_schema_ref: Option<String>,
425    /// The schema name extracted from the response `$ref`, if present.
426    pub response_schema_ref: Option<String>,
427}
428
429/// A resolved field from a component schema.
430#[derive(Debug, Clone, PartialEq)]
431pub struct Field {
432    /// Property name as declared in the schema.
433    pub name: String,
434    /// Resolved type (delegated to `takumi`).
435    pub type_info: TypeInfo,
436    /// Whether the field is listed in the schema's `required` array.
437    pub required: bool,
438    /// The `description` annotation, if any.
439    pub description: Option<String>,
440    /// The `default` value, if any.
441    pub default: Option<serde_json::Value>,
442    /// The `format` annotation (e.g. `date-time`, `int32`).
443    pub format: Option<String>,
444    /// Allowed values when the field declares an `enum` constraint.
445    pub enum_values: Option<Vec<String>>,
446}
447
448/// Result of diffing two schemas by field name.
449#[derive(Debug, Clone, PartialEq)]
450pub struct SchemaDiff {
451    /// Field names present in schema B but absent from schema A.
452    pub added: Vec<String>,
453    /// Field names present in schema A but absent from schema B.
454    pub removed: Vec<String>,
455    /// Fields present in both but with different type or required status.
456    pub changed: Vec<FieldChange>,
457}
458
459/// A field whose type or required status changed between two schema versions.
460#[derive(Debug, Clone, PartialEq)]
461pub struct FieldChange {
462    /// The field name.
463    pub name: String,
464    /// The type in the first (old) schema.
465    pub old_type: TypeInfo,
466    /// The type in the second (new) schema.
467    pub new_type: TypeInfo,
468    /// Whether the `required` status differs between the two schemas.
469    pub required_changed: bool,
470}
471
472/// A group of related CRUD endpoints sharing a common resource name.
473#[derive(Debug, Clone, Default, PartialEq, Eq)]
474pub struct CrudGroup {
475    /// The normalised resource name that ties these endpoints together.
476    pub base_name: String,
477    /// The endpoint that creates this resource, if detected.
478    pub create: Option<Endpoint>,
479    /// The endpoint that reads / retrieves this resource, if detected.
480    pub read: Option<Endpoint>,
481    /// The endpoint that updates this resource, if detected.
482    pub update: Option<Endpoint>,
483    /// The endpoint that deletes this resource, if detected.
484    pub delete: Option<Endpoint>,
485    /// The endpoint that lists instances of this resource, if detected.
486    pub list: Option<Endpoint>,
487}
488
489impl CrudGroup {
490    /// Returns the number of CRUD slots that have been populated.
491    #[must_use]
492    pub fn endpoint_count(&self) -> usize {
493        [
494            self.create.is_some(),
495            self.read.is_some(),
496            self.update.is_some(),
497            self.delete.is_some(),
498            self.list.is_some(),
499        ]
500        .iter()
501        .filter(|&&v| v)
502        .count()
503    }
504
505    /// Returns `true` if all five CRUD+List slots are populated.
506    #[must_use]
507    pub fn is_complete(&self) -> bool {
508        self.endpoint_count() == 5
509    }
510}
511
512impl Spec {
513    /// Load an `OpenAPI` spec from a YAML or JSON file.
514    ///
515    /// # Errors
516    ///
517    /// Returns an error if the file cannot be read, parsed, or is not `OpenAPI` 3.0.x.
518    pub fn load(path: impl AsRef<Path>) -> Result<Self, ForgeError> {
519        let content = std::fs::read_to_string(path.as_ref())?;
520        Self::parse(&content)
521    }
522
523    /// Parse an `OpenAPI` spec from a string (YAML or JSON).
524    ///
525    /// Convenience wrapper around the [`FromStr`] implementation.
526    ///
527    /// # Errors
528    ///
529    /// Returns an error if the string cannot be parsed.
530    pub fn parse(content: &str) -> Result<Self, ForgeError> {
531        let raw: OpenApiSpec = if content.trim_start().starts_with('{') {
532            serde_json::from_str(content)?
533        } else {
534            serde_yaml_ng::from_str(content)?
535        };
536
537        Ok(Self { raw })
538    }
539
540    /// Get all endpoints in the spec.
541    #[must_use]
542    pub fn endpoints(&self) -> Vec<Endpoint> {
543        self.raw
544            .paths
545            .iter()
546            .flat_map(|(path, item)| {
547                let methods: [(&str, &Option<Operation>); 5] = [
548                    ("get", &item.get),
549                    ("post", &item.post),
550                    ("put", &item.put),
551                    ("delete", &item.delete),
552                    ("patch", &item.patch),
553                ];
554                methods.into_iter().filter_map(move |(method, op)| {
555                    op.as_ref().map(|operation| Endpoint {
556                        path: path.clone(),
557                        method: method.to_string(),
558                        operation_id: operation.operation_id.clone(),
559                        summary: operation.summary.clone(),
560                        tags: operation.tags.clone(),
561                        request_schema_ref: Self::extract_request_ref(operation),
562                        response_schema_ref: Self::extract_response_ref(operation),
563                    })
564                })
565            })
566            .collect()
567    }
568
569    /// Look up a component schema by name.
570    ///
571    /// # Errors
572    ///
573    /// Returns `SchemaNotFound` if the schema name does not exist.
574    pub fn schema(&self, name: &str) -> Result<&SchemaObject, ForgeError> {
575        self.raw
576            .components
577            .as_ref()
578            .and_then(|c| c.schemas.get(name))
579            .ok_or_else(|| ForgeError::SchemaNotFound(name.to_string()))
580    }
581
582    /// Get all component schema names.
583    #[must_use]
584    pub fn schema_names(&self) -> Vec<&str> {
585        self.raw
586            .components
587            .as_ref()
588            .map(|c| c.schemas.keys().map(String::as_str).collect())
589            .unwrap_or_default()
590    }
591
592    /// Resolve fields from a named schema, including type info and required status.
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if the schema is not found.
597    pub fn fields(&self, schema_name: &str) -> Result<Vec<Field>, ForgeError> {
598        let schema = self.schema(schema_name)?;
599        Ok(self.resolve_fields(schema))
600    }
601
602    /// Resolve the type info for a sekkei `Schema`.
603    ///
604    /// Delegates to `takumi::schema_to_field_type` for consistent type resolution.
605    #[must_use]
606    pub fn resolve_type(&self, schema: &SchemaObject) -> TypeInfo {
607        takumi::schema_to_field_type(schema)
608    }
609
610    /// Resolve the type info for a `SchemaOrRef` adapter.
611    #[must_use]
612    pub fn resolve_schema_or_ref_type(&self, schema_or_ref: &SchemaOrRef) -> TypeInfo {
613        match schema_or_ref {
614            SchemaOrRef::Ref { ref_path } => {
615                let name = ref_name_from_path(ref_path).unwrap_or("Unknown");
616                TypeInfo::Object(name.to_string())
617            }
618            SchemaOrRef::Schema(schema) => takumi::schema_to_field_type(schema),
619        }
620    }
621
622    /// Diff two schemas by name, showing added/removed/changed fields.
623    ///
624    /// # Errors
625    ///
626    /// Returns an error if either schema is not found.
627    pub fn diff_schemas(&self, name_a: &str, name_b: &str) -> Result<SchemaDiff, ForgeError> {
628        let fields_a = self.fields(name_a)?;
629        let fields_b = self.fields(name_b)?;
630
631        let map_a: IndexMap<&str, &Field> = fields_a.iter().map(|f| (f.name.as_str(), f)).collect();
632        let map_b: IndexMap<&str, &Field> = fields_b.iter().map(|f| (f.name.as_str(), f)).collect();
633
634        let added: Vec<String> = map_b
635            .keys()
636            .filter(|name| !map_a.contains_key(*name))
637            .map(|name| (*name).to_string())
638            .collect();
639
640        let removed: Vec<String> = map_a
641            .keys()
642            .filter(|name| !map_b.contains_key(*name))
643            .map(|name| (*name).to_string())
644            .collect();
645
646        let changed: Vec<FieldChange> = map_b
647            .iter()
648            .filter_map(|(name, field_b)| {
649                let field_a = map_a.get(name)?;
650                if field_a.type_info != field_b.type_info || field_a.required != field_b.required {
651                    Some(FieldChange {
652                        name: (*name).to_string(),
653                        old_type: field_a.type_info.clone(),
654                        new_type: field_b.type_info.clone(),
655                        required_changed: field_a.required != field_b.required,
656                    })
657                } else {
658                    None
659                }
660            })
661            .collect();
662
663        Ok(SchemaDiff {
664            added,
665            removed,
666            changed,
667        })
668    }
669
670    /// Heuristic CRUD grouping of endpoints by operation pattern.
671    ///
672    /// Groups endpoints like `/create-secret`, `/get-secret-value`,
673    /// `/update-secret-val`, `/delete-item` into clusters by matching
674    /// create/get/update/delete prefixes.
675    #[must_use]
676    pub fn group_by_crud_pattern(&self) -> Vec<CrudGroup> {
677        let endpoints = self.endpoints();
678        let mut groups: IndexMap<String, CrudGroup> = IndexMap::new();
679
680        for ep in &endpoints {
681            let path = ep.path.trim_start_matches('/');
682            let op_id = ep.operation_id.as_deref().unwrap_or(path);
683
684            // Detect CRUD verb prefix
685            let (verb, base) = Self::detect_crud_verb(op_id);
686            if base.is_empty() {
687                continue;
688            }
689
690            let group = groups.entry(base.clone()).or_insert_with(|| CrudGroup {
691                base_name: base,
692                ..CrudGroup::default()
693            });
694
695            match verb {
696                CrudVerb::Create => group.create = Some(ep.clone()),
697                CrudVerb::Read => group.read = Some(ep.clone()),
698                CrudVerb::Update => group.update = Some(ep.clone()),
699                CrudVerb::Delete => group.delete = Some(ep.clone()),
700                CrudVerb::List => group.list = Some(ep.clone()),
701                CrudVerb::None => {}
702            }
703        }
704
705        groups.into_values().collect()
706    }
707
708    /// Find an endpoint by its path.
709    #[must_use]
710    pub fn endpoint_by_path(&self, path: &str) -> Option<Endpoint> {
711        self.endpoints().into_iter().find(|e| e.path == path)
712    }
713
714    // --- private helpers ---
715
716    fn resolve_fields(&self, schema: &SchemaObject) -> Vec<Field> {
717        self.resolve_fields_recursive(schema, &mut Vec::new())
718    }
719
720    fn resolve_fields_recursive(
721        &self,
722        schema: &SchemaObject,
723        visited: &mut Vec<String>,
724    ) -> Vec<Field> {
725        let mut fields = Vec::new();
726
727        // Handle allOf by merging properties from all referenced schemas.
728        // sekkei represents allOf as Vec<Schema> where each item may have a ref_path.
729        for item in &schema.all_of {
730            if let Some(ref_path) = &item.ref_path {
731                // This is a $ref inside allOf
732                if let Some(name) = ref_name_from_path(ref_path) {
733                    // Prevent infinite recursion on circular refs
734                    if !visited.contains(&name.to_string()) {
735                        visited.push(name.to_string());
736                        if let Ok(referenced) = self.schema(name) {
737                            fields.extend(self.resolve_fields_recursive(referenced, visited));
738                        }
739                    }
740                }
741            } else {
742                // Inline schema inside allOf
743                fields.extend(self.resolve_fields_recursive(item, visited));
744            }
745        }
746
747        // sekkei properties are BTreeMap<String, Schema> (flat, not SchemaOrRef enum)
748        for (name, prop) in &schema.properties {
749            let required = schema.required.contains(name);
750            let type_info = takumi::schema_to_field_type(prop);
751
752            let description = prop.description.clone();
753            let default = prop.default.clone();
754            let format = prop.format.clone();
755            let enum_values = prop.enum_values.as_ref().map(|vals| {
756                vals.iter()
757                    .map(|v| match v {
758                        serde_json::Value::String(s) => s.clone(),
759                        other => other.to_string(),
760                    })
761                    .collect()
762            });
763
764            // Avoid duplicates from allOf merging -- last definition wins
765            fields.retain(|f: &Field| f.name != *name);
766
767            fields.push(Field {
768                name: name.clone(),
769                type_info,
770                required,
771                description,
772                default,
773                format,
774                enum_values,
775            });
776        }
777
778        fields
779    }
780
781    fn schema_ref_name(schema: &SchemaObject) -> Option<String> {
782        schema
783            .ref_path
784            .as_ref()
785            .and_then(|r| ref_name_from_path(r))
786            .map(String::from)
787    }
788
789    fn extract_request_ref(op: &Operation) -> Option<String> {
790        let body = op.request_body.as_ref()?;
791        let media = body.content.get("application/json")?;
792        Self::schema_ref_name(media.schema.as_ref()?)
793    }
794
795    fn extract_response_ref(op: &Operation) -> Option<String> {
796        let response = op
797            .responses
798            .get("200")
799            .or_else(|| op.responses.get("201"))
800            .or_else(|| op.responses.get("default"))?;
801        let content = response.content.as_ref()?;
802        let media = content.get("application/json")?;
803        Self::schema_ref_name(media.schema.as_ref()?)
804    }
805}
806
807impl FromStr for Spec {
808    type Err = ForgeError;
809
810    fn from_str(s: &str) -> Result<Self, Self::Err> {
811        Self::parse(s)
812    }
813}
814
815#[derive(Clone, Copy)]
816enum CrudVerb {
817    Create,
818    Read,
819    Update,
820    Delete,
821    List,
822    None,
823}
824
825impl Spec {
826    fn detect_crud_verb(operation_id: &str) -> (CrudVerb, String) {
827        let normalized = operation_id.replace('-', "").to_lowercase();
828
829        let prefixes: &[(&str, CrudVerb)] = &[
830            ("create", CrudVerb::Create),
831            ("add", CrudVerb::Create),
832            ("get", CrudVerb::Read),
833            ("describe", CrudVerb::Read),
834            ("update", CrudVerb::Update),
835            ("delete", CrudVerb::Delete),
836            ("remove", CrudVerb::Delete),
837            ("list", CrudVerb::List),
838        ];
839
840        for &(prefix, verb) in prefixes {
841            if let Some(base) = normalized.strip_prefix(prefix) {
842                let original_base = Self::strip_verb_prefix(operation_id);
843                let name = if original_base.is_empty() {
844                    base.to_string()
845                } else {
846                    original_base
847                };
848                return (verb, name);
849            }
850        }
851
852        (CrudVerb::None, String::new())
853    }
854
855    fn strip_verb_prefix(operation_id: &str) -> String {
856        const VERB_PREFIXES: &[&str] = &[
857            "create-",
858            "add-",
859            "get-",
860            "describe-",
861            "update-",
862            "delete-",
863            "remove-",
864            "list-",
865        ];
866        for verb in VERB_PREFIXES {
867            if let Some(rest) = operation_id.to_lowercase().strip_prefix(verb) {
868                return rest.to_string();
869            }
870        }
871        String::new()
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    const MINIMAL_SPEC: &str = r#"
880openapi: "3.0.0"
881info:
882  title: Test API
883  version: "1.0"
884paths:
885  /create-secret:
886    post:
887      operationId: createSecret
888      requestBody:
889        content:
890          application/json:
891            schema:
892              $ref: '#/components/schemas/CreateSecret'
893      responses:
894        "200":
895          description: success
896          content:
897            application/json:
898              schema:
899                $ref: '#/components/schemas/CreateSecretOutput'
900  /get-secret-value:
901    post:
902      operationId: getSecretValue
903      requestBody:
904        content:
905          application/json:
906            schema:
907              $ref: '#/components/schemas/GetSecretValue'
908      responses:
909        "200":
910          description: success
911          content:
912            application/json:
913              schema:
914                $ref: '#/components/schemas/GetSecretValueOutput'
915  /update-secret-val:
916    post:
917      operationId: updateSecretVal
918      requestBody:
919        content:
920          application/json:
921            schema:
922              $ref: '#/components/schemas/UpdateSecretVal'
923      responses:
924        "200":
925          description: success
926  /delete-item:
927    post:
928      operationId: deleteItem
929      requestBody:
930        content:
931          application/json:
932            schema:
933              $ref: '#/components/schemas/DeleteItem'
934      responses:
935        "200":
936          description: success
937components:
938  schemas:
939    CreateSecret:
940      type: object
941      required:
942        - name
943        - value
944      properties:
945        name:
946          type: string
947          description: Secret name
948        value:
949          type: string
950          description: Secret value
951        tags:
952          type: array
953          items:
954            type: string
955        metadata:
956          type: string
957          description: Deprecated
958        token:
959          type: string
960        delete_protection:
961          type: string
962          description: "true/false"
963    CreateSecretOutput:
964      type: object
965      properties:
966        name:
967          type: string
968    GetSecretValue:
969      type: object
970      required:
971        - names
972      properties:
973        names:
974          type: array
975          items:
976            type: string
977        token:
978          type: string
979    GetSecretValueOutput:
980      type: object
981      properties:
982        name:
983          type: string
984        value:
985          type: string
986        type:
987          type: string
988    UpdateSecretVal:
989      type: object
990      required:
991        - name
992        - value
993      properties:
994        name:
995          type: string
996        value:
997          type: string
998        tags:
999          type: array
1000          items:
1001            type: string
1002        token:
1003          type: string
1004    DeleteItem:
1005      type: object
1006      required:
1007        - name
1008      properties:
1009        name:
1010          type: string
1011        token:
1012          type: string
1013"#;
1014
1015    #[test]
1016    fn parse_minimal_spec() {
1017        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1018        assert_eq!(spec.endpoints().len(), 4);
1019    }
1020
1021    #[test]
1022    fn resolve_schema_fields() {
1023        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1024        let fields = spec.fields("CreateSecret").expect("fields");
1025        assert!(fields.len() >= 4);
1026
1027        let name_field = fields
1028            .iter()
1029            .find(|f| f.name == "name")
1030            .expect("name field");
1031        assert!(name_field.required);
1032        assert_eq!(name_field.type_info, TypeInfo::String);
1033
1034        let tags_field = fields
1035            .iter()
1036            .find(|f| f.name == "tags")
1037            .expect("tags field");
1038        assert!(!tags_field.required);
1039        assert_eq!(
1040            tags_field.type_info,
1041            TypeInfo::Array(Box::new(TypeInfo::String))
1042        );
1043    }
1044
1045    #[test]
1046    fn diff_schemas() {
1047        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1048        let diff = spec
1049            .diff_schemas("CreateSecret", "UpdateSecretVal")
1050            .expect("diff");
1051        // CreateSecret has metadata, delete_protection that UpdateSecretVal doesn't
1052        assert!(!diff.removed.is_empty() || !diff.added.is_empty() || !diff.changed.is_empty());
1053    }
1054
1055    #[test]
1056    fn crud_grouping() {
1057        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1058        let groups = spec.group_by_crud_pattern();
1059        assert!(!groups.is_empty());
1060    }
1061
1062    #[test]
1063    fn endpoint_by_path() {
1064        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1065        let ep = spec.endpoint_by_path("/create-secret").expect("found");
1066        assert_eq!(ep.operation_id.as_deref(), Some("createSecret"));
1067        assert_eq!(ep.request_schema_ref.as_deref(), Some("CreateSecret"));
1068    }
1069
1070    #[test]
1071    fn reject_invalid_content() {
1072        let spec_str = "this is definitely not valid yaml or json {{{{";
1073        let result = Spec::from_str(spec_str);
1074        assert!(result.is_err());
1075    }
1076
1077    // --- Enum support tests ---
1078
1079    const ENUM_SPEC: &str = r#"
1080openapi: "3.0.0"
1081info:
1082  title: Enum Test API
1083  version: "1.0"
1084paths: {}
1085components:
1086  schemas:
1087    AccessPermission:
1088      type: object
1089      required:
1090        - permission
1091      properties:
1092        permission:
1093          type: string
1094          enum:
1095            - read
1096            - write
1097            - admin
1098          description: The permission level
1099        status:
1100          type: string
1101          enum:
1102            - active
1103            - inactive
1104            - pending
1105"#;
1106
1107    #[test]
1108    fn enum_values_populated() {
1109        let spec = Spec::from_str(ENUM_SPEC).expect("parse");
1110        let fields = spec.fields("AccessPermission").expect("fields");
1111
1112        let perm = fields
1113            .iter()
1114            .find(|f| f.name == "permission")
1115            .expect("permission field");
1116        assert_eq!(
1117            perm.enum_values,
1118            Some(vec![
1119                "read".to_string(),
1120                "write".to_string(),
1121                "admin".to_string()
1122            ])
1123        );
1124
1125        let status = fields
1126            .iter()
1127            .find(|f| f.name == "status")
1128            .expect("status field");
1129        assert_eq!(
1130            status.enum_values,
1131            Some(vec![
1132                "active".to_string(),
1133                "inactive".to_string(),
1134                "pending".to_string()
1135            ])
1136        );
1137    }
1138
1139    #[test]
1140    fn enum_values_none_when_absent() {
1141        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1142        let fields = spec.fields("CreateSecret").expect("fields");
1143        let name_field = fields
1144            .iter()
1145            .find(|f| f.name == "name")
1146            .expect("name field");
1147        assert!(name_field.enum_values.is_none());
1148    }
1149
1150    // --- allOf composition tests ---
1151
1152    const ALLOF_SPEC: &str = r#"
1153openapi: "3.0.0"
1154info:
1155  title: AllOf Test API
1156  version: "1.0"
1157paths: {}
1158components:
1159  schemas:
1160    BaseResource:
1161      type: object
1162      required:
1163        - id
1164      properties:
1165        id:
1166          type: string
1167          description: Resource ID
1168        created_at:
1169          type: string
1170          format: date-time
1171    AuditFields:
1172      type: object
1173      properties:
1174        updated_by:
1175          type: string
1176        audit_trail:
1177          type: array
1178          items:
1179            type: string
1180    NamedResource:
1181      type: object
1182      allOf:
1183        - $ref: '#/components/schemas/BaseResource'
1184      required:
1185        - name
1186      properties:
1187        name:
1188          type: string
1189          description: Resource name
1190    FullResource:
1191      type: object
1192      allOf:
1193        - $ref: '#/components/schemas/NamedResource'
1194        - $ref: '#/components/schemas/AuditFields'
1195      required:
1196        - status
1197      properties:
1198        status:
1199          type: string
1200          enum:
1201            - active
1202            - archived
1203"#;
1204
1205    #[test]
1206    fn allof_single_ref_merges_fields() {
1207        let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1208        let fields = spec.fields("NamedResource").expect("fields");
1209        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1210        assert!(names.contains(&"id"), "should inherit id from BaseResource");
1211        assert!(
1212            names.contains(&"created_at"),
1213            "should inherit created_at from BaseResource"
1214        );
1215        assert!(names.contains(&"name"), "should have own property name");
1216    }
1217
1218    #[test]
1219    fn allof_nested_chain_merges_all() {
1220        let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1221        let fields = spec.fields("FullResource").expect("fields");
1222        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1223
1224        // From BaseResource (via NamedResource chain)
1225        assert!(names.contains(&"id"), "should have id from BaseResource");
1226        assert!(
1227            names.contains(&"created_at"),
1228            "should have created_at from BaseResource"
1229        );
1230        // From NamedResource
1231        assert!(
1232            names.contains(&"name"),
1233            "should have name from NamedResource"
1234        );
1235        // From AuditFields
1236        assert!(
1237            names.contains(&"updated_by"),
1238            "should have updated_by from AuditFields"
1239        );
1240        assert!(
1241            names.contains(&"audit_trail"),
1242            "should have audit_trail from AuditFields"
1243        );
1244        // Own property
1245        assert!(names.contains(&"status"), "should have own status property");
1246    }
1247
1248    #[test]
1249    fn allof_nested_with_enum() {
1250        let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1251        let fields = spec.fields("FullResource").expect("fields");
1252        let status = fields
1253            .iter()
1254            .find(|f| f.name == "status")
1255            .expect("status field");
1256        assert_eq!(
1257            status.enum_values,
1258            Some(vec!["active".to_string(), "archived".to_string()])
1259        );
1260    }
1261
1262    // --- RpcCrudGrouper tests ---
1263
1264    const AKEYLESS_SPEC: &str = r#"
1265openapi: "3.0.0"
1266info:
1267  title: Akeyless API
1268  version: "2.0"
1269paths:
1270  /auth-method-create-aws-iam:
1271    post:
1272      operationId: authMethodCreateAwsIam
1273      responses:
1274        "200":
1275          description: success
1276  /auth-method-update-aws-iam:
1277    post:
1278      operationId: authMethodUpdateAwsIam
1279      responses:
1280        "200":
1281          description: success
1282  /create-auth-method-azure-ad:
1283    post:
1284      operationId: createAuthMethodAzureAd
1285      responses:
1286        "200":
1287          description: success
1288  /update-auth-method-azure-ad:
1289    post:
1290      operationId: updateAuthMethodAzureAd
1291      responses:
1292        "200":
1293          description: success
1294  /get-auth-method:
1295    post:
1296      operationId: getAuthMethod
1297      responses:
1298        "200":
1299          description: success
1300  /delete-auth-method:
1301    post:
1302      operationId: deleteAuthMethod
1303      responses:
1304        "200":
1305          description: success
1306  /target-create-aws:
1307    post:
1308      operationId: targetCreateAws
1309      responses:
1310        "200":
1311          description: success
1312  /target-update-aws:
1313    post:
1314      operationId: targetUpdateAws
1315      responses:
1316        "200":
1317          description: success
1318  /target-get:
1319    post:
1320      operationId: targetGet
1321      responses:
1322        "200":
1323          description: success
1324  /target-delete:
1325    post:
1326      operationId: targetDelete
1327      responses:
1328        "200":
1329          description: success
1330  /dynamic-secret-create-aws:
1331    post:
1332      operationId: dynamicSecretCreateAws
1333      responses:
1334        "200":
1335          description: success
1336  /dynamic-secret-update-aws:
1337    post:
1338      operationId: dynamicSecretUpdateAws
1339      responses:
1340        "200":
1341          description: success
1342  /dynamic-secret-get:
1343    post:
1344      operationId: dynamicSecretGet
1345      responses:
1346        "200":
1347          description: success
1348  /dynamic-secret-delete:
1349    post:
1350      operationId: dynamicSecretDelete
1351      responses:
1352        "200":
1353          description: success
1354  /create-secret:
1355    post:
1356      operationId: createSecret
1357      responses:
1358        "200":
1359          description: success
1360  /update-secret-val:
1361    post:
1362      operationId: updateSecretVal
1363      responses:
1364        "200":
1365          description: success
1366  /get-secret-value:
1367    post:
1368      operationId: getSecretValue
1369      responses:
1370        "200":
1371          description: success
1372  /describe-item:
1373    post:
1374      operationId: describeItem
1375      responses:
1376        "200":
1377          description: success
1378  /delete-item:
1379    post:
1380      operationId: deleteItem
1381      responses:
1382        "200":
1383          description: success
1384  /gateway-create-producer-aws:
1385    post:
1386      operationId: gatewayCreateProducerAws
1387      responses:
1388        "200":
1389          description: success
1390  /gateway-update-producer-aws:
1391    post:
1392      operationId: gatewayUpdateProducerAws
1393      responses:
1394        "200":
1395          description: success
1396  /rotated-secret-create-mysql:
1397    post:
1398      operationId: rotatedSecretCreateMysql
1399      responses:
1400        "200":
1401          description: success
1402  /rotated-secret-update-mysql:
1403    post:
1404      operationId: rotatedSecretUpdateMysql
1405      responses:
1406        "200":
1407          description: success
1408  /rotated-secret-get:
1409    post:
1410      operationId: rotatedSecretGet
1411      responses:
1412        "200":
1413          description: success
1414  /rotated-secret-delete:
1415    post:
1416      operationId: rotatedSecretDelete
1417      responses:
1418        "200":
1419          description: success
1420components:
1421  schemas: {}
1422"#;
1423
1424    #[test]
1425    fn rpc_grouper_auth_method_pattern_a() {
1426        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1427        let grouper = RpcCrudGrouper::akeyless_patterns();
1428        let groups = grouper.group_spec(&spec);
1429
1430        let aws_iam = groups
1431            .iter()
1432            .find(|g| g.base_name == "auth_method_aws_iam")
1433            .expect("auth_method_aws_iam group");
1434        assert!(aws_iam.create.is_some(), "should have create");
1435        assert_eq!(
1436            aws_iam.create.as_ref().unwrap().path,
1437            "/auth-method-create-aws-iam"
1438        );
1439        assert!(aws_iam.update.is_some(), "should have update");
1440        assert_eq!(
1441            aws_iam.update.as_ref().unwrap().path,
1442            "/auth-method-update-aws-iam"
1443        );
1444    }
1445
1446    #[test]
1447    fn rpc_grouper_auth_method_pattern_b() {
1448        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1449        let grouper = RpcCrudGrouper::akeyless_patterns();
1450        let groups = grouper.group_spec(&spec);
1451
1452        let azure = groups
1453            .iter()
1454            .find(|g| g.base_name == "auth_method_azure_ad")
1455            .expect("auth_method_azure_ad group");
1456        assert!(azure.create.is_some(), "should have create");
1457        assert_eq!(
1458            azure.create.as_ref().unwrap().path,
1459            "/create-auth-method-azure-ad"
1460        );
1461        assert!(azure.update.is_some(), "should have update");
1462    }
1463
1464    #[test]
1465    fn rpc_grouper_auth_method_shared_read_delete() {
1466        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1467        let grouper = RpcCrudGrouper::akeyless_patterns();
1468        let groups = grouper.group_spec(&spec);
1469
1470        let auth = groups
1471            .iter()
1472            .find(|g| g.base_name == "auth_method")
1473            .expect("auth_method group");
1474        assert!(auth.read.is_some(), "should have read");
1475        assert_eq!(auth.read.as_ref().unwrap().path, "/get-auth-method");
1476        assert!(auth.delete.is_some(), "should have delete");
1477        assert_eq!(auth.delete.as_ref().unwrap().path, "/delete-auth-method");
1478    }
1479
1480    #[test]
1481    fn rpc_grouper_targets() {
1482        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1483        let grouper = RpcCrudGrouper::akeyless_patterns();
1484        let groups = grouper.group_spec(&spec);
1485
1486        let target_aws = groups
1487            .iter()
1488            .find(|g| g.base_name == "target_aws")
1489            .expect("target_aws group");
1490        assert!(target_aws.create.is_some());
1491        assert_eq!(
1492            target_aws.create.as_ref().unwrap().path,
1493            "/target-create-aws"
1494        );
1495        assert!(target_aws.update.is_some());
1496        assert_eq!(
1497            target_aws.update.as_ref().unwrap().path,
1498            "/target-update-aws"
1499        );
1500
1501        let target = groups
1502            .iter()
1503            .find(|g| g.base_name == "target")
1504            .expect("target group");
1505        assert!(target.read.is_some());
1506        assert_eq!(target.read.as_ref().unwrap().path, "/target-get");
1507        assert!(target.delete.is_some());
1508        assert_eq!(target.delete.as_ref().unwrap().path, "/target-delete");
1509    }
1510
1511    #[test]
1512    fn rpc_grouper_dynamic_secrets() {
1513        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1514        let grouper = RpcCrudGrouper::akeyless_patterns();
1515        let groups = grouper.group_spec(&spec);
1516
1517        let ds_aws = groups
1518            .iter()
1519            .find(|g| g.base_name == "dynamic_secret_aws")
1520            .expect("dynamic_secret_aws group");
1521        assert!(ds_aws.create.is_some());
1522        assert_eq!(
1523            ds_aws.create.as_ref().unwrap().path,
1524            "/dynamic-secret-create-aws"
1525        );
1526        assert!(ds_aws.update.is_some());
1527
1528        let ds = groups
1529            .iter()
1530            .find(|g| g.base_name == "dynamic_secret")
1531            .expect("dynamic_secret group");
1532        assert!(ds.read.is_some());
1533        assert_eq!(ds.read.as_ref().unwrap().path, "/dynamic-secret-get");
1534        assert!(ds.delete.is_some());
1535    }
1536
1537    #[test]
1538    fn rpc_grouper_static_secrets() {
1539        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1540        let grouper = RpcCrudGrouper::akeyless_patterns();
1541        let groups = grouper.group_spec(&spec);
1542
1543        let ss = groups
1544            .iter()
1545            .find(|g| g.base_name == "static_secret")
1546            .expect("static_secret group");
1547        assert!(ss.create.is_some());
1548        assert_eq!(ss.create.as_ref().unwrap().path, "/create-secret");
1549        assert!(ss.update.is_some());
1550        assert_eq!(ss.update.as_ref().unwrap().path, "/update-secret-val");
1551        assert!(ss.read.is_some());
1552        assert_eq!(ss.read.as_ref().unwrap().path, "/get-secret-value");
1553    }
1554
1555    #[test]
1556    fn rpc_grouper_items() {
1557        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1558        let grouper = RpcCrudGrouper::akeyless_patterns();
1559        let groups = grouper.group_spec(&spec);
1560
1561        let item = groups
1562            .iter()
1563            .find(|g| g.base_name == "item")
1564            .expect("item group");
1565        assert!(item.read.is_some());
1566        assert_eq!(item.read.as_ref().unwrap().path, "/describe-item");
1567        assert!(item.delete.is_some());
1568        assert_eq!(item.delete.as_ref().unwrap().path, "/delete-item");
1569    }
1570
1571    #[test]
1572    fn rpc_grouper_gateway_producers() {
1573        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1574        let grouper = RpcCrudGrouper::akeyless_patterns();
1575        let groups = grouper.group_spec(&spec);
1576
1577        let gp_aws = groups
1578            .iter()
1579            .find(|g| g.base_name == "gateway_producer_aws")
1580            .expect("gateway_producer_aws group");
1581        assert!(gp_aws.create.is_some());
1582        assert_eq!(
1583            gp_aws.create.as_ref().unwrap().path,
1584            "/gateway-create-producer-aws"
1585        );
1586        assert!(gp_aws.update.is_some());
1587        assert_eq!(
1588            gp_aws.update.as_ref().unwrap().path,
1589            "/gateway-update-producer-aws"
1590        );
1591    }
1592
1593    #[test]
1594    fn rpc_grouper_rotated_secrets() {
1595        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1596        let grouper = RpcCrudGrouper::akeyless_patterns();
1597        let groups = grouper.group_spec(&spec);
1598
1599        let rs_mysql = groups
1600            .iter()
1601            .find(|g| g.base_name == "rotated_secret_mysql")
1602            .expect("rotated_secret_mysql group");
1603        assert!(rs_mysql.create.is_some());
1604        assert_eq!(
1605            rs_mysql.create.as_ref().unwrap().path,
1606            "/rotated-secret-create-mysql"
1607        );
1608        assert!(rs_mysql.update.is_some());
1609
1610        let rs = groups
1611            .iter()
1612            .find(|g| g.base_name == "rotated_secret")
1613            .expect("rotated_secret group");
1614        assert!(rs.read.is_some());
1615        assert_eq!(rs.read.as_ref().unwrap().path, "/rotated-secret-get");
1616        assert!(rs.delete.is_some());
1617    }
1618
1619    #[test]
1620    fn rpc_grouper_custom_patterns() {
1621        let spec_str = r#"
1622openapi: "3.0.0"
1623info:
1624  title: Custom API
1625  version: "1.0"
1626paths:
1627  /v1/resource/create:
1628    post:
1629      operationId: resourceCreate
1630      responses:
1631        "200":
1632          description: success
1633  /v1/resource/get:
1634    post:
1635      operationId: resourceGet
1636      responses:
1637        "200":
1638          description: success
1639components:
1640  schemas: {}
1641"#;
1642        let spec = Spec::from_str(spec_str).expect("parse");
1643        let grouper = RpcCrudGrouper::new()
1644            .pattern(RpcPattern::new(
1645                RpcCrudVerb::Create,
1646                "/v1/{resource}/create",
1647                "{0}",
1648            ))
1649            .pattern(RpcPattern::new(
1650                RpcCrudVerb::Read,
1651                "/v1/{resource}/get",
1652                "{0}",
1653            ));
1654        let groups = grouper.group_spec(&spec);
1655        assert_eq!(groups.len(), 1);
1656        assert_eq!(groups[0].base_name, "resource");
1657        assert!(groups[0].create.is_some());
1658        assert!(groups[0].read.is_some());
1659    }
1660
1661    #[test]
1662    fn rpc_grouper_default_patterns() {
1663        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1664        let grouper = RpcCrudGrouper::default_patterns();
1665        let groups = grouper.group_spec(&spec);
1666        // /create-secret -> "secret", /get-secret-value -> "secret_value",
1667        // /update-secret-val -> "secret_val", /delete-item -> "item"
1668        assert!(!groups.is_empty());
1669    }
1670
1671    #[test]
1672    fn rpc_pattern_try_match_exact() {
1673        let pat = RpcPattern::new(RpcCrudVerb::Read, "/get-auth-method", "auth_method");
1674        assert_eq!(
1675            pat.try_match("/get-auth-method"),
1676            Some("auth_method".to_string())
1677        );
1678        assert_eq!(pat.try_match("/get-auth-methods"), None);
1679        assert_eq!(pat.try_match("/delete-auth-method"), None);
1680    }
1681
1682    #[test]
1683    fn rpc_pattern_try_match_with_resource() {
1684        let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
1685        assert_eq!(pat.try_match("/create-secret"), Some("secret".to_string()));
1686        assert_eq!(
1687            pat.try_match("/create-auth-method"),
1688            Some("auth_method".to_string())
1689        );
1690    }
1691
1692    #[test]
1693    fn rpc_pattern_try_match_prefix_and_suffix() {
1694        let pat = RpcPattern::new(
1695            RpcCrudVerb::Create,
1696            "/create-{resource}-target",
1697            "target_{0}",
1698        );
1699        assert_eq!(
1700            pat.try_match("/create-aws-target"),
1701            Some("target_aws".to_string())
1702        );
1703        assert_eq!(pat.try_match("/create-target"), None);
1704    }
1705
1706    #[test]
1707    fn rpc_grouper_default_impl() {
1708        // Verify Default trait works (empty grouper)
1709        let grouper = RpcCrudGrouper::default();
1710        assert!(grouper.group(&[]).is_empty());
1711    }
1712
1713    // --- Existing group_by_crud_pattern still works ---
1714
1715    #[test]
1716    fn legacy_crud_grouping_unchanged() {
1717        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1718        let groups = spec.group_by_crud_pattern();
1719        assert!(!groups.is_empty());
1720        // Verify at least one group has a create operation
1721        let has_create = groups.iter().any(|g| g.create.is_some());
1722        assert!(has_create, "at least one group should have a create");
1723    }
1724
1725    // --- TypeInfo (FieldType) tests for enum variant ---
1726
1727    #[test]
1728    fn type_info_enum_variant_from_takumi() {
1729        // Verify that takumi::FieldType::Enum is available through our TypeInfo alias
1730        let enum_type = TypeInfo::Enum {
1731            values: vec!["a".to_string(), "b".to_string()],
1732            underlying: Box::new(TypeInfo::String),
1733        };
1734        assert_eq!(
1735            enum_type,
1736            TypeInfo::Enum {
1737                values: vec!["a".to_string(), "b".to_string()],
1738                underlying: Box::new(TypeInfo::String),
1739            }
1740        );
1741    }
1742
1743    #[test]
1744    fn resolve_type_delegates_to_takumi() {
1745        let spec = Spec::from_str(ENUM_SPEC).expect("parse");
1746        let schema = spec.schema("AccessPermission").expect("schema");
1747        let perm_prop = &schema.properties["permission"];
1748        let type_info = spec.resolve_type(perm_prop);
1749        // takumi resolves string enums as FieldType::Enum
1750        assert_eq!(
1751            type_info,
1752            TypeInfo::Enum {
1753                values: vec!["read".to_string(), "write".to_string(), "admin".to_string()],
1754                underlying: Box::new(TypeInfo::String),
1755            }
1756        );
1757    }
1758
1759    // ========================================================================
1760    // JSON parsing — ensures the `starts_with('{')` branch in `from_str` works
1761    // ========================================================================
1762
1763    #[test]
1764    fn parse_json_spec() {
1765        let json = r#"{
1766            "openapi": "3.0.0",
1767            "info": { "title": "JSON API", "version": "1.0" },
1768            "paths": {
1769                "/ping": {
1770                    "get": {
1771                        "operationId": "ping",
1772                        "responses": { "200": { "description": "pong" } }
1773                    }
1774                }
1775            },
1776            "components": {
1777                "schemas": {
1778                    "Pong": {
1779                        "type": "object",
1780                        "properties": {
1781                            "msg": { "type": "string" }
1782                        }
1783                    }
1784                }
1785            }
1786        }"#;
1787        let spec = Spec::from_str(json).expect("should parse JSON");
1788        assert_eq!(spec.endpoints().len(), 1);
1789        assert_eq!(spec.endpoints()[0].method, "get");
1790        assert_eq!(spec.schema_names(), vec!["Pong"]);
1791    }
1792
1793    #[test]
1794    fn parse_json_with_leading_whitespace() {
1795        let json = "   \n\t  {\"info\":{\"title\":\"Ws\",\"version\":\"1\"},\"paths\":{}}";
1796        let spec = Spec::from_str(json).expect("should handle leading whitespace before {");
1797        assert!(spec.endpoints().is_empty());
1798    }
1799
1800    #[test]
1801    fn parse_json_invalid_returns_error() {
1802        let result = Spec::from_str("{\"not_a_valid_spec\": true}");
1803        assert!(result.is_err());
1804    }
1805
1806    // ========================================================================
1807    // Spec::load — file-based loading via tempfile
1808    // ========================================================================
1809
1810    #[test]
1811    fn load_yaml_file() {
1812        use std::io::Write;
1813        let mut f = tempfile::NamedTempFile::new().expect("create temp");
1814        write!(f, "{MINIMAL_SPEC}").expect("write");
1815        let spec = Spec::load(f.path()).expect("load");
1816        assert_eq!(spec.endpoints().len(), 4);
1817    }
1818
1819    #[test]
1820    fn load_json_file() {
1821        use std::io::Write;
1822        let mut f = tempfile::NamedTempFile::with_suffix(".json").expect("create temp");
1823        let json = r#"{"openapi":"3.0.0","info":{"title":"F","version":"1"},"paths":{}}"#;
1824        write!(f, "{json}").expect("write");
1825        let spec = Spec::load(f.path()).expect("load json file");
1826        assert!(spec.endpoints().is_empty());
1827    }
1828
1829    #[test]
1830    fn load_nonexistent_file_returns_io_error() {
1831        let result = Spec::load(std::path::Path::new("/tmp/__no_such_file_openapi__.yaml"));
1832        assert!(result.is_err());
1833        let err = result.unwrap_err();
1834        assert!(
1835            matches!(err, ForgeError::Io(_)),
1836            "expected Io error, got: {err:?}"
1837        );
1838    }
1839
1840    // ========================================================================
1841    // schema() and schema_names() — error paths and edge cases
1842    // ========================================================================
1843
1844    #[test]
1845    fn schema_not_found_returns_error() {
1846        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1847        let err = spec.schema("NoSuchSchema").unwrap_err();
1848        assert!(
1849            matches!(err, ForgeError::SchemaNotFound(_)),
1850            "expected SchemaNotFound, got: {err:?}"
1851        );
1852        assert!(err.to_string().contains("NoSuchSchema"));
1853    }
1854
1855    #[test]
1856    fn schema_names_returns_all_names() {
1857        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1858        let names = spec.schema_names();
1859        assert!(names.contains(&"CreateSecret"));
1860        assert!(names.contains(&"GetSecretValue"));
1861        assert!(names.contains(&"UpdateSecretVal"));
1862        assert!(names.contains(&"DeleteItem"));
1863        assert!(names.contains(&"CreateSecretOutput"));
1864        assert!(names.contains(&"GetSecretValueOutput"));
1865    }
1866
1867    #[test]
1868    fn schema_names_empty_when_no_components() {
1869        let yaml = r#"
1870openapi: "3.0.0"
1871info:
1872  title: No Components
1873  version: "1.0"
1874paths: {}
1875"#;
1876        let spec = Spec::from_str(yaml).expect("parse");
1877        assert!(spec.schema_names().is_empty());
1878    }
1879
1880    // ========================================================================
1881    // resolve_schema_or_ref_type — Ref and Schema branches
1882    // ========================================================================
1883
1884    #[test]
1885    fn resolve_schema_or_ref_type_ref_variant() {
1886        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1887        let sor = SchemaOrRef::Ref {
1888            ref_path: "#/components/schemas/CreateSecret".to_string(),
1889        };
1890        let ti = spec.resolve_schema_or_ref_type(&sor);
1891        assert_eq!(ti, TypeInfo::Object("CreateSecret".to_string()));
1892    }
1893
1894    #[test]
1895    fn resolve_schema_or_ref_type_ref_no_slash() {
1896        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1897        let sor = SchemaOrRef::Ref {
1898            ref_path: "Standalone".to_string(),
1899        };
1900        let ti = spec.resolve_schema_or_ref_type(&sor);
1901        assert_eq!(ti, TypeInfo::Object("Standalone".to_string()));
1902    }
1903
1904    #[test]
1905    fn resolve_schema_or_ref_type_schema_variant() {
1906        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1907        let schema = SchemaObject {
1908            schema_type: Some("string".to_string()),
1909            ..SchemaObject::default()
1910        };
1911        let sor = SchemaOrRef::Schema(Box::new(schema));
1912        let ti = spec.resolve_schema_or_ref_type(&sor);
1913        assert_eq!(ti, TypeInfo::String);
1914    }
1915
1916    // ========================================================================
1917    // diff_schemas — detailed assertions and error paths
1918    // ========================================================================
1919
1920    #[test]
1921    fn diff_schemas_identical_schemas() {
1922        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1923        let diff = spec
1924            .diff_schemas("CreateSecret", "CreateSecret")
1925            .expect("diff");
1926        assert!(diff.added.is_empty());
1927        assert!(diff.removed.is_empty());
1928        assert!(diff.changed.is_empty());
1929    }
1930
1931    #[test]
1932    fn diff_schemas_added_and_removed() {
1933        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1934        let diff = spec
1935            .diff_schemas("CreateSecret", "UpdateSecretVal")
1936            .expect("diff");
1937        assert!(
1938            diff.removed.contains(&"metadata".to_string())
1939                || diff.removed.contains(&"delete_protection".to_string()),
1940            "CreateSecret has fields not in UpdateSecretVal: {:?}",
1941            diff.removed
1942        );
1943    }
1944
1945    #[test]
1946    fn diff_schemas_with_type_change() {
1947        let yaml = r#"
1948openapi: "3.0.0"
1949info:
1950  title: Diff
1951  version: "1.0"
1952paths: {}
1953components:
1954  schemas:
1955    A:
1956      type: object
1957      required:
1958        - x
1959      properties:
1960        x:
1961          type: string
1962    B:
1963      type: object
1964      properties:
1965        x:
1966          type: integer
1967"#;
1968        let spec = Spec::from_str(yaml).expect("parse");
1969        let diff = spec.diff_schemas("A", "B").expect("diff");
1970        assert_eq!(diff.changed.len(), 1);
1971        assert_eq!(diff.changed[0].name, "x");
1972        assert_eq!(diff.changed[0].old_type, TypeInfo::String);
1973        assert_eq!(diff.changed[0].new_type, TypeInfo::Integer);
1974        assert!(diff.changed[0].required_changed);
1975    }
1976
1977    #[test]
1978    fn diff_schemas_first_not_found() {
1979        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1980        let err = spec.diff_schemas("Missing", "CreateSecret").unwrap_err();
1981        assert!(matches!(err, ForgeError::SchemaNotFound(_)));
1982    }
1983
1984    #[test]
1985    fn diff_schemas_second_not_found() {
1986        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1987        let err = spec.diff_schemas("CreateSecret", "Missing").unwrap_err();
1988        assert!(matches!(err, ForgeError::SchemaNotFound(_)));
1989    }
1990
1991    // ========================================================================
1992    // endpoint_by_path — not-found case
1993    // ========================================================================
1994
1995    #[test]
1996    fn endpoint_by_path_not_found() {
1997        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1998        assert!(spec.endpoint_by_path("/no-such-path").is_none());
1999    }
2000
2001    // ========================================================================
2002    // Multiple HTTP methods in endpoints()
2003    // ========================================================================
2004
2005    #[test]
2006    fn endpoints_multiple_methods() {
2007        let yaml = r#"
2008openapi: "3.0.0"
2009info:
2010  title: Multi
2011  version: "1.0"
2012paths:
2013  /resource:
2014    get:
2015      operationId: getResource
2016      responses:
2017        "200":
2018          description: ok
2019    post:
2020      operationId: createResource
2021      responses:
2022        "200":
2023          description: ok
2024    put:
2025      operationId: updateResource
2026      responses:
2027        "200":
2028          description: ok
2029    delete:
2030      operationId: deleteResource
2031      responses:
2032        "200":
2033          description: ok
2034    patch:
2035      operationId: patchResource
2036      responses:
2037        "200":
2038          description: ok
2039components:
2040  schemas: {}
2041"#;
2042        let spec = Spec::from_str(yaml).expect("parse");
2043        let eps = spec.endpoints();
2044        assert_eq!(eps.len(), 5);
2045        let methods: Vec<&str> = eps.iter().map(|e| e.method.as_str()).collect();
2046        assert!(methods.contains(&"get"));
2047        assert!(methods.contains(&"post"));
2048        assert!(methods.contains(&"put"));
2049        assert!(methods.contains(&"delete"));
2050        assert!(methods.contains(&"patch"));
2051    }
2052
2053    // ========================================================================
2054    // Empty paths
2055    // ========================================================================
2056
2057    #[test]
2058    fn endpoints_empty_paths() {
2059        let yaml = r#"
2060openapi: "3.0.0"
2061info:
2062  title: Empty
2063  version: "1.0"
2064paths: {}
2065"#;
2066        let spec = Spec::from_str(yaml).expect("parse");
2067        assert!(spec.endpoints().is_empty());
2068    }
2069
2070    // ========================================================================
2071    // Endpoint fields: summary, tags, response_schema_ref
2072    // ========================================================================
2073
2074    #[test]
2075    fn endpoint_summary_and_tags() {
2076        let yaml = r#"
2077openapi: "3.0.0"
2078info:
2079  title: Tags
2080  version: "1.0"
2081paths:
2082  /items:
2083    get:
2084      operationId: listItems
2085      summary: List all items
2086      tags:
2087        - items
2088        - public
2089      responses:
2090        "200":
2091          description: ok
2092components:
2093  schemas: {}
2094"#;
2095        let spec = Spec::from_str(yaml).expect("parse");
2096        let ep = spec.endpoint_by_path("/items").expect("found");
2097        assert_eq!(ep.summary.as_deref(), Some("List all items"));
2098        assert_eq!(ep.tags, vec!["items", "public"]);
2099    }
2100
2101    #[test]
2102    fn endpoint_response_schema_ref() {
2103        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2104        let ep = spec.endpoint_by_path("/create-secret").expect("found");
2105        assert_eq!(
2106            ep.response_schema_ref.as_deref(),
2107            Some("CreateSecretOutput")
2108        );
2109    }
2110
2111    #[test]
2112    fn endpoint_no_request_body() {
2113        let yaml = r#"
2114openapi: "3.0.0"
2115info:
2116  title: NoBody
2117  version: "1.0"
2118paths:
2119  /health:
2120    get:
2121      operationId: healthCheck
2122      responses:
2123        "200":
2124          description: ok
2125components:
2126  schemas: {}
2127"#;
2128        let spec = Spec::from_str(yaml).expect("parse");
2129        let ep = spec.endpoint_by_path("/health").expect("found");
2130        assert!(ep.request_schema_ref.is_none());
2131        assert!(ep.response_schema_ref.is_none());
2132    }
2133
2134    // ========================================================================
2135    // extract_response_ref fallbacks: 201, default
2136    // ========================================================================
2137
2138    #[test]
2139    fn response_ref_fallback_201() {
2140        let yaml = r#"
2141openapi: "3.0.0"
2142info:
2143  title: Resp201
2144  version: "1.0"
2145paths:
2146  /items:
2147    post:
2148      operationId: createItem
2149      responses:
2150        "201":
2151          description: created
2152          content:
2153            application/json:
2154              schema:
2155                $ref: '#/components/schemas/Item'
2156components:
2157  schemas:
2158    Item:
2159      type: object
2160      properties:
2161        id:
2162          type: string
2163"#;
2164        let spec = Spec::from_str(yaml).expect("parse");
2165        let ep = spec.endpoint_by_path("/items").expect("found");
2166        assert_eq!(ep.response_schema_ref.as_deref(), Some("Item"));
2167    }
2168
2169    #[test]
2170    fn response_ref_fallback_default() {
2171        let yaml = r#"
2172openapi: "3.0.0"
2173info:
2174  title: RespDefault
2175  version: "1.0"
2176paths:
2177  /items:
2178    get:
2179      operationId: getItem
2180      responses:
2181        default:
2182          description: default resp
2183          content:
2184            application/json:
2185              schema:
2186                $ref: '#/components/schemas/GenericResp'
2187components:
2188  schemas:
2189    GenericResp:
2190      type: object
2191      properties:
2192        message:
2193          type: string
2194"#;
2195        let spec = Spec::from_str(yaml).expect("parse");
2196        let ep = spec.endpoint_by_path("/items").expect("found");
2197        assert_eq!(ep.response_schema_ref.as_deref(), Some("GenericResp"));
2198    }
2199
2200    // ========================================================================
2201    // fields() — error path, metadata (description, default, format)
2202    // ========================================================================
2203
2204    #[test]
2205    fn fields_missing_schema_returns_error() {
2206        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2207        let err = spec.fields("DoesNotExist").unwrap_err();
2208        assert!(matches!(err, ForgeError::SchemaNotFound(_)));
2209    }
2210
2211    #[test]
2212    fn field_description_and_default() {
2213        let yaml = r#"
2214openapi: "3.0.0"
2215info:
2216  title: FieldMeta
2217  version: "1.0"
2218paths: {}
2219components:
2220  schemas:
2221    Config:
2222      type: object
2223      properties:
2224        timeout:
2225          type: integer
2226          description: Request timeout in ms
2227          default: 30000
2228          format: int32
2229"#;
2230        let spec = Spec::from_str(yaml).expect("parse");
2231        let fields = spec.fields("Config").expect("fields");
2232        let timeout = fields
2233            .iter()
2234            .find(|f| f.name == "timeout")
2235            .expect("timeout");
2236        assert_eq!(
2237            timeout.description.as_deref(),
2238            Some("Request timeout in ms")
2239        );
2240        assert_eq!(timeout.default, Some(serde_json::json!(30000)));
2241        assert_eq!(timeout.format.as_deref(), Some("int32"));
2242    }
2243
2244    #[test]
2245    fn field_required_vs_optional() {
2246        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2247        let fields = spec.fields("CreateSecret").expect("fields");
2248        let name_f = fields.iter().find(|f| f.name == "name").unwrap();
2249        let value_f = fields.iter().find(|f| f.name == "value").unwrap();
2250        let token_f = fields.iter().find(|f| f.name == "token").unwrap();
2251        assert!(name_f.required);
2252        assert!(value_f.required);
2253        assert!(!token_f.required);
2254    }
2255
2256    // ========================================================================
2257    // allOf — inline schema (no ref_path), circular ref protection
2258    // ========================================================================
2259
2260    #[test]
2261    fn allof_inline_schema_merged() {
2262        let yaml = r#"
2263openapi: "3.0.0"
2264info:
2265  title: AllOfInline
2266  version: "1.0"
2267paths: {}
2268components:
2269  schemas:
2270    Combined:
2271      type: object
2272      allOf:
2273        - type: object
2274          properties:
2275            from_inline:
2276              type: string
2277      properties:
2278        own_field:
2279          type: integer
2280"#;
2281        let spec = Spec::from_str(yaml).expect("parse");
2282        let fields = spec.fields("Combined").expect("fields");
2283        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
2284        assert!(names.contains(&"from_inline"));
2285        assert!(names.contains(&"own_field"));
2286    }
2287
2288    #[test]
2289    fn allof_property_override_last_wins() {
2290        let yaml = r#"
2291openapi: "3.0.0"
2292info:
2293  title: AllOfOverride
2294  version: "1.0"
2295paths: {}
2296components:
2297  schemas:
2298    Base:
2299      type: object
2300      properties:
2301        field:
2302          type: string
2303    Override:
2304      type: object
2305      allOf:
2306        - $ref: '#/components/schemas/Base'
2307      required:
2308        - field
2309      properties:
2310        field:
2311          type: integer
2312"#;
2313        let spec = Spec::from_str(yaml).expect("parse");
2314        let fields = spec.fields("Override").expect("fields");
2315        let field = fields.iter().find(|f| f.name == "field").expect("field");
2316        assert_eq!(field.type_info, TypeInfo::Integer);
2317        assert!(field.required);
2318    }
2319
2320    // ========================================================================
2321    // group_by_crud_pattern — edge cases
2322    // ========================================================================
2323
2324    #[test]
2325    fn crud_grouping_skips_unrecognized_verbs() {
2326        let yaml = r#"
2327openapi: "3.0.0"
2328info:
2329  title: Unknown Verbs
2330  version: "1.0"
2331paths:
2332  /custom-action:
2333    post:
2334      operationId: customAction
2335      responses:
2336        "200":
2337          description: ok
2338  /create-item:
2339    post:
2340      operationId: createItem
2341      responses:
2342        "200":
2343          description: ok
2344components:
2345  schemas: {}
2346"#;
2347        let spec = Spec::from_str(yaml).expect("parse");
2348        let groups = spec.group_by_crud_pattern();
2349        let has_custom = groups.iter().any(|g| g.base_name.contains("custom"));
2350        assert!(!has_custom, "custom-action should not create a CRUD group");
2351        let has_item = groups.iter().any(|g| g.create.is_some());
2352        assert!(has_item);
2353    }
2354
2355    #[test]
2356    fn crud_grouping_empty_spec() {
2357        let yaml = r#"
2358openapi: "3.0.0"
2359info:
2360  title: Empty
2361  version: "1.0"
2362paths: {}
2363"#;
2364        let spec = Spec::from_str(yaml).expect("parse");
2365        let groups = spec.group_by_crud_pattern();
2366        assert!(groups.is_empty());
2367    }
2368
2369    #[test]
2370    fn crud_grouping_all_verb_types() {
2371        let yaml = r#"
2372openapi: "3.0.0"
2373info:
2374  title: All Verbs
2375  version: "1.0"
2376paths:
2377  /create-widget:
2378    post:
2379      operationId: createWidget
2380      responses:
2381        "200":
2382          description: ok
2383  /get-widget:
2384    post:
2385      operationId: getWidget
2386      responses:
2387        "200":
2388          description: ok
2389  /update-widget:
2390    post:
2391      operationId: updateWidget
2392      responses:
2393        "200":
2394          description: ok
2395  /delete-widget:
2396    post:
2397      operationId: deleteWidget
2398      responses:
2399        "200":
2400          description: ok
2401  /list-widgets:
2402    post:
2403      operationId: listWidgets
2404      responses:
2405        "200":
2406          description: ok
2407components:
2408  schemas: {}
2409"#;
2410        let spec = Spec::from_str(yaml).expect("parse");
2411        let groups = spec.group_by_crud_pattern();
2412        let widget = groups
2413            .iter()
2414            .find(|g| g.base_name == "widget")
2415            .expect("widget group");
2416        assert!(widget.create.is_some(), "create");
2417        assert!(widget.read.is_some(), "read");
2418        assert!(widget.update.is_some(), "update");
2419        assert!(widget.delete.is_some(), "delete");
2420        let list_group = groups.iter().find(|g| g.list.is_some());
2421        assert!(list_group.is_some(), "list group should exist");
2422    }
2423
2424    #[test]
2425    fn crud_grouping_add_and_remove_verbs() {
2426        let yaml = r#"
2427openapi: "3.0.0"
2428info:
2429  title: Add Remove
2430  version: "1.0"
2431paths:
2432  /add-user:
2433    post:
2434      operationId: addUser
2435      responses:
2436        "200":
2437          description: ok
2438  /remove-user:
2439    post:
2440      operationId: removeUser
2441      responses:
2442        "200":
2443          description: ok
2444  /describe-user:
2445    post:
2446      operationId: describeUser
2447      responses:
2448        "200":
2449          description: ok
2450components:
2451  schemas: {}
2452"#;
2453        let spec = Spec::from_str(yaml).expect("parse");
2454        let groups = spec.group_by_crud_pattern();
2455        let user = groups
2456            .iter()
2457            .find(|g| g.base_name == "user")
2458            .expect("user group");
2459        assert!(user.create.is_some(), "add- should map to create");
2460        assert!(user.delete.is_some(), "remove- should map to delete");
2461        assert!(user.read.is_some(), "describe- should map to read");
2462    }
2463
2464    // ========================================================================
2465    // detect_crud_verb / strip_verb_prefix — thorough edge case coverage
2466    // ========================================================================
2467
2468    #[test]
2469    fn detect_crud_verb_with_hyphenated_operation_id() {
2470        let (verb, base) = Spec::detect_crud_verb("create-auth-method");
2471        assert!(matches!(verb, CrudVerb::Create));
2472        assert_eq!(base, "auth-method");
2473    }
2474
2475    #[test]
2476    fn detect_crud_verb_no_match() {
2477        let (verb, base) = Spec::detect_crud_verb("custom-action");
2478        assert!(matches!(verb, CrudVerb::None));
2479        assert!(base.is_empty());
2480    }
2481
2482    #[test]
2483    fn strip_verb_prefix_all_verbs() {
2484        assert_eq!(Spec::strip_verb_prefix("create-foo"), "foo");
2485        assert_eq!(Spec::strip_verb_prefix("add-foo"), "foo");
2486        assert_eq!(Spec::strip_verb_prefix("get-foo"), "foo");
2487        assert_eq!(Spec::strip_verb_prefix("describe-foo"), "foo");
2488        assert_eq!(Spec::strip_verb_prefix("update-foo"), "foo");
2489        assert_eq!(Spec::strip_verb_prefix("delete-foo"), "foo");
2490        assert_eq!(Spec::strip_verb_prefix("remove-foo"), "foo");
2491        assert_eq!(Spec::strip_verb_prefix("list-foo"), "foo");
2492    }
2493
2494    #[test]
2495    fn strip_verb_prefix_no_match() {
2496        assert_eq!(Spec::strip_verb_prefix("custom-foo"), "");
2497    }
2498
2499    #[test]
2500    fn strip_verb_prefix_case_insensitive() {
2501        assert_eq!(Spec::strip_verb_prefix("Create-Foo"), "foo");
2502        assert_eq!(Spec::strip_verb_prefix("GET-bar"), "bar");
2503    }
2504
2505    // ========================================================================
2506    // RpcPattern — case insensitivity, edge cases
2507    // ========================================================================
2508
2509    #[test]
2510    fn rpc_pattern_case_insensitive_match() {
2511        let pat = RpcPattern::new(RpcCrudVerb::Create, "/Create-{resource}", "{0}");
2512        assert_eq!(pat.try_match("/create-secret"), Some("secret".to_string()));
2513        assert_eq!(pat.try_match("/CREATE-SECRET"), Some("secret".to_string()));
2514    }
2515
2516    #[test]
2517    fn rpc_pattern_empty_resource_returns_none() {
2518        let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
2519        assert_eq!(pat.try_match("/create-"), None);
2520    }
2521
2522    #[test]
2523    fn rpc_pattern_no_resource_placeholder_exact() {
2524        let pat = RpcPattern::new(RpcCrudVerb::Read, "/health-check", "health");
2525        assert_eq!(pat.try_match("/health-check"), Some("health".to_string()));
2526        assert_eq!(pat.try_match("/health-checks"), None);
2527        assert_eq!(pat.try_match("/other"), None);
2528    }
2529
2530    #[test]
2531    fn rpc_pattern_suffix_not_found() {
2532        let pat = RpcPattern::new(
2533            RpcCrudVerb::Create,
2534            "/create-{resource}-target",
2535            "target_{0}",
2536        );
2537        assert_eq!(pat.try_match("/create-aws-bucket"), None);
2538    }
2539
2540    #[test]
2541    fn rpc_pattern_hyphen_to_underscore_in_group() {
2542        let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
2543        assert_eq!(
2544            pat.try_match("/create-my-resource"),
2545            Some("my_resource".to_string())
2546        );
2547    }
2548
2549    #[test]
2550    fn rpc_pattern_prefix_no_match() {
2551        let pat = RpcPattern::new(RpcCrudVerb::Create, "/api/create-{resource}", "{0}");
2552        assert_eq!(pat.try_match("/create-foo"), None);
2553    }
2554
2555    // ========================================================================
2556    // RpcCrudGrouper — patterns() method, unmatched endpoints, empty inputs
2557    // ========================================================================
2558
2559    #[test]
2560    fn rpc_grouper_patterns_method() {
2561        let grouper = RpcCrudGrouper::new().patterns(vec![
2562            RpcPattern::new(RpcCrudVerb::Create, "/make-{resource}", "{0}"),
2563            RpcPattern::new(RpcCrudVerb::Delete, "/destroy-{resource}", "{0}"),
2564        ]);
2565        let endpoints = vec![
2566            Endpoint {
2567                path: "/make-widget".to_string(),
2568                method: "post".to_string(),
2569                operation_id: Some("makeWidget".to_string()),
2570                summary: None,
2571                tags: vec![],
2572                request_schema_ref: None,
2573                response_schema_ref: None,
2574            },
2575            Endpoint {
2576                path: "/destroy-widget".to_string(),
2577                method: "post".to_string(),
2578                operation_id: Some("destroyWidget".to_string()),
2579                summary: None,
2580                tags: vec![],
2581                request_schema_ref: None,
2582                response_schema_ref: None,
2583            },
2584        ];
2585        let groups = grouper.group(&endpoints);
2586        assert_eq!(groups.len(), 1);
2587        assert_eq!(groups[0].base_name, "widget");
2588        assert!(groups[0].create.is_some());
2589        assert!(groups[0].delete.is_some());
2590    }
2591
2592    #[test]
2593    fn rpc_grouper_unmatched_endpoints_ignored() {
2594        let grouper = RpcCrudGrouper::new().pattern(RpcPattern::new(
2595            RpcCrudVerb::Create,
2596            "/create-{resource}",
2597            "{0}",
2598        ));
2599        let endpoints = vec![
2600            Endpoint {
2601                path: "/create-thing".to_string(),
2602                method: "post".to_string(),
2603                operation_id: None,
2604                summary: None,
2605                tags: vec![],
2606                request_schema_ref: None,
2607                response_schema_ref: None,
2608            },
2609            Endpoint {
2610                path: "/random-path".to_string(),
2611                method: "get".to_string(),
2612                operation_id: None,
2613                summary: None,
2614                tags: vec![],
2615                request_schema_ref: None,
2616                response_schema_ref: None,
2617            },
2618        ];
2619        let groups = grouper.group(&endpoints);
2620        assert_eq!(groups.len(), 1);
2621        assert_eq!(groups[0].base_name, "thing");
2622    }
2623
2624    #[test]
2625    fn rpc_grouper_empty_endpoints() {
2626        let grouper = RpcCrudGrouper::default_patterns();
2627        let groups = grouper.group(&[]);
2628        assert!(groups.is_empty());
2629    }
2630
2631    #[test]
2632    fn rpc_grouper_first_pattern_wins() {
2633        let grouper = RpcCrudGrouper::new()
2634            .pattern(RpcPattern::new(
2635                RpcCrudVerb::Create,
2636                "/create-{resource}",
2637                "specific_{0}",
2638            ))
2639            .pattern(RpcPattern::new(
2640                RpcCrudVerb::Create,
2641                "/create-{resource}",
2642                "fallback_{0}",
2643            ));
2644        let endpoints = vec![Endpoint {
2645            path: "/create-item".to_string(),
2646            method: "post".to_string(),
2647            operation_id: None,
2648            summary: None,
2649            tags: vec![],
2650            request_schema_ref: None,
2651            response_schema_ref: None,
2652        }];
2653        let groups = grouper.group(&endpoints);
2654        assert_eq!(groups[0].base_name, "specific_item");
2655    }
2656
2657    // ========================================================================
2658    // Akeyless patterns — list-auth-methods, list-targets, list-items, list-*
2659    // ========================================================================
2660
2661    #[test]
2662    fn rpc_grouper_akeyless_list_auth_methods() {
2663        let spec_str = r#"
2664openapi: "3.0.0"
2665info:
2666  title: List Test
2667  version: "1.0"
2668paths:
2669  /list-auth-methods:
2670    post:
2671      operationId: listAuthMethods
2672      responses:
2673        "200":
2674          description: ok
2675  /list-targets:
2676    post:
2677      operationId: listTargets
2678      responses:
2679        "200":
2680          description: ok
2681  /list-items:
2682    post:
2683      operationId: listItems
2684      responses:
2685        "200":
2686          description: ok
2687  /list-dynamic-secrets:
2688    post:
2689      operationId: listDynamicSecrets
2690      responses:
2691        "200":
2692          description: ok
2693  /list-rotated-secrets:
2694    post:
2695      operationId: listRotatedSecrets
2696      responses:
2697        "200":
2698          description: ok
2699components:
2700  schemas: {}
2701"#;
2702        let spec = Spec::from_str(spec_str).expect("parse");
2703        let grouper = RpcCrudGrouper::akeyless_patterns();
2704        let groups = grouper.group_spec(&spec);
2705
2706        let auth = groups.iter().find(|g| g.base_name == "auth_method");
2707        assert!(
2708            auth.is_some(),
2709            "should have auth_method group for list-auth-methods"
2710        );
2711        assert!(auth.unwrap().list.is_some());
2712
2713        let target = groups.iter().find(|g| g.base_name == "target");
2714        assert!(
2715            target.is_some(),
2716            "should have target group for list-targets"
2717        );
2718        assert!(target.unwrap().list.is_some());
2719
2720        let item = groups.iter().find(|g| g.base_name == "item");
2721        assert!(item.is_some(), "should have item group for list-items");
2722        assert!(item.unwrap().list.is_some());
2723    }
2724
2725    // ========================================================================
2726    // Enum values with non-string entries
2727    // ========================================================================
2728
2729    #[test]
2730    fn enum_values_non_string_entries() {
2731        let yaml = r#"
2732openapi: "3.0.0"
2733info:
2734  title: MixedEnum
2735  version: "1.0"
2736paths: {}
2737components:
2738  schemas:
2739    MixedEnum:
2740      type: object
2741      properties:
2742        code:
2743          type: integer
2744          enum:
2745            - 100
2746            - 200
2747            - 300
2748"#;
2749        let spec = Spec::from_str(yaml).expect("parse");
2750        let fields = spec.fields("MixedEnum").expect("fields");
2751        let code = fields.iter().find(|f| f.name == "code").expect("code");
2752        let enums = code.enum_values.as_ref().expect("should have enum_values");
2753        assert_eq!(enums.len(), 3);
2754        assert!(enums.contains(&"100".to_string()));
2755        assert!(enums.contains(&"200".to_string()));
2756        assert!(enums.contains(&"300".to_string()));
2757    }
2758
2759    // ========================================================================
2760    // Endpoint with operation_id from path fallback in group_by_crud_pattern
2761    // ========================================================================
2762
2763    #[test]
2764    fn crud_grouping_uses_path_when_no_operation_id() {
2765        let yaml = r#"
2766openapi: "3.0.0"
2767info:
2768  title: No OpId
2769  version: "1.0"
2770paths:
2771  /create-thing:
2772    post:
2773      responses:
2774        "200":
2775          description: ok
2776components:
2777  schemas: {}
2778"#;
2779        let spec = Spec::from_str(yaml).expect("parse");
2780        let groups = spec.group_by_crud_pattern();
2781        assert!(
2782            !groups.is_empty(),
2783            "should still group by path when no operation_id"
2784        );
2785        let has_create = groups.iter().any(|g| g.create.is_some());
2786        assert!(has_create);
2787    }
2788
2789    // ========================================================================
2790    // RpcCrudVerb — PartialEq, Debug, Clone, Copy
2791    // ========================================================================
2792
2793    #[test]
2794    fn rpc_crud_verb_traits() {
2795        let verb = RpcCrudVerb::Create;
2796        let cloned = verb;
2797        assert_eq!(verb, cloned);
2798        assert_eq!(verb, RpcCrudVerb::Create);
2799        assert_ne!(verb, RpcCrudVerb::Delete);
2800        let dbg = format!("{verb:?}");
2801        assert!(dbg.contains("Create"));
2802    }
2803
2804    // ========================================================================
2805    // CrudGroup, Endpoint, Field, SchemaDiff, FieldChange — Debug, Clone
2806    // ========================================================================
2807
2808    #[test]
2809    fn structs_are_debug_and_clone() {
2810        let ep = Endpoint {
2811            path: "/test".to_string(),
2812            method: "get".to_string(),
2813            operation_id: Some("testOp".to_string()),
2814            summary: Some("A summary".to_string()),
2815            tags: vec!["tag1".to_string()],
2816            request_schema_ref: None,
2817            response_schema_ref: Some("Output".to_string()),
2818        };
2819        let cloned = ep.clone();
2820        assert_eq!(cloned.path, "/test");
2821        let dbg = format!("{ep:?}");
2822        assert!(dbg.contains("testOp"));
2823
2824        let field = Field {
2825            name: "x".to_string(),
2826            type_info: TypeInfo::String,
2827            required: true,
2828            description: Some("desc".to_string()),
2829            default: Some(serde_json::json!("default")),
2830            format: Some("date".to_string()),
2831            enum_values: None,
2832        };
2833        let field_cloned = field.clone();
2834        assert_eq!(field_cloned.name, "x");
2835        let _ = format!("{field:?}");
2836
2837        let diff = SchemaDiff {
2838            added: vec!["a".to_string()],
2839            removed: vec!["b".to_string()],
2840            changed: vec![FieldChange {
2841                name: "c".to_string(),
2842                old_type: TypeInfo::String,
2843                new_type: TypeInfo::Integer,
2844                required_changed: true,
2845            }],
2846        };
2847        let diff_cloned = diff.clone();
2848        assert_eq!(diff_cloned.added, vec!["a"]);
2849        let _ = format!("{diff:?}");
2850
2851        let group = CrudGroup {
2852            base_name: "test".to_string(),
2853            create: Some(ep.clone()),
2854            read: None,
2855            update: None,
2856            delete: None,
2857            list: None,
2858        };
2859        let group_cloned = group.clone();
2860        assert_eq!(group_cloned.base_name, "test");
2861        let _ = format!("{group:?}");
2862    }
2863
2864    // ========================================================================
2865    // CrudGroup helpers
2866    // ========================================================================
2867
2868    #[test]
2869    fn crud_group_endpoint_count_empty() {
2870        let group = CrudGroup::default();
2871        assert_eq!(group.endpoint_count(), 0);
2872        assert!(!group.is_complete());
2873    }
2874
2875    #[test]
2876    fn crud_group_endpoint_count_partial() {
2877        let ep = Endpoint {
2878            path: "/test".to_string(),
2879            method: "post".to_string(),
2880            operation_id: None,
2881            summary: None,
2882            tags: vec![],
2883            request_schema_ref: None,
2884            response_schema_ref: None,
2885        };
2886        let group = CrudGroup {
2887            base_name: "test".to_string(),
2888            create: Some(ep.clone()),
2889            read: Some(ep),
2890            ..CrudGroup::default()
2891        };
2892        assert_eq!(group.endpoint_count(), 2);
2893        assert!(!group.is_complete());
2894    }
2895
2896    #[test]
2897    fn crud_group_is_complete() {
2898        let ep = Endpoint {
2899            path: "/test".to_string(),
2900            method: "post".to_string(),
2901            operation_id: None,
2902            summary: None,
2903            tags: vec![],
2904            request_schema_ref: None,
2905            response_schema_ref: None,
2906        };
2907        let group = CrudGroup {
2908            base_name: "test".to_string(),
2909            create: Some(ep.clone()),
2910            read: Some(ep.clone()),
2911            update: Some(ep.clone()),
2912            delete: Some(ep.clone()),
2913            list: Some(ep),
2914        };
2915        assert!(group.is_complete());
2916        assert_eq!(group.endpoint_count(), 5);
2917    }
2918
2919    // ========================================================================
2920    // Spec::schema_names with empty schemas map (components present but empty)
2921    // ========================================================================
2922
2923    #[test]
2924    fn schema_names_with_empty_schemas() {
2925        let yaml = r#"
2926openapi: "3.0.0"
2927info:
2928  title: Empty Schemas
2929  version: "1.0"
2930paths: {}
2931components:
2932  schemas: {}
2933"#;
2934        let spec = Spec::from_str(yaml).expect("parse");
2935        assert!(spec.schema_names().is_empty());
2936    }
2937
2938    // ========================================================================
2939    // Request schema ref extraction edge: non-JSON content type
2940    // ========================================================================
2941
2942    #[test]
2943    fn request_ref_non_json_content_type() {
2944        let yaml = r#"
2945openapi: "3.0.0"
2946info:
2947  title: NonJson
2948  version: "1.0"
2949paths:
2950  /upload:
2951    post:
2952      operationId: upload
2953      requestBody:
2954        content:
2955          multipart/form-data:
2956            schema:
2957              type: object
2958      responses:
2959        "200":
2960          description: ok
2961components:
2962  schemas: {}
2963"#;
2964        let spec = Spec::from_str(yaml).expect("parse");
2965        let ep = spec.endpoint_by_path("/upload").expect("found");
2966        assert!(
2967            ep.request_schema_ref.is_none(),
2968            "non-JSON content type should not extract schema ref"
2969        );
2970    }
2971
2972    // ========================================================================
2973    // Response without content
2974    // ========================================================================
2975
2976    #[test]
2977    fn response_without_content() {
2978        let yaml = r#"
2979openapi: "3.0.0"
2980info:
2981  title: NoContent
2982  version: "1.0"
2983paths:
2984  /delete:
2985    delete:
2986      operationId: deleteItem
2987      responses:
2988        "204":
2989          description: No Content
2990components:
2991  schemas: {}
2992"#;
2993        let spec = Spec::from_str(yaml).expect("parse");
2994        let ep = spec.endpoint_by_path("/delete").expect("found");
2995        assert!(ep.response_schema_ref.is_none());
2996    }
2997
2998    // ========================================================================
2999    // Spec with allOf referencing nonexistent schema (resolve failure is silent)
3000    // ========================================================================
3001
3002    #[test]
3003    fn allof_ref_to_missing_schema_silently_skips() {
3004        let yaml = r#"
3005openapi: "3.0.0"
3006info:
3007  title: MissingRef
3008  version: "1.0"
3009paths: {}
3010components:
3011  schemas:
3012    Derived:
3013      type: object
3014      allOf:
3015        - $ref: '#/components/schemas/DoesNotExist'
3016      properties:
3017        own:
3018          type: string
3019"#;
3020        let spec = Spec::from_str(yaml).expect("parse");
3021        let fields = spec.fields("Derived").expect("fields");
3022        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
3023        assert!(names.contains(&"own"));
3024        assert!(!names.contains(&"DoesNotExist"));
3025    }
3026
3027    // ========================================================================
3028    // FromStr trait implementation
3029    // ========================================================================
3030
3031    #[test]
3032    fn from_str_trait_yaml() {
3033        let spec: Spec = MINIMAL_SPEC.parse().expect("FromStr should parse YAML");
3034        assert_eq!(spec.endpoints().len(), 4);
3035    }
3036
3037    #[test]
3038    fn from_str_trait_json() {
3039        let json = r#"{"openapi":"3.0.0","info":{"title":"J","version":"1"},"paths":{}}"#;
3040        let spec: Spec = json.parse().expect("FromStr should parse JSON");
3041        assert!(spec.endpoints().is_empty());
3042    }
3043
3044    #[test]
3045    fn from_str_trait_error() {
3046        let result: Result<Spec, _> = "{{totally broken".parse();
3047        assert!(result.is_err());
3048    }
3049
3050    #[test]
3051    fn parse_convenience_method() {
3052        let spec = Spec::parse(MINIMAL_SPEC).expect("parse");
3053        assert_eq!(spec.endpoints().len(), 4);
3054    }
3055
3056    // ========================================================================
3057    // Spec — Debug + Clone derives
3058    // ========================================================================
3059
3060    #[test]
3061    fn spec_debug_and_clone() {
3062        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3063        let cloned = spec.clone();
3064        assert_eq!(cloned.endpoints().len(), spec.endpoints().len());
3065        let dbg = format!("{spec:?}");
3066        assert!(dbg.contains("Spec"));
3067    }
3068
3069    // ========================================================================
3070    // RpcCrudGrouper — Debug + Clone derives
3071    // ========================================================================
3072
3073    #[test]
3074    fn rpc_crud_grouper_debug_and_clone() {
3075        let grouper = RpcCrudGrouper::default_patterns();
3076        let cloned = grouper.clone();
3077        let dbg = format!("{grouper:?}");
3078        assert!(dbg.contains("RpcCrudGrouper"));
3079        // Cloned grouper should produce identical results
3080        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3081        let g1 = grouper.group_spec(&spec);
3082        let g2 = cloned.group_spec(&spec);
3083        assert_eq!(g1.len(), g2.len());
3084    }
3085
3086    // ========================================================================
3087    // RpcPattern — Debug + Clone derives
3088    // ========================================================================
3089
3090    #[test]
3091    fn rpc_pattern_debug_and_clone() {
3092        let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
3093        let cloned = pat.clone();
3094        assert_eq!(cloned.template, "/create-{resource}");
3095        assert_eq!(cloned.group_name, "{0}");
3096        assert_eq!(cloned.verb, RpcCrudVerb::Create);
3097        let dbg = format!("{pat:?}");
3098        assert!(dbg.contains("RpcPattern"));
3099    }
3100
3101    // ========================================================================
3102    // RpcCrudVerb — all variant equality comparisons
3103    // ========================================================================
3104
3105    #[test]
3106    fn rpc_crud_verb_all_variants_eq() {
3107        let variants = [
3108            RpcCrudVerb::Create,
3109            RpcCrudVerb::Read,
3110            RpcCrudVerb::Update,
3111            RpcCrudVerb::Delete,
3112            RpcCrudVerb::List,
3113        ];
3114        for (i, v1) in variants.iter().enumerate() {
3115            for (j, v2) in variants.iter().enumerate() {
3116                if i == j {
3117                    assert_eq!(v1, v2);
3118                } else {
3119                    assert_ne!(v1, v2);
3120                }
3121            }
3122        }
3123    }
3124
3125    // ========================================================================
3126    // Spec::endpoints — field extraction completeness
3127    // ========================================================================
3128
3129    #[test]
3130    fn endpoints_request_and_response_refs_complete() {
3131        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3132        let ep_create = spec.endpoint_by_path("/create-secret").unwrap();
3133        assert_eq!(
3134            ep_create.request_schema_ref.as_deref(),
3135            Some("CreateSecret")
3136        );
3137        assert_eq!(
3138            ep_create.response_schema_ref.as_deref(),
3139            Some("CreateSecretOutput")
3140        );
3141
3142        let ep_get = spec.endpoint_by_path("/get-secret-value").unwrap();
3143        assert_eq!(ep_get.request_schema_ref.as_deref(), Some("GetSecretValue"));
3144        assert_eq!(
3145            ep_get.response_schema_ref.as_deref(),
3146            Some("GetSecretValueOutput")
3147        );
3148
3149        let ep_update = spec.endpoint_by_path("/update-secret-val").unwrap();
3150        assert_eq!(
3151            ep_update.request_schema_ref.as_deref(),
3152            Some("UpdateSecretVal")
3153        );
3154        assert!(
3155            ep_update.response_schema_ref.is_none(),
3156            "update has no response ref"
3157        );
3158
3159        let ep_delete = spec.endpoint_by_path("/delete-item").unwrap();
3160        assert_eq!(ep_delete.request_schema_ref.as_deref(), Some("DeleteItem"));
3161        assert!(ep_delete.response_schema_ref.is_none());
3162    }
3163
3164    // ========================================================================
3165    // diff_schemas — symmetric diff properties
3166    // ========================================================================
3167
3168    #[test]
3169    fn diff_schemas_symmetric_added_removed() {
3170        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3171        let forward = spec
3172            .diff_schemas("CreateSecret", "UpdateSecretVal")
3173            .expect("diff");
3174        let reverse = spec
3175            .diff_schemas("UpdateSecretVal", "CreateSecret")
3176            .expect("diff");
3177        assert_eq!(forward.added, reverse.removed);
3178        assert_eq!(forward.removed, reverse.added);
3179    }
3180
3181    // ========================================================================
3182    // resolve_type — various schema types
3183    // ========================================================================
3184
3185    #[test]
3186    fn resolve_type_integer() {
3187        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3188        let schema = SchemaObject {
3189            schema_type: Some("integer".to_string()),
3190            ..SchemaObject::default()
3191        };
3192        assert_eq!(spec.resolve_type(&schema), TypeInfo::Integer);
3193    }
3194
3195    #[test]
3196    fn resolve_type_boolean() {
3197        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3198        let schema = SchemaObject {
3199            schema_type: Some("boolean".to_string()),
3200            ..SchemaObject::default()
3201        };
3202        assert_eq!(spec.resolve_type(&schema), TypeInfo::Boolean);
3203    }
3204
3205    #[test]
3206    fn resolve_type_array_of_integers() {
3207        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3208        let item_schema = SchemaObject {
3209            schema_type: Some("integer".to_string()),
3210            ..SchemaObject::default()
3211        };
3212        let schema = SchemaObject {
3213            schema_type: Some("array".to_string()),
3214            items: Some(Box::new(item_schema)),
3215            ..SchemaObject::default()
3216        };
3217        assert_eq!(
3218            spec.resolve_type(&schema),
3219            TypeInfo::Array(Box::new(TypeInfo::Integer))
3220        );
3221    }
3222
3223    // ========================================================================
3224    // resolve_schema_or_ref_type — empty ref_path edge case
3225    // ========================================================================
3226
3227    #[test]
3228    fn resolve_schema_or_ref_type_empty_ref() {
3229        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3230        let sor = SchemaOrRef::Ref {
3231            ref_path: String::new(),
3232        };
3233        let ti = spec.resolve_schema_or_ref_type(&sor);
3234        assert_eq!(ti, TypeInfo::Object(String::new()));
3235    }
3236
3237    // ========================================================================
3238    // Spec::schema — look up existing schemas
3239    // ========================================================================
3240
3241    #[test]
3242    fn schema_lookup_returns_correct_schema() {
3243        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3244        let schema = spec.schema("CreateSecret").unwrap();
3245        assert!(schema.properties.contains_key("name"));
3246        assert!(schema.properties.contains_key("value"));
3247        assert!(schema.required.contains(&"name".to_string()));
3248    }
3249
3250    // ========================================================================
3251    // group_spec — via RpcCrudGrouper on Spec directly
3252    // ========================================================================
3253
3254    #[test]
3255    fn rpc_grouper_group_spec_same_as_group() {
3256        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
3257        let grouper = RpcCrudGrouper::akeyless_patterns();
3258        let via_spec = grouper.group_spec(&spec);
3259        let via_endpoints = grouper.group(&spec.endpoints());
3260        assert_eq!(via_spec.len(), via_endpoints.len());
3261        for (s, e) in via_spec.iter().zip(via_endpoints.iter()) {
3262            assert_eq!(s.base_name, e.base_name);
3263        }
3264    }
3265
3266    // ========================================================================
3267    // CrudGroup — verb count helper test
3268    // ========================================================================
3269
3270    #[test]
3271    fn crud_group_verb_coverage() {
3272        let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
3273        let grouper = RpcCrudGrouper::akeyless_patterns();
3274        let groups = grouper.group_spec(&spec);
3275
3276        let total_endpoints: usize = groups
3277            .iter()
3278            .map(|g| {
3279                [
3280                    g.create.is_some(),
3281                    g.read.is_some(),
3282                    g.update.is_some(),
3283                    g.delete.is_some(),
3284                    g.list.is_some(),
3285                ]
3286                .iter()
3287                .filter(|&&v| v)
3288                .count()
3289            })
3290            .sum();
3291        assert!(
3292            total_endpoints > 0,
3293            "should have matched at least some endpoints"
3294        );
3295    }
3296
3297    // ========================================================================
3298    // Multiple patterns matching different verbs for same resource
3299    // ========================================================================
3300
3301    #[test]
3302    fn rpc_grouper_multiple_verbs_same_resource() {
3303        let grouper = RpcCrudGrouper::default_patterns();
3304        let endpoints = vec![
3305            Endpoint {
3306                path: "/create-widget".to_string(),
3307                method: "post".to_string(),
3308                operation_id: None,
3309                summary: None,
3310                tags: vec![],
3311                request_schema_ref: None,
3312                response_schema_ref: None,
3313            },
3314            Endpoint {
3315                path: "/get-widget".to_string(),
3316                method: "get".to_string(),
3317                operation_id: None,
3318                summary: None,
3319                tags: vec![],
3320                request_schema_ref: None,
3321                response_schema_ref: None,
3322            },
3323            Endpoint {
3324                path: "/update-widget".to_string(),
3325                method: "put".to_string(),
3326                operation_id: None,
3327                summary: None,
3328                tags: vec![],
3329                request_schema_ref: None,
3330                response_schema_ref: None,
3331            },
3332            Endpoint {
3333                path: "/delete-widget".to_string(),
3334                method: "delete".to_string(),
3335                operation_id: None,
3336                summary: None,
3337                tags: vec![],
3338                request_schema_ref: None,
3339                response_schema_ref: None,
3340            },
3341            Endpoint {
3342                path: "/list-widget".to_string(),
3343                method: "get".to_string(),
3344                operation_id: None,
3345                summary: None,
3346                tags: vec![],
3347                request_schema_ref: None,
3348                response_schema_ref: None,
3349            },
3350        ];
3351        let groups = grouper.group(&endpoints);
3352        assert_eq!(groups.len(), 1);
3353        let w = &groups[0];
3354        assert_eq!(w.base_name, "widget");
3355        assert!(w.create.is_some());
3356        assert!(w.read.is_some());
3357        assert!(w.update.is_some());
3358        assert!(w.delete.is_some());
3359        assert!(w.list.is_some());
3360    }
3361
3362    // ========================================================================
3363    // Error source chain tests (std::error::Error)
3364    // ========================================================================
3365
3366    #[test]
3367    fn forge_error_source_io() {
3368        use std::error::Error;
3369        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
3370        let err = ForgeError::Io(inner);
3371        assert!(err.source().is_some());
3372    }
3373
3374    #[test]
3375    fn forge_error_source_yaml() {
3376        use std::error::Error;
3377        let yaml_err =
3378            serde_yaml_ng::from_str::<serde_json::Value>("{{bad").expect_err("should fail");
3379        let err = ForgeError::Yaml(yaml_err);
3380        assert!(err.source().is_some());
3381    }
3382
3383    #[test]
3384    fn forge_error_source_json() {
3385        use std::error::Error;
3386        let json_err =
3387            serde_json::from_str::<serde_json::Value>("not json").expect_err("should fail");
3388        let err = ForgeError::Json(json_err);
3389        assert!(err.source().is_some());
3390    }
3391
3392    #[test]
3393    fn forge_error_source_schema_not_found() {
3394        use std::error::Error;
3395        let err = ForgeError::SchemaNotFound("X".into());
3396        assert!(err.source().is_none());
3397    }
3398
3399    #[test]
3400    fn forge_error_source_unresolved_ref() {
3401        use std::error::Error;
3402        let err = ForgeError::UnresolvedRef("X".into());
3403        assert!(err.source().is_none());
3404    }
3405
3406    #[test]
3407    fn forge_error_source_unsupported_version() {
3408        use std::error::Error;
3409        let err = ForgeError::UnsupportedVersion("2.0".into());
3410        assert!(err.source().is_none());
3411    }
3412
3413    // ========================================================================
3414    // detect_crud_verb — all verb types
3415    // ========================================================================
3416
3417    #[test]
3418    fn detect_crud_verb_get() {
3419        let (verb, base) = Spec::detect_crud_verb("get-user");
3420        assert!(matches!(verb, CrudVerb::Read));
3421        assert_eq!(base, "user");
3422    }
3423
3424    #[test]
3425    fn detect_crud_verb_describe() {
3426        let (verb, base) = Spec::detect_crud_verb("describe-item");
3427        assert!(matches!(verb, CrudVerb::Read));
3428        assert_eq!(base, "item");
3429    }
3430
3431    #[test]
3432    fn detect_crud_verb_update() {
3433        let (verb, base) = Spec::detect_crud_verb("update-config");
3434        assert!(matches!(verb, CrudVerb::Update));
3435        assert_eq!(base, "config");
3436    }
3437
3438    #[test]
3439    fn detect_crud_verb_delete() {
3440        let (verb, base) = Spec::detect_crud_verb("delete-entry");
3441        assert!(matches!(verb, CrudVerb::Delete));
3442        assert_eq!(base, "entry");
3443    }
3444
3445    #[test]
3446    fn detect_crud_verb_remove() {
3447        let (verb, base) = Spec::detect_crud_verb("remove-member");
3448        assert!(matches!(verb, CrudVerb::Delete));
3449        assert_eq!(base, "member");
3450    }
3451
3452    #[test]
3453    fn detect_crud_verb_add() {
3454        let (verb, base) = Spec::detect_crud_verb("add-member");
3455        assert!(matches!(verb, CrudVerb::Create));
3456        assert_eq!(base, "member");
3457    }
3458
3459    #[test]
3460    fn detect_crud_verb_list() {
3461        let (verb, base) = Spec::detect_crud_verb("list-users");
3462        assert!(matches!(verb, CrudVerb::List));
3463        assert_eq!(base, "users");
3464    }
3465
3466    #[test]
3467    fn detect_crud_verb_camel_case() {
3468        let (verb, base) = Spec::detect_crud_verb("createUser");
3469        assert!(matches!(verb, CrudVerb::Create));
3470        assert!(!base.is_empty());
3471    }
3472
3473    // ========================================================================
3474    // Spec with nested object types
3475    // ========================================================================
3476
3477    #[test]
3478    fn fields_nested_object_type() {
3479        let yaml = r#"
3480openapi: "3.0.0"
3481info:
3482  title: Nested
3483  version: "1.0"
3484paths: {}
3485components:
3486  schemas:
3487    Outer:
3488      type: object
3489      properties:
3490        inner:
3491          type: object
3492          properties:
3493            value:
3494              type: string
3495"#;
3496        let spec = Spec::from_str(yaml).expect("parse");
3497        let fields = spec.fields("Outer").expect("fields");
3498        assert_eq!(fields.len(), 1);
3499        assert_eq!(fields[0].name, "inner");
3500    }
3501
3502    // ========================================================================
3503    // Spec with number type
3504    // ========================================================================
3505
3506    #[test]
3507    fn resolve_type_number() {
3508        let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3509        let schema = SchemaObject {
3510            schema_type: Some("number".to_string()),
3511            ..SchemaObject::default()
3512        };
3513        let ti = spec.resolve_type(&schema);
3514        assert_eq!(ti, TypeInfo::Number);
3515    }
3516
3517    // ========================================================================
3518    // Endpoint with all five HTTP methods properly handled
3519    // ========================================================================
3520
3521    #[test]
3522    fn endpoint_method_ordering_is_deterministic() {
3523        let yaml = r#"
3524openapi: "3.0.0"
3525info:
3526  title: Order
3527  version: "1.0"
3528paths:
3529  /resource:
3530    get:
3531      operationId: getR
3532      responses:
3533        "200":
3534          description: ok
3535    post:
3536      operationId: postR
3537      responses:
3538        "200":
3539          description: ok
3540    put:
3541      operationId: putR
3542      responses:
3543        "200":
3544          description: ok
3545    delete:
3546      operationId: deleteR
3547      responses:
3548        "200":
3549          description: ok
3550    patch:
3551      operationId: patchR
3552      responses:
3553        "200":
3554          description: ok
3555components:
3556  schemas: {}
3557"#;
3558        let spec = Spec::from_str(yaml).expect("parse");
3559        let eps = spec.endpoints();
3560        let methods: Vec<&str> = eps.iter().map(|e| e.method.as_str()).collect();
3561        assert_eq!(methods, vec!["get", "post", "put", "delete", "patch"]);
3562    }
3563
3564    // ========================================================================
3565    // Spec with multiple paths
3566    // ========================================================================
3567
3568    #[test]
3569    fn multiple_paths_all_enumerated() {
3570        let yaml = r#"
3571openapi: "3.0.0"
3572info:
3573  title: Multi
3574  version: "1.0"
3575paths:
3576  /a:
3577    get:
3578      operationId: getA
3579      responses:
3580        "200":
3581          description: ok
3582  /b:
3583    post:
3584      operationId: postB
3585      responses:
3586        "200":
3587          description: ok
3588  /c:
3589    put:
3590      operationId: putC
3591      responses:
3592        "200":
3593          description: ok
3594components:
3595  schemas: {}
3596"#;
3597        let spec = Spec::from_str(yaml).expect("parse");
3598        let eps = spec.endpoints();
3599        assert_eq!(eps.len(), 3);
3600        let paths: Vec<&str> = eps.iter().map(|e| e.path.as_str()).collect();
3601        assert!(paths.contains(&"/a"));
3602        assert!(paths.contains(&"/b"));
3603        assert!(paths.contains(&"/c"));
3604    }
3605
3606    // ========================================================================
3607    // SchemaOrRef::from_schema round-trip properties
3608    // ========================================================================
3609
3610    #[test]
3611    #[allow(deprecated)]
3612    fn schema_or_ref_from_schema_ref_name_round_trip() {
3613        let schema = sekkei::Schema {
3614            ref_path: Some("#/components/schemas/Test".to_string()),
3615            ..sekkei::Schema::default()
3616        };
3617        let sor = SchemaOrRef::from_schema(&schema);
3618        assert_eq!(sor.ref_name(), Some("Test"));
3619    }
3620
3621    // ========================================================================
3622    // Akeyless pattern B: create-{variant}-target
3623    // ========================================================================
3624
3625    #[test]
3626    fn rpc_grouper_target_pattern_b() {
3627        let spec_str = r#"
3628openapi: "3.0.0"
3629info:
3630  title: Target B
3631  version: "1.0"
3632paths:
3633  /create-gke-target:
3634    post:
3635      operationId: createGkeTarget
3636      responses:
3637        "200":
3638          description: ok
3639  /update-gke-target:
3640    post:
3641      operationId: updateGkeTarget
3642      responses:
3643        "200":
3644          description: ok
3645components:
3646  schemas: {}
3647"#;
3648        let spec = Spec::from_str(spec_str).expect("parse");
3649        let grouper = RpcCrudGrouper::akeyless_patterns();
3650        let groups = grouper.group_spec(&spec);
3651        let tgt = groups
3652            .iter()
3653            .find(|g| g.base_name == "target_gke")
3654            .expect("target_gke group");
3655        assert!(tgt.create.is_some());
3656        assert!(tgt.update.is_some());
3657    }
3658
3659    // ========================================================================
3660    // Akeyless dynamic-secret pattern B: {verb}-dynamic-secret-{variant}
3661    // ========================================================================
3662
3663    #[test]
3664    fn rpc_grouper_dynamic_secret_pattern_b() {
3665        let spec_str = r#"
3666openapi: "3.0.0"
3667info:
3668  title: DynSec B
3669  version: "1.0"
3670paths:
3671  /create-dynamic-secret-mysql:
3672    post:
3673      operationId: createDynamicSecretMysql
3674      responses:
3675        "200":
3676          description: ok
3677  /update-dynamic-secret-mysql:
3678    post:
3679      operationId: updateDynamicSecretMysql
3680      responses:
3681        "200":
3682          description: ok
3683components:
3684  schemas: {}
3685"#;
3686        let spec = Spec::from_str(spec_str).expect("parse");
3687        let grouper = RpcCrudGrouper::akeyless_patterns();
3688        let groups = grouper.group_spec(&spec);
3689        let ds = groups
3690            .iter()
3691            .find(|g| g.base_name == "dynamic_secret_mysql")
3692            .expect("dynamic_secret_mysql group");
3693        assert!(ds.create.is_some());
3694        assert!(ds.update.is_some());
3695    }
3696
3697    // ========================================================================
3698    // Akeyless rotated-secret pattern B: {verb}-rotated-secret-{variant}
3699    // ========================================================================
3700
3701    #[test]
3702    fn rpc_grouper_rotated_secret_pattern_b() {
3703        let spec_str = r#"
3704openapi: "3.0.0"
3705info:
3706  title: RotSec B
3707  version: "1.0"
3708paths:
3709  /create-rotated-secret-postgres:
3710    post:
3711      operationId: createRotatedSecretPostgres
3712      responses:
3713        "200":
3714          description: ok
3715  /update-rotated-secret-postgres:
3716    post:
3717      operationId: updateRotatedSecretPostgres
3718      responses:
3719        "200":
3720          description: ok
3721components:
3722  schemas: {}
3723"#;
3724        let spec = Spec::from_str(spec_str).expect("parse");
3725        let grouper = RpcCrudGrouper::akeyless_patterns();
3726        let groups = grouper.group_spec(&spec);
3727        let rs = groups
3728            .iter()
3729            .find(|g| g.base_name == "rotated_secret_postgres")
3730            .expect("rotated_secret_postgres group");
3731        assert!(rs.create.is_some());
3732        assert!(rs.update.is_some());
3733    }
3734
3735    // ========================================================================
3736    // Gateway delete-producer pattern
3737    // ========================================================================
3738
3739    #[test]
3740    fn rpc_grouper_gateway_delete_producer() {
3741        let spec_str = r#"
3742openapi: "3.0.0"
3743info:
3744  title: GW Delete
3745  version: "1.0"
3746paths:
3747  /gateway-delete-producer-aws:
3748    post:
3749      operationId: gatewayDeleteProducerAws
3750      responses:
3751        "200":
3752          description: ok
3753components:
3754  schemas: {}
3755"#;
3756        let spec = Spec::from_str(spec_str).expect("parse");
3757        let grouper = RpcCrudGrouper::akeyless_patterns();
3758        let groups = grouper.group_spec(&spec);
3759        let gp = groups
3760            .iter()
3761            .find(|g| g.base_name == "gateway_producer_aws")
3762            .expect("gateway_producer_aws group");
3763        assert!(gp.delete.is_some());
3764    }
3765
3766    // ========================================================================
3767    // Display / FromStr round-trip for RpcCrudVerb
3768    // ========================================================================
3769
3770    #[test]
3771    fn rpc_crud_verb_display_round_trip() {
3772        let verbs = [
3773            RpcCrudVerb::Create,
3774            RpcCrudVerb::Read,
3775            RpcCrudVerb::Update,
3776            RpcCrudVerb::Delete,
3777            RpcCrudVerb::List,
3778        ];
3779        for verb in verbs {
3780            let s = verb.to_string();
3781            let parsed: RpcCrudVerb = s.parse().expect("round-trip parse");
3782            assert_eq!(parsed, verb);
3783        }
3784    }
3785
3786    #[test]
3787    fn rpc_crud_verb_from_str_aliases() {
3788        assert_eq!("get".parse::<RpcCrudVerb>().unwrap(), RpcCrudVerb::Read);
3789        assert_eq!(
3790            "describe".parse::<RpcCrudVerb>().unwrap(),
3791            RpcCrudVerb::Read
3792        );
3793        assert_eq!(
3794            "remove".parse::<RpcCrudVerb>().unwrap(),
3795            RpcCrudVerb::Delete
3796        );
3797    }
3798
3799    #[test]
3800    fn rpc_crud_verb_from_str_case_insensitive() {
3801        assert_eq!(
3802            "CREATE".parse::<RpcCrudVerb>().unwrap(),
3803            RpcCrudVerb::Create
3804        );
3805        assert_eq!(
3806            "Delete".parse::<RpcCrudVerb>().unwrap(),
3807            RpcCrudVerb::Delete
3808        );
3809    }
3810
3811    #[test]
3812    fn rpc_crud_verb_from_str_invalid() {
3813        let err = "foobar".parse::<RpcCrudVerb>().unwrap_err();
3814        let msg = err.to_string();
3815        assert!(msg.contains("unknown CRUD verb"));
3816    }
3817}