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