vespera_macro 0.4.0

Procedural macros for Vespera - automatic route discovery and OpenAPI generation
Documentation
//! Metadata collection and storage for routes and schemas

use std::collections::{BTreeMap, HashMap};

use serde::{Deserialize, Serialize};

/// Header parameter declared at the route site via `headers = [...]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeaderParam {
    pub name: String,
    #[serde(default)]
    pub required: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// Route metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteMetadata {
    /// HTTP method
    pub method: String,
    /// Route path
    pub path: String,
    /// Function name
    pub function_name: String,
    /// Module path
    pub module_path: String,
    /// File path
    pub file_path: String,

    /// Declared non-200 success status from the `status` attribute.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub success_status: Option<u16>,
    /// Additional error status codes from `error_status` attribute
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error_status: Option<Vec<u16>>,
    /// Typed error responses from `responses` attribute.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub typed_responses: Option<Vec<(u16, String)>>,
    /// Tags for `OpenAPI` grouping
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    /// Per-route OpenAPI security requirements.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub security: Option<Vec<String>>,
    /// Header parameters declared by custom extractors at the route site.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub headers: Vec<HeaderParam>,
    /// Explicit OpenAPI operationId override.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub operation_id: Option<String>,
    /// OpenAPI operation summary.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    /// Operation-level request example JSON/string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_example: Option<serde_json::Value>,
    /// Operation-level response example JSON/string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub response_example: Option<serde_json::Value>,
    /// Whether the OpenAPI operation is deprecated.
    #[serde(default)]
    pub deprecated: bool,
    /// Description for `OpenAPI` (from route attribute or doc comment)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// Struct metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructMetadata {
    /// Struct name
    pub name: String,
    /// Struct definition (as string for serialization)
    pub definition: String,
    /// Whether to include in `OpenAPI` spec (components/schemas)
    /// - true: from #[derive(Schema)] - appears in openapi.json (DEFAULT)
    /// - false: from cross-file lookup - only for `schema_type`! source, NOT in openapi.json
    #[serde(default = "default_include_in_openapi")]
    pub include_in_openapi: bool,
    /// Whether the public component name came from `#[schema(name = "...")]`.
    /// Explicit names are global and opt out of export prefix namespacing.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub explicit_schema_name: bool,
    /// Pre-extracted default values for fields with `#[serde(default = "fn_name")]`.
    /// Key: Rust field name, Value: extracted default value.
    /// Populated by `#[derive(Schema)]` to avoid AST re-parsing in `vespera!()`.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub field_defaults: BTreeMap<String, serde_json::Value>,
    /// Stable source identity for proc-macro-server re-expansions.
    ///
    /// This is not part of the OpenAPI output. It lets `#[derive(Schema)]`
    /// replace metadata for the same source item after an IDE edit while still
    /// rejecting two distinct items that claim the same OpenAPI schema name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_identity: Option<String>,
}

const fn default_include_in_openapi() -> bool {
    true
}

impl Default for StructMetadata {
    fn default() -> Self {
        Self {
            name: String::new(),
            definition: String::new(),
            include_in_openapi: true,
            explicit_schema_name: false,
            field_defaults: BTreeMap::new(),
            source_identity: None,
        }
    }
}

impl StructMetadata {
    /// Create a new `StructMetadata` with `include_in_openapi` defaulting to true
    pub const fn new(name: String, definition: String) -> Self {
        Self {
            name,
            definition,
            include_in_openapi: true,
            explicit_schema_name: false,
            field_defaults: BTreeMap::new(),
            source_identity: None,
        }
    }

    /// Create a new `StructMetadata` for model types (not included in `OpenAPI`)
    pub const fn new_model(name: String, definition: String) -> Self {
        Self {
            name,
            definition,
            include_in_openapi: false,
            explicit_schema_name: false,
            field_defaults: BTreeMap::new(),
            source_identity: None,
        }
    }

    /// Attach the source identity used for same-item replacement in global storage.
    #[must_use]
    pub fn with_source_identity(mut self, source_identity: String) -> Self {
        self.source_identity = Some(source_identity);
        self
    }
}

/// Cron job metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronMetadata {
    /// Cron expression (e.g., "0 */5 * * * *")
    pub expression: String,
    /// Function name
    pub function_name: String,
    /// Module path (e.g., "cron::cleanup")
    pub module_path: String,
    /// File path
    pub file_path: String,
}

/// Collected metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectedMetadata {
    /// Routes
    pub routes: Vec<RouteMetadata>,
    /// Structs
    pub structs: Vec<StructMetadata>,
    /// Cron jobs
    #[serde(default)]
    pub crons: Vec<CronMetadata>,
}

impl CollectedMetadata {
    pub const fn new() -> Self {
        Self {
            routes: Vec::new(),
            structs: Vec::new(),
            crons: Vec::new(),
        }
    }

    /// Check for duplicate schema names among `include_in_openapi` structs.
    /// Returns `Err` with a descriptive message if duplicates are found.
    pub fn check_duplicate_schema_names(&self) -> Result<(), String> {
        let mut seen: HashMap<&str, usize> = HashMap::new();
        for (i, s) in self.structs.iter().enumerate() {
            if !s.include_in_openapi {
                continue;
            }
            if let Some(&prev_idx) = seen.get(s.name.as_str()) {
                // Only report if definitions actually differ (identical re-registration is OK)
                if self.structs[prev_idx].definition != s.definition {
                    let origins = match (
                        self.structs[prev_idx].source_identity.as_deref(),
                        s.source_identity.as_deref(),
                    ) {
                        (Some(first), Some(second)) => {
                            format!(" Conflicting definitions came from {first} and {second}.")
                        }
                        _ => String::new(),
                    };
                    return Err(format!(
                        "Duplicate OpenAPI schema name '{}'. Two different structs produce the same schema name, which would corrupt the OpenAPI spec.{origins} Rename one of them or use #[schema(name = \"...\")].",
                        s.name,
                    ));
                }
            } else {
                seen.insert(&s.name, i);
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_struct_metadata_new() {
        let meta = StructMetadata::new("User".to_string(), "struct User {}".to_string());
        assert_eq!(meta.name, "User");
        assert_eq!(meta.definition, "struct User {}");
        assert!(meta.include_in_openapi); // Should default to true
    }

    #[test]
    fn test_struct_metadata_new_model() {
        let meta = StructMetadata::new_model("Model".to_string(), "struct Model {}".to_string());
        assert_eq!(meta.name, "Model");
        assert_eq!(meta.definition, "struct Model {}");
        assert!(!meta.include_in_openapi); // Should be false for models
    }

    #[test]
    fn test_struct_metadata_default() {
        let meta = StructMetadata::default();
        assert_eq!(meta.name, "");
        assert_eq!(meta.definition, "");
        assert!(meta.include_in_openapi); // Default to true
    }

    #[test]
    fn test_struct_metadata_serde_with_include_in_openapi() {
        let json = r#"{"name":"User","definition":"struct User {}","include_in_openapi":false}"#;
        let meta: StructMetadata = serde_json::from_str(json).unwrap();
        assert_eq!(meta.name, "User");
        assert!(!meta.include_in_openapi);
    }

    #[test]
    fn test_struct_metadata_serde_without_include_in_openapi() {
        // This triggers the default_include_in_openapi() function (lines 45-46)
        let json = r#"{"name":"User","definition":"struct User {}"}"#;
        let meta: StructMetadata = serde_json::from_str(json).unwrap();
        assert_eq!(meta.name, "User");
        assert!(meta.include_in_openapi); // Should default to true via serde default
    }

    #[test]
    fn test_struct_metadata_roundtrip() {
        let original =
            StructMetadata::new("Test".to_string(), "struct Test { x: i32 }".to_string());
        let json = serde_json::to_string(&original).unwrap();
        let restored: StructMetadata = serde_json::from_str(&json).unwrap();
        assert_eq!(original.name, restored.name);
        assert_eq!(original.definition, restored.definition);
        assert_eq!(original.include_in_openapi, restored.include_in_openapi);
        assert!(restored.field_defaults.is_empty());
    }

    #[test]
    fn test_collected_metadata_new() {
        let meta = CollectedMetadata::new();
        assert!(meta.routes.is_empty());
        assert!(meta.structs.is_empty());
    }

    #[test]
    fn test_check_duplicate_schema_names_no_duplicates() {
        let mut meta = CollectedMetadata::new();
        meta.structs
            .push(StructMetadata::new("User".into(), "struct User {}".into()));
        meta.structs
            .push(StructMetadata::new("Post".into(), "struct Post {}".into()));
        assert!(meta.check_duplicate_schema_names().is_ok());
    }

    #[test]
    fn test_check_duplicate_schema_names_different_definitions() {
        let mut meta = CollectedMetadata::new();
        meta.structs.push(StructMetadata::new(
            "User".into(),
            "struct User { id: i32 }".into(),
        ));
        meta.structs.push(StructMetadata::new(
            "User".into(),
            "struct User { name: String }".into(),
        ));
        let err = meta.check_duplicate_schema_names().unwrap_err();
        assert!(
            err.contains("Duplicate OpenAPI schema name 'User'"),
            "got: {err}"
        );
    }

    #[test]
    fn test_check_duplicate_schema_names_identical_definition_ok() {
        let mut meta = CollectedMetadata::new();
        let def = "struct User { id: i32 }".to_string();
        meta.structs
            .push(StructMetadata::new("User".into(), def.clone()));
        meta.structs.push(StructMetadata::new("User".into(), def));
        assert!(meta.check_duplicate_schema_names().is_ok());
    }

    #[test]
    fn test_check_duplicate_schema_names_ignores_models() {
        let mut meta = CollectedMetadata::new();
        meta.structs.push(StructMetadata::new_model(
            "Model".into(),
            "struct Model { id: i32 }".into(),
        ));
        meta.structs.push(StructMetadata::new_model(
            "Model".into(),
            "struct Model { name: String }".into(),
        ));
        // Models (include_in_openapi=false) are not checked
        assert!(meta.check_duplicate_schema_names().is_ok());
    }

    #[test]
    fn test_check_duplicate_schema_names_empty() {
        let meta = CollectedMetadata::new();
        assert!(meta.check_duplicate_schema_names().is_ok());
    }
}