Skip to main content

tonic_rest_openapi/
discover.rs

1//! Proto descriptor parsing for extracting RPC metadata.
2//!
3//! Parses a `FileDescriptorSet` from raw bytes and extracts:
4//! - **Streaming ops**: `(HTTP method, path)` pairs for server-streaming RPCs
5//! - **Operation ID mapping**: `ServiceName_MethodName` for every annotated RPC
6//! - **Validation constraints**: `validate.rules` → JSON Schema constraints
7//! - **Enum rewrites**: prefix-stripped enum value mappings
8//! - **Redirect paths**: endpoints returning 302 redirects
9//! - **UUID schema**: auto-detected UUID wrapper type
10//! - **Path param constraints**: per-endpoint path parameter metadata
11//!
12//! This keeps proto files as the **single source of truth** — the `OpenAPI`
13//! post-processor auto-detects streaming endpoints and resolves operation IDs
14//! instead of relying on hardcoded lists.
15
16use std::collections::HashMap;
17
18use prost::Message;
19
20use crate::descriptor::{
21    self, DescriptorProto, FieldDescriptorProto, FileDescriptorSet, field_type,
22};
23use crate::error;
24
25/// A streaming operation: `(HTTP method, path)`.
26///
27/// Extracted from proto RPCs that are `server_streaming = true` and have
28/// a `google.api.http` annotation.
29#[derive(Debug, Clone)]
30pub struct StreamingOp {
31    /// HTTP method (e.g., `"get"`).
32    pub method: String,
33    /// URL path (e.g., `"/v1/users"`).
34    pub path: String,
35}
36
37/// All RPC metadata extracted from proto descriptors.
38///
39/// Populated once via [`discover()`], then consumed by
40/// [`PatchConfig`](crate::PatchConfig). Access extracted data through the
41/// public accessor methods below.
42#[derive(Debug, Default)]
43#[non_exhaustive]
44pub struct ProtoMetadata {
45    /// Server-streaming RPCs with HTTP annotations.
46    pub(crate) streaming_ops: Vec<StreamingOp>,
47
48    /// All RPC operation IDs, keyed by short method name.
49    pub(crate) operation_ids: Vec<OperationEntry>,
50
51    /// Validation constraints extracted from `validate.rules` field options.
52    pub(crate) field_constraints: Vec<SchemaConstraints>,
53
54    /// Enum value rewrites for fields whose runtime serde strips prefixes.
55    pub(crate) enum_rewrites: Vec<EnumRewrite>,
56
57    /// HTTP paths for redirect endpoints (auto-detected from proto).
58    pub(crate) redirect_paths: Vec<String>,
59
60    /// gnostic schema name for UUID wrapper type (e.g., `core.v1.UUID`).
61    pub(crate) uuid_schema: Option<String>,
62
63    /// Field constraints for path parameters, keyed by HTTP path.
64    pub(crate) path_param_constraints: Vec<PathParamInfo>,
65
66    /// Raw → stripped enum value mapping for all prefix-stripped enums.
67    pub(crate) enum_value_map: HashMap<String, String>,
68}
69
70impl ProtoMetadata {
71    /// Server-streaming RPCs with HTTP annotations.
72    #[must_use]
73    pub fn streaming_ops(&self) -> &[StreamingOp] {
74        &self.streaming_ops
75    }
76
77    /// All RPC operation IDs extracted from the descriptor set.
78    #[must_use]
79    pub fn operation_ids(&self) -> &[OperationEntry] {
80        &self.operation_ids
81    }
82
83    /// Validation constraints from `validate.rules` field options.
84    #[must_use]
85    pub fn field_constraints(&self) -> &[SchemaConstraints] {
86        &self.field_constraints
87    }
88
89    /// Enum value rewrites for prefix-stripped enums.
90    #[must_use]
91    pub fn enum_rewrites(&self) -> &[EnumRewrite] {
92        &self.enum_rewrites
93    }
94
95    /// HTTP paths for redirect endpoints.
96    #[must_use]
97    pub fn redirect_paths(&self) -> &[String] {
98        &self.redirect_paths
99    }
100
101    /// gnostic schema name for the auto-detected UUID wrapper type.
102    #[must_use]
103    pub fn uuid_schema(&self) -> Option<&str> {
104        self.uuid_schema.as_deref()
105    }
106
107    /// Path parameter constraints, keyed by HTTP path template.
108    #[must_use]
109    pub fn path_param_constraints(&self) -> &[PathParamInfo] {
110        &self.path_param_constraints
111    }
112
113    /// Raw → stripped enum value mapping for all prefix-stripped enums.
114    #[must_use]
115    pub fn enum_value_map(&self) -> &HashMap<String, String> {
116        &self.enum_value_map
117    }
118}
119
120/// Maps a short proto method name to its gnostic operation ID.
121#[derive(Debug, Clone)]
122pub struct OperationEntry {
123    /// Short method name from proto (e.g., `Authenticate`).
124    pub method_name: String,
125    /// gnostic operation ID: `ServiceName_MethodName`.
126    pub operation_id: String,
127}
128
129/// Validation constraints for all fields in one schema.
130#[derive(Debug, Clone)]
131pub struct SchemaConstraints {
132    /// Schema name in gnostic format (e.g., `auth.v1.ClientInfo`).
133    pub schema: String,
134    /// Per-field constraints.
135    pub fields: Vec<FieldConstraint>,
136}
137
138/// Enum value rewrite for a schema field whose runtime serde strips prefixes.
139#[derive(Debug, Clone)]
140pub struct EnumRewrite {
141    /// Schema name in gnostic format (e.g., `operations.v1.HealthResponse`).
142    pub schema: String,
143    /// Field name in camelCase (e.g., `status`).
144    pub field: String,
145    /// Rewritten enum values matching runtime wire format (e.g., `["healthy", "unhealthy"]`).
146    pub values: Vec<String>,
147}
148
149/// Path parameter constraint info for a specific HTTP endpoint.
150#[derive(Debug, Clone)]
151pub struct PathParamInfo {
152    /// HTTP path template (e.g., `/v1/auth/sessions/{device_id}`).
153    pub path: String,
154    /// Path parameter constraints, one per template variable.
155    pub params: Vec<PathParamConstraint>,
156}
157
158/// Constraint for a single path parameter.
159#[derive(Debug, Clone)]
160pub struct PathParamConstraint {
161    /// Parameter name as it appears in the URL template (`snake_case`).
162    pub name: String,
163    /// Human-readable description from proto field comment (if available).
164    pub description: Option<String>,
165    /// Whether this parameter is a UUID type (references `core.v1.UUID`).
166    pub is_uuid: bool,
167    /// `minLength` (string) or `minimum` (integer).
168    pub min: Option<u64>,
169    /// `maxLength` (string) or `maximum` (integer).
170    pub max: Option<u64>,
171}
172
173/// A single field's validation constraints, mapped to JSON Schema.
174#[derive(Debug, Clone)]
175pub struct FieldConstraint {
176    /// Field name in camelCase (gnostic output format).
177    pub field: String,
178    /// `minLength` for strings, `minimum` for integers.
179    pub min: Option<u64>,
180    /// `maxLength` for strings, `maximum` for integers.
181    pub max: Option<u64>,
182    /// Regex pattern (from `validate.rules.string.pattern`).
183    pub pattern: Option<String>,
184    /// Enum of allowed string values (from `validate.rules.string.in`).
185    pub enum_values: Vec<String>,
186    /// Whether this is a `message.required` field → goes into schema `required` array.
187    pub required: bool,
188    /// Whether this is a UUID field (from `validate.rules.string.uuid = true`).
189    pub is_uuid: bool,
190    /// Proto field type: true if numeric (int32/uint32/uint64), false if string.
191    pub is_numeric: bool,
192    /// `minimum` for signed integers (int32). Mutually exclusive with `min`.
193    /// When present, the JSON Schema should use this instead of `min`.
194    pub signed_min: Option<i64>,
195    /// `maximum` for signed integers (int32). Mutually exclusive with `max`.
196    /// When present, the JSON Schema should use this instead of `max`.
197    pub signed_max: Option<i64>,
198}
199
200/// Parse proto descriptor bytes and extract all RPC metadata.
201///
202/// Accepts raw `FileDescriptorSet` bytes (e.g., from `buf build --as-file-descriptor-set`
203/// or `tonic_build`'s compiled descriptor). Returns metadata shared across all `OpenAPI`
204/// patches.
205///
206/// # Errors
207///
208/// Returns an error if the descriptor bytes cannot be decoded.
209pub fn discover(descriptor_bytes: &[u8]) -> error::Result<ProtoMetadata> {
210    let fdset = FileDescriptorSet::decode(descriptor_bytes)?;
211
212    let streaming_ops = extract_streaming_ops(&fdset);
213    let operation_ids = extract_operation_ids(&fdset);
214    let field_constraints = extract_field_constraints(&fdset);
215    let (enum_rewrites, enum_value_map) = extract_enum_rewrites(&fdset);
216    let redirect_paths = extract_redirect_paths(&fdset);
217    let uuid_schema = detect_uuid_schema(&fdset);
218    let path_param_constraints = extract_path_param_constraints(&fdset);
219
220    Ok(ProtoMetadata {
221        streaming_ops,
222        operation_ids,
223        field_constraints,
224        enum_rewrites,
225        redirect_paths,
226        uuid_schema,
227        path_param_constraints,
228        enum_value_map,
229    })
230}
231
232/// Resolve short method names to gnostic operation IDs using proto metadata.
233///
234/// Given `["Authenticate", "SignUp"]` and the proto descriptor mapping,
235/// returns `["AuthService_Authenticate", "AuthService_SignUp"]`.
236///
237/// Supports both bare method names (`"Authenticate"`) and service-qualified
238/// names (`"AuthService.Authenticate"`). Qualified names are matched first;
239/// bare names fall back to unambiguous lookup (exactly one match).
240///
241/// # Errors
242///
243/// Returns an error if any method name is not found in the proto descriptors,
244/// or if a bare method name matches multiple services (ambiguous).
245pub fn resolve_operation_ids(
246    metadata: &ProtoMetadata,
247    method_names: &[&str],
248) -> error::Result<Vec<String>> {
249    method_names
250        .iter()
251        .map(|name| resolve_single_operation_id(metadata, name))
252        .collect()
253}
254
255/// Resolve a single method name to its operation ID.
256///
257/// Checks for qualified `Service.Method` format first, then falls back
258/// to bare method name with ambiguity detection.
259fn resolve_single_operation_id(metadata: &ProtoMetadata, name: &str) -> error::Result<String> {
260    // Check for qualified "Service.Method" format
261    if let Some((service, method)) = name.split_once('.') {
262        let qualified_id = format!("{service}_{method}");
263        return metadata
264            .operation_ids
265            .iter()
266            .find(|e| e.operation_id == qualified_id)
267            .map(|e| e.operation_id.clone())
268            .ok_or_else(|| error::Error::MethodNotFound {
269                method: name.to_string(),
270            });
271    }
272
273    // Bare method name: collect all matches
274    let matches: Vec<&OperationEntry> = metadata
275        .operation_ids
276        .iter()
277        .filter(|e| e.method_name == *name)
278        .collect();
279
280    match matches.len() {
281        0 => Err(error::Error::MethodNotFound {
282            method: name.to_string(),
283        }),
284        1 => Ok(matches[0].operation_id.clone()),
285        _ => {
286            let services: Vec<&str> = matches.iter().map(|e| e.operation_id.as_str()).collect();
287            Err(error::Error::AmbiguousMethodName {
288                method: name.to_string(),
289                candidates: services.iter().map(ToString::to_string).collect(),
290            })
291        }
292    }
293}
294
295/// Walk all services/methods and collect streaming ops with HTTP annotations.
296fn extract_streaming_ops(fdset: &FileDescriptorSet) -> Vec<StreamingOp> {
297    let mut ops = Vec::new();
298
299    for file in &fdset.file {
300        for service in &file.service {
301            for method in &service.method {
302                if !method.server_streaming.unwrap_or(false) {
303                    continue;
304                }
305
306                let Some((http_method, path)) = descriptor::extract_http_pattern(method) else {
307                    continue;
308                };
309
310                ops.push(StreamingOp {
311                    method: http_method.to_string(),
312                    path: path.to_string(),
313                });
314            }
315        }
316    }
317
318    ops
319}
320
321/// Walk all services/methods and build `method_name → operation_id` mapping.
322fn extract_operation_ids(fdset: &FileDescriptorSet) -> Vec<OperationEntry> {
323    let mut entries = Vec::new();
324
325    for file in &fdset.file {
326        for service in &file.service {
327            let service_name = service.name.as_deref().unwrap_or("");
328
329            for method in &service.method {
330                if method
331                    .options
332                    .as_ref()
333                    .and_then(|o| o.http.as_ref())
334                    .and_then(|h| h.pattern.as_ref())
335                    .is_none()
336                {
337                    continue;
338                }
339
340                let method_name = method.name.as_deref().unwrap_or("");
341                entries.push(OperationEntry {
342                    method_name: method_name.to_string(),
343                    operation_id: format!("{service_name}_{method_name}"),
344                });
345            }
346        }
347    }
348
349    entries
350}
351
352/// Walk all messages and extract `validate.rules` as `SchemaConstraints`.
353fn extract_field_constraints(fdset: &FileDescriptorSet) -> Vec<SchemaConstraints> {
354    let mut result = Vec::new();
355
356    for file in &fdset.file {
357        let package = file.package.as_deref().unwrap_or("");
358        collect_message_constraints(&mut result, package, &file.message_type);
359    }
360
361    result
362}
363
364/// Recursively collect constraints from messages (handles nested types).
365fn collect_message_constraints(
366    result: &mut Vec<SchemaConstraints>,
367    parent_path: &str,
368    messages: &[DescriptorProto],
369) {
370    for msg in messages {
371        let msg_name = msg.name.as_deref().unwrap_or("");
372        let schema = format!("{parent_path}.{msg_name}");
373
374        let fields: Vec<FieldConstraint> =
375            msg.field.iter().filter_map(field_to_constraint).collect();
376
377        if !fields.is_empty() {
378            result.push(SchemaConstraints {
379                schema: schema.clone(),
380                fields,
381            });
382        }
383
384        collect_message_constraints(result, &schema, &msg.nested_type);
385    }
386}
387
388/// Convert a single proto field's `validate.rules` to a `FieldConstraint`.
389#[allow(
390    clippy::too_many_lines,
391    clippy::case_sensitive_file_extension_comparisons
392)]
393fn field_to_constraint(field: &FieldDescriptorProto) -> Option<FieldConstraint> {
394    const JSON_SAFE_INT_MAX: u64 = 9_007_199_254_740_991;
395
396    let rules = field.options.as_ref()?.rules.as_ref()?;
397    let proto_name = field.name.as_deref().unwrap_or("");
398    let camel_name = snake_to_lower_camel(proto_name);
399    let field_type_id = field.r#type.unwrap_or(0);
400
401    let msg_required = rules
402        .message
403        .as_ref()
404        .and_then(|m| m.required)
405        .unwrap_or(false);
406
407    // String rules
408    if let Some(sr) = &rules.string {
409        let has_content = sr.min_len.is_some()
410            || sr.max_len.is_some()
411            || sr.pattern.is_some()
412            || !sr.r#in.is_empty()
413            || sr.uuid.unwrap_or(false);
414
415        if has_content || msg_required {
416            let implied_required = sr.min_len.unwrap_or(0) >= 1 || !sr.r#in.is_empty();
417            return Some(FieldConstraint {
418                field: camel_name,
419                min: sr.min_len,
420                max: sr.max_len,
421                signed_min: None,
422                signed_max: None,
423                pattern: sr.pattern.clone(),
424                enum_values: sr.r#in.clone(),
425                required: msg_required || implied_required,
426                is_uuid: sr.uuid.unwrap_or(false),
427                is_numeric: false,
428            });
429        }
430    }
431
432    // Int32 rules — use i64 to avoid sign loss and overflow
433    if let Some(ir) = &rules.int32 {
434        let min = ir
435            .gte
436            .map(i64::from)
437            .or(ir.gt.map(|v| i64::from(v).saturating_add(1)));
438        let max = ir
439            .lte
440            .map(i64::from)
441            .or(ir.lt.map(|v| i64::from(v).saturating_sub(1)));
442        if min.is_some() || max.is_some() {
443            return Some(FieldConstraint {
444                field: camel_name,
445                min: None,
446                max: None,
447                signed_min: min,
448                signed_max: max,
449                pattern: None,
450                enum_values: Vec::new(),
451                required: msg_required,
452                is_uuid: false,
453                is_numeric: true,
454            });
455        }
456    }
457
458    // UInt32 rules — use saturating_sub to avoid underflow when lt = 0
459    if let Some(ur) = &rules.uint32 {
460        let min = ur
461            .gte
462            .map(u64::from)
463            .or(ur.gt.map(|v| u64::from(v).saturating_add(1)));
464        let max = ur
465            .lte
466            .map(u64::from)
467            .or(ur.lt.map(|v| u64::from(v).saturating_sub(1)));
468        if min.is_some() || max.is_some() {
469            return Some(FieldConstraint {
470                field: camel_name,
471                min,
472                max,
473                signed_min: None,
474                signed_max: None,
475                pattern: None,
476                enum_values: Vec::new(),
477                required: msg_required,
478                is_uuid: false,
479                is_numeric: true,
480            });
481        }
482    }
483
484    // UInt64 rules — propagate when within JSON safe integer range
485    // Convert exclusive bounds to inclusive (gt → +1, lt → −1) like int32/uint32.
486    if let Some(u64r) = &rules.uint64 {
487        let min = u64r.gte.or(u64r.gt.map(|v| v.saturating_add(1)));
488        let max = u64r.lte.or(u64r.lt.map(|v| v.saturating_sub(1)));
489        let min_val = min.unwrap_or(0);
490        let max_val = max;
491
492        let fits_in_json = max_val.is_some_and(|m| m <= JSON_SAFE_INT_MAX);
493
494        if fits_in_json || msg_required {
495            return Some(FieldConstraint {
496                field: camel_name,
497                min: if fits_in_json && min_val > 0 {
498                    Some(min_val)
499                } else {
500                    None
501                },
502                max: if fits_in_json { max_val } else { None },
503                signed_min: None,
504                signed_max: None,
505                pattern: None,
506                enum_values: Vec::new(),
507                required: msg_required,
508                is_uuid: false,
509                is_numeric: fits_in_json,
510            });
511        }
512    }
513
514    // Enum rules (not_in typically used for "must not be UNSPECIFIED")
515    if let Some(er) = &rules.r#enum {
516        let enum_required = er.not_in.contains(&0);
517        if enum_required || msg_required {
518            return Some(FieldConstraint {
519                field: camel_name,
520                min: None,
521                max: None,
522                signed_min: None,
523                signed_max: None,
524                pattern: None,
525                enum_values: Vec::new(),
526                required: enum_required || msg_required,
527                is_uuid: false,
528                is_numeric: false,
529            });
530        }
531    }
532
533    // Message-only constraint (required without string/int rules)
534    if msg_required {
535        let is_uuid = field_type_id == field_type::MESSAGE
536            && field
537                .type_name
538                .as_deref()
539                .is_some_and(|t| t.ends_with(".UUID")); // proto type name, not file extension
540
541        return Some(FieldConstraint {
542            field: camel_name,
543            min: None,
544            max: None,
545            signed_min: None,
546            signed_max: None,
547            pattern: None,
548            enum_values: Vec::new(),
549            required: true,
550            is_uuid,
551            is_numeric: false,
552        });
553    }
554
555    None
556}
557
558/// Extract enum rewrites for schemas containing prefix-stripped enums.
559fn extract_enum_rewrites(fdset: &FileDescriptorSet) -> (Vec<EnumRewrite>, HashMap<String, String>) {
560    let mut prefix_enums: Vec<(String, String, Vec<String>)> = Vec::new();
561
562    for file in &fdset.file {
563        let package = file.package.as_deref().unwrap_or("");
564        for enum_desc in &file.enum_type {
565            let enum_name = enum_desc.name.as_deref().unwrap_or("");
566            let fqn = format!(".{package}.{enum_name}");
567
568            let values: Vec<&str> = enum_desc
569                .value
570                .iter()
571                .filter_map(|v| v.name.as_deref())
572                .collect();
573
574            let Some(detected_prefix) = detect_enum_prefix(&values) else {
575                continue;
576            };
577
578            if values.iter().all(|v| v.starts_with(&detected_prefix)) {
579                let stripped: Vec<String> = values
580                    .iter()
581                    .map(|v| v[detected_prefix.len()..].to_lowercase())
582                    .collect();
583                prefix_enums.push((fqn, detected_prefix, stripped));
584            }
585        }
586    }
587
588    if prefix_enums.is_empty() {
589        return (Vec::new(), HashMap::new());
590    }
591
592    // Build global raw → stripped value map
593    let mut enum_value_map = HashMap::new();
594    for file in &fdset.file {
595        for enum_desc in &file.enum_type {
596            let values: Vec<&str> = enum_desc
597                .value
598                .iter()
599                .filter_map(|v| v.name.as_deref())
600                .collect();
601
602            let Some(detected_prefix) = detect_enum_prefix(&values) else {
603                continue;
604            };
605
606            for raw in &values {
607                if let Some(suffix) = raw.strip_prefix(detected_prefix.as_str()) {
608                    enum_value_map.insert(raw.to_string(), suffix.to_lowercase());
609                }
610            }
611        }
612    }
613
614    // Find all message fields referencing these enums (type 14 = TYPE_ENUM)
615    let mut rewrites = Vec::new();
616
617    for file in &fdset.file {
618        let package = file.package.as_deref().unwrap_or("");
619        collect_enum_rewrite_fields(&mut rewrites, package, &file.message_type, &prefix_enums);
620    }
621
622    (rewrites, enum_value_map)
623}
624
625/// Recursively scan messages for enum fields referencing prefix-stripped enums.
626fn collect_enum_rewrite_fields(
627    rewrites: &mut Vec<EnumRewrite>,
628    parent_path: &str,
629    messages: &[DescriptorProto],
630    prefix_enums: &[(String, String, Vec<String>)],
631) {
632    for msg in messages {
633        let msg_name = msg.name.as_deref().unwrap_or("");
634        let schema = format!("{parent_path}.{msg_name}");
635
636        for field in &msg.field {
637            if field.r#type != Some(field_type::ENUM) {
638                continue;
639            }
640
641            let Some(type_name) = field.type_name.as_deref() else {
642                continue;
643            };
644
645            if let Some((_, _, stripped_values)) =
646                prefix_enums.iter().find(|(fqn, _, _)| fqn == type_name)
647            {
648                let field_name = snake_to_lower_camel(field.name.as_deref().unwrap_or(""));
649
650                rewrites.push(EnumRewrite {
651                    schema: schema.clone(),
652                    field: field_name,
653                    values: stripped_values.clone(),
654                });
655            }
656        }
657
658        collect_enum_rewrite_fields(rewrites, &schema, &msg.nested_type, prefix_enums);
659    }
660}
661
662/// Detect the common `UPPER_SNAKE_CASE_` prefix shared by all enum values.
663///
664/// Returns `None` if values don't share a common `_`-terminated prefix.
665fn detect_enum_prefix(values: &[&str]) -> Option<String> {
666    if values.is_empty() {
667        return None;
668    }
669
670    let first = values[0];
671    let common_len = first
672        .char_indices()
673        .find(|&(i, _)| values[1..].iter().any(|v| !v[..].starts_with(&first[..=i])))
674        .map_or(first.len(), |(i, _)| i);
675
676    let prefix = &first[..common_len];
677    let last_underscore = prefix.rfind('_')?;
678    let prefix = &first[..=last_underscore];
679
680    if prefix.len() < 3 {
681        return None;
682    }
683
684    Some(prefix.to_string())
685}
686
687/// Detect redirect endpoints by examining response message types.
688fn extract_redirect_paths(fdset: &FileDescriptorSet) -> Vec<String> {
689    let mut redirect_types: Vec<String> = Vec::new();
690    for file in &fdset.file {
691        let package = file.package.as_deref().unwrap_or("");
692        collect_redirect_message_types(&mut redirect_types, package, &file.message_type);
693    }
694
695    if redirect_types.is_empty() {
696        return Vec::new();
697    }
698
699    let mut paths = Vec::new();
700    for file in &fdset.file {
701        for service in &file.service {
702            for method in &service.method {
703                let output_type = method.output_type.as_deref().unwrap_or("");
704                if !redirect_types.iter().any(|t| t == output_type) {
705                    continue;
706                }
707
708                if let Some((_, path)) = descriptor::extract_http_pattern(method) {
709                    paths.push(path.to_string());
710                }
711            }
712        }
713    }
714
715    paths
716}
717
718/// Recursively find messages with a `redirect_url` field.
719fn collect_redirect_message_types(
720    result: &mut Vec<String>,
721    package: &str,
722    messages: &[DescriptorProto],
723) {
724    for msg in messages {
725        let msg_name = msg.name.as_deref().unwrap_or("");
726        let has_redirect_url = msg
727            .field
728            .iter()
729            .any(|f| f.name.as_deref() == Some("redirect_url"));
730
731        if has_redirect_url {
732            result.push(format!(".{package}.{msg_name}"));
733        }
734
735        collect_redirect_message_types(result, package, &msg.nested_type);
736    }
737}
738
739/// Detect the UUID wrapper schema name from proto descriptors.
740fn detect_uuid_schema(fdset: &FileDescriptorSet) -> Option<String> {
741    for file in &fdset.file {
742        let package = file.package.as_deref().unwrap_or("");
743        for msg in &file.message_type {
744            let msg_name = msg.name.as_deref().unwrap_or("");
745
746            if msg.field.len() != 1 {
747                continue;
748            }
749            let field = &msg.field[0];
750            if field.name.as_deref() != Some("value") || field.r#type != Some(field_type::STRING) {
751                continue;
752            }
753
754            let has_uuid_pattern = field
755                .options
756                .as_ref()
757                .and_then(|o| o.rules.as_ref())
758                .and_then(|r| r.string.as_ref())
759                .and_then(|s| s.pattern.as_deref())
760                .is_some_and(|p| p.contains("0-9a-fA-F"));
761
762            if has_uuid_pattern {
763                return Some(format!("{package}.{msg_name}"));
764            }
765        }
766    }
767    None
768}
769
770/// Extract path parameter constraints from proto HTTP path templates.
771#[allow(clippy::case_sensitive_file_extension_comparisons)] // proto type names, not file paths
772fn extract_path_param_constraints(fdset: &FileDescriptorSet) -> Vec<PathParamInfo> {
773    let mut messages: HashMap<String, &[FieldDescriptorProto]> = HashMap::new();
774    for file in &fdset.file {
775        let package = file.package.as_deref().unwrap_or("");
776        collect_message_fields(&mut messages, package, &file.message_type);
777    }
778
779    let mut result = Vec::new();
780
781    for file in &fdset.file {
782        for service in &file.service {
783            for method in &service.method {
784                let Some((_, path)) = descriptor::extract_http_pattern(method) else {
785                    continue;
786                };
787
788                let param_names: Vec<&str> = path
789                    .split('{')
790                    .skip(1)
791                    .filter_map(|s| s.split('}').next())
792                    .collect();
793
794                if param_names.is_empty() {
795                    continue;
796                }
797
798                let input_type = method.input_type.as_deref().unwrap_or("");
799                let fields = messages.get(input_type).copied().unwrap_or_default();
800
801                let params: Vec<PathParamConstraint> = param_names
802                    .iter()
803                    .filter_map(|&param| {
804                        let root_field = param.split('.').next().unwrap_or(param);
805                        let field = fields
806                            .iter()
807                            .find(|f| f.name.as_deref() == Some(root_field))?;
808
809                        let is_uuid = field.r#type == Some(field_type::MESSAGE)
810                            && field
811                                .type_name
812                                .as_deref()
813                                .is_some_and(|t| t.ends_with(".UUID")); // proto type name, not file extension
814
815                        let (min, max) = field
816                            .options
817                            .as_ref()
818                            .and_then(|o| o.rules.as_ref())
819                            .and_then(|rules| rules.string.as_ref())
820                            .map(|s| (s.min_len, s.max_len))
821                            .unwrap_or_default();
822
823                        Some(PathParamConstraint {
824                            name: param
825                                .split('.')
826                                .enumerate()
827                                .map(|(i, seg)| {
828                                    if i == 0 {
829                                        snake_to_lower_camel(seg)
830                                    } else {
831                                        seg.to_string()
832                                    }
833                                })
834                                .collect::<Vec<_>>()
835                                .join("."),
836                            description: None,
837                            is_uuid,
838                            min,
839                            max,
840                        })
841                    })
842                    .collect();
843
844                if !params.is_empty() {
845                    let gnostic_path = convert_path_template_to_camel(path);
846                    result.push(PathParamInfo {
847                        path: gnostic_path,
848                        params,
849                    });
850                }
851            }
852        }
853    }
854
855    result
856}
857
858/// Convert proto path template variables to gnostic's camelCase format.
859fn convert_path_template_to_camel(path: &str) -> String {
860    let mut result = String::with_capacity(path.len());
861    let mut rest = path;
862
863    while let Some(start) = rest.find('{') {
864        let Some(end) = rest[start..].find('}') else {
865            break;
866        };
867        let end = start + end;
868
869        result.push_str(&rest[..=start]);
870        let var = &rest[start + 1..end];
871
872        if let Some((root, suffix)) = var.split_once('.') {
873            result.push_str(&snake_to_lower_camel(root));
874            result.push('.');
875            result.push_str(suffix);
876        } else {
877            result.push_str(&snake_to_lower_camel(var));
878        }
879
880        result.push('}');
881        rest = &rest[end + 1..];
882    }
883
884    result.push_str(rest);
885    result
886}
887
888/// Build a lookup table: message FQN → field descriptors.
889fn collect_message_fields<'a>(
890    result: &mut HashMap<String, &'a [FieldDescriptorProto]>,
891    parent_path: &str,
892    messages: &'a [DescriptorProto],
893) {
894    for msg in messages {
895        let msg_name = msg.name.as_deref().unwrap_or("");
896        let fqn = format!(".{parent_path}.{msg_name}");
897        result.insert(fqn.clone(), &msg.field);
898        collect_message_fields(result, &fqn[1..], &msg.nested_type);
899    }
900}
901
902/// Convert `snake_case` to `lowerCamelCase` (matches gnostic JSON field names).
903pub(crate) fn snake_to_lower_camel(s: &str) -> String {
904    let mut result = String::new();
905    let mut capitalize_next = false;
906    for c in s.chars() {
907        if c == '_' {
908            capitalize_next = true;
909        } else if capitalize_next {
910            result.push(c.to_ascii_uppercase());
911            capitalize_next = false;
912        } else {
913            result.push(c);
914        }
915    }
916    result
917}
918
919#[cfg(test)]
920mod tests {
921    use prost::Message as _;
922
923    use crate::descriptor::*;
924
925    use super::*;
926
927    fn make_field(name: &str, ty: i32) -> FieldDescriptorProto {
928        FieldDescriptorProto {
929            name: Some(name.to_string()),
930            r#type: Some(ty),
931            type_name: None,
932            options: None,
933        }
934    }
935
936    fn make_service_with_http(
937        service_name: &str,
938        method_name: &str,
939        pattern: HttpPattern,
940        server_streaming: bool,
941    ) -> ServiceDescriptorProto {
942        ServiceDescriptorProto {
943            name: Some(service_name.to_string()),
944            method: vec![MethodDescriptorProto {
945                name: Some(method_name.to_string()),
946                input_type: Some(".test.v1.Request".to_string()),
947                output_type: Some(".test.v1.Response".to_string()),
948                options: Some(MethodOptions {
949                    http: Some(HttpRule {
950                        pattern: Some(pattern),
951                        body: String::new(),
952                    }),
953                }),
954                client_streaming: None,
955                server_streaming: Some(server_streaming),
956            }],
957        }
958    }
959
960    fn make_fdset_with_services(services: Vec<ServiceDescriptorProto>) -> FileDescriptorSet {
961        FileDescriptorSet {
962            file: vec![FileDescriptorProto {
963                name: Some("test.proto".to_string()),
964                package: Some("test.v1".to_string()),
965                message_type: vec![DescriptorProto {
966                    name: Some("Request".to_string()),
967                    field: vec![make_field("name", field_type::STRING)],
968                    nested_type: vec![],
969                }],
970                enum_type: vec![],
971                service: services,
972            }],
973        }
974    }
975
976    #[test]
977    fn discover_extracts_streaming_ops() {
978        let fdset = make_fdset_with_services(vec![make_service_with_http(
979            "TestService",
980            "ListItems",
981            HttpPattern::Get("/v1/items".to_string()),
982            true,
983        )]);
984        let bytes = fdset.encode_to_vec();
985        let metadata = discover(&bytes).unwrap();
986
987        assert_eq!(metadata.streaming_ops.len(), 1);
988        assert_eq!(metadata.streaming_ops[0].method, "get");
989        assert_eq!(metadata.streaming_ops[0].path, "/v1/items");
990    }
991
992    #[test]
993    fn discover_skips_non_streaming() {
994        let fdset = make_fdset_with_services(vec![make_service_with_http(
995            "TestService",
996            "GetItem",
997            HttpPattern::Get("/v1/items/{id}".to_string()),
998            false,
999        )]);
1000        let bytes = fdset.encode_to_vec();
1001        let metadata = discover(&bytes).unwrap();
1002
1003        assert!(metadata.streaming_ops.is_empty());
1004    }
1005
1006    #[test]
1007    fn discover_extracts_operation_ids() {
1008        let fdset = make_fdset_with_services(vec![make_service_with_http(
1009            "ItemService",
1010            "CreateItem",
1011            HttpPattern::Post("/v1/items".to_string()),
1012            false,
1013        )]);
1014        let bytes = fdset.encode_to_vec();
1015        let metadata = discover(&bytes).unwrap();
1016
1017        assert_eq!(metadata.operation_ids.len(), 1);
1018        assert_eq!(metadata.operation_ids[0].method_name, "CreateItem");
1019        assert_eq!(
1020            metadata.operation_ids[0].operation_id,
1021            "ItemService_CreateItem"
1022        );
1023    }
1024
1025    #[test]
1026    fn resolve_operation_ids_success() {
1027        let fdset = make_fdset_with_services(vec![make_service_with_http(
1028            "AuthService",
1029            "Authenticate",
1030            HttpPattern::Post("/v1/auth/authenticate".to_string()),
1031            false,
1032        )]);
1033        let bytes = fdset.encode_to_vec();
1034        let metadata = discover(&bytes).unwrap();
1035
1036        let resolved = resolve_operation_ids(&metadata, &["Authenticate"]).unwrap();
1037        assert_eq!(resolved, vec!["AuthService_Authenticate"]);
1038    }
1039
1040    #[test]
1041    fn resolve_operation_ids_missing() {
1042        let fdset = make_fdset_with_services(vec![]);
1043        let bytes = fdset.encode_to_vec();
1044        let metadata = discover(&bytes).unwrap();
1045
1046        let result = resolve_operation_ids(&metadata, &["NonExistent"]);
1047        assert!(result.is_err());
1048    }
1049
1050    #[test]
1051    fn resolve_qualified_service_method() {
1052        let fdset = FileDescriptorSet {
1053            file: vec![FileDescriptorProto {
1054                name: Some("test.proto".to_string()),
1055                package: Some("test.v1".to_string()),
1056                message_type: vec![DescriptorProto {
1057                    name: Some("Request".to_string()),
1058                    field: vec![make_field("name", field_type::STRING)],
1059                    nested_type: vec![],
1060                }],
1061                enum_type: vec![],
1062                service: vec![
1063                    make_service_with_http(
1064                        "AuthService",
1065                        "Delete",
1066                        HttpPattern::Delete("/v1/auth".to_string()),
1067                        false,
1068                    ),
1069                    make_service_with_http(
1070                        "UserService",
1071                        "Delete",
1072                        HttpPattern::Delete("/v1/users".to_string()),
1073                        false,
1074                    ),
1075                ],
1076            }],
1077        };
1078        let bytes = fdset.encode_to_vec();
1079        let metadata = discover(&bytes).unwrap();
1080
1081        // Qualified name should resolve correctly
1082        let resolved = resolve_operation_ids(&metadata, &["AuthService.Delete"]).unwrap();
1083        assert_eq!(resolved, vec!["AuthService_Delete"]);
1084
1085        let resolved = resolve_operation_ids(&metadata, &["UserService.Delete"]).unwrap();
1086        assert_eq!(resolved, vec!["UserService_Delete"]);
1087    }
1088
1089    #[test]
1090    fn resolve_ambiguous_bare_name_errors() {
1091        let fdset = FileDescriptorSet {
1092            file: vec![FileDescriptorProto {
1093                name: Some("test.proto".to_string()),
1094                package: Some("test.v1".to_string()),
1095                message_type: vec![DescriptorProto {
1096                    name: Some("Request".to_string()),
1097                    field: vec![make_field("name", field_type::STRING)],
1098                    nested_type: vec![],
1099                }],
1100                enum_type: vec![],
1101                service: vec![
1102                    make_service_with_http(
1103                        "AuthService",
1104                        "Delete",
1105                        HttpPattern::Delete("/v1/auth".to_string()),
1106                        false,
1107                    ),
1108                    make_service_with_http(
1109                        "UserService",
1110                        "Delete",
1111                        HttpPattern::Delete("/v1/users".to_string()),
1112                        false,
1113                    ),
1114                ],
1115            }],
1116        };
1117        let bytes = fdset.encode_to_vec();
1118        let metadata = discover(&bytes).unwrap();
1119
1120        // Bare "Delete" is ambiguous — should error
1121        let result = resolve_operation_ids(&metadata, &["Delete"]);
1122        assert!(result.is_err());
1123        let err = result.unwrap_err();
1124        let err_msg = err.to_string();
1125        assert!(
1126            err_msg.contains("ambiguous"),
1127            "error should mention ambiguity: {err_msg}"
1128        );
1129        assert!(
1130            err_msg.contains("Service.Method"),
1131            "error should suggest qualified syntax: {err_msg}"
1132        );
1133    }
1134
1135    #[test]
1136    fn snake_to_lower_camel_basic() {
1137        assert_eq!(snake_to_lower_camel("device_id"), "deviceId");
1138        assert_eq!(snake_to_lower_camel("user_id"), "userId");
1139        assert_eq!(snake_to_lower_camel("name"), "name");
1140        assert_eq!(snake_to_lower_camel("client_version"), "clientVersion");
1141    }
1142
1143    #[test]
1144    fn convert_path_template_to_camel_works() {
1145        assert_eq!(
1146            convert_path_template_to_camel("/v1/sessions/{device_id}"),
1147            "/v1/sessions/{deviceId}"
1148        );
1149        assert_eq!(
1150            convert_path_template_to_camel("/v1/users/{user_id.value}"),
1151            "/v1/users/{userId.value}"
1152        );
1153        assert_eq!(convert_path_template_to_camel("/v1/items"), "/v1/items");
1154    }
1155
1156    #[test]
1157    fn detect_enum_prefix_common() {
1158        let values = ["HEALTH_STATUS_HEALTHY", "HEALTH_STATUS_UNHEALTHY"];
1159        assert_eq!(
1160            detect_enum_prefix(&values),
1161            Some("HEALTH_STATUS_".to_string())
1162        );
1163    }
1164
1165    #[test]
1166    fn detect_enum_prefix_none_for_no_common() {
1167        let values = ["FOO", "BAR"];
1168        assert_eq!(detect_enum_prefix(&values), None);
1169    }
1170
1171    #[test]
1172    fn detect_enum_prefix_empty() {
1173        let values: &[&str] = &[];
1174        assert_eq!(detect_enum_prefix(values), None);
1175    }
1176
1177    #[test]
1178    fn enum_rewrites_detected() {
1179        let fdset = FileDescriptorSet {
1180            file: vec![FileDescriptorProto {
1181                name: Some("test.proto".to_string()),
1182                package: Some("test.v1".to_string()),
1183                message_type: vec![DescriptorProto {
1184                    name: Some("Response".to_string()),
1185                    field: vec![FieldDescriptorProto {
1186                        name: Some("status".to_string()),
1187                        r#type: Some(field_type::ENUM),
1188                        type_name: Some(".test.v1.Status".to_string()),
1189                        options: None,
1190                    }],
1191                    nested_type: vec![],
1192                }],
1193                enum_type: vec![EnumDescriptorProto {
1194                    name: Some("Status".to_string()),
1195                    value: vec![
1196                        EnumValueDescriptorProto {
1197                            name: Some("STATUS_UNSPECIFIED".to_string()),
1198                            number: Some(0),
1199                        },
1200                        EnumValueDescriptorProto {
1201                            name: Some("STATUS_ACTIVE".to_string()),
1202                            number: Some(1),
1203                        },
1204                    ],
1205                }],
1206                service: vec![],
1207            }],
1208        };
1209        let bytes = fdset.encode_to_vec();
1210        let metadata = discover(&bytes).unwrap();
1211
1212        assert_eq!(metadata.enum_rewrites.len(), 1);
1213        assert_eq!(metadata.enum_rewrites[0].schema, "test.v1.Response");
1214        assert_eq!(metadata.enum_rewrites[0].field, "status");
1215        assert_eq!(
1216            metadata.enum_rewrites[0].values,
1217            vec!["unspecified", "active"]
1218        );
1219    }
1220
1221    #[test]
1222    fn redirect_paths_detected() {
1223        let fdset = FileDescriptorSet {
1224            file: vec![FileDescriptorProto {
1225                name: Some("test.proto".to_string()),
1226                package: Some("test.v1".to_string()),
1227                message_type: vec![DescriptorProto {
1228                    name: Some("RedirectResponse".to_string()),
1229                    field: vec![make_field("redirect_url", field_type::STRING)],
1230                    nested_type: vec![],
1231                }],
1232                enum_type: vec![],
1233                service: vec![ServiceDescriptorProto {
1234                    name: Some("TestService".to_string()),
1235                    method: vec![MethodDescriptorProto {
1236                        name: Some("DoRedirect".to_string()),
1237                        input_type: Some(".test.v1.Request".to_string()),
1238                        output_type: Some(".test.v1.RedirectResponse".to_string()),
1239                        options: Some(MethodOptions {
1240                            http: Some(HttpRule {
1241                                pattern: Some(HttpPattern::Get("/v1/redirect".to_string())),
1242                                body: String::new(),
1243                            }),
1244                        }),
1245                        client_streaming: None,
1246                        server_streaming: None,
1247                    }],
1248                }],
1249            }],
1250        };
1251        let bytes = fdset.encode_to_vec();
1252        let metadata = discover(&bytes).unwrap();
1253
1254        assert_eq!(metadata.redirect_paths, vec!["/v1/redirect"]);
1255    }
1256
1257    #[test]
1258    fn nested_message_constraints_use_qualified_path() {
1259        let fdset = FileDescriptorSet {
1260            file: vec![FileDescriptorProto {
1261                name: Some("test.proto".to_string()),
1262                package: Some("test.v1".to_string()),
1263                message_type: vec![DescriptorProto {
1264                    name: Some("Outer".to_string()),
1265                    field: vec![FieldDescriptorProto {
1266                        name: Some("name".to_string()),
1267                        r#type: Some(field_type::STRING),
1268                        type_name: None,
1269                        options: Some(FieldOptions {
1270                            rules: Some(FieldRules {
1271                                string: Some(StringRules {
1272                                    min_len: Some(1),
1273                                    max_len: Some(100),
1274                                    pattern: None,
1275                                    r#in: vec![],
1276                                    uuid: None,
1277                                }),
1278                                ..Default::default()
1279                            }),
1280                        }),
1281                    }],
1282                    nested_type: vec![DescriptorProto {
1283                        name: Some("Inner".to_string()),
1284                        field: vec![FieldDescriptorProto {
1285                            name: Some("value".to_string()),
1286                            r#type: Some(field_type::STRING),
1287                            type_name: None,
1288                            options: Some(FieldOptions {
1289                                rules: Some(FieldRules {
1290                                    string: Some(StringRules {
1291                                        min_len: Some(3),
1292                                        max_len: None,
1293                                        pattern: None,
1294                                        r#in: vec![],
1295                                        uuid: None,
1296                                    }),
1297                                    ..Default::default()
1298                                }),
1299                            }),
1300                        }],
1301                        nested_type: vec![],
1302                    }],
1303                }],
1304                enum_type: vec![],
1305                service: vec![],
1306            }],
1307        };
1308        let bytes = fdset.encode_to_vec();
1309        let metadata = discover(&bytes).unwrap();
1310
1311        // Outer message should be "test.v1.Outer"
1312        let outer = metadata
1313            .field_constraints
1314            .iter()
1315            .find(|c| c.schema == "test.v1.Outer");
1316        assert!(outer.is_some(), "Outer constraint should exist");
1317
1318        // Nested message should be "test.v1.Outer.Inner", NOT "test.v1.Inner"
1319        let inner = metadata
1320            .field_constraints
1321            .iter()
1322            .find(|c| c.schema == "test.v1.Outer.Inner");
1323        assert!(
1324            inner.is_some(),
1325            "Nested Inner constraint should use fully qualified path: {:?}",
1326            metadata
1327                .field_constraints
1328                .iter()
1329                .map(|c| &c.schema)
1330                .collect::<Vec<_>>()
1331        );
1332
1333        // Ensure the old wrong path is NOT present
1334        let wrong = metadata
1335            .field_constraints
1336            .iter()
1337            .find(|c| c.schema == "test.v1.Inner");
1338        assert!(
1339            wrong.is_none(),
1340            "Should not have bare 'test.v1.Inner' schema"
1341        );
1342    }
1343
1344    #[test]
1345    fn nested_message_fields_use_qualified_fqn() {
1346        let fdset = FileDescriptorSet {
1347            file: vec![FileDescriptorProto {
1348                name: Some("test.proto".to_string()),
1349                package: Some("test.v1".to_string()),
1350                message_type: vec![DescriptorProto {
1351                    name: Some("Outer".to_string()),
1352                    field: vec![make_field("name", field_type::STRING)],
1353                    nested_type: vec![DescriptorProto {
1354                        name: Some("Inner".to_string()),
1355                        field: vec![make_field("value", field_type::STRING)],
1356                        nested_type: vec![],
1357                    }],
1358                }],
1359                enum_type: vec![],
1360                service: vec![ServiceDescriptorProto {
1361                    name: Some("Svc".to_string()),
1362                    method: vec![MethodDescriptorProto {
1363                        name: Some("Do".to_string()),
1364                        input_type: Some(".test.v1.Outer.Inner".to_string()),
1365                        output_type: Some(".test.v1.Outer".to_string()),
1366                        options: Some(MethodOptions {
1367                            http: Some(HttpRule {
1368                                pattern: Some(HttpPattern::Get("/v1/outer/{value}".to_string())),
1369                                body: String::new(),
1370                            }),
1371                        }),
1372                        client_streaming: None,
1373                        server_streaming: None,
1374                    }],
1375                }],
1376            }],
1377        };
1378        let bytes = fdset.encode_to_vec();
1379        let metadata = discover(&bytes).unwrap();
1380
1381        // Path param lookup should resolve .test.v1.Outer.Inner (not .test.v1.Inner)
1382        assert!(
1383            !metadata.path_param_constraints.is_empty(),
1384            "should find path params via nested message FQN"
1385        );
1386    }
1387
1388    #[test]
1389    fn nested_enum_rewrites_use_qualified_schema() {
1390        let fdset = FileDescriptorSet {
1391            file: vec![FileDescriptorProto {
1392                name: Some("test.proto".to_string()),
1393                package: Some("test.v1".to_string()),
1394                message_type: vec![DescriptorProto {
1395                    name: Some("Outer".to_string()),
1396                    field: vec![],
1397                    nested_type: vec![DescriptorProto {
1398                        name: Some("Inner".to_string()),
1399                        field: vec![FieldDescriptorProto {
1400                            name: Some("status".to_string()),
1401                            r#type: Some(field_type::ENUM),
1402                            type_name: Some(".test.v1.Status".to_string()),
1403                            options: None,
1404                        }],
1405                        nested_type: vec![],
1406                    }],
1407                }],
1408                enum_type: vec![EnumDescriptorProto {
1409                    name: Some("Status".to_string()),
1410                    value: vec![
1411                        EnumValueDescriptorProto {
1412                            name: Some("STATUS_UNSPECIFIED".to_string()),
1413                            number: Some(0),
1414                        },
1415                        EnumValueDescriptorProto {
1416                            name: Some("STATUS_ACTIVE".to_string()),
1417                            number: Some(1),
1418                        },
1419                    ],
1420                }],
1421                service: vec![],
1422            }],
1423        };
1424        let bytes = fdset.encode_to_vec();
1425        let metadata = discover(&bytes).unwrap();
1426
1427        assert_eq!(metadata.enum_rewrites.len(), 1);
1428        // Should be qualified as "test.v1.Outer.Inner", not "test.v1.Inner"
1429        assert_eq!(
1430            metadata.enum_rewrites[0].schema, "test.v1.Outer.Inner",
1431            "nested enum rewrite should use fully qualified schema path"
1432        );
1433    }
1434
1435    #[test]
1436    fn int32_boundary_values_no_overflow() {
1437        // Test gt = i32::MAX should not overflow
1438        let fdset = FileDescriptorSet {
1439            file: vec![FileDescriptorProto {
1440                name: Some("test.proto".to_string()),
1441                package: Some("test.v1".to_string()),
1442                message_type: vec![DescriptorProto {
1443                    name: Some("Request".to_string()),
1444                    field: vec![FieldDescriptorProto {
1445                        name: Some("count".to_string()),
1446                        r#type: Some(field_type::INT32),
1447                        type_name: None,
1448                        options: Some(FieldOptions {
1449                            rules: Some(FieldRules {
1450                                int32: Some(Int32Rules {
1451                                    gte: Some(-100),
1452                                    lte: Some(100),
1453                                    gt: None,
1454                                    lt: None,
1455                                }),
1456                                ..Default::default()
1457                            }),
1458                        }),
1459                    }],
1460                    nested_type: vec![],
1461                }],
1462                enum_type: vec![],
1463                service: vec![],
1464            }],
1465        };
1466        let bytes = fdset.encode_to_vec();
1467        let metadata = discover(&bytes).unwrap();
1468
1469        assert_eq!(metadata.field_constraints.len(), 1);
1470        let fc = &metadata.field_constraints[0].fields[0];
1471        assert_eq!(fc.signed_min, Some(-100));
1472        assert_eq!(fc.signed_max, Some(100));
1473        assert!(fc.is_numeric);
1474    }
1475
1476    #[test]
1477    fn uint32_lt_zero_no_underflow() {
1478        // Test lt = 0 should not underflow (saturates to 0)
1479        let fdset = FileDescriptorSet {
1480            file: vec![FileDescriptorProto {
1481                name: Some("test.proto".to_string()),
1482                package: Some("test.v1".to_string()),
1483                message_type: vec![DescriptorProto {
1484                    name: Some("Request".to_string()),
1485                    field: vec![FieldDescriptorProto {
1486                        name: Some("count".to_string()),
1487                        r#type: Some(field_type::UINT32),
1488                        type_name: None,
1489                        options: Some(FieldOptions {
1490                            rules: Some(FieldRules {
1491                                uint32: Some(UInt32Rules {
1492                                    lt: Some(0),
1493                                    lte: None,
1494                                    gt: None,
1495                                    gte: None,
1496                                }),
1497                                ..Default::default()
1498                            }),
1499                        }),
1500                    }],
1501                    nested_type: vec![],
1502                }],
1503                enum_type: vec![],
1504                service: vec![],
1505            }],
1506        };
1507        let bytes = fdset.encode_to_vec();
1508        // Should not panic from underflow
1509        let metadata = discover(&bytes).unwrap();
1510        assert_eq!(metadata.field_constraints.len(), 1);
1511        let fc = &metadata.field_constraints[0].fields[0];
1512        assert_eq!(fc.max, Some(0)); // saturated
1513    }
1514
1515    #[test]
1516    fn uint64_exclusive_bounds_converted_to_inclusive() {
1517        // Proto: uint64 content_size = 3 [(validate.rules).uint64 = {gt: 0, lte: 10485760}];
1518        // gt: 0 → minimum: 1 (exclusive → inclusive), lte: 10485760 → maximum: 10485760
1519        let fdset = FileDescriptorSet {
1520            file: vec![FileDescriptorProto {
1521                name: Some("test.proto".to_string()),
1522                package: Some("test.v1".to_string()),
1523                message_type: vec![DescriptorProto {
1524                    name: Some("Request".to_string()),
1525                    field: vec![FieldDescriptorProto {
1526                        name: Some("content_size".to_string()),
1527                        r#type: Some(field_type::UINT64),
1528                        type_name: None,
1529                        options: Some(FieldOptions {
1530                            rules: Some(FieldRules {
1531                                uint64: Some(UInt64Rules {
1532                                    gt: Some(0),
1533                                    gte: None,
1534                                    lt: None,
1535                                    lte: Some(10_485_760),
1536                                }),
1537                                ..Default::default()
1538                            }),
1539                        }),
1540                    }],
1541                    nested_type: vec![],
1542                }],
1543                enum_type: vec![],
1544                service: vec![],
1545            }],
1546        };
1547        let bytes = fdset.encode_to_vec();
1548        let metadata = discover(&bytes).unwrap();
1549
1550        assert_eq!(metadata.field_constraints.len(), 1);
1551        let fc = &metadata.field_constraints[0].fields[0];
1552        assert_eq!(fc.field, "contentSize");
1553        assert_eq!(fc.min, Some(1), "gt:0 should become minimum:1");
1554        assert_eq!(fc.max, Some(10_485_760));
1555        assert!(fc.is_numeric);
1556    }
1557}