use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::OffsetDateTime; use crate::{ edition::OperationSecurityLevel, ids::{AuthProfileId, OperationId, SampleId}, protocol::{ExportMode, HttpMethod, Protocol}, }; fn default_operation_category() -> String { "general".to_owned() } fn default_operation_security_level() -> OperationSecurityLevel { OperationSecurityLevel::Standard } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationStatus { Draft, Testing, Published, Archived, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RestTarget { pub base_url: String, pub method: HttpMethod, pub path_template: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub static_headers: BTreeMap, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Target { Rest(RestTarget), } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RetryPolicy { pub max_attempts: u32, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResponseCachePolicy { pub ttl_ms: u64, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExecutionConfig { pub timeout_ms: u64, #[serde(skip_serializing_if = "Option::is_none")] pub retry_policy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub response_cache: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auth_profile_ref: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolExample { pub input: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolDescription { pub title: String, pub description: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub examples: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Samples { #[serde(skip_serializing_if = "Option::is_none")] pub input_json_sample_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub output_json_sample_ref: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GeneratedDraftStatus { None, Available, Stale, Failed, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GeneratedDraft { pub status: GeneratedDraftStatus, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_types: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub generated_at: Option, pub input_schema_generated: bool, pub output_schema_generated: bool, pub input_mapping_generated: bool, pub output_mapping_generated: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigExport { pub format_version: String, pub export_mode: ExportMode, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct WizardState { #[serde(default, skip_serializing_if = "Option::is_none")] pub input_sample: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_sample: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub test_input: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Operation { pub id: OperationId, pub name: String, pub display_name: String, #[serde(default = "default_operation_category")] pub category: String, pub protocol: Protocol, #[serde(default = "default_operation_security_level")] pub security_level: OperationSecurityLevel, pub status: OperationStatus, pub version: u32, pub target: Target, pub input_schema: TSchema, pub output_schema: TSchema, pub input_mapping: TMapping, pub output_mapping: TMapping, pub execution_config: ExecutionConfig, pub tool_description: ToolDescription, #[serde(skip_serializing_if = "Option::is_none")] pub samples: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_draft: Option, #[serde(skip_serializing_if = "Option::is_none")] pub config_export: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub wizard_state: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, #[serde(default, skip_serializing_if = "Option::is_none")] #[serde(with = "time::serde::rfc3339::option")] pub published_at: Option, } impl Operation { pub fn tool_name(&self) -> &str { &self.name } pub fn is_draft(&self) -> bool { self.status == OperationStatus::Draft } pub fn is_published(&self) -> bool { self.status == OperationStatus::Published } pub fn protocol(&self) -> Protocol { self.protocol } pub fn auth_profile_ref(&self) -> Option<&AuthProfileId> { self.execution_config.auth_profile_ref.as_ref() } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ auth::{AuthConfig, AuthProfile, BearerAuthConfig}, edition::OperationSecurityLevel, ids::{AuthProfileId, OperationId}, operation::{ ConfigExport, ExecutionConfig, Operation, OperationStatus, RestTarget, Samples, Target, ToolDescription, ToolExample, }, protocol::{AuthKind, ExportMode, HttpMethod, Protocol}, }; fn timestamp(value: &str) -> OffsetDateTime { OffsetDateTime::parse(value, &Rfc3339).unwrap() } #[test] fn rest_target_serializes_with_kind_tag() { let target = Target::Rest(RestTarget { base_url: "https://api.example.com".to_owned(), method: HttpMethod::Post, path_template: "/v1/leads".to_owned(), static_headers: BTreeMap::new(), }); let value = serde_json::to_value(target).unwrap(); assert_eq!(value["kind"], "rest"); assert_eq!(value["method"], "POST"); } #[test] fn operation_exposes_local_domain_helpers() { let operation = test_operation(OperationStatus::Draft); assert_eq!(operation.tool_name(), "crm_create_lead"); assert!(operation.is_draft()); assert!(!operation.is_published()); assert_eq!(operation.protocol(), Protocol::Rest); assert_eq!( operation.auth_profile_ref().map(|value| value.as_str()), Some("auth_01") ); } #[test] fn auth_profile_serializes_secret_ids_without_secret_values() { let profile = AuthProfile { id: AuthProfileId::new("auth_01"), workspace_id: crate::ids::WorkspaceId::new("ws_01"), name: "crm-prod-bearer".to_owned(), kind: AuthKind::Bearer, config: AuthConfig::Bearer(BearerAuthConfig { header_name: "Authorization".to_owned(), secret_id: crate::ids::SecretId::new("secret_crm_prod_token"), }), created_at: timestamp("2026-03-25T08:00:00Z"), updated_at: timestamp("2026-03-25T08:00:00Z"), }; let value = serde_json::to_value(profile).unwrap(); assert_eq!(value["kind"], "bearer"); assert_eq!( value["config"]["bearer"]["secret_id"], "secret_crm_prod_token" ); } #[test] fn operation_roundtrips_through_yaml() { let operation = Operation { status: OperationStatus::Published, version: 3, config_export: Some(ConfigExport { format_version: "1".to_owned(), export_mode: ExportMode::Portable, }), published_at: Some(timestamp("2026-03-25T08:15:00Z")), ..test_operation(OperationStatus::Draft) }; let yaml = serde_yaml::to_string(&operation).unwrap(); let restored: Operation = serde_yaml::from_str(&yaml).unwrap(); assert!(yaml.contains("protocol: rest")); assert!(yaml.contains("security_level: standard")); assert!(yaml.contains("export_mode: portable")); assert_eq!(restored, operation); } #[test] fn operation_yaml_deserializes_without_optional_published_at() { let yaml = r#" id: op_01 name: crm_create_lead display_name: Create Lead category: sales protocol: rest security_level: standard status: draft version: 1 target: kind: rest base_url: https://api.example.com method: POST path_template: /v1/leads static_headers: {} input_schema: type: object output_schema: type: object input_mapping: rules: [] output_mapping: rules: [] execution_config: timeout_ms: 10000 headers: {} tool_description: title: Create CRM lead description: Creates a new lead. tags: [] examples: [] created_at: 2026-03-25T08:00:00Z updated_at: 2026-03-25T08:10:00Z "#; let restored: Operation = serde_yaml::from_str(yaml).unwrap(); assert_eq!(restored.security_level, OperationSecurityLevel::Standard); assert_eq!(restored.published_at, None); } fn test_operation(status: OperationStatus) -> Operation { Operation { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), category: "sales".to_owned(), protocol: Protocol::Rest, security_level: OperationSecurityLevel::Standard, status, version: 1, target: Target::Rest(RestTarget { base_url: "https://api.example.com".to_owned(), method: HttpMethod::Post, path_template: "/v1/leads".to_owned(), static_headers: BTreeMap::new(), }), input_schema: json!({"type":"object","fields":{"email":{"type":"string","required":true}}}), output_schema: json!({"type":"object","fields":{"id":{"type":"string","required":true}}}), input_mapping: json!({"rules":[{"source":"$.mcp.email","target":"$.request.body.email"}]}), output_mapping: json!({"rules":[{"source":"$.response.body.id","target":"$.output.id"}]}), execution_config: ExecutionConfig { timeout_ms: 10_000, retry_policy: None, response_cache: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), }, tool_description: ToolDescription { title: "Create CRM lead".to_owned(), description: "Creates a new lead.".to_owned(), tags: vec!["crm".to_owned()], examples: vec![ToolExample { input: json!({"email":"user@example.com"}), }], }, samples: Some(Samples::default()), generated_draft: None, config_export: None, wizard_state: None, created_at: timestamp("2026-03-25T08:00:00Z"), updated_at: timestamp("2026-03-25T08:10:00Z"), published_at: None, } } }