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, field_type, DescriptorProto, FieldDescriptorProto, FileDescriptorSet,
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    if let Some(u64r) = &rules.uint64 {
486        let gt = u64r.gt.unwrap_or(0);
487        let gte = u64r.gte.unwrap_or(0);
488        let min_val = gt.max(gte);
489        let lt = u64r.lt;
490        let lte = u64r.lte;
491        let max_val = lt.or(lte);
492
493        let fits_in_json = max_val.is_some_and(|m| m <= JSON_SAFE_INT_MAX);
494
495        if fits_in_json || msg_required {
496            return Some(FieldConstraint {
497                field: camel_name,
498                min: if fits_in_json && min_val > 0 {
499                    Some(min_val)
500                } else {
501                    None
502                },
503                max: if fits_in_json { max_val } else { None },
504                signed_min: None,
505                signed_max: None,
506                pattern: None,
507                enum_values: Vec::new(),
508                required: msg_required,
509                is_uuid: false,
510                is_numeric: fits_in_json,
511            });
512        }
513    }
514
515    // Enum rules (not_in typically used for "must not be UNSPECIFIED")
516    if let Some(er) = &rules.r#enum {
517        let enum_required = er.not_in.contains(&0);
518        if enum_required || msg_required {
519            return Some(FieldConstraint {
520                field: camel_name,
521                min: None,
522                max: None,
523                signed_min: None,
524                signed_max: None,
525                pattern: None,
526                enum_values: Vec::new(),
527                required: enum_required || msg_required,
528                is_uuid: false,
529                is_numeric: false,
530            });
531        }
532    }
533
534    // Message-only constraint (required without string/int rules)
535    if msg_required {
536        let is_uuid = field_type_id == field_type::MESSAGE
537            && field
538                .type_name
539                .as_deref()
540                .is_some_and(|t| t.ends_with(".UUID")); // proto type name, not file extension
541
542        return Some(FieldConstraint {
543            field: camel_name,
544            min: None,
545            max: None,
546            signed_min: None,
547            signed_max: None,
548            pattern: None,
549            enum_values: Vec::new(),
550            required: true,
551            is_uuid,
552            is_numeric: false,
553        });
554    }
555
556    None
557}
558
559/// Extract enum rewrites for schemas containing prefix-stripped enums.
560fn extract_enum_rewrites(fdset: &FileDescriptorSet) -> (Vec<EnumRewrite>, HashMap<String, String>) {
561    let mut prefix_enums: Vec<(String, String, Vec<String>)> = Vec::new();
562
563    for file in &fdset.file {
564        let package = file.package.as_deref().unwrap_or("");
565        for enum_desc in &file.enum_type {
566            let enum_name = enum_desc.name.as_deref().unwrap_or("");
567            let fqn = format!(".{package}.{enum_name}");
568
569            let values: Vec<&str> = enum_desc
570                .value
571                .iter()
572                .filter_map(|v| v.name.as_deref())
573                .collect();
574
575            let Some(detected_prefix) = detect_enum_prefix(&values) else {
576                continue;
577            };
578
579            if values.iter().all(|v| v.starts_with(&detected_prefix)) {
580                let stripped: Vec<String> = values
581                    .iter()
582                    .map(|v| v[detected_prefix.len()..].to_lowercase())
583                    .collect();
584                prefix_enums.push((fqn, detected_prefix, stripped));
585            }
586        }
587    }
588
589    if prefix_enums.is_empty() {
590        return (Vec::new(), HashMap::new());
591    }
592
593    // Build global raw → stripped value map
594    let mut enum_value_map = HashMap::new();
595    for file in &fdset.file {
596        for enum_desc in &file.enum_type {
597            let values: Vec<&str> = enum_desc
598                .value
599                .iter()
600                .filter_map(|v| v.name.as_deref())
601                .collect();
602
603            let Some(detected_prefix) = detect_enum_prefix(&values) else {
604                continue;
605            };
606
607            for raw in &values {
608                if let Some(suffix) = raw.strip_prefix(detected_prefix.as_str()) {
609                    enum_value_map.insert(raw.to_string(), suffix.to_lowercase());
610                }
611            }
612        }
613    }
614
615    // Find all message fields referencing these enums (type 14 = TYPE_ENUM)
616    let mut rewrites = Vec::new();
617
618    for file in &fdset.file {
619        let package = file.package.as_deref().unwrap_or("");
620        collect_enum_rewrite_fields(&mut rewrites, package, &file.message_type, &prefix_enums);
621    }
622
623    (rewrites, enum_value_map)
624}
625
626/// Recursively scan messages for enum fields referencing prefix-stripped enums.
627fn collect_enum_rewrite_fields(
628    rewrites: &mut Vec<EnumRewrite>,
629    parent_path: &str,
630    messages: &[DescriptorProto],
631    prefix_enums: &[(String, String, Vec<String>)],
632) {
633    for msg in messages {
634        let msg_name = msg.name.as_deref().unwrap_or("");
635        let schema = format!("{parent_path}.{msg_name}");
636
637        for field in &msg.field {
638            if field.r#type != Some(field_type::ENUM) {
639                continue;
640            }
641
642            let Some(type_name) = field.type_name.as_deref() else {
643                continue;
644            };
645
646            if let Some((_, _, stripped_values)) =
647                prefix_enums.iter().find(|(fqn, _, _)| fqn == type_name)
648            {
649                let field_name = snake_to_lower_camel(field.name.as_deref().unwrap_or(""));
650
651                rewrites.push(EnumRewrite {
652                    schema: schema.clone(),
653                    field: field_name,
654                    values: stripped_values.clone(),
655                });
656            }
657        }
658
659        collect_enum_rewrite_fields(rewrites, &schema, &msg.nested_type, prefix_enums);
660    }
661}
662
663/// Detect the common `UPPER_SNAKE_CASE_` prefix shared by all enum values.
664///
665/// Returns `None` if values don't share a common `_`-terminated prefix.
666fn detect_enum_prefix(values: &[&str]) -> Option<String> {
667    if values.is_empty() {
668        return None;
669    }
670
671    let first = values[0];
672    let common_len = first
673        .char_indices()
674        .find(|&(i, _)| values[1..].iter().any(|v| !v[..].starts_with(&first[..=i])))
675        .map_or(first.len(), |(i, _)| i);
676
677    let prefix = &first[..common_len];
678    let last_underscore = prefix.rfind('_')?;
679    let prefix = &first[..=last_underscore];
680
681    if prefix.len() < 3 {
682        return None;
683    }
684
685    Some(prefix.to_string())
686}
687
688/// Detect redirect endpoints by examining response message types.
689fn extract_redirect_paths(fdset: &FileDescriptorSet) -> Vec<String> {
690    let mut redirect_types: Vec<String> = Vec::new();
691    for file in &fdset.file {
692        let package = file.package.as_deref().unwrap_or("");
693        collect_redirect_message_types(&mut redirect_types, package, &file.message_type);
694    }
695
696    if redirect_types.is_empty() {
697        return Vec::new();
698    }
699
700    let mut paths = Vec::new();
701    for file in &fdset.file {
702        for service in &file.service {
703            for method in &service.method {
704                let output_type = method.output_type.as_deref().unwrap_or("");
705                if !redirect_types.iter().any(|t| t == output_type) {
706                    continue;
707                }
708
709                if let Some((_, path)) = descriptor::extract_http_pattern(method) {
710                    paths.push(path.to_string());
711                }
712            }
713        }
714    }
715
716    paths
717}
718
719/// Recursively find messages with a `redirect_url` field.
720fn collect_redirect_message_types(
721    result: &mut Vec<String>,
722    package: &str,
723    messages: &[DescriptorProto],
724) {
725    for msg in messages {
726        let msg_name = msg.name.as_deref().unwrap_or("");
727        let has_redirect_url = msg
728            .field
729            .iter()
730            .any(|f| f.name.as_deref() == Some("redirect_url"));
731
732        if has_redirect_url {
733            result.push(format!(".{package}.{msg_name}"));
734        }
735
736        collect_redirect_message_types(result, package, &msg.nested_type);
737    }
738}
739
740/// Detect the UUID wrapper schema name from proto descriptors.
741fn detect_uuid_schema(fdset: &FileDescriptorSet) -> Option<String> {
742    for file in &fdset.file {
743        let package = file.package.as_deref().unwrap_or("");
744        for msg in &file.message_type {
745            let msg_name = msg.name.as_deref().unwrap_or("");
746
747            if msg.field.len() != 1 {
748                continue;
749            }
750            let field = &msg.field[0];
751            if field.name.as_deref() != Some("value") || field.r#type != Some(field_type::STRING) {
752                continue;
753            }
754
755            let has_uuid_pattern = field
756                .options
757                .as_ref()
758                .and_then(|o| o.rules.as_ref())
759                .and_then(|r| r.string.as_ref())
760                .and_then(|s| s.pattern.as_deref())
761                .is_some_and(|p| p.contains("0-9a-fA-F"));
762
763            if has_uuid_pattern {
764                return Some(format!("{package}.{msg_name}"));
765            }
766        }
767    }
768    None
769}
770
771/// Extract path parameter constraints from proto HTTP path templates.
772#[allow(clippy::case_sensitive_file_extension_comparisons)] // proto type names, not file paths
773fn extract_path_param_constraints(fdset: &FileDescriptorSet) -> Vec<PathParamInfo> {
774    let mut messages: HashMap<String, &[FieldDescriptorProto]> = HashMap::new();
775    for file in &fdset.file {
776        let package = file.package.as_deref().unwrap_or("");
777        collect_message_fields(&mut messages, package, &file.message_type);
778    }
779
780    let mut result = Vec::new();
781
782    for file in &fdset.file {
783        for service in &file.service {
784            for method in &service.method {
785                let Some((_, path)) = descriptor::extract_http_pattern(method) else {
786                    continue;
787                };
788
789                let param_names: Vec<&str> = path
790                    .split('{')
791                    .skip(1)
792                    .filter_map(|s| s.split('}').next())
793                    .collect();
794
795                if param_names.is_empty() {
796                    continue;
797                }
798
799                let input_type = method.input_type.as_deref().unwrap_or("");
800                let fields = messages.get(input_type).copied().unwrap_or_default();
801
802                let params: Vec<PathParamConstraint> = param_names
803                    .iter()
804                    .filter_map(|&param| {
805                        let root_field = param.split('.').next().unwrap_or(param);
806                        let field = fields
807                            .iter()
808                            .find(|f| f.name.as_deref() == Some(root_field))?;
809
810                        let is_uuid = field.r#type == Some(field_type::MESSAGE)
811                            && field
812                                .type_name
813                                .as_deref()
814                                .is_some_and(|t| t.ends_with(".UUID")); // proto type name, not file extension
815
816                        let (min, max) = field
817                            .options
818                            .as_ref()
819                            .and_then(|o| o.rules.as_ref())
820                            .and_then(|rules| rules.string.as_ref())
821                            .map(|s| (s.min_len, s.max_len))
822                            .unwrap_or_default();
823
824                        Some(PathParamConstraint {
825                            name: param
826                                .split('.')
827                                .enumerate()
828                                .map(|(i, seg)| {
829                                    if i == 0 {
830                                        snake_to_lower_camel(seg)
831                                    } else {
832                                        seg.to_string()
833                                    }
834                                })
835                                .collect::<Vec<_>>()
836                                .join("."),
837                            description: None,
838                            is_uuid,
839                            min,
840                            max,
841                        })
842                    })
843                    .collect();
844
845                if !params.is_empty() {
846                    let gnostic_path = convert_path_template_to_camel(path);
847                    result.push(PathParamInfo {
848                        path: gnostic_path,
849                        params,
850                    });
851                }
852            }
853        }
854    }
855
856    result
857}
858
859/// Convert proto path template variables to gnostic's camelCase format.
860fn convert_path_template_to_camel(path: &str) -> String {
861    let mut result = String::with_capacity(path.len());
862    let mut rest = path;
863
864    while let Some(start) = rest.find('{') {
865        let Some(end) = rest[start..].find('}') else {
866            break;
867        };
868        let end = start + end;
869
870        result.push_str(&rest[..=start]);
871        let var = &rest[start + 1..end];
872
873        if let Some((root, suffix)) = var.split_once('.') {
874            result.push_str(&snake_to_lower_camel(root));
875            result.push('.');
876            result.push_str(suffix);
877        } else {
878            result.push_str(&snake_to_lower_camel(var));
879        }
880
881        result.push('}');
882        rest = &rest[end + 1..];
883    }
884
885    result.push_str(rest);
886    result
887}
888
889/// Build a lookup table: message FQN → field descriptors.
890fn collect_message_fields<'a>(
891    result: &mut HashMap<String, &'a [FieldDescriptorProto]>,
892    parent_path: &str,
893    messages: &'a [DescriptorProto],
894) {
895    for msg in messages {
896        let msg_name = msg.name.as_deref().unwrap_or("");
897        let fqn = format!(".{parent_path}.{msg_name}");
898        result.insert(fqn.clone(), &msg.field);
899        collect_message_fields(result, &fqn[1..], &msg.nested_type);
900    }
901}
902
903/// Convert `snake_case` to `lowerCamelCase` (matches gnostic JSON field names).
904pub(crate) fn snake_to_lower_camel(s: &str) -> String {
905    let mut result = String::new();
906    let mut capitalize_next = false;
907    for c in s.chars() {
908        if c == '_' {
909            capitalize_next = true;
910        } else if capitalize_next {
911            result.push(c.to_ascii_uppercase());
912            capitalize_next = false;
913        } else {
914            result.push(c);
915        }
916    }
917    result
918}
919
920#[cfg(test)]
921mod tests {
922    use prost::Message as _;
923
924    use crate::descriptor::*;
925
926    use super::*;
927
928    fn make_field(name: &str, ty: i32) -> FieldDescriptorProto {
929        FieldDescriptorProto {
930            name: Some(name.to_string()),
931            r#type: Some(ty),
932            type_name: None,
933            options: None,
934        }
935    }
936
937    fn make_service_with_http(
938        service_name: &str,
939        method_name: &str,
940        pattern: HttpPattern,
941        server_streaming: bool,
942    ) -> ServiceDescriptorProto {
943        ServiceDescriptorProto {
944            name: Some(service_name.to_string()),
945            method: vec![MethodDescriptorProto {
946                name: Some(method_name.to_string()),
947                input_type: Some(".test.v1.Request".to_string()),
948                output_type: Some(".test.v1.Response".to_string()),
949                options: Some(MethodOptions {
950                    http: Some(HttpRule {
951                        pattern: Some(pattern),
952                        body: String::new(),
953                    }),
954                }),
955                client_streaming: None,
956                server_streaming: Some(server_streaming),
957            }],
958        }
959    }
960
961    fn make_fdset_with_services(services: Vec<ServiceDescriptorProto>) -> FileDescriptorSet {
962        FileDescriptorSet {
963            file: vec![FileDescriptorProto {
964                name: Some("test.proto".to_string()),
965                package: Some("test.v1".to_string()),
966                message_type: vec![DescriptorProto {
967                    name: Some("Request".to_string()),
968                    field: vec![make_field("name", field_type::STRING)],
969                    nested_type: vec![],
970                }],
971                enum_type: vec![],
972                service: services,
973            }],
974        }
975    }
976
977    #[test]
978    fn discover_extracts_streaming_ops() {
979        let fdset = make_fdset_with_services(vec![make_service_with_http(
980            "TestService",
981            "ListItems",
982            HttpPattern::Get("/v1/items".to_string()),
983            true,
984        )]);
985        let bytes = fdset.encode_to_vec();
986        let metadata = discover(&bytes).unwrap();
987
988        assert_eq!(metadata.streaming_ops.len(), 1);
989        assert_eq!(metadata.streaming_ops[0].method, "get");
990        assert_eq!(metadata.streaming_ops[0].path, "/v1/items");
991    }
992
993    #[test]
994    fn discover_skips_non_streaming() {
995        let fdset = make_fdset_with_services(vec![make_service_with_http(
996            "TestService",
997            "GetItem",
998            HttpPattern::Get("/v1/items/{id}".to_string()),
999            false,
1000        )]);
1001        let bytes = fdset.encode_to_vec();
1002        let metadata = discover(&bytes).unwrap();
1003
1004        assert!(metadata.streaming_ops.is_empty());
1005    }
1006
1007    #[test]
1008    fn discover_extracts_operation_ids() {
1009        let fdset = make_fdset_with_services(vec![make_service_with_http(
1010            "ItemService",
1011            "CreateItem",
1012            HttpPattern::Post("/v1/items".to_string()),
1013            false,
1014        )]);
1015        let bytes = fdset.encode_to_vec();
1016        let metadata = discover(&bytes).unwrap();
1017
1018        assert_eq!(metadata.operation_ids.len(), 1);
1019        assert_eq!(metadata.operation_ids[0].method_name, "CreateItem");
1020        assert_eq!(
1021            metadata.operation_ids[0].operation_id,
1022            "ItemService_CreateItem"
1023        );
1024    }
1025
1026    #[test]
1027    fn resolve_operation_ids_success() {
1028        let fdset = make_fdset_with_services(vec![make_service_with_http(
1029            "AuthService",
1030            "Authenticate",
1031            HttpPattern::Post("/v1/auth/authenticate".to_string()),
1032            false,
1033        )]);
1034        let bytes = fdset.encode_to_vec();
1035        let metadata = discover(&bytes).unwrap();
1036
1037        let resolved = resolve_operation_ids(&metadata, &["Authenticate"]).unwrap();
1038        assert_eq!(resolved, vec!["AuthService_Authenticate"]);
1039    }
1040
1041    #[test]
1042    fn resolve_operation_ids_missing() {
1043        let fdset = make_fdset_with_services(vec![]);
1044        let bytes = fdset.encode_to_vec();
1045        let metadata = discover(&bytes).unwrap();
1046
1047        let result = resolve_operation_ids(&metadata, &["NonExistent"]);
1048        assert!(result.is_err());
1049    }
1050
1051    #[test]
1052    fn resolve_qualified_service_method() {
1053        let fdset = FileDescriptorSet {
1054            file: vec![FileDescriptorProto {
1055                name: Some("test.proto".to_string()),
1056                package: Some("test.v1".to_string()),
1057                message_type: vec![DescriptorProto {
1058                    name: Some("Request".to_string()),
1059                    field: vec![make_field("name", field_type::STRING)],
1060                    nested_type: vec![],
1061                }],
1062                enum_type: vec![],
1063                service: vec![
1064                    make_service_with_http(
1065                        "AuthService",
1066                        "Delete",
1067                        HttpPattern::Delete("/v1/auth".to_string()),
1068                        false,
1069                    ),
1070                    make_service_with_http(
1071                        "UserService",
1072                        "Delete",
1073                        HttpPattern::Delete("/v1/users".to_string()),
1074                        false,
1075                    ),
1076                ],
1077            }],
1078        };
1079        let bytes = fdset.encode_to_vec();
1080        let metadata = discover(&bytes).unwrap();
1081
1082        // Qualified name should resolve correctly
1083        let resolved = resolve_operation_ids(&metadata, &["AuthService.Delete"]).unwrap();
1084        assert_eq!(resolved, vec!["AuthService_Delete"]);
1085
1086        let resolved = resolve_operation_ids(&metadata, &["UserService.Delete"]).unwrap();
1087        assert_eq!(resolved, vec!["UserService_Delete"]);
1088    }
1089
1090    #[test]
1091    fn resolve_ambiguous_bare_name_errors() {
1092        let fdset = FileDescriptorSet {
1093            file: vec![FileDescriptorProto {
1094                name: Some("test.proto".to_string()),
1095                package: Some("test.v1".to_string()),
1096                message_type: vec![DescriptorProto {
1097                    name: Some("Request".to_string()),
1098                    field: vec![make_field("name", field_type::STRING)],
1099                    nested_type: vec![],
1100                }],
1101                enum_type: vec![],
1102                service: vec![
1103                    make_service_with_http(
1104                        "AuthService",
1105                        "Delete",
1106                        HttpPattern::Delete("/v1/auth".to_string()),
1107                        false,
1108                    ),
1109                    make_service_with_http(
1110                        "UserService",
1111                        "Delete",
1112                        HttpPattern::Delete("/v1/users".to_string()),
1113                        false,
1114                    ),
1115                ],
1116            }],
1117        };
1118        let bytes = fdset.encode_to_vec();
1119        let metadata = discover(&bytes).unwrap();
1120
1121        // Bare "Delete" is ambiguous — should error
1122        let result = resolve_operation_ids(&metadata, &["Delete"]);
1123        assert!(result.is_err());
1124        let err = result.unwrap_err();
1125        let err_msg = err.to_string();
1126        assert!(
1127            err_msg.contains("ambiguous"),
1128            "error should mention ambiguity: {err_msg}"
1129        );
1130        assert!(
1131            err_msg.contains("Service.Method"),
1132            "error should suggest qualified syntax: {err_msg}"
1133        );
1134    }
1135
1136    #[test]
1137    fn snake_to_lower_camel_basic() {
1138        assert_eq!(snake_to_lower_camel("device_id"), "deviceId");
1139        assert_eq!(snake_to_lower_camel("user_id"), "userId");
1140        assert_eq!(snake_to_lower_camel("name"), "name");
1141        assert_eq!(snake_to_lower_camel("client_version"), "clientVersion");
1142    }
1143
1144    #[test]
1145    fn convert_path_template_to_camel_works() {
1146        assert_eq!(
1147            convert_path_template_to_camel("/v1/sessions/{device_id}"),
1148            "/v1/sessions/{deviceId}"
1149        );
1150        assert_eq!(
1151            convert_path_template_to_camel("/v1/users/{user_id.value}"),
1152            "/v1/users/{userId.value}"
1153        );
1154        assert_eq!(convert_path_template_to_camel("/v1/items"), "/v1/items");
1155    }
1156
1157    #[test]
1158    fn detect_enum_prefix_common() {
1159        let values = ["HEALTH_STATUS_HEALTHY", "HEALTH_STATUS_UNHEALTHY"];
1160        assert_eq!(
1161            detect_enum_prefix(&values),
1162            Some("HEALTH_STATUS_".to_string())
1163        );
1164    }
1165
1166    #[test]
1167    fn detect_enum_prefix_none_for_no_common() {
1168        let values = ["FOO", "BAR"];
1169        assert_eq!(detect_enum_prefix(&values), None);
1170    }
1171
1172    #[test]
1173    fn detect_enum_prefix_empty() {
1174        let values: &[&str] = &[];
1175        assert_eq!(detect_enum_prefix(values), None);
1176    }
1177
1178    #[test]
1179    fn enum_rewrites_detected() {
1180        let fdset = FileDescriptorSet {
1181            file: vec![FileDescriptorProto {
1182                name: Some("test.proto".to_string()),
1183                package: Some("test.v1".to_string()),
1184                message_type: vec![DescriptorProto {
1185                    name: Some("Response".to_string()),
1186                    field: vec![FieldDescriptorProto {
1187                        name: Some("status".to_string()),
1188                        r#type: Some(field_type::ENUM),
1189                        type_name: Some(".test.v1.Status".to_string()),
1190                        options: None,
1191                    }],
1192                    nested_type: vec![],
1193                }],
1194                enum_type: vec![EnumDescriptorProto {
1195                    name: Some("Status".to_string()),
1196                    value: vec![
1197                        EnumValueDescriptorProto {
1198                            name: Some("STATUS_UNSPECIFIED".to_string()),
1199                            number: Some(0),
1200                        },
1201                        EnumValueDescriptorProto {
1202                            name: Some("STATUS_ACTIVE".to_string()),
1203                            number: Some(1),
1204                        },
1205                    ],
1206                }],
1207                service: vec![],
1208            }],
1209        };
1210        let bytes = fdset.encode_to_vec();
1211        let metadata = discover(&bytes).unwrap();
1212
1213        assert_eq!(metadata.enum_rewrites.len(), 1);
1214        assert_eq!(metadata.enum_rewrites[0].schema, "test.v1.Response");
1215        assert_eq!(metadata.enum_rewrites[0].field, "status");
1216        assert_eq!(
1217            metadata.enum_rewrites[0].values,
1218            vec!["unspecified", "active"]
1219        );
1220    }
1221
1222    #[test]
1223    fn redirect_paths_detected() {
1224        let fdset = FileDescriptorSet {
1225            file: vec![FileDescriptorProto {
1226                name: Some("test.proto".to_string()),
1227                package: Some("test.v1".to_string()),
1228                message_type: vec![DescriptorProto {
1229                    name: Some("RedirectResponse".to_string()),
1230                    field: vec![make_field("redirect_url", field_type::STRING)],
1231                    nested_type: vec![],
1232                }],
1233                enum_type: vec![],
1234                service: vec![ServiceDescriptorProto {
1235                    name: Some("TestService".to_string()),
1236                    method: vec![MethodDescriptorProto {
1237                        name: Some("DoRedirect".to_string()),
1238                        input_type: Some(".test.v1.Request".to_string()),
1239                        output_type: Some(".test.v1.RedirectResponse".to_string()),
1240                        options: Some(MethodOptions {
1241                            http: Some(HttpRule {
1242                                pattern: Some(HttpPattern::Get("/v1/redirect".to_string())),
1243                                body: String::new(),
1244                            }),
1245                        }),
1246                        client_streaming: None,
1247                        server_streaming: None,
1248                    }],
1249                }],
1250            }],
1251        };
1252        let bytes = fdset.encode_to_vec();
1253        let metadata = discover(&bytes).unwrap();
1254
1255        assert_eq!(metadata.redirect_paths, vec!["/v1/redirect"]);
1256    }
1257
1258    #[test]
1259    fn nested_message_constraints_use_qualified_path() {
1260        let fdset = FileDescriptorSet {
1261            file: vec![FileDescriptorProto {
1262                name: Some("test.proto".to_string()),
1263                package: Some("test.v1".to_string()),
1264                message_type: vec![DescriptorProto {
1265                    name: Some("Outer".to_string()),
1266                    field: vec![FieldDescriptorProto {
1267                        name: Some("name".to_string()),
1268                        r#type: Some(field_type::STRING),
1269                        type_name: None,
1270                        options: Some(FieldOptions {
1271                            rules: Some(FieldRules {
1272                                string: Some(StringRules {
1273                                    min_len: Some(1),
1274                                    max_len: Some(100),
1275                                    pattern: None,
1276                                    r#in: vec![],
1277                                    uuid: None,
1278                                }),
1279                                ..Default::default()
1280                            }),
1281                        }),
1282                    }],
1283                    nested_type: vec![DescriptorProto {
1284                        name: Some("Inner".to_string()),
1285                        field: vec![FieldDescriptorProto {
1286                            name: Some("value".to_string()),
1287                            r#type: Some(field_type::STRING),
1288                            type_name: None,
1289                            options: Some(FieldOptions {
1290                                rules: Some(FieldRules {
1291                                    string: Some(StringRules {
1292                                        min_len: Some(3),
1293                                        max_len: None,
1294                                        pattern: None,
1295                                        r#in: vec![],
1296                                        uuid: None,
1297                                    }),
1298                                    ..Default::default()
1299                                }),
1300                            }),
1301                        }],
1302                        nested_type: vec![],
1303                    }],
1304                }],
1305                enum_type: vec![],
1306                service: vec![],
1307            }],
1308        };
1309        let bytes = fdset.encode_to_vec();
1310        let metadata = discover(&bytes).unwrap();
1311
1312        // Outer message should be "test.v1.Outer"
1313        let outer = metadata
1314            .field_constraints
1315            .iter()
1316            .find(|c| c.schema == "test.v1.Outer");
1317        assert!(outer.is_some(), "Outer constraint should exist");
1318
1319        // Nested message should be "test.v1.Outer.Inner", NOT "test.v1.Inner"
1320        let inner = metadata
1321            .field_constraints
1322            .iter()
1323            .find(|c| c.schema == "test.v1.Outer.Inner");
1324        assert!(
1325            inner.is_some(),
1326            "Nested Inner constraint should use fully qualified path: {:?}",
1327            metadata
1328                .field_constraints
1329                .iter()
1330                .map(|c| &c.schema)
1331                .collect::<Vec<_>>()
1332        );
1333
1334        // Ensure the old wrong path is NOT present
1335        let wrong = metadata
1336            .field_constraints
1337            .iter()
1338            .find(|c| c.schema == "test.v1.Inner");
1339        assert!(
1340            wrong.is_none(),
1341            "Should not have bare 'test.v1.Inner' schema"
1342        );
1343    }
1344
1345    #[test]
1346    fn nested_message_fields_use_qualified_fqn() {
1347        let fdset = FileDescriptorSet {
1348            file: vec![FileDescriptorProto {
1349                name: Some("test.proto".to_string()),
1350                package: Some("test.v1".to_string()),
1351                message_type: vec![DescriptorProto {
1352                    name: Some("Outer".to_string()),
1353                    field: vec![make_field("name", field_type::STRING)],
1354                    nested_type: vec![DescriptorProto {
1355                        name: Some("Inner".to_string()),
1356                        field: vec![make_field("value", field_type::STRING)],
1357                        nested_type: vec![],
1358                    }],
1359                }],
1360                enum_type: vec![],
1361                service: vec![ServiceDescriptorProto {
1362                    name: Some("Svc".to_string()),
1363                    method: vec![MethodDescriptorProto {
1364                        name: Some("Do".to_string()),
1365                        input_type: Some(".test.v1.Outer.Inner".to_string()),
1366                        output_type: Some(".test.v1.Outer".to_string()),
1367                        options: Some(MethodOptions {
1368                            http: Some(HttpRule {
1369                                pattern: Some(HttpPattern::Get("/v1/outer/{value}".to_string())),
1370                                body: String::new(),
1371                            }),
1372                        }),
1373                        client_streaming: None,
1374                        server_streaming: None,
1375                    }],
1376                }],
1377            }],
1378        };
1379        let bytes = fdset.encode_to_vec();
1380        let metadata = discover(&bytes).unwrap();
1381
1382        // Path param lookup should resolve .test.v1.Outer.Inner (not .test.v1.Inner)
1383        assert!(
1384            !metadata.path_param_constraints.is_empty(),
1385            "should find path params via nested message FQN"
1386        );
1387    }
1388
1389    #[test]
1390    fn nested_enum_rewrites_use_qualified_schema() {
1391        let fdset = FileDescriptorSet {
1392            file: vec![FileDescriptorProto {
1393                name: Some("test.proto".to_string()),
1394                package: Some("test.v1".to_string()),
1395                message_type: vec![DescriptorProto {
1396                    name: Some("Outer".to_string()),
1397                    field: vec![],
1398                    nested_type: vec![DescriptorProto {
1399                        name: Some("Inner".to_string()),
1400                        field: vec![FieldDescriptorProto {
1401                            name: Some("status".to_string()),
1402                            r#type: Some(field_type::ENUM),
1403                            type_name: Some(".test.v1.Status".to_string()),
1404                            options: None,
1405                        }],
1406                        nested_type: vec![],
1407                    }],
1408                }],
1409                enum_type: vec![EnumDescriptorProto {
1410                    name: Some("Status".to_string()),
1411                    value: vec![
1412                        EnumValueDescriptorProto {
1413                            name: Some("STATUS_UNSPECIFIED".to_string()),
1414                            number: Some(0),
1415                        },
1416                        EnumValueDescriptorProto {
1417                            name: Some("STATUS_ACTIVE".to_string()),
1418                            number: Some(1),
1419                        },
1420                    ],
1421                }],
1422                service: vec![],
1423            }],
1424        };
1425        let bytes = fdset.encode_to_vec();
1426        let metadata = discover(&bytes).unwrap();
1427
1428        assert_eq!(metadata.enum_rewrites.len(), 1);
1429        // Should be qualified as "test.v1.Outer.Inner", not "test.v1.Inner"
1430        assert_eq!(
1431            metadata.enum_rewrites[0].schema, "test.v1.Outer.Inner",
1432            "nested enum rewrite should use fully qualified schema path"
1433        );
1434    }
1435
1436    #[test]
1437    fn int32_boundary_values_no_overflow() {
1438        // Test gt = i32::MAX should not overflow
1439        let fdset = FileDescriptorSet {
1440            file: vec![FileDescriptorProto {
1441                name: Some("test.proto".to_string()),
1442                package: Some("test.v1".to_string()),
1443                message_type: vec![DescriptorProto {
1444                    name: Some("Request".to_string()),
1445                    field: vec![FieldDescriptorProto {
1446                        name: Some("count".to_string()),
1447                        r#type: Some(field_type::INT32),
1448                        type_name: None,
1449                        options: Some(FieldOptions {
1450                            rules: Some(FieldRules {
1451                                int32: Some(Int32Rules {
1452                                    gte: Some(-100),
1453                                    lte: Some(100),
1454                                    gt: None,
1455                                    lt: None,
1456                                }),
1457                                ..Default::default()
1458                            }),
1459                        }),
1460                    }],
1461                    nested_type: vec![],
1462                }],
1463                enum_type: vec![],
1464                service: vec![],
1465            }],
1466        };
1467        let bytes = fdset.encode_to_vec();
1468        let metadata = discover(&bytes).unwrap();
1469
1470        assert_eq!(metadata.field_constraints.len(), 1);
1471        let fc = &metadata.field_constraints[0].fields[0];
1472        assert_eq!(fc.signed_min, Some(-100));
1473        assert_eq!(fc.signed_max, Some(100));
1474        assert!(fc.is_numeric);
1475    }
1476
1477    #[test]
1478    fn uint32_lt_zero_no_underflow() {
1479        // Test lt = 0 should not underflow (saturates to 0)
1480        let fdset = FileDescriptorSet {
1481            file: vec![FileDescriptorProto {
1482                name: Some("test.proto".to_string()),
1483                package: Some("test.v1".to_string()),
1484                message_type: vec![DescriptorProto {
1485                    name: Some("Request".to_string()),
1486                    field: vec![FieldDescriptorProto {
1487                        name: Some("count".to_string()),
1488                        r#type: Some(field_type::UINT32),
1489                        type_name: None,
1490                        options: Some(FieldOptions {
1491                            rules: Some(FieldRules {
1492                                uint32: Some(UInt32Rules {
1493                                    lt: Some(0),
1494                                    lte: None,
1495                                    gt: None,
1496                                    gte: None,
1497                                }),
1498                                ..Default::default()
1499                            }),
1500                        }),
1501                    }],
1502                    nested_type: vec![],
1503                }],
1504                enum_type: vec![],
1505                service: vec![],
1506            }],
1507        };
1508        let bytes = fdset.encode_to_vec();
1509        // Should not panic from underflow
1510        let metadata = discover(&bytes).unwrap();
1511        assert_eq!(metadata.field_constraints.len(), 1);
1512        let fc = &metadata.field_constraints[0].fields[0];
1513        assert_eq!(fc.max, Some(0)); // saturated
1514    }
1515}