oas_forge/
visitor.rs

1use serde_json::{Value, json};
2use syn::spanned::Spanned;
3use syn::visit::{self, Visit};
4use syn::{Attribute, Expr, File, ImplItemFn, ItemEnum, ItemFn, ItemMod, ItemStruct, ItemType};
5
6/// Extracted item type
7#[derive(Debug)]
8pub enum ExtractedItem {
9    /// Standard @openapi body
10    Schema {
11        name: Option<String>,
12        content: String,
13        line: usize,
14    },
15    /// @openapi-fragment Name(args...)
16    Fragment {
17        name: String,
18        params: Vec<String>,
19        content: String,
20        line: usize,
21    },
22    /// @openapi<T, U>
23    Blueprint {
24        name: String,
25        params: Vec<String>,
26        content: String,
27        line: usize,
28    },
29    // Raw DSL block (for late binding)
30    RouteDSL {
31        content: String,
32        line: usize,
33        operation_id: String,
34    },
35}
36
37#[derive(Default)]
38pub struct OpenApiVisitor {
39    pub items: Vec<ExtractedItem>,
40    pub current_tags: Vec<String>,
41}
42
43impl OpenApiVisitor {
44    // Process doc attributes on items (structs, fns, types)
45    // Updated: No longer accepts generated_content. Strictly for @openapi blocks (Paths/Fragments).
46    fn check_attributes(
47        &mut self,
48        attrs: &[Attribute],
49        item_ident: Option<String>,
50        item_line: usize,
51    ) {
52        let doc_lines = crate::doc_parser::extract_doc_comments(attrs);
53
54        let has_openapi = doc_lines.iter().any(|l| l.contains("@openapi"));
55
56        // Safety: Only process if explicit @openapi tag exists
57        if !has_openapi {
58            return;
59        }
60
61        let full_doc = doc_lines.join("\n");
62        self.parse_doc_block(&full_doc, item_ident, item_line);
63    }
64
65    fn parse_doc_block(&mut self, doc: &str, item_ident: Option<String>, line: usize) {
66        let lines: Vec<&str> = doc.lines().collect();
67        // Naive unindent
68        let min_indent = lines
69            .iter()
70            .filter(|line| !line.trim().is_empty())
71            .map(|line| line.chars().take_while(|c| *c == ' ').count())
72            .min()
73            .unwrap_or(0);
74
75        let unindented: Vec<String> = lines
76            .into_iter()
77            .map(|l| {
78                if l.len() >= min_indent {
79                    l[min_indent..].to_string()
80                } else {
81                    l.to_string()
82                }
83            })
84            .collect();
85        let content = unindented.join("\n");
86
87        let mut sections = Vec::new();
88        let mut current_header = String::new();
89        let mut current_body = Vec::new();
90
91        for line in content.lines() {
92            let trimmed = line.trim();
93            if trimmed.starts_with("@openapi") {
94                if !current_header.is_empty() || !current_body.is_empty() {
95                    sections.push((current_header.clone(), current_body.join("\n")));
96                }
97                current_header = trimmed.to_string();
98                current_body.clear();
99            } else if trimmed.starts_with('{') && current_header.is_empty() {
100                if !current_header.is_empty() || !current_body.is_empty() {
101                    sections.push((current_header.clone(), current_body.join("\n")));
102                }
103                current_header = "@json".to_string();
104                current_body.push(line.to_string());
105            } else {
106                current_body.push(line.to_string());
107            }
108        }
109        if !current_header.is_empty() || !current_body.is_empty() {
110            sections.push((current_header, current_body.join("\n")));
111        }
112
113        for (header, body) in sections {
114            let mut body_content = body.trim().to_string();
115
116            if header.starts_with("@openapi-fragment") {
117                let rest = header.strip_prefix("@openapi-fragment").unwrap().trim();
118                let (name, params) = if let Some(idx) = rest.find('(') {
119                    let name = rest[..idx].trim().to_string();
120                    let params_str = rest[idx + 1..].trim_end_matches(')');
121                    let params: Vec<String> = params_str
122                        .split(',')
123                        .map(|p| p.trim().to_string())
124                        .filter(|p| !p.is_empty())
125                        .collect();
126                    (name, params)
127                } else {
128                    (rest.to_string(), Vec::new())
129                };
130
131                self.items.push(ExtractedItem::Fragment {
132                    name,
133                    params,
134                    content: body_content,
135                    line,
136                });
137            } else if header.starts_with("@openapi-type") {
138                let name = header
139                    .strip_prefix("@openapi-type")
140                    .unwrap()
141                    .trim()
142                    .to_string();
143                // Wrap content in schema definition
144                let wrapped = wrap_in_schema(&name, &body_content);
145                self.items.push(ExtractedItem::Schema {
146                    name: Some(name),
147                    content: wrapped,
148                    line,
149                });
150            } else if header.starts_with("@openapi") && header.contains('<') {
151                if let Some(start) = header.find('<') {
152                    if let Some(end) = header.rfind('>') {
153                        let params_str = &header[start + 1..end];
154                        let params: Vec<String> = params_str
155                            .split(',')
156                            .map(|p| p.trim().to_string())
157                            .filter(|p| !p.is_empty())
158                            .collect();
159
160                        if let Some(ident) = &item_ident {
161                            self.items.push(ExtractedItem::Blueprint {
162                                name: ident.clone(),
163                                params,
164                                content: body_content,
165                                line,
166                            });
167                        }
168                    }
169                }
170            } else if (header.starts_with("@openapi") && !header.contains('<'))
171                || header == "@json"
172                || header.is_empty()
173            {
174                // TAG INJECTION
175                if !self.current_tags.is_empty() {
176                    let tags_yaml_list = self
177                        .current_tags
178                        .iter()
179                        .map(|t| format!("- {}", t))
180                        .collect::<Vec<_>>();
181
182                    let verbs = [
183                        "get:", "post:", "put:", "delete:", "patch:", "head:", "options:", "trace:",
184                    ];
185                    let mut new_lines = Vec::new();
186                    let mut injected_any = false;
187
188                    for line in body_content.lines() {
189                        new_lines.push(line.to_string());
190                        let trimmed = line.trim();
191                        if verbs.contains(&trimmed) {
192                            let indent = line.chars().take_while(|c| *c == ' ').count();
193                            let child_indent = " ".repeat(indent + 2);
194
195                            if !body_content.contains("tags:") {
196                                new_lines.push(format!("{}tags:", child_indent));
197                                for tag in &tags_yaml_list {
198                                    new_lines.push(format!("{}  {}", child_indent, tag));
199                                }
200                                injected_any = true;
201                            }
202                        }
203                    }
204
205                    if injected_any {
206                        body_content = new_lines.join("\n");
207                    }
208                }
209
210                // Auto-Wrap Heuristic (Only for manual blocks now)
211                let starts_with_toplevel = body_content.lines().any(|line| {
212                    let trimmed = line.trim();
213                    if trimmed.starts_with("#") {
214                        return false;
215                    }
216                    if let Some(key) = trimmed.split(':').next() {
217                        matches!(
218                            key.trim(),
219                            "openapi"
220                                | "info"
221                                | "paths"
222                                | "components"
223                                | "tags"
224                                | "servers"
225                                | "security"
226                        )
227                    } else {
228                        false
229                    }
230                });
231
232                let final_content = if !starts_with_toplevel && !body_content.trim().is_empty() {
233                    if let Some(n) = &item_ident {
234                        wrap_in_schema(n, &body_content)
235                    } else {
236                        body_content
237                    }
238                } else {
239                    body_content
240                };
241
242                self.items.push(ExtractedItem::Schema {
243                    name: item_ident.clone(),
244                    content: final_content,
245                    line,
246                });
247            }
248        }
249    }
250    // Helper to process a single struct field
251    fn process_struct_field(
252        field: &syn::Field,
253        rename_rule: &Option<String>,
254    ) -> (String, Value, bool) {
255        let default_field_name = field.ident.as_ref().unwrap().to_string();
256
257        // Extract field info
258        let (mut field_final_name, field_desc, _, field_doc_lines, _, _) =
259            crate::doc_parser::extract_naming_and_doc(&field.attrs, &default_field_name);
260
261        // Apply Rename Rule
262        // Only apply if the name hasn't been explicitly renamed via attributes
263        // AND there is a rename rule present.
264        if field_final_name == default_field_name {
265            if let Some(rule) = rename_rule {
266                field_final_name = crate::doc_parser::apply_casing(&field_final_name, rule);
267            }
268        }
269
270        let (mut field_schema, is_required) = map_syn_type_to_openapi(&field.ty);
271
272        // Field Description
273        if !field_desc.is_empty() {
274            if let Value::Object(map) = &mut field_schema {
275                map.insert("description".to_string(), Value::String(field_desc));
276            }
277        }
278
279        // Validation Attributes
280        let validation_props = crate::doc_parser::extract_validation(&field.attrs);
281        if !validation_props.as_object().unwrap().is_empty() {
282            json_merge(&mut field_schema, validation_props);
283        }
284
285        // Field Overrides (@openapi lines)
286        let mut field_openapi_lines = Vec::new();
287        let mut collecting_openapi = false;
288        for line in &field_doc_lines {
289            let trimmed = line.trim();
290            if trimmed.starts_with("@openapi") {
291                collecting_openapi = true;
292                let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
293                if !rest.is_empty() && !rest.starts_with("rename") {
294                    field_openapi_lines.push(rest.to_string());
295                }
296            } else if collecting_openapi {
297                field_openapi_lines.push(line.to_string());
298            }
299        }
300
301        if !field_openapi_lines.is_empty() {
302            let override_yaml = field_openapi_lines.join("\n");
303            match serde_yaml::from_str::<Value>(&override_yaml) {
304                Ok(override_val) => {
305                    if !override_val.is_null() {
306                        json_merge(&mut field_schema, override_val);
307                    }
308                }
309                Err(e) => {
310                    log::warn!(
311                        "Failed to parse @openapi override for field '{}': {}",
312                        default_field_name,
313                        e
314                    );
315                }
316            }
317        }
318
319        (field_final_name, field_schema, is_required)
320    }
321    fn process_enum_variant(
322        variant: &syn::Variant,
323        rename_rule: &Option<String>,
324    ) -> Option<String> {
325        if !matches!(variant.fields, syn::Fields::Unit) {
326            return None;
327        }
328        let default_variant_name = variant.ident.to_string();
329        // Extract variant info (renaming only)
330        let (mut variant_final_name, _, _, _, _, _) =
331            crate::doc_parser::extract_naming_and_doc(&variant.attrs, &default_variant_name);
332
333        // Apply Rename Rule
334        if variant_final_name == default_variant_name {
335            if let Some(rule) = rename_rule {
336                variant_final_name = crate::doc_parser::apply_casing(&variant_final_name, rule);
337            }
338        }
339        Some(variant_final_name)
340    }
341}
342
343// Helper to wrap content in components/schemas
344fn wrap_in_schema(name: &str, content: &str) -> String {
345    let indented = content
346        .lines()
347        .map(|l| format!("      {}", l))
348        .collect::<Vec<_>>()
349        .join("\n");
350    format!("components:\n  schemas:\n    {}:\n{}", name, indented)
351}
352
353pub use crate::type_mapper::map_syn_type_to_openapi;
354
355// Deep Merge Helper for JSON Values
356pub fn json_merge(a: &mut Value, b: Value) {
357    match (a, b) {
358        (Value::Object(a), Value::Object(b)) => {
359            for (k, v) in b {
360                json_merge(a.entry(k).or_insert(Value::Null), v);
361            }
362        }
363        (a, b) => *a = b,
364    }
365}
366
367impl<'ast> Visit<'ast> for OpenApiVisitor {
368    fn visit_file(&mut self, i: &'ast File) {
369        // State machine for file-level doc blocks
370        let mut current_block_type: Option<String> = None;
371        let mut current_block_lines = Vec::new();
372        let mut start_line = 1;
373
374        // Process file attributes (inner doc comments)
375        for attr in &i.attrs {
376            if attr.path().is_ident("doc") {
377                if let syn::Meta::NameValue(meta) = &attr.meta {
378                    if let Expr::Lit(expr_lit) = &meta.value {
379                        if let syn::Lit::Str(lit_str) = &expr_lit.lit {
380                            let raw_line = lit_str.value();
381                            let trimmed = raw_line.trim();
382
383                            if trimmed.starts_with("@openapi-type") {
384                                // Flush previous if exists
385                                if !current_block_lines.is_empty() {
386                                    let body = current_block_lines.join("\n");
387                                    if let Some(name) = current_block_type.take() {
388                                        let wrapped = wrap_in_schema(&name, &body);
389                                        self.items.push(ExtractedItem::Schema {
390                                            name: Some(name),
391                                            content: wrapped,
392                                            line: start_line,
393                                        });
394                                    } else {
395                                        // Standard Root/Fragment block
396                                        self.parse_doc_block(&body, None, start_line);
397                                    }
398                                    current_block_lines.clear();
399                                }
400
401                                // Start New Type
402                                if let Some(name) = trimmed.strip_prefix("@openapi-type") {
403                                    current_block_type = Some(name.trim().to_string());
404                                    start_line = attr.span().start().line;
405                                }
406                            } else if trimmed.starts_with("@openapi") {
407                                // Flush previous
408                                if !current_block_lines.is_empty() {
409                                    let body = current_block_lines.join("\n");
410                                    if let Some(name) = current_block_type.take() {
411                                        let wrapped = wrap_in_schema(&name, &body);
412                                        self.items.push(ExtractedItem::Schema {
413                                            name: Some(name),
414                                            content: wrapped,
415                                            line: start_line,
416                                        });
417                                    } else {
418                                        self.parse_doc_block(&body, None, start_line);
419                                    }
420                                    current_block_lines.clear();
421                                }
422
423                                // Start Root/Fragment
424                                current_block_type = None;
425                                start_line = attr.span().start().line;
426                                current_block_lines.push(raw_line); // preserve header
427                            } else if trimmed.starts_with("@route") {
428                                // Flush previous unless it was just general docs
429                                if !current_block_lines.is_empty() && current_block_type.is_some() {
430                                    // If we were building a type, flush it
431                                    let body = current_block_lines.join("\n");
432                                    if let Some(name) = current_block_type.take() {
433                                        let wrapped = wrap_in_schema(&name, &body);
434                                        self.items.push(ExtractedItem::Schema {
435                                            name: Some(name),
436                                            content: wrapped,
437                                            line: start_line,
438                                        });
439                                    }
440                                    current_block_lines.clear();
441                                    start_line = attr.span().start().line;
442                                } else if current_block_lines.is_empty() {
443                                    start_line = attr.span().start().line;
444                                }
445
446                                current_block_type = None;
447                                current_block_lines.push(raw_line);
448                            } else if !current_block_lines.is_empty()
449                                || current_block_type.is_some()
450                            {
451                                current_block_lines.push(raw_line);
452                            }
453                        }
454                    }
455                }
456            } else {
457                // Flush on non-doc attr to be safe
458                if !current_block_lines.is_empty() {
459                    let body = current_block_lines.join("\n");
460                    if let Some(name) = current_block_type.take() {
461                        let wrapped = wrap_in_schema(&name, &body);
462                        self.items.push(ExtractedItem::Schema {
463                            name: Some(name),
464                            content: wrapped,
465                            line: start_line,
466                        });
467                    } else {
468                        // Check if it's a virtual route
469                        if body.contains("@route") {
470                            self.items.push(ExtractedItem::RouteDSL {
471                                content: body,
472                                line: start_line,
473                                operation_id: format!("virtual_route_{}", start_line),
474                            });
475                        } else {
476                            self.parse_doc_block(&body, None, start_line);
477                        }
478                    }
479                    current_block_lines.clear();
480                }
481            }
482        }
483
484        // Flush EOF
485        if !current_block_lines.is_empty() {
486            let body = current_block_lines.join("\n");
487            if let Some(name) = current_block_type {
488                let wrapped = wrap_in_schema(&name, &body);
489                self.items.push(ExtractedItem::Schema {
490                    name: Some(name),
491                    content: wrapped,
492                    line: start_line,
493                });
494            } else {
495                // Check if it's a virtual route
496                if body.contains("@route") {
497                    self.items.push(ExtractedItem::RouteDSL {
498                        content: body,
499                        line: start_line,
500                        operation_id: format!("virtual_route_{}", start_line),
501                    });
502                } else {
503                    self.parse_doc_block(&body, None, start_line);
504                }
505            }
506        }
507
508        visit::visit_file(self, i);
509    }
510
511    fn visit_item_fn(&mut self, i: &'ast ItemFn) {
512        let mut doc_lines = Vec::new();
513        for attr in &i.attrs {
514            if attr.path().is_ident("doc") {
515                if let syn::Meta::NameValue(meta) = &attr.meta {
516                    if let Expr::Lit(expr_lit) = &meta.value {
517                        if let syn::Lit::Str(lit_str) = &expr_lit.lit {
518                            doc_lines.push(lit_str.value());
519                        }
520                    }
521                }
522            }
523        }
524
525        // Check for DSL trigger
526        let has_route = doc_lines.iter().any(|l| l.trim().starts_with("@route"));
527
528        if !has_route {
529            // Legacy Fallback
530            self.check_attributes(&i.attrs, None, i.span().start().line);
531            visit::visit_item_fn(self, i);
532            return;
533        }
534
535        // Emitting Raw DSL for late binding
536        let content = doc_lines.join("\n");
537        self.items.push(ExtractedItem::RouteDSL {
538            content,
539            line: i.span().start().line,
540            operation_id: i.sig.ident.to_string(),
541        });
542
543        visit::visit_item_fn(self, i);
544    }
545
546    fn visit_item_type(&mut self, i: &'ast ItemType) {
547        let ident = i.ident.to_string();
548        let (mut schema, _) = map_syn_type_to_openapi(&i.ty);
549
550        // Docs & Overrides
551        let mut desc_lines = Vec::new();
552        let mut openapi_lines = Vec::new();
553        let mut collecting_openapi = false;
554
555        for attr in &i.attrs {
556            if attr.path().is_ident("doc") {
557                if let syn::Meta::NameValue(meta) = &attr.meta {
558                    if let Expr::Lit(expr_lit) = &meta.value {
559                        if let syn::Lit::Str(lit_str) = &expr_lit.lit {
560                            let val = lit_str.value();
561                            let trimmed = val.trim();
562
563                            if trimmed.starts_with("@openapi") {
564                                collecting_openapi = true;
565                                let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
566                                if !rest.is_empty() {
567                                    openapi_lines.push(rest.to_string());
568                                }
569                            } else if collecting_openapi {
570                                openapi_lines.push(val.to_string());
571                            } else {
572                                desc_lines.push(val.trim().to_string());
573                            }
574                        }
575                    }
576                }
577            } else {
578                collecting_openapi = false;
579            }
580        }
581
582        if !desc_lines.is_empty() {
583            let desc_str = desc_lines.join(" ");
584            if let Value::Object(map) = &mut schema {
585                map.insert("description".to_string(), Value::String(desc_str));
586            }
587        }
588
589        if !openapi_lines.is_empty() {
590            let override_yaml = openapi_lines.join("\n");
591            if let Ok(override_val) = serde_yaml::from_str::<Value>(&override_yaml) {
592                if !override_val.is_null() {
593                    json_merge(&mut schema, override_val);
594                }
595            }
596        }
597
598        if let Ok(generated) = serde_yaml::to_string(&schema) {
599            let trimmed = generated.trim_start_matches("---\n").to_string();
600            let wrapped = wrap_in_schema(&ident, &trimmed);
601            self.items.push(ExtractedItem::Schema {
602                name: Some(ident),
603                content: wrapped,
604                line: i.span().start().line,
605            });
606        }
607
608        visit::visit_item_type(self, i);
609    }
610
611    fn visit_item_struct(&mut self, i: &'ast ItemStruct) {
612        // 1. Extract Info & Renaming
613        let default_name = i.ident.to_string();
614        let (final_name, struct_desc, rename_rule, doc_lines, _, _) =
615            crate::doc_parser::extract_naming_and_doc(&i.attrs, &default_name);
616
617        // Safety: Explicit export only (check raw doc lines for @openapi tag)
618        if !doc_lines.iter().any(|l| l.contains("@openapi")) {
619            visit::visit_item_struct(self, i);
620            return;
621        }
622
623        let mut properties = serde_json::Map::new();
624        let mut required_fields = Vec::new();
625        let mut has_fields = false;
626
627        if let syn::Fields::Named(fields) = &i.fields {
628            for field in &fields.named {
629                has_fields = true;
630                let (field_final_name, field_schema, is_required) =
631                    Self::process_struct_field(field, &rename_rule);
632
633                properties.insert(field_final_name.clone(), field_schema);
634                if is_required {
635                    required_fields.push(field_final_name);
636                }
637            }
638        }
639
640        // Struct Level Schema
641        let mut schema = if has_fields {
642            let mut s = json!({
643                "type": "object",
644                "properties": properties
645            });
646            if !required_fields.is_empty() {
647                if let Value::Object(map) = &mut s {
648                    map.insert("required".to_string(), json!(required_fields));
649                }
650            }
651            s
652        } else {
653            // Unit Struct
654            json!({ "type": "object" })
655        };
656
657        // Struct Description
658        if !struct_desc.is_empty() {
659            json_merge(&mut schema, json!({ "description": struct_desc }));
660        }
661
662        // Struct Overrides & Blueprint
663        let mut openapi_lines = Vec::new();
664        let mut collecting_openapi = false;
665        let mut blueprint_params: Option<Vec<String>> = None;
666
667        for line in &doc_lines {
668            let trimmed = line.trim();
669            if trimmed.starts_with("@openapi") {
670                collecting_openapi = true;
671                let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
672
673                if !rest.is_empty() && !rest.starts_with("rename") && !rest.starts_with("-type") {
674                    if rest.contains('<') {
675                        // Blueprint detection
676                        if let Some(start) = rest.find('<') {
677                            if let Some(end) = rest.rfind('>') {
678                                let params_str = &rest[start + 1..end];
679                                blueprint_params = Some(
680                                    params_str
681                                        .split(',')
682                                        .map(|p| p.trim().to_string())
683                                        .filter(|p| !p.is_empty())
684                                        .collect(),
685                                );
686
687                                let after_gt = rest[end + 1..].trim();
688                                if !after_gt.is_empty() {
689                                    openapi_lines.push(after_gt.to_string());
690                                }
691                            }
692                        }
693                    } else {
694                        openapi_lines.push(rest.to_string());
695                    }
696                }
697            } else if collecting_openapi {
698                openapi_lines.push(line.to_string());
699            }
700        }
701
702        if !openapi_lines.is_empty() {
703            let override_yaml = openapi_lines.join("\n");
704            match serde_yaml::from_str::<Value>(&override_yaml) {
705                Ok(override_val) => {
706                    if !override_val.is_null() {
707                        json_merge(&mut schema, override_val);
708                    }
709                }
710                Err(e) => {
711                    log::warn!(
712                        "Failed to parse @openapi override for struct '{}': {}",
713                        final_name,
714                        e
715                    );
716                }
717            }
718        }
719
720        // Final Serialize
721        match serde_yaml::to_string(&schema) {
722            Ok(generated) => {
723                let trimmed = generated.trim_start_matches("---\n").to_string();
724
725                if let Some(params) = blueprint_params {
726                    self.items.push(ExtractedItem::Blueprint {
727                        name: final_name,
728                        params,
729                        content: trimmed,
730                        line: i.span().start().line,
731                    });
732                } else {
733                    let wrapped = wrap_in_schema(&final_name, &trimmed);
734                    self.items.push(ExtractedItem::Schema {
735                        name: Some(final_name),
736                        content: wrapped,
737                        line: i.span().start().line,
738                    });
739                }
740            }
741            Err(e) => {
742                log::error!(
743                    "Failed to serialize schema for struct '{}': {}",
744                    default_name,
745                    e
746                );
747            }
748        }
749
750        visit::visit_item_struct(self, i);
751    }
752
753    fn visit_item_enum(&mut self, i: &'ast ItemEnum) {
754        // 1. Extract Info & Renaming
755        let default_name = i.ident.to_string();
756        let (final_name, enum_desc, rename_rule, doc_lines, serde_tag, serde_content) =
757            crate::doc_parser::extract_naming_and_doc(&i.attrs, &default_name);
758
759        // Safety: Explicit export only
760        if !doc_lines.iter().any(|l| l.contains("@openapi")) {
761            visit::visit_item_enum(self, i);
762            return;
763        }
764
765        // ADJACENTLY TAGGED ENUM LOGIC
766        if let Some(tag_prop) = serde_tag {
767            // This is a "oneOf" container enum
768            // 1. Generate Variant Schemas
769            let mut variant_refs = Vec::new();
770            let mut mapping = serde_json::Map::new();
771
772            for v in &i.variants {
773                let default_variant_name = v.ident.to_string();
774                let (variant_final_value, variant_desc, _, _, _, _) =
775                    crate::doc_parser::extract_naming_and_doc(&v.attrs, &default_variant_name);
776
777                // Apply Rename Rule (to the TAG value, NOT the schema name)
778                let tag_value = if variant_final_value == default_variant_name {
779                    if let Some(rule) = &rename_rule {
780                        crate::doc_parser::apply_casing(&variant_final_value, rule)
781                    } else {
782                        variant_final_value
783                    }
784                } else {
785                    variant_final_value
786                };
787
788                // Variant Schema Name: {EnumName}{VariantName} (PascalCase)
789                let variant_schema_name = format!("{}{}", final_name, v.ident);
790                let variant_ref = format!("#/components/schemas/{}", variant_schema_name);
791
792                variant_refs.push(json!({ "$ref": variant_ref }));
793                mapping.insert(tag_value.clone(), json!(variant_ref));
794
795                // Build Variant Schema
796                let mut properties = serde_json::Map::new();
797                let mut required = vec![tag_prop.clone()];
798
799                // Force Tag Property
800                properties.insert(
801                    tag_prop.clone(),
802                    json!({
803                        "type": "string",
804                        "enum": [tag_value],
805                        "description": format!("Discriminator: {}", tag_value)
806                    }),
807                );
808
809                // Variant Fields
810                let mut content_schema = None;
811
812                if let syn::Fields::Named(fields) = &v.fields {
813                    let mut inner_props = serde_json::Map::new();
814                    let mut inner_req = Vec::new();
815
816                    for field in &fields.named {
817                        let (f_name, f_schema, f_req) =
818                            Self::process_struct_field(field, &rename_rule);
819                        inner_props.insert(f_name.clone(), f_schema);
820                        if f_req {
821                            inner_req.push(f_name);
822                        }
823                    }
824                    content_schema = Some(json!({
825                        "type": "object",
826                        "properties": inner_props,
827                        "required": inner_req
828                    }));
829                } else if let syn::Fields::Unnamed(fields) = &v.fields {
830                    // Tuple Variants
831                    if fields.unnamed.len() == 1 {
832                        let field = &fields.unnamed[0];
833                        let (mut schema, _) =
834                            crate::type_mapper::map_syn_type_to_openapi(&field.ty);
835
836                        // Apply validation attributes
837                        let validation = crate::doc_parser::extract_validation(&field.attrs);
838                        if validation.is_object() {
839                            crate::visitor::json_merge(&mut schema, validation);
840                        }
841
842                        content_schema = Some(schema);
843                    } else {
844                        log::warn!(
845                            "Tuple variants with >1 fields in tagged enums are complex. Skipping fields for {}",
846                            default_variant_name
847                        );
848                    }
849                }
850
851                if let Some(content_prop) = &serde_content {
852                    // ADJACENTLY TAGGED: { "tag": "...", "content": <Inner> }
853                    if let Some(inner) = content_schema {
854                        properties.insert(content_prop.clone(), inner);
855                        required.push(content_prop.clone());
856                    }
857                } else {
858                    // INTERNALLY TAGGED: { "tag": "...", ...fields }
859                    // Only works for Struct variants or Unit variants.
860                    // Tuple variants in Internally Tagged are usually invalid or map to something else, but here we merge fields.
861                    if let Some(inner) = content_schema {
862                        if let Some(props) = inner.get("properties").and_then(|p| p.as_object()) {
863                            for (k, v) in props {
864                                properties.insert(k.clone(), v.clone());
865                            }
866                        }
867                        if let Some(req) = inner.get("required").and_then(|r| r.as_array()) {
868                            for r in req {
869                                if let Some(s) = r.as_str() {
870                                    required.push(s.to_string());
871                                }
872                            }
873                        }
874                    }
875                }
876
877                let mut variant_schema = json!({
878                    "type": "object",
879                    "properties": properties,
880                    "required": required
881                });
882
883                if !variant_desc.is_empty() {
884                    json_merge(&mut variant_schema, json!({ "description": variant_desc }));
885                }
886
887                // Emit Variant Schema
888                if let Ok(generated) = serde_yaml::to_string(&variant_schema) {
889                    let trimmed = generated.trim_start_matches("---\n").to_string();
890                    let wrapped = wrap_in_schema(&variant_schema_name, &trimmed);
891                    self.items.push(ExtractedItem::Schema {
892                        name: Some(variant_schema_name),
893                        content: wrapped,
894                        line: v.span().start().line,
895                    });
896                }
897            }
898
899            // 2. Generate Main Discriminator Schema
900            let mut main_schema = json!({
901                "type": "object",
902                "oneOf": variant_refs,
903                "discriminator": {
904                    "propertyName": tag_prop,
905                    "mapping": mapping
906                }
907            });
908
909            if !enum_desc.is_empty() {
910                json_merge(&mut main_schema, json!({ "description": enum_desc }));
911            }
912
913            // Emit Main Schema
914            if let Ok(generated) = serde_yaml::to_string(&main_schema) {
915                let trimmed = generated.trim_start_matches("---\n").to_string();
916                let wrapped = wrap_in_schema(&final_name, &trimmed);
917                self.items.push(ExtractedItem::Schema {
918                    name: Some(final_name),
919                    content: wrapped,
920                    line: i.span().start().line,
921                });
922            }
923
924            visit::visit_item_enum(self, i);
925            return;
926        }
927
928        // STANDARD STRING ENUM LOGIC
929        let mut variants = Vec::new();
930        for v in &i.variants {
931            if let Some(variant_name) = Self::process_enum_variant(v, &rename_rule) {
932                variants.push(variant_name);
933            }
934        }
935
936        let mut schema = if !variants.is_empty() {
937            json!({
938                "type": "string",
939                "enum": variants
940            })
941        } else {
942            json!({ "type": "string" }) // fallback
943        };
944
945        // Enum Description
946        if !enum_desc.is_empty() {
947            json_merge(&mut schema, json!({ "description": enum_desc }));
948        }
949
950        // Enum Overrides & Blueprint
951        let mut openapi_lines = Vec::new();
952        let mut collecting_openapi = false;
953        let mut blueprint_params: Option<Vec<String>> = None;
954
955        for line in &doc_lines {
956            let trimmed = line.trim();
957            if trimmed.starts_with("@openapi") {
958                collecting_openapi = true;
959                let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
960
961                if !rest.is_empty() && !rest.starts_with("rename") && !rest.starts_with("-type") {
962                    if rest.contains('<') {
963                        // Blueprint detection
964                        if let Some(start) = rest.find('<') {
965                            if let Some(end) = rest.rfind('>') {
966                                let params_str = &rest[start + 1..end];
967                                blueprint_params = Some(
968                                    params_str
969                                        .split(',')
970                                        .map(|p| p.trim().to_string())
971                                        .filter(|p| !p.is_empty())
972                                        .collect(),
973                                );
974
975                                let after_gt = rest[end + 1..].trim();
976                                if !after_gt.is_empty() {
977                                    openapi_lines.push(after_gt.to_string());
978                                }
979                            }
980                        }
981                    } else {
982                        openapi_lines.push(rest.to_string());
983                    }
984                }
985            } else if collecting_openapi {
986                openapi_lines.push(line.to_string());
987            }
988        }
989
990        if !openapi_lines.is_empty() {
991            let override_yaml = openapi_lines.join("\n");
992            match serde_yaml::from_str::<Value>(&override_yaml) {
993                Ok(override_val) => {
994                    if !override_val.is_null() {
995                        json_merge(&mut schema, override_val);
996                    }
997                }
998                Err(e) => {
999                    log::warn!(
1000                        "Failed to parse @openapi override for enum '{}': {}",
1001                        final_name,
1002                        e
1003                    );
1004                }
1005            }
1006        }
1007
1008        // Only emit if we have variants OR overrides
1009        if !variants.is_empty() || !openapi_lines.is_empty() {
1010            if let Ok(generated) = serde_yaml::to_string(&schema) {
1011                let trimmed = generated.trim_start_matches("---\n").to_string();
1012
1013                if let Some(params) = blueprint_params {
1014                    self.items.push(ExtractedItem::Blueprint {
1015                        name: final_name,
1016                        params,
1017                        content: trimmed,
1018                        line: i.span().start().line,
1019                    });
1020                } else {
1021                    let wrapped = wrap_in_schema(&final_name, &trimmed);
1022                    self.items.push(ExtractedItem::Schema {
1023                        name: Some(final_name),
1024                        content: wrapped,
1025                        line: i.span().start().line,
1026                    });
1027                }
1028            }
1029        }
1030
1031        visit::visit_item_enum(self, i);
1032    }
1033
1034    fn visit_item_mod(&mut self, i: &'ast ItemMod) {
1035        let mut found_tags = Vec::new();
1036        for attr in &i.attrs {
1037            if attr.path().is_ident("doc") {
1038                if let syn::Meta::NameValue(meta) = &attr.meta {
1039                    if let Expr::Lit(expr_lit) = &meta.value {
1040                        if let syn::Lit::Str(lit_str) = &expr_lit.lit {
1041                            let val = lit_str.value();
1042                            if val.contains("tags:") {
1043                                if let Some(start) = val.find('[') {
1044                                    if let Some(end) = val.find(']') {
1045                                        let content = &val[start + 1..end];
1046                                        for t in content.split(',') {
1047                                            found_tags.push(t.trim().to_string());
1048                                        }
1049                                    }
1050                                }
1051                            }
1052                        }
1053                    }
1054                }
1055            }
1056        }
1057
1058        let old_len = self.current_tags.len();
1059        self.current_tags.extend(found_tags);
1060
1061        self.check_attributes(&i.attrs, None, i.span().start().line);
1062        visit::visit_item_mod(self, i);
1063
1064        self.current_tags.truncate(old_len);
1065    }
1066
1067    fn visit_impl_item_fn(&mut self, i: &'ast ImplItemFn) {
1068        self.check_attributes(&i.attrs, None, i.span().start().line);
1069        visit::visit_impl_item_fn(self, i);
1070    }
1071}
1072
1073pub fn extract_from_file(path: std::path::PathBuf) -> crate::error::Result<Vec<ExtractedItem>> {
1074    let content = std::fs::read_to_string(&path)?;
1075    let parsed_file = syn::parse_file(&content).map_err(|e| crate::error::Error::Parse {
1076        file: path.clone(),
1077        source: e,
1078    })?;
1079
1080    let mut visitor = OpenApiVisitor::default();
1081    visitor.visit_file(&parsed_file);
1082
1083    Ok(visitor.items)
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    #[test]
1091    fn test_struct_reflection() {
1092        let code = r#"
1093            /// @openapi
1094            struct MyStruct {
1095                pub id: String,
1096                pub count: i32,
1097                pub active: bool,
1098                pub tags: Vec<String>,
1099                pub meta: Option<String>
1100            }
1101        "#;
1102        let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1103
1104        let mut visitor = OpenApiVisitor::default();
1105        visitor.visit_item_struct(&item_struct);
1106
1107        assert_eq!(visitor.items.len(), 1);
1108        match &visitor.items[0] {
1109            ExtractedItem::Schema { name, content, .. } => {
1110                assert_eq!(name.as_ref().unwrap(), "MyStruct");
1111                // Check reflection
1112                assert!(content.contains("type: object"));
1113                assert!(content.contains("properties"));
1114                assert!(content.contains("id"));
1115                assert!(content.contains("type: string"));
1116                assert!(content.contains("count"));
1117                assert!(content.contains("type: integer"));
1118
1119                // Vec
1120                assert!(content.contains("tags"));
1121                assert!(content.contains("type: array"));
1122
1123                // Option -> Not required
1124                assert!(content.contains("required"));
1125                assert!(content.contains("id"));
1126                assert!(content.contains("count"));
1127                assert!(content.contains("tags"));
1128                // meta should NOT be in required
1129            }
1130            _ => panic!("Expected Schema"),
1131        }
1132    }
1133
1134    #[test]
1135    fn test_module_tags() {
1136        let code = r#"
1137            /// @openapi
1138            /// tags: [GroupA]
1139            mod my_mod {
1140                /// @openapi
1141                /// paths:
1142                ///   /test:
1143                ///     get:
1144                ///       description: op
1145                fn my_fn() {}
1146            }
1147        "#;
1148        let item_mod: ItemMod = syn::parse_str(code).expect("Failed to parse mod");
1149
1150        let mut visitor = OpenApiVisitor::default();
1151        visitor.visit_item_mod(&item_mod);
1152
1153        assert_eq!(visitor.items.len(), 2);
1154        match &visitor.items[1] {
1155            ExtractedItem::Schema { content, .. } => {
1156                assert!(
1157                    content.contains("tags:"),
1158                    "Function should have tags injected"
1159                );
1160                assert!(content.contains("- GroupA"));
1161                assert!(content.contains("/test:"));
1162            }
1163            _ => panic!("Expected Schema"),
1164        }
1165    }
1166
1167    #[test]
1168    fn test_complex_types_and_docs() {
1169        let code = r#"
1170            /// @openapi
1171            struct Complex {
1172                /// Primary Identifier
1173                pub id: Uuid,
1174                /// @openapi example: "user@example.com"
1175                pub email: String,
1176                pub created_at: DateTime<Utc>,
1177                pub metadata: HashMap<String, String>,
1178                pub scores: Vec<f64>,
1179                pub config: Option<serde_json::Value>
1180            }
1181        "#;
1182        let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1183
1184        let mut visitor = OpenApiVisitor::default();
1185        visitor.visit_item_struct(&item_struct);
1186
1187        match &visitor.items[0] {
1188            ExtractedItem::Schema { content, .. } => {
1189                // Check doc comment merge
1190                assert!(
1191                    content.contains("description: Primary Identifier"),
1192                    "Should merge doc comments"
1193                );
1194
1195                // Check attribute override
1196                assert!(
1197                    content.contains("example: user@example.com"),
1198                    "Should merge @openapi attributes"
1199                );
1200
1201                // Check Types
1202                assert!(content.contains("format: uuid"));
1203                assert!(content.contains("format: date-time"));
1204                assert!(content.contains("format: double"));
1205                assert!(content.contains("additionalProperties")); // Map
1206
1207                // Option -> Not required
1208                let _required_idx = content.find("required").unwrap();
1209                let _config_idx = content.find("config").unwrap();
1210                // We can't strictly check line order easily with contains, but we know config (Option) shouldn't be in required list
1211                // However, let's just assert content does not have "- config" inside the required block.
1212                // Since this is YAML generated by serde, it's reliable.
1213            }
1214            _ => panic!("Expected Schema"),
1215        }
1216    }
1217
1218    #[test]
1219    fn test_visitor_bugs_v0_4_2() {
1220        // 1. Generic Fallback Test ($T)
1221        let code_generic = r#"
1222            /// @openapi
1223            struct Container<T> {
1224                pub item: T,
1225            }
1226        "#;
1227        let item_struct: ItemStruct = syn::parse_str(code_generic).expect("Failed to parse struct");
1228        let mut visitor = OpenApiVisitor::default();
1229        visitor.visit_item_struct(&item_struct);
1230        match &visitor.items[0] {
1231            ExtractedItem::Schema { content, .. } => {
1232                // FIX 3: Should contain $ref: $T, NOT #/components/schemas/T
1233                assert!(
1234                    content.contains("$ref: $T"),
1235                    "Should use Smart Ref for generics (expected $ref: $T)"
1236                );
1237            }
1238            _ => panic!("Expected Schema"),
1239        }
1240
1241        // 2. Multi-line Field Docs Test
1242        let code_multiline = r#"
1243            /// @openapi
1244            struct User {
1245                /// @openapi
1246                /// example:
1247                ///   - "Alice"
1248                ///   - "Bob"
1249                pub names: Vec<String>
1250            }
1251        "#;
1252        let item_struct_m: ItemStruct =
1253            syn::parse_str(code_multiline).expect("Failed to parse struct");
1254        let mut visitor_m = OpenApiVisitor::default();
1255        visitor_m.visit_item_struct(&item_struct_m);
1256        match &visitor_m.items[0] {
1257            ExtractedItem::Schema { content, .. } => {
1258                // FIX 2: Should correctly parse the YAML list
1259                assert!(content.contains("example:"), "Should contain example key");
1260                assert!(
1261                    content.contains("- Alice"),
1262                    "Should parse multi-line attributes (- Alice)"
1263                );
1264            }
1265            _ => panic!("Expected Schema"),
1266        }
1267
1268        // 3. Tag Injection Test (Indentation)
1269        let code_tags = r#"
1270            /// @openapi
1271            /// tags: [MyTag]
1272            mod my_mod {
1273                 /// @openapi
1274                 /// paths:
1275                 ///   /foo:
1276                 ///     get:
1277                 ///       description: op
1278                 fn my_fn() {}
1279            }
1280        "#;
1281        let item_mod: ItemMod = syn::parse_str(code_tags).expect("Failed to parse mod");
1282        let mut visitor_t = OpenApiVisitor::default();
1283        visitor_t.visit_item_mod(&item_mod);
1284        match &visitor_t.items[1] {
1285            // Item 1 is the fn
1286            ExtractedItem::Schema { content, .. } => {
1287                // FIX 1: Indentation check
1288                let get_idx = content.find("get:").unwrap();
1289                let tags_idx = content.find("tags:").unwrap();
1290
1291                // Tags must appear AFTER get
1292                assert!(tags_idx > get_idx, "Tags should be inside/after get");
1293
1294                // Tags must appear BEFORE description (if injected at top of block)
1295                let desc_idx = content.find("description:").unwrap();
1296                assert!(
1297                    tags_idx < desc_idx,
1298                    "Tags should be injected before description (top of block)"
1299                );
1300            }
1301            _ => panic!("Expected Schema"),
1302        }
1303    }
1304
1305    #[test]
1306    fn test_visitor_pollution_v0_4_3() {
1307        let code = r#"
1308            /// @openapi
1309            struct Clean {
1310                /// Clean Description
1311                /// @openapi example: "dirty"
1312                pub field: String,
1313            }
1314        "#;
1315        let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1316        let mut visitor = OpenApiVisitor::default();
1317        visitor.visit_item_struct(&item_struct);
1318
1319        match &visitor.items[0] {
1320            ExtractedItem::Schema { content, .. } => {
1321                // Description should be "Clean Description"
1322                // It should NOT contain "@openapi" or "example: dirty"
1323                // But the example should be merged into the schema separately.
1324
1325                assert!(content.contains("description: Clean Description"));
1326                assert!(
1327                    !content.contains("description: Clean Description @openapi"),
1328                    "Should Clean Description"
1329                );
1330                assert!(
1331                    content.contains("example: dirty"),
1332                    "Should still have the example"
1333                );
1334            }
1335            _ => panic!("Expected Schema"),
1336        }
1337    }
1338
1339    #[test]
1340    fn test_type_alias_reflection() {
1341        let code = r#"
1342            /// @openapi
1343            /// format: uuid
1344            /// description: User ID Alias
1345            type UserId = String;
1346        "#;
1347        let item_type: ItemType = syn::parse_str(code).expect("Failed to parse type");
1348
1349        let mut visitor = OpenApiVisitor::default();
1350        visitor.visit_item_type(&item_type);
1351
1352        assert_eq!(visitor.items.len(), 1);
1353        match &visitor.items[0] {
1354            ExtractedItem::Schema { name, content, .. } => {
1355                assert_eq!(name.as_ref().unwrap(), "UserId");
1356                assert!(content.contains("type: string"));
1357                assert!(content.contains("format: uuid"));
1358                assert!(content.contains("description: User ID Alias"));
1359            }
1360            _ => panic!("Expected Schema"),
1361        }
1362    }
1363
1364    #[test]
1365    fn test_virtual_types_unit_struct() {
1366        let code = r#"
1367            /// @openapi
1368            /// type: string
1369            /// enum: [A, B]
1370            struct MyEnum;
1371        "#;
1372        let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1373        let mut visitor = OpenApiVisitor::default();
1374        visitor.visit_item_struct(&item_struct);
1375
1376        // This relies on implicit schema parsing from docs
1377        assert_eq!(visitor.items.len(), 1);
1378        match &visitor.items[0] {
1379            ExtractedItem::Schema { name, content, .. } => {
1380                assert_eq!(name.as_ref().unwrap(), "MyEnum");
1381                assert!(content.contains("type: string"));
1382                assert!(content.contains("enum:"));
1383                assert!(content.contains("A"));
1384                assert!(content.contains("B"));
1385            }
1386            _ => panic!("Expected Schema"),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_global_virtual_type() {
1392        let code = r#"
1393            //! @openapi-type Email
1394            //! type: string
1395            //! format: email
1396            //! description: Valid email address
1397            
1398            // Other code...
1399            fn main() {}
1400        "#;
1401        // Parse as File because it's a file attribute (inner doc comment)
1402        let file: File = syn::parse_str(code).expect("Failed to parse file");
1403
1404        let mut visitor = OpenApiVisitor::default();
1405        visitor.visit_file(&file);
1406
1407        // Should find Email schema
1408        let email_schema = visitor.items.iter().find(|i| {
1409            if let ExtractedItem::Schema { name, .. } = i {
1410                name.as_deref() == Some("Email")
1411            } else {
1412                false
1413            }
1414        });
1415
1416        assert!(email_schema.is_some(), "Should find Email schema");
1417        match email_schema.unwrap() {
1418            ExtractedItem::Schema { content, .. } => {
1419                assert!(content.contains("type: string"));
1420                assert!(content.contains("format: email"));
1421            }
1422            _ => panic!("Expected Schema"),
1423        }
1424    }
1425
1426    #[test]
1427    fn test_route_dsl_basic() {
1428        let code = r#"
1429            /// Get Users
1430            /// Returns a list of users.
1431            /// @route GET /users
1432            /// @tag Users
1433            fn get_users() {}
1434        "#;
1435        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1436        let mut visitor = OpenApiVisitor::default();
1437        visitor.visit_item_fn(&item_fn);
1438
1439        assert_eq!(visitor.items.len(), 1);
1440        if let ExtractedItem::RouteDSL {
1441            content,
1442            operation_id,
1443            ..
1444        } = &visitor.items[0]
1445        {
1446            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1447            let yaml =
1448                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL Parsing failed");
1449
1450            assert!(yaml.contains("paths:"));
1451            assert!(yaml.contains("/users:"));
1452            assert!(yaml.contains("get:"));
1453            assert!(yaml.contains("summary: Get Users"));
1454            assert!(yaml.contains("description:"));
1455            assert!(yaml.contains("Returns a list of users."));
1456            assert!(yaml.contains("tags:"));
1457            assert!(yaml.contains("- Users"));
1458        } else {
1459            panic!("Expected RouteDSL item, got {:?}", &visitor.items[0]);
1460        }
1461    }
1462
1463    #[test]
1464    fn test_route_dsl_params() {
1465        let code = r#"
1466            /// @route GET /users/{id}
1467            /// @path-param id: u32 "User ID"
1468            /// @query-param filter: Option<String> "Name filter"
1469            fn get_user() {}
1470        "#;
1471        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1472        let mut visitor = OpenApiVisitor::default();
1473        visitor.visit_item_fn(&item_fn);
1474
1475        if let ExtractedItem::RouteDSL {
1476            content,
1477            operation_id,
1478            ..
1479        } = &visitor.items[0]
1480        {
1481            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1482            let yaml =
1483                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1484
1485            // Path Param
1486            assert!(yaml.contains("name: id"));
1487            assert!(yaml.contains("in: path"));
1488
1489            assert!(yaml.contains("required: true"));
1490            assert!(yaml.contains("format: int32"));
1491
1492            // Query Param
1493            assert!(yaml.contains("name: filter"));
1494            assert!(yaml.contains("in: query"));
1495            assert!(yaml.contains("required: false")); // Option<String>
1496        } else {
1497            panic!("Expected RouteDSL item");
1498        }
1499    }
1500
1501    #[test]
1502    fn test_route_dsl_body_return() {
1503        let code = r#"
1504            /// @route POST /users
1505            /// @body String text/plain
1506            /// @return 201: u64 "Created ID"
1507            fn create_user() {}
1508        "#;
1509        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1510        let mut visitor = OpenApiVisitor::default();
1511        visitor.visit_item_fn(&item_fn);
1512
1513        if let ExtractedItem::RouteDSL {
1514            content,
1515            operation_id,
1516            ..
1517        } = &visitor.items[0]
1518        {
1519            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1520            let yaml =
1521                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1522
1523            // Body
1524            assert!(yaml.contains("requestBody:"));
1525            assert!(yaml.contains("text/plain:")); // MIME
1526            assert!(yaml.contains("schema:"));
1527            assert!(yaml.contains("type: string"));
1528
1529            // Return
1530            assert!(yaml.contains("responses:"));
1531            assert!(yaml.contains("'201':"));
1532            assert!(yaml.contains("description: Created ID"));
1533            assert!(yaml.contains("format: int64"));
1534        } else {
1535            panic!("Expected RouteDSL item");
1536        }
1537    }
1538
1539    #[test]
1540    fn test_route_dsl_security() {
1541        let code = r#"
1542            /// @route GET /secure
1543            /// @security oidcAuth("read")
1544            fn secure_op() {}
1545        "#;
1546        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1547        let mut visitor = OpenApiVisitor::default();
1548        visitor.visit_item_fn(&item_fn);
1549
1550        if let ExtractedItem::RouteDSL {
1551            content,
1552            operation_id,
1553            ..
1554        } = &visitor.items[0]
1555        {
1556            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1557            let yaml =
1558                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1559
1560            assert!(yaml.contains("security:"));
1561            assert!(yaml.contains("- oidcAuth:"));
1562            assert!(yaml.contains("- read"));
1563        } else {
1564            panic!("Expected RouteDSL item");
1565        }
1566    }
1567
1568    #[test]
1569    fn test_route_dsl_generics_and_unit() {
1570        let code = r#"
1571            /// @route POST /test
1572            /// @return 200: $Page<User> "Generic List"
1573            /// @return 204: () "Nothing"
1574            fn test_op() {}
1575        "#;
1576        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1577        let mut visitor = OpenApiVisitor::default();
1578        visitor.visit_item_fn(&item_fn);
1579
1580        if let ExtractedItem::RouteDSL {
1581            content,
1582            operation_id,
1583            ..
1584        } = &visitor.items[0]
1585        {
1586            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1587            let yaml =
1588                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1589
1590            // 1. Verify Generic is RAW (Crucial for Monomorphizer)
1591            assert!(yaml.contains("$ref: $Page<User>"));
1592            assert!(!yaml.contains("#/components/schemas/$Page<User>")); // MUST FAIL if wrapped
1593
1594            // 2. Verify Unit has NO content
1595            assert!(yaml.contains("'204':"));
1596            assert!(yaml.contains("description: Nothing"));
1597            // Ensure 204 block does not have "content:"
1598            // (We check strict context or absence of content key for 204)
1599            let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1600            let resp_204 = &json["paths"]["/test"]["post"]["responses"]["204"];
1601            assert!(
1602                resp_204.get("content").is_none(),
1603                "204 response should not have content"
1604            );
1605        } else {
1606            panic!("Expected RouteDSL item");
1607        }
1608    }
1609
1610    #[test]
1611    fn test_route_dsl_unit_return() {
1612        let code = r#"
1613            /// @route DELETE /delete
1614            /// @return 204: "Deleted Successfully"
1615            /// @return 202: () "Accepted"
1616            fn delete_op() {}
1617        "#;
1618        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1619        let mut visitor = OpenApiVisitor::default();
1620        visitor.visit_item_fn(&item_fn);
1621
1622        if let ExtractedItem::RouteDSL {
1623            content,
1624            operation_id,
1625            ..
1626        } = &visitor.items[0]
1627        {
1628            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1629            let yaml =
1630                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1631
1632            // Parse to verify structure
1633            let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1634            let responses = &json["paths"]["/delete"]["delete"]["responses"];
1635
1636            // Case 1: Implicit Unit ("Deleted Successfully")
1637            let resp_204 = &responses["204"];
1638            assert_eq!(resp_204["description"], "Deleted Successfully");
1639            assert!(
1640                resp_204.get("content").is_none(),
1641                "204 should have no content"
1642            );
1643
1644            // Case 2: Explicit Unit (())
1645            let resp_202 = &responses["202"];
1646            assert_eq!(resp_202["description"], "Accepted");
1647            assert!(
1648                resp_202.get("content").is_none(),
1649                "202 should have no content"
1650            );
1651        } else {
1652            panic!("Expected RouteDSL item");
1653        }
1654    }
1655}
1656
1657#[cfg(test)]
1658mod dsl_tests {
1659    use super::*;
1660
1661    #[test]
1662    fn test_route_dsl_inline_params() {
1663        let code = r#"
1664            /// @route GET /items/{id: u32 "Item ID"}
1665            fn get_item() {}
1666        "#;
1667        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1668        let mut visitor = OpenApiVisitor::default();
1669        visitor.visit_item_fn(&item_fn);
1670
1671        if let ExtractedItem::RouteDSL {
1672            content,
1673            operation_id,
1674            ..
1675        } = &visitor.items[0]
1676        {
1677            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1678            let yaml =
1679                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1680
1681            // 1. Check path normalization
1682            assert!(yaml.contains("/items/{id}:"));
1683
1684            // 2. Check parameter extraction
1685            let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1686            let params = &json["paths"]["/items/{id}"]["get"]["parameters"];
1687            assert!(params.is_array());
1688            assert_eq!(params.as_array().unwrap().len(), 1);
1689
1690            let p = &params[0];
1691            assert_eq!(p["name"], "id");
1692            assert_eq!(p["in"], "path");
1693            assert_eq!(p["required"], true);
1694            assert_eq!(p["description"], "Item ID");
1695            assert_eq!(p["schema"]["type"], "integer");
1696            assert_eq!(p["schema"]["format"], "int32");
1697        } else {
1698            panic!("Expected RouteDSL item");
1699        }
1700    }
1701
1702    #[test]
1703    fn test_route_dsl_flexible_params() {
1704        let code = r#"
1705            /// @route GET /search
1706            /// @query-param q: String "Search Query"
1707            /// @query-param sort: deprecated required example="desc" "Sort Order"
1708            fn search() {}
1709        "#;
1710        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1711        let mut visitor = OpenApiVisitor::default();
1712        visitor.visit_item_fn(&item_fn);
1713
1714        if let ExtractedItem::RouteDSL {
1715            content,
1716            operation_id,
1717            ..
1718        } = &visitor.items[0]
1719        {
1720            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1721            let yaml =
1722                crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1723
1724            let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1725            let params = &json["paths"]["/search"]["get"]["parameters"];
1726            let params_arr = params.as_array().unwrap();
1727
1728            // Param 'q' (Standard)
1729            let q = params_arr.iter().find(|p| p["name"] == "q").unwrap();
1730            assert_eq!(q["description"], "Search Query");
1731
1732            // Param 'sort' (Flexible)
1733            let sort = params_arr.iter().find(|p| p["name"] == "sort").unwrap();
1734            assert_eq!(sort["deprecated"], true);
1735            assert_eq!(sort["required"], true);
1736            assert_eq!(sort["example"], "desc");
1737            assert_eq!(sort["description"], "Sort Order");
1738        } else {
1739            panic!("Expected RouteDSL item");
1740        }
1741    }
1742
1743    #[test]
1744    #[should_panic(expected = "Missing definition for path parameter 'id'")]
1745    fn test_route_dsl_validation_error() {
1746        let code = r#"
1747            /// @route GET /items/{id}
1748            fn get_item_fail() {}
1749        "#;
1750        let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1751        let mut visitor = OpenApiVisitor::default();
1752        visitor.visit_item_fn(&item_fn);
1753
1754        // This should panic
1755        if let ExtractedItem::RouteDSL {
1756            content,
1757            operation_id,
1758            ..
1759        } = &visitor.items[0]
1760        {
1761            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1762            let _ = crate::dsl::parse_route_dsl(&lines, operation_id);
1763        }
1764    }
1765
1766    #[test]
1767    fn test_doc_comment_as_description() {
1768        let code = r#"
1769            /// This is a user struct.
1770            /// It has multiple lines.
1771            /// @openapi
1772            struct User { name: String }
1773        "#;
1774        let item: ItemStruct = syn::parse_str(code).unwrap();
1775        let mut v = OpenApiVisitor::default();
1776        v.visit_item_struct(&item);
1777
1778        match &v.items[0] {
1779            ExtractedItem::Schema { content, .. } => {
1780                assert!(
1781                    content.contains("description: This is a user struct. It has multiple lines.")
1782                );
1783            }
1784            _ => panic!("Expected Schema"),
1785        }
1786    }
1787
1788    #[test]
1789    fn test_description_override() {
1790        let code = r#"
1791            /// Original Docs
1792            /// @openapi
1793            /// description: Overridden
1794            struct User { name: String }
1795        "#;
1796        let item: ItemStruct = syn::parse_str(code).unwrap();
1797        let mut v = OpenApiVisitor::default();
1798        v.visit_item_struct(&item);
1799
1800        match &v.items[0] {
1801            ExtractedItem::Schema { content, .. } => {
1802                assert!(content.contains("description: Overridden"));
1803                // json_merge overwrites scalars, so Original Docs is lost in favor of explicit override
1804            }
1805            _ => panic!("Expected Schema"),
1806        }
1807    }
1808
1809    #[test]
1810    fn test_implicit_safety() {
1811        let code = r#"
1812            /// Hidden internal struct
1813            struct Internal { secret: String }
1814        "#;
1815        let item: ItemStruct = syn::parse_str(code).unwrap();
1816        let mut v = OpenApiVisitor::default();
1817        v.visit_item_struct(&item);
1818        assert!(
1819            v.items.is_empty(),
1820            "Should not export struct without @openapi tag"
1821        );
1822    }
1823}